Compare commits
31 Commits
f1200425af
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d46e7f56 | |||
| 8e24487172 | |||
| e6e6c4d43a | |||
| 150557ce2c | |||
| 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 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
CLAUDE.MD
|
||||||
|
|
||||||
# local SQLite database
|
# local SQLite database
|
||||||
ghostgrid.db
|
ghostgrid.db
|
||||||
|
|||||||
165
ARCHITECTURE.md
165
ARCHITECTURE.md
@ -1,20 +1,10 @@
|
|||||||
# GhostGrid
|
# GhostGrid
|
||||||
## Architecture Reference
|
## Architecture Reference
|
||||||
|
|
||||||
**Version:** 1.1
|
|
||||||
**Date:** June 8, 2026
|
|
||||||
**Status:** Living document — single source of truth for the codebase
|
**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.
|
> Use this document as the starting context for any future task on GhostGrid. It describes the whole application: purpose, stack, file layout, data model, REST API, frontend structure, integrations, background jobs, security, and deployment.
|
||||||
|
|
||||||
### Revision History
|
|
||||||
|
|
||||||
| Version | Date | Changes |
|
|
||||||
|---------|------|---------|
|
|
||||||
| 1.2 | Jun 8, 2026 | Removed `caddy_prod_domain` / `caddy_dev_domain` settings; Caddy now routes only custom entries |
|
|
||||||
| 1.1 | Jun 8, 2026 | Dropped the migration layer (fresh-install schema); renamed the `caddy_routes` table to `caddy` |
|
|
||||||
| 1.0 | Jun 8, 2026 | Initial architecture reference generated from the codebase |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Executive Summary
|
## 1. Executive Summary
|
||||||
@ -46,7 +36,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
+-----------------------------------------------------------------------------+
|
+-----------------------------------------------------------------------------+
|
||||||
| GHOSTGRID PLATFORM |
|
| GHOSTGRID PLATFORM |
|
||||||
+-----------------------------------------------------------------------------+
|
+-----------------------------------------------------------------------------+
|
||||||
| +---------------------------------------------------------------------+ |
|
| +---------------------------------------------------------------------+ |
|
||||||
| | PRESENTATION LAYER | |
|
| | PRESENTATION LAYER | |
|
||||||
@ -59,7 +49,7 @@
|
|||||||
| | authFetch > Bearer <JWT> |
|
| | authFetch > Bearer <JWT> |
|
||||||
| +---------------------------------------------------------------------+ |
|
| +---------------------------------------------------------------------+ |
|
||||||
| | APPLICATION LAYER (server.ts) | |
|
| | APPLICATION LAYER (server.ts) | |
|
||||||
| | Single Express process — serves API + frontend | |
|
| | Single Express process — serves API + frontend | |
|
||||||
| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | |
|
| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | |
|
||||||
| | | Auth | | Devices | | Labs | | Bookings | | Logs | | |
|
| | | Auth | | Devices | | Labs | | Bookings | | Logs | | |
|
||||||
| | | (JWT/MSAL)| | CRUD | | CRUD | | CRUD | | | | |
|
| | | (JWT/MSAL)| | CRUD | | CRUD | | CRUD | | | | |
|
||||||
@ -125,7 +115,7 @@
|
|||||||
| Component | Technology | Purpose |
|
| Component | Technology | Purpose |
|
||||||
|-----------|------------|---------|
|
|-----------|------------|---------|
|
||||||
| Database | SQLite via `better-sqlite3` | Single file `ghostgrid.db`, WAL journal mode, synchronous queries |
|
| Database | SQLite via `better-sqlite3` | Single file `ghostgrid.db`, WAL journal mode, synchronous queries |
|
||||||
| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | 8 tables defined in full and created on boot (fresh-install model, no migrations) |
|
| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | Baseline 8 tables created on boot via `server-db.ts`; additive changes to live DBs go in `server-migrations.ts` (see §4.4) |
|
||||||
| Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` |
|
| Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -205,7 +195,7 @@ Networking (optional, managed in-app)
|
|||||||
|
|
||||||
## 4. Database Schema Design
|
## 4. Database Schema Design
|
||||||
|
|
||||||
SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Every table is defined in full and created idempotently on boot (`CREATE TABLE IF NOT EXISTS`) — the app assumes a fresh install, so there is no migration layer.
|
SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Baseline tables are created idempotently on boot (`CREATE TABLE IF NOT EXISTS` in `server-db.ts`). Additive changes to live DBs (new columns, tables, default settings) are applied by the migration runner in `server-migrations.ts`. See §4.4.
|
||||||
|
|
||||||
### 4.1 Schema (as created in `server-db.ts`)
|
### 4.1 Schema (as created in `server-db.ts`)
|
||||||
|
|
||||||
@ -241,8 +231,11 @@ CREATE TABLE IF NOT EXISTS labs (
|
|||||||
deviceIds TEXT NOT NULL, -- JSON string: string[]
|
deviceIds TEXT NOT NULL, -- JSON string: string[]
|
||||||
topology TEXT NOT NULL, -- JSON string: TopologyLink[]
|
topology TEXT NOT NULL, -- JSON string: TopologyLink[]
|
||||||
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
|
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
|
||||||
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''
|
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT '',
|
||||||
|
scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'personal'
|
||||||
|
ownerId TEXT NOT NULL DEFAULT '' -- userId of creator; '' = legacy (pre-migration)
|
||||||
);
|
);
|
||||||
|
-- scope/ownerId were added after initial release via idempotent ALTER TABLE in server-db.ts
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bookings (
|
CREATE TABLE IF NOT EXISTS bookings (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -287,12 +280,13 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS caddy (
|
CREATE TABLE IF NOT EXISTS caddy (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
hostname TEXT NOT NULL,
|
hostname TEXT NOT NULL,
|
||||||
upstream TEXT NOT NULL,
|
upstream TEXT NOT NULL,
|
||||||
tls INTEGER NOT NULL DEFAULT 1,
|
tls INTEGER NOT NULL DEFAULT 1,
|
||||||
compress INTEGER NOT NULL DEFAULT 1,
|
compress INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
redirect TEXT NOT NULL DEFAULT '', -- optional 'redir / <path>' for the bare root
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -304,7 +298,7 @@ CREATE TABLE IF NOT EXISTS caddy (
|
|||||||
| JSON columns | `labs.deviceIds` and `labs.topology` are JSON strings, parsed in the API layer |
|
| JSON columns | `labs.deviceIds` and `labs.topology` are JSON strings, parsed in the API layer |
|
||||||
| Booleans | Booking flags (`notified`, `emailSent`, `ansible*Triggered`) are `INTEGER` 0/1, mapped to JS booleans on read |
|
| Booleans | Booking flags (`notified`, `emailSent`, `ansible*Triggered`) are `INTEGER` 0/1, mapped to JS booleans on read |
|
||||||
| Cascade | No FK cascades; referential cleanup is done in code (e.g. deleting a device scrubs it from every lab's `deviceIds`/`topology`) |
|
| Cascade | No FK cascades; referential cleanup is done in code (e.g. deleting a device scrubs it from every lab's `deviceIds`/`topology`) |
|
||||||
| Schema changes | Fresh-install model — edit the `CREATE TABLE` block in `server-db.ts` directly; there is no migration helper |
|
| Schema changes | Baseline schema in `server-db.ts` (`CREATE TABLE IF NOT EXISTS`). New columns / tables / default settings for live DBs go in `server-migrations.ts` as an appended migration object. See §4.4. |
|
||||||
|
|
||||||
### 4.3 Settings (key/value config)
|
### 4.3 Settings (key/value config)
|
||||||
|
|
||||||
@ -319,6 +313,29 @@ Seeded with `INSERT OR IGNORE` (defaults below). Secret keys are never returned
|
|||||||
|
|
||||||
🔒 = in `SECRET_KEYS`, masked on read, only updated when a non-`__SET__` value is sent.
|
🔒 = in `SECRET_KEYS`, masked on read, only updated when a non-`__SET__` value is sent.
|
||||||
|
|
||||||
|
### 4.4 Migration System
|
||||||
|
|
||||||
|
Additive schema changes to live databases are handled by `server-migrations.ts`. The runner executes once at the start of `startServer()`, before routes or background jobs, and is synchronous (`better-sqlite3`).
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. Creates (idempotently) a `_migrations` table: `id TEXT PRIMARY KEY, applied_at TEXT`.
|
||||||
|
2. Iterates the `migrations` array in order. For each entry whose `id` is not in `_migrations`, runs `up(db)` inside a transaction and records the id on success.
|
||||||
|
3. If `up()` throws, the transaction rolls back and the id is not recorded — the migration retries on the next start.
|
||||||
|
4. Already-applied migrations are skipped forever.
|
||||||
|
|
||||||
|
**Rules for adding a migration:**
|
||||||
|
|
||||||
|
- Append to the end of the `migrations` array in `server-migrations.ts`. **Never reorder or remove entries.**
|
||||||
|
- Use a unique, descriptive `id` — format: `NNNN_short_description` (e.g. `0001_bookings_add_color`).
|
||||||
|
- Only additive DDL: `ALTER TABLE … ADD COLUMN`, `CREATE TABLE IF NOT EXISTS`, `INSERT OR IGNORE INTO settings`.
|
||||||
|
- New default settings for **live DBs** go here. Day-0 seeds (fresh install) stay in `DEFAULT_SETTINGS` in `server-db.ts`.
|
||||||
|
- New settings still need allow-listing in `PUT /api/settings` (and `SECRET_KEYS` if secret) — the migration only seeds the value.
|
||||||
|
|
||||||
|
**Fresh-install behavior:** `server-db.ts` creates all baseline tables first (at module import). `runMigrations` then applies every migration in the array — safe because all migrations are additive over the baseline.
|
||||||
|
|
||||||
|
**`_migrations` is excluded** from `/api/database/import` and `/api/database/info` (not in their explicit table lists) — migration state belongs to the current install, not the imported data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. API Design
|
## 5. API Design
|
||||||
@ -355,9 +372,9 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
|
|||||||
|
|
|
|
||||||
+-- /labs
|
+-- /labs
|
||||||
| +-- GET / # List labs (parses deviceIds/topology JSON) [auth]
|
| +-- GET / # List labs (parses deviceIds/topology JSON) [auth]
|
||||||
| +-- POST / # Create lab [auth]
|
| +-- POST / # Create lab; sets ownerId=req.user [auth]
|
||||||
| +-- PUT /{id} # Update lab [auth]
|
| +-- PUT /{id} # Update lab; 403 if not owner/admin/legacy [auth]
|
||||||
| +-- DELETE /{id} # Delete + cancel upcoming bookings [auth]
|
| +-- DELETE /{id} # Delete + cancel upcoming bookings; same 403 guard [auth]
|
||||||
|
|
|
|
||||||
+-- /bookings
|
+-- /bookings
|
||||||
| +-- GET / # List bookings (int flags > booleans) [auth]
|
| +-- GET / # List bookings (int flags > booleans) [auth]
|
||||||
@ -365,6 +382,12 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
|
|||||||
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
|
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
|
||||||
| +-- DELETE /{id} # Delete booking [auth]
|
| +-- DELETE /{id} # Delete booking [auth]
|
||||||
|
|
|
|
||||||
|
+-- /events
|
||||||
|
| +-- GET / # SSE stream; token via ?token= query param [auth]
|
||||||
|
| | # Sends full snapshot on connect, then pushes
|
||||||
|
| | # bookings/devices/labs/logs/links/users-update
|
||||||
|
| | # events after every mutation or background job
|
||||||
|
|
|
||||||
+-- /logs
|
+-- /logs
|
||||||
| +-- GET / # All logs, newest first [auth]
|
| +-- GET / # All logs, newest first [auth]
|
||||||
| +-- POST / # Manual log entry [auth]
|
| +-- POST / # Manual log entry [auth]
|
||||||
@ -384,8 +407,9 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
|
|||||||
|
|
|
|
||||||
+-- /caddy
|
+-- /caddy
|
||||||
+-- GET /status # Caddy admin API reachable? [auth]
|
+-- GET /status # Caddy admin API reachable? [auth]
|
||||||
+-- GET /routes # { system, custom } routes [auth]
|
+-- GET /routes # Custom routes (plain array) [auth]
|
||||||
+-- POST /routes # Add custom route + push config [auth]
|
+-- 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]
|
+-- DELETE /routes/{id} # Remove custom route + push config [auth]
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -398,8 +422,10 @@ Auth model
|
|||||||
+-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user)
|
+-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user)
|
||||||
+-- Middleware
|
+-- Middleware
|
||||||
| +-- requireAuth — verifies JWT, sets req.user; applied to all data routes
|
| +-- requireAuth — verifies JWT, sets req.user; applied to all data routes
|
||||||
| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED
|
| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED to routes
|
||||||
+-- Roles: role column defaults to 'User'; no route currently enforces admin
|
+-- Roles: role column defaults to 'User'
|
||||||
|
+-- Lab ownership: PUT/DELETE /api/labs/:id enforce inline ownership check
|
||||||
|
| (owner || admin || legacy-lab with ownerId=''); 403 otherwise
|
||||||
```
|
```
|
||||||
|
|
||||||
**Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user.
|
**Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user.
|
||||||
@ -442,6 +468,7 @@ Step 2 for each device:
|
|||||||
- on change: write a 'status' log
|
- on change: write a 'status' log
|
||||||
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
|
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
|
||||||
HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint)
|
HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint)
|
||||||
|
After each cycle: broadcastDevices() + broadcastLogs() > SSE push to all clients
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.2 Ansible Semaphore — Playbook Automation
|
### 6.2 Ansible Semaphore — Playbook Automation
|
||||||
@ -462,6 +489,7 @@ triggerSemaphoreTask(templateId, extraVars):
|
|||||||
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
|
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
|
||||||
> store returned job id on booking; log success/failure
|
> store returned job id on booking; log success/failure
|
||||||
(a booking with no template id is marked triggered > not retried)
|
(a booking with no template id is marked triggered > not retried)
|
||||||
|
After each cycle: broadcastBookings() + broadcastLogs() > SSE push to all clients
|
||||||
|
|
||||||
Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' }
|
Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' }
|
||||||
GET /api/semaphore/templates (proxy for UI dropdowns)
|
GET /api/semaphore/templates (proxy for UI dropdowns)
|
||||||
@ -472,11 +500,54 @@ Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown
|
|||||||
```
|
```
|
||||||
buildCaddyfile():
|
buildCaddyfile():
|
||||||
{ local_certs } # global block
|
{ local_certs } # global block
|
||||||
per custom route { [encode] [tls internal] reverse_proxy <upstream> }
|
per custom route { [encode] [tls internal] [redir / <redirect>] reverse_proxy <upstream> { … } }
|
||||||
|
redirect set → `redir / <path>` redirects only the bare root '/'
|
||||||
|
(other paths pass through; e.g. CheckMK served at /<site>/check_mk/)
|
||||||
|
every reverse_proxy block carries standard forwarding headers:
|
||||||
|
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)
|
pushCaddyConfig(): POST <caddy_admin_url>/load (Content-Type: text/caddyfile)
|
||||||
called on startup, after settings save, after route add/delete
|
called on startup, after settings save, after route add/delete
|
||||||
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true')
|
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true'
|
||||||
|
or if this instance is not the Caddy manager)
|
||||||
|
|
||||||
|
Ownership — one Caddy serves the whole container (admin API on :2019); POST /load
|
||||||
|
replaces the ENTIRE config. Only the instance with env CADDY_MANAGER=true (production)
|
||||||
|
pushes, seeds routes, and accepts route edits (POST/PUT/DELETE → 403 otherwise). The
|
||||||
|
other instance shows the Caddy section read-only (/api/auth/config → caddyManaged:false)
|
||||||
|
and never pushes — otherwise its own (partial) config would clobber the owner's. The
|
||||||
|
owner's caddy table therefore holds ALL routes (both GhostGrid domains + every service).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.4 First-start Initialization
|
||||||
|
|
||||||
|
Runs in `startServer()` on every startup — each step is idempotent.
|
||||||
|
|
||||||
|
```
|
||||||
|
Default admin user (only on a blank database):
|
||||||
|
if users table is empty:
|
||||||
|
INSERT user (name='admin', role='admin', email='admin@ghostgrid.local', password=bcrypt('admin'))
|
||||||
|
→ log "[Init] Default admin user created"
|
||||||
|
|
||||||
|
Caddy route import (re-deploy safety net, Caddy manager only):
|
||||||
|
if CADDY_MANAGER === 'true' AND caddy_enabled === 'true' AND caddy table is empty:
|
||||||
|
importCaddyfileRoutes() → seed routes from /etc/caddy/Caddyfile
|
||||||
|
(also runs in PUT /api/settings on the disabled → enabled transition)
|
||||||
|
|
||||||
|
Default settings:
|
||||||
|
INSERT OR IGNORE all DEFAULT_SETTINGS keys from server-db.ts
|
||||||
|
→ existing values in the settings table are never overwritten
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -521,12 +592,14 @@ src/
|
|||||||
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
|
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
|
||||||
+-- Effects:
|
+-- Effects:
|
||||||
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
|
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
|
||||||
| +-- Load data on login
|
| +-- Load data on login (one Promise.all; initial seed before SSE connects)
|
||||||
| +-- Poll GET /api/devices every 30s (surface CheckMK-driven status changes)
|
| +-- SSE connection to GET /api/events — receives full snapshot on (re)connect,
|
||||||
|
| | then live pushes for bookings/devices/labs/logs/links/users on any mutation
|
||||||
|
| | or background job; auth-error event triggers logout on token expiry
|
||||||
| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away)
|
| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away)
|
||||||
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
|
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
|
||||||
handleAddLogManually — call API via authFetch, update local state,
|
handleAddLogManually — call API via authFetch, update local state
|
||||||
most then refetch /api/logs
|
(SSE pushes the authoritative state to all tabs within ~1s)
|
||||||
|
|
||||||
(* persisted to localStorage)
|
(* persisted to localStorage)
|
||||||
```
|
```
|
||||||
@ -553,6 +626,9 @@ Device Inventory
|
|||||||
|
|
||||||
Lab Templates + Topology
|
Lab Templates + Topology
|
||||||
+-- Lab CRUD; Semaphore setup/teardown template selection
|
+-- Lab CRUD; Semaphore setup/teardown template selection
|
||||||
|
+-- Scope toggle (Global / Personal) per lab; Personal labs visible only to owner + admins
|
||||||
|
+-- List sectioned: "My Topologies" / "Global Topologies" / "Others' Personal" (admin only)
|
||||||
|
+-- Edit/Delete buttons hidden for labs the current user cannot modify
|
||||||
+-- Topology link editor (fromDevice > toDevice, link type)
|
+-- Topology link editor (fromDevice > toDevice, link type)
|
||||||
+-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular)
|
+-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular)
|
||||||
|
|
||||||
@ -560,7 +636,9 @@ Settings
|
|||||||
+-- Microsoft Entra ID (OAuth SSO, redirect-URI helper, allowed group)
|
+-- Microsoft Entra ID (OAuth SSO, redirect-URI helper, allowed group)
|
||||||
+-- CheckMK (API URL/user/secret, sync interval, "Run sync now")
|
+-- CheckMK (API URL/user/secret, sync interval, "Run sync now")
|
||||||
+-- Ansible Semaphore (API URL/token/project, "Test connection")
|
+-- Ansible Semaphore (API URL/token/project, "Test connection")
|
||||||
+-- Caddy (admin URL, custom route management)
|
+-- Caddy (admin URL, custom route management;
|
||||||
|
auto-seeded from /etc/caddy/Caddyfile on first enable;
|
||||||
|
https:// upstream → TLS proxy, certificate not verified)
|
||||||
+-- Secret inputs use the __SET__ sentinel (blank = keep existing)
|
+-- Secret inputs use the __SET__ sentinel (blank = keep existing)
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -575,7 +653,7 @@ The single contract between frontend and backend — imported by **both** `serve
|
|||||||
| `DeviceType` | `'Switch' \| 'Access-Point' \| 'Firewall' \| 'Controller' \| (string & {})` — presets + free-form |
|
| `DeviceType` | `'Switch' \| 'Access-Point' \| 'Firewall' \| 'Controller' \| (string & {})` — presets + free-form |
|
||||||
| `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` |
|
| `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` |
|
||||||
| `TopologyLink` | `{ fromDevice, toDevice, type }` (e.g. `LACP-Trunk`, `Uplink`, `OOB-Management`) |
|
| `TopologyLink` | `{ fromDevice, toDevice, type }` (e.g. `LACP-Trunk`, `Uplink`, `OOB-Management`) |
|
||||||
| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs |
|
| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs; `scope: 'global' \| 'personal'`, `ownerId: string` (userId of creator, `''` for legacy rows) |
|
||||||
| `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs |
|
| `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs |
|
||||||
| `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` |
|
| `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` |
|
||||||
| `User` | `{ id, name, role, email }` (never password on the client) |
|
| `User` | `{ id, name, role, email }` (never password on the client) |
|
||||||
@ -634,6 +712,7 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
|
|||||||
| `APP_URL` | `http://localhost:<PORT>` | Base URL for deriving the Azure redirect URI |
|
| `APP_URL` | `http://localhost:<PORT>` | Base URL for deriving the Azure redirect URI |
|
||||||
| `PORT` | `3000` | HTTP listen port |
|
| `PORT` | `3000` | HTTP listen port |
|
||||||
| `NODE_ENV` | — | `production` switches to static `dist/` serving |
|
| `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 |
|
| `CHECKMK_API_URL` / `CHECKMK_API_USER` / `CHECKMK_API_SECRET` | — | Fallbacks if not set in the Settings UI |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -655,6 +734,8 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
|
|||||||
| +-- role column ('User'/'admin') exists |
|
| +-- role column ('User'/'admin') exists |
|
||||||
| +-- ⚠ requireAdmin defined but NOT applied — any |
|
| +-- ⚠ requireAdmin defined but NOT applied — any |
|
||||||
| authenticated user can read/write settings + users |
|
| authenticated user can read/write settings + users |
|
||||||
|
| +-- Lab ownership enforced on PUT/DELETE /api/labs/:id |
|
||||||
|
| (owner || admin || legacy ownerId=''); 403 otherwise |
|
||||||
+-------------------------------------------------------------+
|
+-------------------------------------------------------------+
|
||||||
| Secret Handling |
|
| Secret Handling |
|
||||||
| +-- Integration secrets stored in settings table |
|
| +-- Integration secrets stored in settings table |
|
||||||
@ -678,8 +759,10 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
|
|||||||
```
|
```
|
||||||
GhostGrid/
|
GhostGrid/
|
||||||
+-- server.ts # Express app: all routes, auth, integrations, background jobs
|
+-- server.ts # Express app: all routes, auth, integrations, background jobs
|
||||||
+-- server-db.ts # SQLite connection, full schema, settings/Caddy helpers
|
+-- server-db.ts # SQLite connection, full schema, settings/Caddy/log helpers (uid, addLog)
|
||||||
+-- index.html # Vite HTML entry (#root > src/main.tsx)
|
+-- 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
|
+-- vite.config.ts # Vite + React + Tailwind; '@' alias > repo root
|
||||||
+-- tsconfig.json # noEmit, react-jsx, bundler resolution
|
+-- tsconfig.json # noEmit, react-jsx, bundler resolution
|
||||||
+-- package.json # scripts + deps (package name "react-example" is vestigial)
|
+-- package.json # scripts + deps (package name "react-example" is vestigial)
|
||||||
@ -734,7 +817,7 @@ GhostGrid/
|
|||||||
| CheckMK/Semaphore outage | Integration loops catch errors, log them, and retry next cycle; non-fatal |
|
| CheckMK/Semaphore outage | Integration loops catch errors, log them, and retry next cycle; non-fatal |
|
||||||
| Caddy admin API unreachable | `pushCaddyConfig()` failures are logged as warnings; routes apply when Caddy starts |
|
| Caddy admin API unreachable | `pushCaddyConfig()` failures are logged as warnings; routes apply when Caddy starts |
|
||||||
| Data loss | Back up `ghostgrid.db` + `-wal`/`-shm`; each instance has its own DB |
|
| Data loss | Back up `ghostgrid.db` + `-wal`/`-shm`; each instance has its own DB |
|
||||||
| Schema evolution | Edit the `CREATE TABLE` block in `server-db.ts` (fresh-install model, no migrations); new settings need seed + allow-list (+ `SECRET_KEYS` if secret) |
|
| Schema evolution | For fresh installs, edit the `CREATE TABLE` block in `server-db.ts`. For live DBs (new columns, tables, default settings), append a migration to `server-migrations.ts` (see §4.4). New settings still need allow-listing in `PUT /api/settings` (+ `SECRET_KEYS` if secret). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -802,7 +885,9 @@ Express (server.ts) ──► better-sqlite3 (ghostgrid.db, WAL)
|
|||||||
- `labs.deviceIds` / `labs.topology` are JSON strings in SQLite, parsed in the API.
|
- `labs.deviceIds` / `labs.topology` are JSON strings in SQLite, parsed in the API.
|
||||||
- Booking boolean flags are 0/1 integers in SQLite, mapped on read.
|
- Booking boolean flags are 0/1 integers in SQLite, mapped on read.
|
||||||
- A new settings key must be: **seeded** in `server-db.ts`, **allow-listed** in `PUT /api/settings`, and (if secret) added to `SECRET_KEYS`.
|
- A new settings key must be: **seeded** in `server-db.ts`, **allow-listed** in `PUT /api/settings`, and (if secret) added to `SECRET_KEYS`.
|
||||||
- Schema changes go straight into the `CREATE TABLE` block in `server-db.ts` — fresh-install model, no migration helper.
|
- Baseline schema in `server-db.ts`. Additive schema changes (new columns, tables, default settings) go in `server-migrations.ts` as an appended migration object — never edit existing entries (see §4.4).
|
||||||
|
- The SPA catch-all (`app.get('*')`) + static serving are registered **last** in `startServer()`, after every `/api` route — otherwise GET `/api/*` falls through to `index.html`. All `/api` responses carry `Cache-Control: no-store`.
|
||||||
|
- One Caddy per container; `POST /load` replaces the whole config. Only the `CADDY_MANAGER=true` instance may push/seed/edit routes — never let the non-manager push.
|
||||||
- All user-facing strings are in **English**.
|
- All user-facing strings are in **English**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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 |
84
server-db.ts
84
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);
|
||||||
@ -27,7 +30,7 @@ db.exec(`
|
|||||||
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 '',
|
cmkUrl TEXT NOT NULL DEFAULT '',
|
||||||
cmkHostname TEXT NOT NULL DEFAULT ''
|
cmkHostname TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -53,10 +56,10 @@ db.exec(`
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
notified INTEGER NOT NULL DEFAULT 0,
|
notified INTEGER NOT NULL DEFAULT 0,
|
||||||
emailSent INTEGER NOT NULL DEFAULT 0,
|
emailSent INTEGER NOT NULL DEFAULT 0,
|
||||||
ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0,
|
semaphoreSetupTriggered INTEGER NOT NULL DEFAULT 0,
|
||||||
ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0,
|
semaphoreTeardownTriggered INTEGER NOT NULL DEFAULT 0,
|
||||||
ansibleSetupJobId TEXT NOT NULL DEFAULT '',
|
semaphoreSetupJobId TEXT NOT NULL DEFAULT '',
|
||||||
ansibleTeardownJobId TEXT NOT NULL DEFAULT ''
|
semaphoreTeardownJobId TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
@ -86,15 +89,20 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS caddy (
|
CREATE TABLE IF NOT EXISTS caddy (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
hostname TEXT NOT NULL,
|
hostname TEXT NOT NULL,
|
||||||
upstream TEXT NOT NULL,
|
upstream TEXT NOT NULL,
|
||||||
tls INTEGER NOT NULL DEFAULT 1,
|
tls INTEGER NOT NULL DEFAULT 1,
|
||||||
compress INTEGER NOT NULL DEFAULT 1,
|
compress INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
redirect TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run.
|
||||||
|
try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {}
|
||||||
|
try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {}
|
||||||
|
|
||||||
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
|
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
|
||||||
const DEFAULT_SETTINGS: Record<string, string> = {
|
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||||
azure_enabled: 'false',
|
azure_enabled: 'false',
|
||||||
@ -113,7 +121,7 @@ const DEFAULT_SETTINGS: Record<string, string> = {
|
|||||||
semaphore_api_token: '',
|
semaphore_api_token: '',
|
||||||
semaphore_project_id: '',
|
semaphore_project_id: '',
|
||||||
caddy_enabled: 'false',
|
caddy_enabled: 'false',
|
||||||
caddy_admin_url: 'http://localhost:2019',
|
caddy_admin_url: 'http://127.0.0.1:2019',
|
||||||
};
|
};
|
||||||
|
|
||||||
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||||
@ -133,28 +141,68 @@ export function getAllSettings(): Record<string, string> {
|
|||||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insertLog = db.prepare(
|
||||||
|
'INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a logbook entry. `deviceId`/`userId` default to NULL; `timestamp`
|
||||||
|
* defaults to now (pass one to share a single timestamp across a batch).
|
||||||
|
* Returns the generated log id.
|
||||||
|
*/
|
||||||
|
export function addLog(
|
||||||
|
type: string,
|
||||||
|
message: string,
|
||||||
|
opts: { deviceId?: string | null; userId?: string | null; timestamp?: string } = {},
|
||||||
|
): string {
|
||||||
|
const id = uid('log');
|
||||||
|
insertLog.run(id, opts.timestamp ?? new Date().toISOString(), type, message, opts.deviceId ?? null, opts.userId ?? null);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reverse-proxy route as stored (booleans are SQLite 0/1 integers). */
|
||||||
export interface CaddyRoute {
|
export interface CaddyRoute {
|
||||||
id: number;
|
id: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
upstream: string;
|
upstream: string;
|
||||||
tls: number;
|
tls: number;
|
||||||
compress: number;
|
compress: number;
|
||||||
|
redirect: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fields a caller supplies to create or update a route (JS booleans). */
|
||||||
|
export interface CaddyRouteInput {
|
||||||
|
hostname: string;
|
||||||
|
upstream: string;
|
||||||
|
tls: boolean;
|
||||||
|
compress: boolean;
|
||||||
|
redirect?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function getCaddyRoutes(): CaddyRoute[] {
|
export function getCaddyRoutes(): CaddyRoute[] {
|
||||||
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
|
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute {
|
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
|
||||||
|
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute {
|
||||||
const { lastInsertRowid } = db.prepare(
|
const { lastInsertRowid } = db.prepare(
|
||||||
'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)'
|
'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)',
|
||||||
).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0);
|
).run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '');
|
||||||
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute;
|
return getCaddyRouteById(Number(lastInsertRowid))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCaddyRoute(id: number, route: CaddyRouteInput): CaddyRoute {
|
||||||
|
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect = ? WHERE id = ?')
|
||||||
|
.run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '', id);
|
||||||
|
return getCaddyRouteById(id)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCaddyRoute(id: number): void {
|
export function deleteCaddyRoute(id: number): void {
|
||||||
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
|
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
48
server-migrations.ts
Normal file
48
server-migrations.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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[] = [
|
||||||
|
{
|
||||||
|
id: '0001_rename_device_checkMkUrl_to_cmkUrl',
|
||||||
|
up: (db) => {
|
||||||
|
db.exec(`ALTER TABLE devices RENAME COLUMN checkMkUrl TO cmkUrl`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0002_rename_booking_ansible_to_semaphore',
|
||||||
|
up: (db) => {
|
||||||
|
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleSetupTriggered TO semaphoreSetupTriggered`);
|
||||||
|
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleTeardownTriggered TO semaphoreTeardownTriggered`);
|
||||||
|
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleSetupJobId TO semaphoreSetupJobId`);
|
||||||
|
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleTeardownJobId TO semaphoreTeardownJobId`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/App.tsx
233
src/App.tsx
@ -53,8 +53,10 @@ export default function App() {
|
|||||||
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 [cmkEnabled, setCmkEnabled] = useState(false);
|
||||||
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
|
const [cmkBaseUrl, setCmkBaseUrl] = useState('');
|
||||||
|
const [isProduction, setIsProduction] = useState(false);
|
||||||
|
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@ -143,7 +145,7 @@ export default function App() {
|
|||||||
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
if (logsRes.ok) setLogs(await logsRes.json());
|
||||||
if (linksRes.ok) setLinks(await linksRes.json());
|
if (linksRes.ok) setLinks(await linksRes.json());
|
||||||
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); }
|
if (configRes.ok) { const cfg = await configRes.json(); setCmkEnabled(!!cfg.cmkEnabled); setCmkBaseUrl(cfg.cmkBaseUrl || ''); 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 {
|
||||||
@ -153,23 +155,53 @@ export default function App() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
// Cyclic device-status check: poll the inventory every 30s so CheckMK-driven
|
// SSE connection: real-time push for all shared data.
|
||||||
// status changes (online/offline) surface without a manual reload. The backend
|
// EventSource does not support Authorization headers, so the JWT is passed
|
||||||
// is the source of truth - it syncs each device's status from the CheckMK API.
|
// as a query parameter. The server sends a full snapshot on every (re)connect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
const refreshDevices = async () => {
|
const token = getToken();
|
||||||
try {
|
if (!token) return;
|
||||||
const res = await authFetch('/api/devices');
|
|
||||||
if (res.ok) setDevices(await res.json());
|
const evtSource = new EventSource(`/api/events?token=${encodeURIComponent(token)}`);
|
||||||
} catch {
|
|
||||||
// transient network/server hiccup - keep last known state, retry next tick
|
evtSource.addEventListener('bookings-update', (e: MessageEvent) => {
|
||||||
}
|
try { setBookings(JSON.parse(e.data) as Booking[]); } catch {}
|
||||||
|
});
|
||||||
|
evtSource.addEventListener('devices-update', (e: MessageEvent) => {
|
||||||
|
try { setDevices(JSON.parse(e.data) as Device[]); } catch {}
|
||||||
|
});
|
||||||
|
evtSource.addEventListener('labs-update', (e: MessageEvent) => {
|
||||||
|
try { setLabs(JSON.parse(e.data) as LabTemplate[]); } catch {}
|
||||||
|
});
|
||||||
|
evtSource.addEventListener('logs-update', (e: MessageEvent) => {
|
||||||
|
try { setLogs(JSON.parse(e.data) as LogEntry[]); } catch {}
|
||||||
|
});
|
||||||
|
evtSource.addEventListener('links-update', (e: MessageEvent) => {
|
||||||
|
try { setLinks(JSON.parse(e.data) as QuickLink[]); } catch {}
|
||||||
|
});
|
||||||
|
evtSource.addEventListener('users-update', (e: MessageEvent) => {
|
||||||
|
try { setUsers(JSON.parse(e.data) as User[]); } catch {}
|
||||||
|
});
|
||||||
|
evtSource.addEventListener('auth-error', () => {
|
||||||
|
evtSource.close();
|
||||||
|
clearSession();
|
||||||
|
setCurrentUser(null);
|
||||||
|
});
|
||||||
|
evtSource.onerror = () => {
|
||||||
|
console.debug('[SSE] Connection error, retrying...');
|
||||||
};
|
};
|
||||||
const id = setInterval(refreshDevices, 30_000);
|
|
||||||
return () => clearInterval(id);
|
return () => evtSource.close();
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
|
// Keep the booking details modal in sync when SSE updates the bookings list.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedBookingForDetails) return;
|
||||||
|
const fresh = bookings.find(b => b.id === selectedBookingForDetails.id);
|
||||||
|
setSelectedBookingForDetails(fresh ?? null);
|
||||||
|
}, [bookings]);
|
||||||
|
|
||||||
// Upcoming-booking reminder - checks every 60s, fires once per booking
|
// Upcoming-booking reminder - checks every 60s, fires once per booking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser || bookings.length === 0) return;
|
if (!currentUser || bookings.length === 0) return;
|
||||||
@ -217,10 +249,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setBookings(prev => [data.booking, ...prev]);
|
|
||||||
if (data.alertGenerated) setNotifications(prev => [data.alertGenerated, ...prev]);
|
if (data.alertGenerated) setNotifications(prev => [data.alertGenerated, ...prev]);
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
}
|
||||||
} catch (err) { console.error('[App] Error adding booking:', err); }
|
} catch (err) { console.error('[App] Error adding booking:', err); }
|
||||||
};
|
};
|
||||||
@ -233,111 +262,62 @@ export default function App() {
|
|||||||
body: JSON.stringify({ status: 'cancelled', operatorName: currentUser!.name }),
|
body: JSON.stringify({ status: 'cancelled', operatorName: currentUser!.name }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated = await res.json();
|
|
||||||
setBookings(prev => prev.map(b => b.id === bookingId ? updated : b));
|
|
||||||
if (selectedBookingForDetails?.id === bookingId) setSelectedBookingForDetails(updated);
|
|
||||||
const labName = booking ? (labs.find(l => l.id === booking.labId)?.name ?? 'Lab') : 'Lab';
|
const labName = booking ? (labs.find(l => l.id === booking.labId)?.name ?? 'Lab') : 'Lab';
|
||||||
setNotifications(prev => [`Booking cancelled: "${labName}" - slot has been released.`, ...prev]);
|
setNotifications(prev => [`Booking cancelled: "${labName}" - slot has been released.`, ...prev]);
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
}
|
||||||
} catch (err) { console.error('[App] Error cancelling booking:', err); }
|
} catch (err) { console.error('[App] Error cancelling booking:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteBooking = async (bookingId: string) => {
|
const handleDeleteBooking = async (bookingId: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/bookings/${bookingId}`, { method: 'DELETE' });
|
await authFetch(`/api/bookings/${bookingId}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
|
||||||
setBookings(prev => prev.filter(b => b.id !== bookingId));
|
|
||||||
if (selectedBookingForDetails?.id === bookingId) setSelectedBookingForDetails(null);
|
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error deleting booking:', err); }
|
} catch (err) { console.error('[App] Error deleting booking:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Device handlers
|
// Device handlers
|
||||||
const handleAddDevice = async (newDev: Omit<Device, 'id'>) => {
|
const handleAddDevice = async (newDev: Omit<Device, 'id'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/devices', { method: 'POST', body: JSON.stringify(newDev) });
|
await authFetch('/api/devices', { method: 'POST', body: JSON.stringify(newDev) });
|
||||||
if (res.ok) {
|
|
||||||
const created = await res.json();
|
|
||||||
setDevices(prev => [...prev, created]);
|
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error adding device:', err); }
|
} catch (err) { console.error('[App] Error adding device:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateDevice = async (updatedDev: Device) => {
|
const handleUpdateDevice = async (updatedDev: Device) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/devices/${updatedDev.id}`, {
|
await authFetch(`/api/devices/${updatedDev.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...updatedDev, operatorName: currentUser!.name }),
|
body: JSON.stringify({ ...updatedDev, operatorName: currentUser!.name }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
|
||||||
const updated = await res.json();
|
|
||||||
setDevices(prev => prev.map(d => d.id === updatedDev.id ? updated : d));
|
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error updating device:', err); }
|
} catch (err) { console.error('[App] Error updating device:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDevice = async (id: string) => {
|
const handleDeleteDevice = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/devices/${id}`, { method: 'DELETE' });
|
await authFetch(`/api/devices/${id}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
|
||||||
setDevices(prev => prev.filter(d => d.id !== id));
|
|
||||||
const labsRes = await authFetch('/api/labs');
|
|
||||||
if (labsRes.ok) setLabs(await labsRes.json());
|
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error deleting device:', err); }
|
} catch (err) { console.error('[App] Error deleting device:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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) });
|
await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
||||||
if (res.ok) {
|
|
||||||
const created = await res.json();
|
|
||||||
setLabs(prev => [...prev, created]);
|
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error adding lab:', err); }
|
} catch (err) { console.error('[App] Error adding lab:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateLab = async (updatedLab: LabTemplate) => {
|
const handleUpdateLab = async (updatedLab: LabTemplate) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/labs/${updatedLab.id}`, { method: 'PUT', body: JSON.stringify(updatedLab) });
|
await authFetch(`/api/labs/${updatedLab.id}`, { method: 'PUT', body: JSON.stringify(updatedLab) });
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setLabs(prev => prev.map(l => l.id === updatedLab.id ? data : l));
|
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error updating lab:', err); }
|
} catch (err) { console.error('[App] Error updating lab:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLab = async (id: string) => {
|
const handleDeleteLab = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/labs/${id}`, { method: 'DELETE' });
|
await authFetch(`/api/labs/${id}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
|
||||||
setLabs(prev => prev.filter(l => l.id !== id));
|
|
||||||
setBookings(prev => prev.map(b => b.labId === id && b.status === 'upcoming' ? { ...b, status: 'cancelled' as const } : b));
|
|
||||||
const logsRes = await authFetch('/api/logs');
|
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error deleting lab:', err); }
|
} catch (err) { console.error('[App] Error deleting lab:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLogManually = async (newLogEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
|
const handleAddLogManually = async (newLogEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/logs', { method: 'POST', body: JSON.stringify(newLogEntry) });
|
await authFetch('/api/logs', { method: 'POST', body: JSON.stringify(newLogEntry) });
|
||||||
if (res.ok) { const log = await res.json(); setLogs(prev => [log, ...prev]); }
|
|
||||||
} catch (err) { console.error('[App] Error adding log:', err); }
|
} catch (err) { console.error('[App] Error adding log:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -345,17 +325,25 @@ export default function App() {
|
|||||||
const handleDeleteUser = async (id: string) => {
|
const handleDeleteUser = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
|
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
if (res.ok) setUsers(prev => prev.filter(u => u.id !== id));
|
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
|
||||||
else { const d = await res.json(); throw new Error(d.error); }
|
|
||||||
} catch (err: any) { throw err; }
|
} catch (err: any) { throw err; }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateUser = async (id: string, name: string, email: string) => {
|
const handleUpdateUser = async (id: string, name: string, email: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
|
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
|
||||||
|
if (!res.ok) { 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) {
|
if (res.ok) {
|
||||||
const updated = await res.json();
|
const updated: User = await res.json();
|
||||||
setUsers(prev => prev.map(u => u.id === id ? updated : u));
|
// Update currentUser immediately if the acting user changed their own role,
|
||||||
|
// since currentUser is not driven by the users SSE event.
|
||||||
|
if (updated.id === currentUser?.id) setCurrentUser(updated);
|
||||||
} else { const d = await res.json(); throw new Error(d.error); }
|
} else { const d = await res.json(); throw new Error(d.error); }
|
||||||
} catch (err: any) { throw err; }
|
} catch (err: any) { throw err; }
|
||||||
};
|
};
|
||||||
@ -363,28 +351,19 @@ export default function App() {
|
|||||||
// 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 {
|
||||||
const res = await authFetch('/api/links', { method: 'POST', body: JSON.stringify(newLink) });
|
await authFetch('/api/links', { method: 'POST', body: JSON.stringify(newLink) });
|
||||||
if (res.ok) {
|
|
||||||
const created = await res.json();
|
|
||||||
setLinks(prev => [...prev, created]);
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error adding link:', err); }
|
} catch (err) { console.error('[App] Error adding link:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateLink = async (updated: QuickLink) => {
|
const handleUpdateLink = async (updated: QuickLink) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/links/${updated.id}`, { method: 'PUT', body: JSON.stringify(updated) });
|
await authFetch(`/api/links/${updated.id}`, { method: 'PUT', body: JSON.stringify(updated) });
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setLinks(prev => prev.map(l => l.id === updated.id ? data : l));
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('[App] Error updating link:', err); }
|
} catch (err) { console.error('[App] Error updating link:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLink = async (id: string) => {
|
const handleDeleteLink = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/links/${id}`, { method: 'DELETE' });
|
await authFetch(`/api/links/${id}`, { method: 'DELETE' });
|
||||||
if (res.ok) setLinks(prev => prev.filter(l => l.id !== id));
|
|
||||||
} catch (err) { console.error('[App] Error deleting link:', err); }
|
} catch (err) { console.error('[App] Error deleting link:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -432,12 +411,12 @@ export default function App() {
|
|||||||
// Startup check not done yet
|
// Startup check not done yet
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col items-center justify-center p-4">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<div className="p-4 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)] inline-flex">
|
<div className="p-4 bg-card border border-line rounded-2xl shadow-lg inline-flex">
|
||||||
<GhostGridLogo className="w-16 h-16 animate-pulse" />
|
<GhostGridLogo className="w-16 h-16" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 font-mono">booting...</p>
|
<p className="text-xs text-fg-muted font-mono">booting...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -454,16 +433,16 @@ export default function App() {
|
|||||||
// Loading data after login
|
// Loading data after login
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col items-center justify-center p-4">
|
||||||
<div className="text-center space-y-6 max-w-sm">
|
<div className="text-center space-y-6 max-w-sm">
|
||||||
<div className="p-4 bg-slate-950/80 border border-slate-850 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)] inline-flex">
|
<div className="p-4 bg-card border border-line rounded-2xl shadow-lg inline-flex">
|
||||||
<GhostGridLogo className="w-20 h-20 animate-pulse" />
|
<GhostGridLogo className="w-20 h-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-base font-bold tracking-tight text-white">GhostGrid Virtualization</h2>
|
<h2 className="text-base font-bold tracking-tight text-fg">GhostGrid Virtualization</h2>
|
||||||
<p className="text-xs text-slate-400 leading-normal">Mounting the SQLite file, walking the topology graph and pulling fresh box states. tail -f the spinner...</p>
|
<p className="text-xs text-fg-muted leading-normal">Mounting the SQLite file, walking the topology graph and pulling fresh box states. tail -f the spinner...</p>
|
||||||
<div className="inline-flex items-center gap-1 bg-cyan-950/40 border border-cyan-900/50 rounded-full px-2.5 py-0.5 text-[9px] font-mono text-cyan-400 font-semibold mt-1">
|
<div className="inline-flex items-center gap-1 bg-info-soft border border-info-line rounded-full px-2.5 py-0.5 text-[9px] font-mono text-info font-semibold mt-1">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-ping"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-info animate-ping"></span>
|
||||||
SQLITE DATABASE HYDRATION ONGOING
|
SQLITE DATABASE HYDRATION ONGOING
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -473,7 +452,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col selection:bg-emerald-500/30 selection:text-emerald-300" id="main-root">
|
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col selection:bg-emerald-500/30 selection:text-emerald-300" id="main-root">
|
||||||
|
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
@ -484,36 +463,37 @@ 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">
|
||||||
<aside
|
<aside
|
||||||
className={`w-full bg-[#0F172A] border-r border-[#1E293B] p-4 flex flex-col justify-between py-6 shrink-0 transition-all duration-200 ${navCollapsed ? 'md:w-20' : 'md:w-64'}`}
|
className={`w-full bg-header border-r border-line p-4 flex flex-col justify-between py-6 shrink-0 transition-all duration-200 ${navCollapsed ? 'md:w-20' : 'md:w-64'}`}
|
||||||
id="nav-sidebar"
|
id="nav-sidebar"
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Collapse toggle */}
|
{/* Collapse toggle */}
|
||||||
<div className={`flex items-center px-1 ${navCollapsed ? 'md:justify-center justify-between' : 'justify-between'}`}>
|
<div className={`flex items-center px-1 ${navCollapsed ? 'md:justify-center justify-between' : 'justify-between'}`}>
|
||||||
<span className={`text-[10px] uppercase font-mono font-bold tracking-wider text-slate-500 ${navCollapsed ? 'md:hidden' : 'block'}`}>Main Menu</span>
|
<span className={`text-[10px] uppercase font-mono font-bold tracking-wider text-fg-faint ${navCollapsed ? 'md:hidden' : 'block'}`}>Main Menu</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setNavCollapsed(c => !c)}
|
onClick={() => setNavCollapsed(c => !c)}
|
||||||
title={navCollapsed ? 'Expand menu' : 'Collapse menu'}
|
title={navCollapsed ? 'Expand menu' : 'Collapse menu'}
|
||||||
className="p-1.5 rounded-md text-slate-400 hover:text-emerald-400 hover:bg-slate-900 transition-all hover:cursor-pointer"
|
className="p-1.5 rounded-md text-fg-muted hover:text-success hover:bg-inner transition-all hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
{navCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
|
{navCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-4">
|
<nav className="space-y-4">
|
||||||
{navigationGroups.map((group, gi) => (
|
{navigationGroups.filter(g => g.label !== 'System' || currentUser.role.toLowerCase() === 'admin').map((group, gi) => (
|
||||||
<div key={gi} className="space-y-1">
|
<div key={gi} className="space-y-1">
|
||||||
{group.label && (
|
{group.label && (
|
||||||
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-slate-600 px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
|
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-fg-faint px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
|
||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Thin divider stands in for the group label when collapsed */}
|
{/* Thin divider stands in for the group label when collapsed */}
|
||||||
{group.label && navCollapsed && <div className="hidden md:block h-px bg-slate-850 mx-2" />}
|
{group.label && navCollapsed && <div className="hidden md:block h-px bg-line mx-2" />}
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isActive = activeTab === item.id;
|
const isActive = activeTab === item.id;
|
||||||
return (
|
return (
|
||||||
@ -526,8 +506,8 @@ export default function App() {
|
|||||||
title={navCollapsed ? item.label : undefined}
|
title={navCollapsed ? item.label : undefined}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-xs font-semibold font-sans tracking-wide transition-all ${navCollapsed ? 'md:justify-center' : ''} ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-xs font-semibold font-sans tracking-wide transition-all ${navCollapsed ? 'md:justify-center' : ''} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gradient-to-r from-emerald-500/10 to-teal-500/5 border-l-4 border-emerald-500 text-white shadow-[inset_1px_0_10px_rgba(16,185,129,0.03)]'
|
? 'bg-success-soft border-l-2 border-success text-fg'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-900'
|
: 'text-fg-muted hover:text-fg hover:bg-inner'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
@ -540,15 +520,15 @@ export default function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`bg-slate-950 p-4 border border-slate-900 rounded-xl space-y-2 mt-8 ${navCollapsed ? 'hidden' : 'hidden md:block'}`}>
|
<div className={`bg-inner p-4 border border-line rounded-xl space-y-2 mt-8 ${navCollapsed ? 'hidden' : 'hidden md:block'}`}>
|
||||||
<h4 className="text-[10px] text-emerald-400 font-mono font-bold">Overall Status</h4>
|
<h4 className="text-[10px] text-success font-mono font-bold">Overall Status</h4>
|
||||||
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
|
<div className="text-[11px] text-fg-muted 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-success 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-primary 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.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
|
<div>Online: <span className="text-fg font-semibold font-mono">{devices.filter(d => d.status === 'online').length}<span className="text-fg-faint">/{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-fg 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 mt-2" /></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 p-6 md:p-8 overflow-y-auto" id="main-content-display">
|
<main className="flex-1 p-6 md:p-8 overflow-y-auto" id="main-content-display">
|
||||||
@ -558,6 +538,7 @@ export default function App() {
|
|||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
users={users}
|
||||||
links={links}
|
links={links}
|
||||||
onCancelBooking={handleCancelBooking}
|
onCancelBooking={handleCancelBooking}
|
||||||
onDeleteBooking={handleDeleteBooking}
|
onDeleteBooking={handleDeleteBooking}
|
||||||
@ -574,7 +555,8 @@ export default function App() {
|
|||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
checkmkEnabled={checkmkEnabled}
|
users={users}
|
||||||
|
cmkEnabled={cmkEnabled}
|
||||||
onAddBooking={handleAddBooking}
|
onAddBooking={handleAddBooking}
|
||||||
onCancelBooking={handleCancelBooking}
|
onCancelBooking={handleCancelBooking}
|
||||||
onDeleteBooking={handleDeleteBooking}
|
onDeleteBooking={handleDeleteBooking}
|
||||||
@ -584,8 +566,8 @@ export default function App() {
|
|||||||
{activeTab === 'devices' && (
|
{activeTab === 'devices' && (
|
||||||
<DeviceInventory
|
<DeviceInventory
|
||||||
devices={devices}
|
devices={devices}
|
||||||
checkmkEnabled={checkmkEnabled}
|
cmkEnabled={cmkEnabled}
|
||||||
checkmkBaseUrl={checkmkBaseUrl}
|
cmkBaseUrl={cmkBaseUrl}
|
||||||
onAddDevice={handleAddDevice}
|
onAddDevice={handleAddDevice}
|
||||||
onUpdateDevice={handleUpdateDevice}
|
onUpdateDevice={handleUpdateDevice}
|
||||||
onDeleteDevice={handleDeleteDevice}
|
onDeleteDevice={handleDeleteDevice}
|
||||||
@ -595,6 +577,8 @@ export default function App() {
|
|||||||
<LabTemplates
|
<LabTemplates
|
||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
currentUser={currentUser!}
|
||||||
|
semaphoreEnabled={semaphoreEnabled}
|
||||||
onAddLab={handleAddLab}
|
onAddLab={handleAddLab}
|
||||||
onUpdateLab={handleUpdateLab}
|
onUpdateLab={handleUpdateLab}
|
||||||
onDeleteLab={handleDeleteLab}
|
onDeleteLab={handleDeleteLab}
|
||||||
@ -617,6 +601,7 @@ export default function App() {
|
|||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
onDeleteUser={handleDeleteUser}
|
onDeleteUser={handleDeleteUser}
|
||||||
onUpdateUser={handleUpdateUser}
|
onUpdateUser={handleUpdateUser}
|
||||||
|
onSetRole={handleSetUserRole}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
@ -628,7 +613,7 @@ export default function App() {
|
|||||||
onAddLog={handleAddLogManually}
|
onAddLog={handleAddLogManually}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && currentUser.role.toLowerCase() === 'admin' && (
|
||||||
<Settings currentUser={currentUser} />
|
<Settings currentUser={currentUser} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { Booking, LabTemplate, Device, User } from '../types';
|
import { Booking, LabTemplate, Device, User } from '../types';
|
||||||
import {
|
import {
|
||||||
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
|
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
|
||||||
@ -17,7 +17,8 @@ interface BookingCalendarProps {
|
|||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
checkmkEnabled: boolean;
|
users: User[];
|
||||||
|
cmkEnabled: 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;
|
||||||
@ -68,12 +69,19 @@ const TIME_OPTIONS = ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:
|
|||||||
|
|
||||||
// ── component ──────────────────────────────────────────────────────────────
|
// ── component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export default function BookingCalendar({
|
export default function BookingCalendar({
|
||||||
bookings,
|
bookings,
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
currentUser,
|
currentUser,
|
||||||
checkmkEnabled,
|
users,
|
||||||
|
cmkEnabled,
|
||||||
onAddBooking,
|
onAddBooking,
|
||||||
onCancelBooking,
|
onCancelBooking,
|
||||||
onDeleteBooking,
|
onDeleteBooking,
|
||||||
@ -172,11 +180,23 @@ export default function BookingCalendar({
|
|||||||
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
|
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
|
||||||
}, [quickDuration]);
|
}, [quickDuration]);
|
||||||
|
|
||||||
|
const bookableLabs = useMemo(() => labs.filter(l =>
|
||||||
|
l.scope === 'global' ||
|
||||||
|
l.ownerId === currentUser.id ||
|
||||||
|
currentUser.role?.toLowerCase() === 'admin'
|
||||||
|
), [labs, currentUser.id, currentUser.role]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedLabId && !bookableLabs.find(l => l.id === selectedLabId)) {
|
||||||
|
setSelectedLabId(bookableLabs[0]?.id || '');
|
||||||
|
}
|
||||||
|
}, [bookableLabs]);
|
||||||
|
|
||||||
// A lab is quick-bookable when every device is free (regardless of online status).
|
// A lab is quick-bookable when every device is free (regardless of online status).
|
||||||
const availableLabs = useMemo(() => labs.filter(lab =>
|
const availableLabs = useMemo(() => bookableLabs.filter(lab =>
|
||||||
lab.deviceIds.length > 0 &&
|
lab.deviceIds.length > 0 &&
|
||||||
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
|
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
|
||||||
), [labs, devices, bookings, quickWindow]);
|
), [bookableLabs, devices, bookings, quickWindow]);
|
||||||
|
|
||||||
const availableDevices = useMemo(() => devices.filter(dev =>
|
const availableDevices = useMemo(() => devices.filter(dev =>
|
||||||
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
|
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
|
||||||
@ -217,10 +237,8 @@ export default function BookingCalendar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickBookDevice = (device: Device) => {
|
const handleQuickBookDevice = (device: Device) => {
|
||||||
// Find or pick a lab that contains this device; fall back to device ID as labId marker
|
|
||||||
const hostLab = labs.find(l => l.deviceIds.includes(device.id));
|
|
||||||
onAddBooking({
|
onAddBooking({
|
||||||
labId: hostLab?.id ?? `device:${device.id}`,
|
labId: `device:${device.id}`,
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
startDateTime: toLocalISO(quickWindow.start),
|
startDateTime: toLocalISO(quickWindow.start),
|
||||||
endDateTime: toLocalISO(quickWindow.end),
|
endDateTime: toLocalISO(quickWindow.end),
|
||||||
@ -238,23 +256,23 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
{/* ── Quick Booking Modal ── */}
|
{/* ── Quick Booking Modal ── */}
|
||||||
{showQuickPanel && (
|
{showQuickPanel && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-overlay backdrop-blur-sm">
|
||||||
<div className="w-full max-w-lg bg-[#0F172A] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
|
<div className="w-full max-w-lg bg-card border border-line-strong rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-line">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
|
<Zap className="w-4 h-4 text-success fill-success/30" />
|
||||||
<h3 className="font-bold text-sm text-white font-sans">Quick Booking</h3>
|
<h3 className="font-bold text-sm text-fg font-sans">Quick Booking</h3>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowQuickPanel(false)} className="text-slate-400 hover:text-white transition-colors">
|
<button onClick={() => setShowQuickPanel(false)} className="text-fg-muted hover:text-fg transition-colors">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration Selector */}
|
{/* Duration Selector */}
|
||||||
<div className="px-5 pt-4 space-y-1">
|
<div className="px-5 pt-4 space-y-1">
|
||||||
<p className="text-[11px] text-slate-400 font-sans">Duration starting now:</p>
|
<p className="text-[11px] text-fg-muted font-sans">Duration starting now:</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[1, 2, 4, 8].map(h => (
|
{[1, 2, 4, 8].map(h => (
|
||||||
<button
|
<button
|
||||||
@ -263,15 +281,15 @@ export default function BookingCalendar({
|
|||||||
className={`flex-1 py-2 rounded-lg text-xs font-semibold font-sans border transition-all ${
|
className={`flex-1 py-2 rounded-lg text-xs font-semibold font-sans border transition-all ${
|
||||||
quickDuration === h
|
quickDuration === h
|
||||||
? 'bg-emerald-600 border-emerald-500 text-white'
|
? 'bg-emerald-600 border-emerald-500 text-white'
|
||||||
: 'bg-slate-900 border-slate-800 text-slate-300 hover:border-emerald-700 hover:text-emerald-400'
|
: 'bg-inner border-line text-fg-muted hover:border-success hover:text-success'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{h}h
|
{h}h
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 font-mono">
|
<p className="text-[10px] text-fg-faint 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>
|
||||||
@ -281,7 +299,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setQuickTab('labs')}
|
onClick={() => setQuickTab('labs')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
||||||
quickTab === 'labs' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
|
quickTab === 'labs' ? 'bg-success-soft text-success border border-success-line' : 'text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Layers className="w-3.5 h-3.5" /> Lab Topologies ({availableLabs.length} available)
|
<Layers className="w-3.5 h-3.5" /> Lab Topologies ({availableLabs.length} available)
|
||||||
@ -289,7 +307,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setQuickTab('devices')}
|
onClick={() => setQuickTab('devices')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
||||||
quickTab === 'devices' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
|
quickTab === 'devices' ? 'bg-success-soft text-success border border-success-line' : 'text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="w-3.5 h-3.5" /> Individual Devices ({availableDevices.length} free)
|
<Server className="w-3.5 h-3.5" /> Individual Devices ({availableDevices.length} free)
|
||||||
@ -300,19 +318,19 @@ 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 for {quickDuration}h right now. All slots leased.</p>
|
<p className="text-xs text-fg-muted 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;
|
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-inner border border-line rounded-lg hover:border-success-line 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-fg 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-fg-muted 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-fg-faint truncate mt-0.5">{labDevices.map(d => d.hostname).join(', ')}</p>
|
||||||
{offlineCount > 0 && (
|
{offlineCount > 0 && (
|
||||||
<p className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono mt-0.5">
|
<p className="flex items-center gap-0.5 text-[9px] text-warning font-mono mt-0.5">
|
||||||
<AlertTriangle className="w-2.5 h-2.5 shrink-0" />{offlineCount} device{offlineCount !== 1 ? 's' : ''} not reachable
|
<AlertTriangle className="w-2.5 h-2.5 shrink-0" />{offlineCount} device{offlineCount !== 1 ? 's' : ''} not reachable
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -334,20 +352,20 @@ export default function BookingCalendar({
|
|||||||
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
|
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
|
||||||
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 ${
|
||||||
free ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60'
|
free ? 'bg-inner border-line hover:border-success-line' : 'bg-surface border-line 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-success' : status === 'offline' ? 'bg-rose' : 'bg-fg-faint'}`} />
|
||||||
<p className="text-xs font-bold text-white font-mono">{device.hostname}</p>
|
<p className="text-xs font-bold text-fg font-mono">{device.hostname}</p>
|
||||||
{!online && free && (
|
{!online && free && (
|
||||||
<span className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono" title="Not reachable in CheckMK">
|
<span className="flex items-center gap-0.5 text-[9px] text-warning font-mono" title="Not reachable in CheckMK">
|
||||||
<AlertTriangle className="w-2.5 h-2.5" />{status}
|
<AlertTriangle className="w-2.5 h-2.5" />{status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
|
<p className="text-[10px] text-fg-muted font-mono">{device.type} · {device.ip}</p>
|
||||||
<p className="text-[9px] text-slate-500">{device.location}</p>
|
<p className="text-[9px] text-fg-faint">{device.location}</p>
|
||||||
</div>
|
</div>
|
||||||
{free ? (
|
{free ? (
|
||||||
<button
|
<button
|
||||||
@ -357,7 +375,7 @@ export default function BookingCalendar({
|
|||||||
Book
|
Book
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span>
|
<span className="shrink-0 text-[10px] text-rose font-mono font-semibold">Busy</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -369,32 +387,32 @@ export default function BookingCalendar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── LEFT: Visual Schedule Grid ── */}
|
{/* ── LEFT: Visual Schedule Grid ── */}
|
||||||
<div className="lg:col-span-8 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="timeline-card">
|
<div className="lg:col-span-8 bg-card border border-line rounded-xl p-5 flex flex-col h-full font-sans" id="timeline-card">
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2">
|
||||||
<Calendar className="text-emerald-400 w-5 h-5" />
|
<Calendar className="text-success w-5 h-5" />
|
||||||
Bookings
|
Bookings
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">Who has which box, and until when. mutex for hardware, basically.</p>
|
<p className="text-xs text-fg-muted">Who has which box, and until when. mutex for hardware, basically.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day navigation */}
|
{/* Day navigation */}
|
||||||
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-lg border border-slate-805 shrink-0">
|
<div className="flex items-center gap-1 bg-inner p-1 rounded-lg border border-line shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDayOffset(dayOffset - 1)}
|
onClick={() => setDayOffset(dayOffset - 1)}
|
||||||
disabled={dayOffset <= -30}
|
disabled={dayOffset <= -30}
|
||||||
className="p-1 px-1.5 bg-slate-900 border border-slate-800 text-slate-350 hover:text-white rounded disabled:opacity-30 transition-opacity"
|
className="p-1 px-1.5 bg-card border border-line text-fg-muted hover:text-fg rounded disabled:opacity-30 transition-opacity"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-3.5 h-3.5" />
|
<ChevronLeft className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="text-xs font-semibold px-2.5 text-center text-slate-200 min-w-[130px] font-mono select-none">
|
<div className="text-xs font-semibold px-2.5 text-center text-fg min-w-[130px] font-mono select-none">
|
||||||
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
|
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDayOffset(dayOffset + 1)}
|
onClick={() => setDayOffset(dayOffset + 1)}
|
||||||
className="p-1 px-1.5 bg-slate-900 border border-slate-800 text-slate-350 hover:text-white rounded transition-opacity"
|
className="p-1 px-1.5 bg-card border border-line text-fg-muted hover:text-fg rounded transition-opacity"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -402,32 +420,32 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matrix Grid */}
|
{/* Matrix Grid */}
|
||||||
<div className="flex-1 overflow-x-auto rounded-lg border border-slate-850 p-1 bg-slate-950/40">
|
<div className="flex-1 overflow-x-auto rounded-lg border border-line p-1 bg-inner">
|
||||||
<div style={{ minWidth: '860px' }}>
|
<div style={{ minWidth: '860px' }}>
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div
|
<div
|
||||||
className="border-b border-slate-800 pb-1"
|
className="border-b border-line pb-1"
|
||||||
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
||||||
>
|
>
|
||||||
<div className="text-left pl-3 text-[10px] text-slate-400 font-sans font-bold self-center">Device</div>
|
<div className="text-left pl-3 text-[10px] text-fg-muted font-sans font-bold self-center">Device</div>
|
||||||
{TIME_SLOTS.map((slot, i) => (
|
{TIME_SLOTS.map((slot, i) => (
|
||||||
<div key={i} className="text-center py-1 border-l border-slate-855">
|
<div key={i} className="text-center py-1 border-l border-line">
|
||||||
<span className="text-[9px] font-mono text-slate-300 leading-none">{slot.label}</span>
|
<span className="text-[9px] font-mono text-fg-muted leading-none">{slot.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Device rows */}
|
{/* Device rows */}
|
||||||
<div className="divide-y divide-slate-850 max-h-[460px] overflow-y-auto">
|
<div className="divide-y divide-line max-h-[460px] overflow-y-auto">
|
||||||
{devices.map((device) => (
|
{devices.map((device) => (
|
||||||
<div
|
<div
|
||||||
key={device.id}
|
key={device.id}
|
||||||
className="items-center group hover:bg-slate-900/35"
|
className="items-center group hover:bg-card"
|
||||||
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
||||||
>
|
>
|
||||||
<div className="pl-3 py-2 text-left">
|
<div className="pl-3 py-2 text-left">
|
||||||
<p className="font-mono font-bold text-[11px] text-white group-hover:text-emerald-400 transition-colors leading-none truncate">{device.hostname}</p>
|
<p className="font-mono font-bold text-[11px] text-fg group-hover:text-success transition-colors leading-none truncate">{device.hostname}</p>
|
||||||
<p className="text-[9px] font-mono text-slate-500 mt-0.5 leading-none">{device.type}</p>
|
<p className="text-[9px] font-mono text-fg-faint mt-0.5 leading-none">{device.type}</p>
|
||||||
</div>
|
</div>
|
||||||
{TIME_SLOTS.map((slot, sIdx) => {
|
{TIME_SLOTS.map((slot, sIdx) => {
|
||||||
const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end);
|
const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end);
|
||||||
@ -436,8 +454,8 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
if (!cur) {
|
if (!cur) {
|
||||||
return (
|
return (
|
||||||
<div key={sIdx} className="px-0.5 py-1.5 h-10 flex items-center border-l border-slate-850">
|
<div key={sIdx} className="px-0.5 py-1.5 h-10 flex items-center border-l border-line">
|
||||||
<div className="w-full h-full rounded border border-dashed border-slate-800/40 hover:border-slate-700/60 transition-all" />
|
<div className="w-full h-full rounded border border-dashed border-line hover:border-line-strong transition-all" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -452,24 +470,31 @@ export default function BookingCalendar({
|
|||||||
: isLast ? 'rounded-r'
|
: isLast ? 'rounded-r'
|
||||||
: '';
|
: '';
|
||||||
const borderCls = isMe
|
const borderCls = isMe
|
||||||
? `bg-emerald-500/30 border-emerald-500/60 hover:bg-emerald-500/45 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`
|
? `bg-success/30 border-success/60 hover:bg-success/45 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`
|
||||||
: `bg-indigo-500/25 border-indigo-500/50 hover:bg-indigo-500/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`;
|
: `bg-primary/25 border-primary/50 hover:bg-primary/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sIdx}
|
key={sIdx}
|
||||||
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-slate-850`}
|
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-line`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelectBookingDetails(cur)}
|
onClick={() => onSelectBookingDetails(cur)}
|
||||||
title={`${slot.label} - ${TIME_SLOTS[sIdx + 1]?.label ?? '20:00'}\n${lab?.name ?? 'Device booking'}\n${isMe ? 'My booking' : 'Colleague'}`}
|
title={`${slot.label} - ${TIME_SLOTS[sIdx + 1]?.label ?? '20:00'}\n${lab?.name ?? 'Device booking'}\n${isMe ? 'My booking' : 'Colleague'}`}
|
||||||
className={`w-full h-full cursor-pointer transition-all overflow-hidden flex flex-col justify-center ${radius} ${borderCls}`}
|
className={`w-full h-full cursor-pointer transition-all overflow-hidden flex flex-col justify-center ${radius} ${borderCls}`}
|
||||||
>
|
>
|
||||||
{isFirst && (
|
{isFirst && (() => {
|
||||||
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-white/90">
|
const booker = users.find(u => u.id === cur.userId);
|
||||||
{lab?.name ?? 'Device'}
|
const name = booker?.name ?? '';
|
||||||
</span>
|
const label = (isFirst && isLast)
|
||||||
)}
|
? initials(name)
|
||||||
|
: name.split(' ')[0];
|
||||||
|
return (
|
||||||
|
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-fg">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -481,11 +506,11 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="mt-4 pt-4 border-t border-slate-855 flex items-center justify-between text-[11px] font-sans text-slate-400">
|
<div className="mt-4 pt-4 border-t border-line flex items-center justify-between text-[11px] font-sans text-fg-muted">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-emerald-500/30 border border-emerald-500/60" /> My Booking</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-success/30 border border-success/60" /> My Booking</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-indigo-500/25 border border-indigo-500/50" /> Colleague's Allocation</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-primary/25 border border-primary/50" /> Colleague's Allocation</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded border border-dashed border-slate-800/40" /> Available</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded border border-dashed border-line" /> Available</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="italic">Double-bookings get rejected at commit time. no race conditions on your watch.</p>
|
<p className="italic">Double-bookings get rejected at commit time. no race conditions on your watch.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -495,13 +520,13 @@ export default function BookingCalendar({
|
|||||||
<div className="lg:col-span-4 space-y-6" id="booking-actions-card">
|
<div className="lg:col-span-4 space-y-6" id="booking-actions-card">
|
||||||
|
|
||||||
{/* Quick Booking Trigger */}
|
{/* Quick Booking Trigger */}
|
||||||
<div className="bg-[#1D2535] border border-emerald-900/30 rounded-xl p-5 shadow-sm relative overflow-hidden">
|
<div className="bg-success-soft border border-success-line rounded-xl p-5 shadow-sm relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
|
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5 mb-1.5 font-sans">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-1.5 mb-1.5 font-sans">
|
||||||
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
|
<Zap className="w-4 h-4 text-success fill-success/30" />
|
||||||
Quick Booking
|
Quick Booking
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-slate-400 leading-relaxed font-sans mb-4">
|
<p className="text-[11px] text-fg-muted leading-relaxed font-sans mb-4">
|
||||||
Pick a duration, then grab a free lab or a single box from the live list. ttl-based hardware leasing, basically.
|
Pick a duration, then grab a free lab or a single box from the live list. ttl-based hardware leasing, basically.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -510,7 +535,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
key={h}
|
key={h}
|
||||||
onClick={() => { setQuickDuration(h); setShowQuickPanel(true); }}
|
onClick={() => { setQuickDuration(h); setShowQuickPanel(true); }}
|
||||||
className="py-2.5 bg-slate-900 border border-slate-800 hover:border-emerald-500 hover:bg-slate-900 text-slate-200 hover:text-emerald-400 font-sans font-semibold text-xs rounded-lg transition-all"
|
className="py-2.5 bg-card border border-line hover:border-success text-fg-muted hover:text-success font-sans font-semibold text-xs rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
{h}h
|
{h}h
|
||||||
</button>
|
</button>
|
||||||
@ -519,37 +544,37 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowQuickPanel(true)}
|
onClick={() => setShowQuickPanel(true)}
|
||||||
className="w-full py-2 bg-emerald-600/20 hover:bg-emerald-600/30 border border-emerald-800/50 text-emerald-400 font-semibold text-xs rounded-lg flex items-center justify-center gap-1.5 transition-all"
|
className="w-full py-2 bg-success/15 hover:bg-success/25 border border-success-line text-success font-semibold text-xs rounded-lg flex items-center justify-center gap-1.5 transition-all"
|
||||||
>
|
>
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
Show Available Now
|
Show Available Now
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-3 text-[10px] text-slate-500 font-mono">
|
<div className="mt-3 flex items-center gap-3 text-[10px] text-fg-faint font-mono">
|
||||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />{availableLabs.length} labs free</span>
|
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-success" />{availableLabs.length} labs free</span>
|
||||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-cyan-400" />{availableDevices.length} devices free</span>
|
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-info" />{availableDevices.length} devices free</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Standard Booking Form */}
|
{/* Standard Booking Form */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3">
|
||||||
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
|
<Calendar className="w-4.5 h-4.5 text-primary" />
|
||||||
Reserve Slot
|
Reserve Slot
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleCreateBooking} className="space-y-4 text-xs">
|
<form onSubmit={handleCreateBooking} className="space-y-4 text-xs">
|
||||||
{/* Resource type toggle: whole lab topology or a single device */}
|
{/* Resource type toggle: whole lab topology or a single device */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Reserve</label>
|
<label className="block text-fg-muted font-semibold mb-1">Reserve</label>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setResourceType('lab')}
|
onClick={() => setResourceType('lab')}
|
||||||
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
||||||
resourceType === 'lab'
|
resourceType === 'lab'
|
||||||
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
|
? 'bg-primary-soft border-primary text-primary'
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Layers className="w-3.5 h-3.5" /> Topology
|
<Layers className="w-3.5 h-3.5" /> Topology
|
||||||
@ -559,8 +584,8 @@ export default function BookingCalendar({
|
|||||||
onClick={() => setResourceType('device')}
|
onClick={() => setResourceType('device')}
|
||||||
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
||||||
resourceType === 'device'
|
resourceType === 'device'
|
||||||
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
|
? 'bg-primary-soft border-primary text-primary'
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="w-3.5 h-3.5" /> Single Device
|
<Server className="w-3.5 h-3.5" /> Single Device
|
||||||
@ -570,24 +595,35 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
{resourceType === 'lab' ? (
|
{resourceType === 'lab' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Topology</label>
|
<label className="block text-fg-muted font-semibold mb-1">Topology</label>
|
||||||
<select
|
<select
|
||||||
value={selectedLabId}
|
value={selectedLabId}
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary"
|
||||||
>
|
>
|
||||||
{labs.map((l) => (
|
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
|
||||||
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
<optgroup label="Global Topologies">
|
||||||
))}
|
{bookableLabs.filter(l => l.scope === 'global').map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{bookableLabs.filter(l => l.scope === 'personal').length > 0 && (
|
||||||
|
<optgroup label="My Personal Topologies">
|
||||||
|
{bookableLabs.filter(l => l.scope === 'personal').map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Device</label>
|
<label className="block text-fg-muted font-semibold mb-1">Device</label>
|
||||||
<select
|
<select
|
||||||
value={selectedDeviceId}
|
value={selectedDeviceId}
|
||||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
onChange={(e) => setSelectedDeviceId(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 font-mono"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
|
||||||
>
|
>
|
||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
<option key={d.id} value={d.id}>
|
<option key={d.id} value={d.id}>
|
||||||
@ -600,7 +636,7 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Start Date</label>
|
<label className="block text-fg-muted font-semibold mb-1">Start Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
@ -613,38 +649,38 @@ export default function BookingCalendar({
|
|||||||
setDayOffset(Math.round((sel - today) / 86_400_000));
|
setDayOffset(Math.round((sel - today) / 86_400_000));
|
||||||
if (e.target.value > endDate) setEndDate(e.target.value);
|
if (e.target.value > endDate) setEndDate(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 font-mono text-xs"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">End Date</label>
|
<label className="block text-fg-muted font-semibold mb-1">End Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
min={startDate}
|
min={startDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(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 font-mono text-xs"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Start</label>
|
<label className="block text-fg-muted font-semibold mb-1">Start</label>
|
||||||
<select
|
<select
|
||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
onChange={(e) => setStartTime(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 font-mono"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
|
||||||
>
|
>
|
||||||
{TIME_OPTIONS.slice(0, -1).map(t => <option key={t} value={t}>{t}</option>)}
|
{TIME_OPTIONS.slice(0, -1).map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">End</label>
|
<label className="block text-fg-muted font-semibold mb-1">End</label>
|
||||||
<select
|
<select
|
||||||
value={endTime}
|
value={endTime}
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
onChange={(e) => setEndTime(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 font-mono"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
|
||||||
>
|
>
|
||||||
{TIME_OPTIONS.slice(1).map(t => <option key={t} value={t}>{t}</option>)}
|
{TIME_OPTIONS.slice(1).map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@ -652,14 +688,13 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Notes / Objective</label>
|
<label className="block text-fg-muted font-semibold mb-1">Notes / Objective</label>
|
||||||
<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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -670,16 +705,16 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
if (conflict.hasConflict) {
|
if (conflict.hasConflict) {
|
||||||
return (
|
return (
|
||||||
<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-soft p-2.5 rounded border border-rose-line flex gap-2 text-rose text-[11px] leading-normal">
|
||||||
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
|
<AlertCircle className="w-4 h-4 text-rose shrink-0" />
|
||||||
<span>{conflict.message}</span>
|
<span>{conflict.message}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (checkmkEnabled && offline.length > 0) {
|
if (cmkEnabled && offline.length > 0) {
|
||||||
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">
|
<div className="bg-warning-soft p-2.5 rounded border border-warning-line flex gap-2 text-warning text-[11px] leading-normal">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Warning – {offline.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {offline.length === 1 ? 'is' : 'are'} not reachable in CheckMK. Booking will still be created.
|
Warning – {offline.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {offline.length === 1 ? 'is' : 'are'} not reachable in CheckMK. Booking will still be created.
|
||||||
</span>
|
</span>
|
||||||
@ -687,8 +722,8 @@ export default function BookingCalendar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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-success-soft p-2.5 rounded border border-success-line flex gap-2 text-success text-[11px] leading-normal">
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
|
||||||
<span>Timeframe is available.</span>
|
<span>Timeframe is available.</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -702,7 +737,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs transition-colors hover:cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-600"
|
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs transition-colors hover:cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-600"
|
||||||
>
|
>
|
||||||
Confirm Reservation
|
Confirm Reservation
|
||||||
</button>
|
</button>
|
||||||
@ -713,7 +748,7 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Reservation Table ── */}
|
{/* ── Reservation Table ── */}
|
||||||
<div className="lg:col-span-12 bg-[#1E293B] border border-slate-800 rounded-xl p-5 sm:p-6 font-sans shadow-sm" id="reservation-registry-card">
|
<div className="lg:col-span-12 bg-card border border-line rounded-xl p-5 sm:p-6 font-sans shadow-sm" id="reservation-registry-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowReservations(s => !s)}
|
onClick={() => setShowReservations(s => !s)}
|
||||||
@ -721,26 +756,26 @@ export default function BookingCalendar({
|
|||||||
aria-expanded={showReservations}
|
aria-expanded={showReservations}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${showReservations ? '' : '-rotate-90'}`} />
|
<ChevronDown className={`w-4 h-4 text-fg-muted transition-transform ${showReservations ? '' : '-rotate-90'}`} />
|
||||||
<Database className="w-4 h-4 text-emerald-400" />
|
<Database className="w-4 h-4 text-success" />
|
||||||
Reservations
|
Reservations
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
|
<p className="text-xs text-fg-muted pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] bg-slate-950 px-2.5 py-1 rounded font-mono font-bold text-slate-300 border border-slate-800">
|
<span className="text-[10px] bg-inner px-2.5 py-1 rounded font-mono font-bold text-fg-muted border border-line">
|
||||||
DATABASE SELECT: {bookings.length} RECORDS
|
DATABASE SELECT: {bookings.length} RECORDS
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!showReservations ? null : bookings.length === 0 ? (
|
{!showReservations ? null : bookings.length === 0 ? (
|
||||||
<p className="mt-4 text-slate-500 text-xs text-center py-6 italic border border-dashed border-slate-800 rounded-lg">
|
<p className="mt-4 text-fg-faint text-xs text-center py-6 italic border border-dashed border-line rounded-lg">
|
||||||
No active reservation structures currently exist inside the database.
|
No active reservation structures currently exist inside the database.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/10">
|
<div className="mt-4 overflow-x-auto rounded-lg border border-line bg-inner">
|
||||||
<table className="w-full text-xs text-left text-slate-300 divide-y divide-slate-800">
|
<table className="w-full text-xs text-left text-fg-muted divide-y divide-line">
|
||||||
<thead className="bg-[#0f172a]/60 text-slate-400 font-mono text-[10px] uppercase tracking-wider">
|
<thead className="bg-inner text-fg-muted font-mono text-[10px] uppercase tracking-wider">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">ID</th>
|
<th className="px-4 py-3">ID</th>
|
||||||
<th className="px-4 py-3">Topology / Resource</th>
|
<th className="px-4 py-3">Topology / Resource</th>
|
||||||
@ -750,7 +785,7 @@ export default function BookingCalendar({
|
|||||||
<th className="px-4 py-3 text-right font-sans">Actions</th>
|
<th className="px-4 py-3 text-right font-sans">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800 bg-slate-950/10 font-sans">
|
<tbody className="divide-y divide-line bg-card font-sans">
|
||||||
{bookings.map((b) => {
|
{bookings.map((b) => {
|
||||||
const lab = labs.find(l => l.id === b.labId);
|
const lab = labs.find(l => l.id === b.labId);
|
||||||
const isDeviceBooking = b.labId?.startsWith('device:');
|
const isDeviceBooking = b.labId?.startsWith('device:');
|
||||||
@ -760,35 +795,35 @@ export default function BookingCalendar({
|
|||||||
const tStart = new Date(b.startDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const tStart = new Date(b.startDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
const tEnd = new Date(b.endDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const tEnd = new Date(b.endDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
return (
|
return (
|
||||||
<tr key={b.id} className="hover:bg-slate-900/40 transition">
|
<tr key={b.id} className="hover:bg-inner transition">
|
||||||
<td className="px-4 py-3.5 font-mono font-bold text-emerald-500/80">#{b.id.slice(-8)}</td>
|
<td className="px-4 py-3.5 font-mono font-bold text-success">#{b.id.slice(-8)}</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className="text-slate-100 font-semibold block">{isDeviceBooking ? (device?.hostname ?? deviceId) : (lab?.name ?? 'Unknown')}</span>
|
<span className="text-fg font-semibold block">{isDeviceBooking ? (device?.hostname ?? deviceId) : (lab?.name ?? 'Unknown')}</span>
|
||||||
<span className="text-[10px] text-slate-400 font-mono">{isDeviceBooking ? 'Device booking' : (lab?.location ?? 'Staging')}</span>
|
<span className="text-[10px] text-fg-muted font-mono">{isDeviceBooking ? 'Device booking' : (lab?.location ?? 'Staging')}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5 font-mono">
|
<td className="px-4 py-3.5 font-mono">
|
||||||
<span className="block text-slate-200">{day}</span>
|
<span className="block text-fg">{day}</span>
|
||||||
<span className="text-[10px] text-slate-400">{tStart} - {tEnd}</span>
|
<span className="text-[10px] text-fg-muted">{tStart} - {tEnd}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold border capitalize ${
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold border capitalize ${
|
||||||
b.status === 'active' ? 'bg-emerald-950/60 border-emerald-500/30 text-emerald-400' :
|
b.status === 'active' ? 'bg-success-soft border-success-line text-success' :
|
||||||
b.status === 'upcoming' ? 'bg-indigo-950/60 border-indigo-500/30 text-indigo-400' :
|
b.status === 'upcoming' ? 'bg-primary-soft border-primary-line text-primary' :
|
||||||
b.status === 'completed' ? 'bg-slate-900 border-slate-800 text-slate-400' :
|
b.status === 'completed' ? 'bg-inner border-line text-fg-muted' :
|
||||||
'bg-rose-950/60 border-rose-500/20 text-rose-400 font-bold'
|
'bg-rose-soft border-rose-line text-rose font-bold'
|
||||||
}`}>{b.status}</span>
|
}`}>{b.status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5 text-slate-400 max-w-[150px] truncate">{b.notes || '-'}</td>
|
<td className="px-4 py-3.5 text-fg-muted max-w-[150px] truncate">{b.notes || '-'}</td>
|
||||||
<td className="px-4 py-3.5 text-right space-x-1 whitespace-nowrap">
|
<td className="px-4 py-3.5 text-right space-x-1 whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectBookingDetails(b)}
|
onClick={() => onSelectBookingDetails(b)}
|
||||||
className="px-2.5 py-1.5 bg-slate-900 border border-slate-800 hover:border-slate-700 text-cyan-400 hover:text-cyan-300 rounded text-[11px] font-semibold cursor-pointer"
|
className="px-2.5 py-1.5 bg-inner border border-line hover:border-line-strong text-info hover:opacity-80 rounded text-[11px] font-semibold cursor-pointer"
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (confirm(`Delete reservation #${b.id.slice(-8)}?`)) onDeleteBooking(b.id); }}
|
onClick={() => { if (confirm(`Delete reservation #${b.id.slice(-8)}?`)) onDeleteBooking(b.id); }}
|
||||||
className="px-2.5 py-1.5 text-[11px] bg-rose-950/20 hover:bg-rose-900/30 text-rose-400 hover:text-rose-300 rounded transition cursor-pointer"
|
className="px-2.5 py-1.5 text-[11px] bg-rose-soft hover:opacity-80 text-rose rounded transition cursor-pointer"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -47,10 +47,10 @@ export default function BookingDetailsModal({
|
|||||||
const [localTeardownTriggered, setLocalTeardownTriggered] = useState(false);
|
const [localTeardownTriggered, setLocalTeardownTriggered] = useState(false);
|
||||||
const [localTeardownJobId, setLocalTeardownJobId] = useState('');
|
const [localTeardownJobId, setLocalTeardownJobId] = useState('');
|
||||||
|
|
||||||
const setupTriggered = booking.ansibleSetupTriggered || localSetupTriggered;
|
const setupTriggered = booking.semaphoreSetupTriggered || localSetupTriggered;
|
||||||
const setupJobId = booking.ansibleSetupJobId || localSetupJobId;
|
const setupJobId = booking.semaphoreSetupJobId || localSetupJobId;
|
||||||
const teardownTriggered = booking.ansibleTeardownTriggered || localTeardownTriggered;
|
const teardownTriggered = booking.semaphoreTeardownTriggered || localTeardownTriggered;
|
||||||
const teardownJobId = booking.ansibleTeardownJobId || localTeardownJobId;
|
const teardownJobId = booking.semaphoreTeardownJobId || localTeardownJobId;
|
||||||
|
|
||||||
async function manualTrigger(type: 'setup' | 'teardown') {
|
async function manualTrigger(type: 'setup' | 'teardown') {
|
||||||
setTriggering(true);
|
setTriggering(true);
|
||||||
@ -118,26 +118,26 @@ export default function BookingDetailsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
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-overlay 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-card border border-line 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]">
|
||||||
|
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<div className="bg-slate-900 px-6 py-4 border-b border-slate-800 flex items-center justify-between font-sans">
|
<div className="bg-inner px-6 py-4 border-b border-line flex items-center justify-between font-sans">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-1.5 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-400">
|
<div className="p-1.5 bg-success-soft border border-success-line rounded-lg text-success">
|
||||||
<HardDrive className="w-5 h-5" />
|
<HardDrive className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<span>Reservation Details</span>
|
<span>Reservation Details</span>
|
||||||
<span className="text-slate-500 font-mono font-normal">#{booking.id}</span>
|
<span className="text-fg-faint font-mono font-normal">#{booking.id}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-slate-400">Inspect allocation status and diagnostic automation APIs</p>
|
<p className="text-[11px] text-fg-muted">Inspect allocation status and diagnostic automation APIs</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-slate-400 hover:text-white p-1 hover:bg-slate-800 rounded transition-colors"
|
className="text-fg-muted hover:text-fg p-1 hover:bg-inner rounded transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -150,10 +150,10 @@ export default function BookingDetailsModal({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||||
|
|
||||||
{/* Left Box: Meta stats block */}
|
{/* Left Box: Meta stats block */}
|
||||||
<div className="md:col-span-5 bg-slate-950/60 rounded-xl p-4 border border-slate-850 space-y-4">
|
<div className="md:col-span-5 bg-inner rounded-xl p-4 border border-line space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-slate-500">Scheduled Blueprint</span>
|
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-fg-faint">Scheduled Blueprint</span>
|
||||||
<h4 className="text-base font-bold text-white mt-0.5">{lab?.name || 'Standalone Test Lab'}</h4>
|
<h4 className="text-base font-bold text-fg mt-0.5">{lab?.name || 'Standalone Test Lab'}</h4>
|
||||||
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono capitalize mt-2 font-semibold border"
|
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono capitalize mt-2 font-semibold border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
@ -180,35 +180,35 @@ export default function BookingDetailsModal({
|
|||||||
|
|
||||||
{/* Time blocks */}
|
{/* Time blocks */}
|
||||||
<div className="space-y-2.5 font-sans">
|
<div className="space-y-2.5 font-sans">
|
||||||
<div className="flex gap-2.5 text-xs text-slate-300">
|
<div className="flex gap-2.5 text-xs text-fg-muted">
|
||||||
<Calendar className="w-4.5 h-4.5 text-emerald-400 shrink-0 mt-0.5" />
|
<Calendar className="w-4.5 h-4.5 text-success shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Start Time</span>
|
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Start Time</span>
|
||||||
<span className="font-mono text-slate-200">{startFormatted}</span>
|
<span className="font-mono text-fg">{startFormatted}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2.5 text-xs text-slate-300">
|
<div className="flex gap-2.5 text-xs text-fg-muted">
|
||||||
<Clock className="w-4.5 h-4.5 text-indigo-400 shrink-0 mt-0.5" />
|
<Clock className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Terminations On</span>
|
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Terminations On</span>
|
||||||
<span className="font-mono text-slate-200">{endFormatted}</span>
|
<span className="font-mono text-fg">{endFormatted}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2.5 text-xs text-slate-300">
|
<div className="flex gap-2.5 text-xs text-fg-muted">
|
||||||
<UserIcon className="w-4.5 h-4.5 text-slate-500 shrink-0 mt-0.5" />
|
<UserIcon className="w-4.5 h-4.5 text-fg-faint shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Reserved Operator</span>
|
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Reserved Operator</span>
|
||||||
<span className="text-slate-200">{creator.name}</span>
|
<span className="text-fg">{creator.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operator Notes */}
|
{/* Operator Notes */}
|
||||||
<div className="pt-3 border-t border-slate-850/80 font-sans text-xs">
|
<div className="pt-3 border-t border-line font-sans text-xs">
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider mb-1">Testing Objective Notes</span>
|
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider mb-1">Testing Objective Notes</span>
|
||||||
<p className="text-slate-300 leading-relaxed italic bg-slate-900 border border-slate-850 p-2.5 rounded">
|
<p className="text-fg-muted leading-relaxed italic bg-inner border border-line p-2.5 rounded">
|
||||||
"{booking.notes || 'No objectives specified.'}"
|
"{booking.notes || 'No objectives specified.'}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -216,27 +216,27 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Box: Allocated Device checklist */}
|
{/* Right Box: Allocated Device checklist */}
|
||||||
<div className="md:col-span-7 bg-slate-900/35 border border-slate-800 rounded-xl p-4 flex flex-col justify-between">
|
<div className="md:col-span-7 bg-inner border border-line rounded-xl p-4 flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-emerald-400">Allocated Nodes Pool ({mappedDevices.length})</span>
|
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-success">Allocated Nodes Pool ({mappedDevices.length})</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">Location: {lab?.location}</span>
|
<span className="text-[10px] text-fg-faint font-mono">Location: {lab?.location}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mappedDevices.length === 0 ? (
|
{mappedDevices.length === 0 ? (
|
||||||
<p className="text-xs text-slate-400 italic py-6 text-center">No hardware nodes directly mapped to this scenario.</p>
|
<p className="text-xs text-fg-muted italic py-6 text-center">No hardware nodes directly mapped to this scenario.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-1">
|
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-1">
|
||||||
{mappedDevices.map((device) => (
|
{mappedDevices.map((device) => (
|
||||||
<div key={device.id} className="p-3 bg-slate-950/65 border border-slate-850 hover:border-slate-800 rounded-lg flex items-center justify-between font-sans">
|
<div key={device.id} className="p-3 bg-card border border-line hover:border-line-strong rounded-lg flex items-center justify-between font-sans">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-400' : 'bg-rose-400'}`} />
|
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-success' : 'bg-rose'}`} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
|
<p className="text-xs font-mono font-bold text-fg leading-none">{device.hostname}</p>
|
||||||
<p className="text-[9px] text-slate-400 mt-1 font-mono leading-none">{device.type} • {device.location}</p>
|
<p className="text-[9px] text-fg-muted mt-1 font-mono leading-none">{device.type} • {device.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono font-bold text-emerald-400">{device.ip}</span>
|
<span className="text-xs font-mono font-bold text-success">{device.ip}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -244,8 +244,8 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notice */}
|
{/* Notice */}
|
||||||
<div className="bg-slate-900 p-3 rounded-lg border border-slate-800 flex gap-2.5 text-[11px] leading-normal text-slate-400 mt-4 font-sans">
|
<div className="bg-inner p-3 rounded-lg border border-line flex gap-2.5 text-[11px] leading-normal text-fg-muted mt-4 font-sans">
|
||||||
<HelpCircle className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
<HelpCircle className="w-4 h-4 text-success shrink-0 mt-0.5" />
|
||||||
<span>During the active slot, GhostGrid automatically binds these switches exclusively to your namespace and drops custom console-lines to open CLI pipelines.</span>
|
<span>During the active slot, GhostGrid automatically binds these switches exclusively to your namespace and drops custom console-lines to open CLI pipelines.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -254,27 +254,27 @@ export default function BookingDetailsModal({
|
|||||||
|
|
||||||
{/* Ansible Semaphore automation status */}
|
{/* Ansible Semaphore automation status */}
|
||||||
{(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && (
|
{(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && (
|
||||||
<div className="border border-orange-900/40 rounded-xl bg-orange-950/10 p-4 font-sans">
|
<div className="border border-orange-line rounded-xl bg-orange-soft p-4 font-sans">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Terminal className="w-4 h-4 text-orange-400" />
|
<Terminal className="w-4 h-4 text-orange" />
|
||||||
<span className="text-[10px] uppercase tracking-wider font-bold text-orange-400">Ansible Automation</span>
|
<span className="text-[10px] uppercase tracking-wider font-bold text-orange">Ansible Automation</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{lab.semaphoreSetupTemplateId && (
|
{lab.semaphoreSetupTemplateId && (
|
||||||
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
|
<div className="bg-inner border border-line rounded-lg p-3 space-y-2">
|
||||||
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Setup</p>
|
<p className="text-[10px] text-fg-muted uppercase tracking-wide font-semibold">Setup</p>
|
||||||
{setupTriggered ? (
|
{setupTriggered ? (
|
||||||
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
|
<div className="flex items-center gap-1.5 text-success text-xs font-mono">
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
{setupJobId ? `Job #${setupJobId}` : 'Triggered'}
|
{setupJobId ? `Job #${setupJobId}` : 'Triggered'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500 font-mono">Pending</span>
|
<span className="text-xs text-fg-faint font-mono">Pending</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => manualTrigger('setup')}
|
onClick={() => manualTrigger('setup')}
|
||||||
disabled={triggering || booking.status === 'cancelled'}
|
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"
|
className="flex items-center gap-1 px-2 py-1 bg-orange-soft hover:opacity-80 border border-orange-line text-orange text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Play className="w-2.5 h-2.5" /> Trigger now
|
<Play className="w-2.5 h-2.5" /> Trigger now
|
||||||
</button>
|
</button>
|
||||||
@ -283,20 +283,20 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{lab.semaphoreTeardownTemplateId && (
|
{lab.semaphoreTeardownTemplateId && (
|
||||||
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
|
<div className="bg-inner border border-line rounded-lg p-3 space-y-2">
|
||||||
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Teardown</p>
|
<p className="text-[10px] text-fg-muted uppercase tracking-wide font-semibold">Teardown</p>
|
||||||
{teardownTriggered ? (
|
{teardownTriggered ? (
|
||||||
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
|
<div className="flex items-center gap-1.5 text-success text-xs font-mono">
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
{teardownJobId ? `Job #${teardownJobId}` : 'Triggered'}
|
{teardownJobId ? `Job #${teardownJobId}` : 'Triggered'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500 font-mono">Pending</span>
|
<span className="text-xs text-fg-faint font-mono">Pending</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => manualTrigger('teardown')}
|
onClick={() => manualTrigger('teardown')}
|
||||||
disabled={triggering || booking.status === 'cancelled'}
|
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"
|
className="flex items-center gap-1 px-2 py-1 bg-orange-soft hover:opacity-80 border border-orange-line text-orange text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Play className="w-2.5 h-2.5" /> Trigger now
|
<Play className="w-2.5 h-2.5" /> Trigger now
|
||||||
</button>
|
</button>
|
||||||
@ -306,7 +306,7 @@ export default function BookingDetailsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{triggerStatus && (
|
{triggerStatus && (
|
||||||
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
|
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-danger' : 'text-success'}`}>
|
||||||
{triggerStatus}
|
{triggerStatus}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -339,22 +339,24 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="bg-slate-900 px-6 py-4 border-t border-slate-800 flex justify-between items-center font-sans gap-3 flex-wrap">
|
<div className="bg-inner px-6 py-4 border-t border-line flex justify-between items-center font-sans gap-3 flex-wrap">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
||||||
{/* Delete button option */}
|
{/* Delete button option — admin only */}
|
||||||
<button
|
{currentUser.role.toLowerCase() === 'admin' && (
|
||||||
onClick={() => {
|
<button
|
||||||
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
|
onClick={() => {
|
||||||
onDelete(booking.id);
|
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
|
||||||
onClose();
|
onDelete(booking.id);
|
||||||
}
|
onClose();
|
||||||
}}
|
}
|
||||||
className="px-3 py-1.5 bg-rose-950/40 border border-rose-900/60 text-rose-400 hover:bg-rose-900 hover:text-white rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
|
}}
|
||||||
>
|
className="px-3 py-1.5 bg-rose-soft border border-rose-line text-rose hover:opacity-80 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Purge Entry (SQLite DELETE)</span>
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Purge Entry (SQLite DELETE)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cancel Status Toggle */}
|
{/* Cancel Status Toggle */}
|
||||||
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
|
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
|
||||||
@ -365,7 +367,7 @@ export default function BookingDetailsModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 bg-amber-950/40 border border-amber-700/50 text-amber-400 hover:bg-amber-900/60 hover:border-amber-500 hover:text-amber-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
|
className="px-3 py-1.5 bg-warning-soft border border-warning-line text-warning hover:opacity-80 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
<Ban className="w-3.5 h-3.5" />
|
<Ban className="w-3.5 h-3.5" />
|
||||||
<span>Cancel Reservation</span>
|
<span>Cancel Reservation</span>
|
||||||
@ -376,7 +378,7 @@ export default function BookingDetailsModal({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-550 text-slate-950 hover:text-slate-1000 font-bold rounded-lg text-xs transition-colors hover:cursor-pointer"
|
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-lg text-xs transition-colors hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
Acknowledge Specs
|
Acknowledge Specs
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ interface DashboardProps {
|
|||||||
bookings: Booking[];
|
bookings: Booking[];
|
||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
|
users: User[];
|
||||||
links: QuickLink[];
|
links: QuickLink[];
|
||||||
onCancelBooking: (id: string) => void;
|
onCancelBooking: (id: string) => void;
|
||||||
onDeleteBooking: (id: string) => void;
|
onDeleteBooking: (id: string) => void;
|
||||||
@ -26,8 +27,8 @@ interface DashboardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LINK_ACCENT: Record<string, string> = {
|
const LINK_ACCENT: Record<string, string> = {
|
||||||
emerald: 'text-emerald-400', cyan: 'text-cyan-400', indigo: 'text-indigo-400',
|
emerald: 'text-success', cyan: 'text-info', indigo: 'text-primary',
|
||||||
amber: 'text-amber-400', rose: 'text-rose-400', violet: 'text-violet-400',
|
amber: 'text-warning', rose: 'text-rose', violet: 'text-violet',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Dashboard({
|
export default function Dashboard({
|
||||||
@ -35,6 +36,7 @@ export default function Dashboard({
|
|||||||
bookings,
|
bookings,
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
|
users,
|
||||||
links,
|
links,
|
||||||
onCancelBooking,
|
onCancelBooking,
|
||||||
onDeleteBooking,
|
onDeleteBooking,
|
||||||
@ -53,14 +55,15 @@ export default function Dashboard({
|
|||||||
|
|
||||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||||
const personalBookings = bookings.filter(b => b.userId === currentUser.id && b.status !== 'cancelled');
|
const personalBookings = bookings.filter(b => b.userId === currentUser.id && b.status !== 'cancelled');
|
||||||
// "Active" = currently running, plus a 1h grace window after the end so
|
// "Active" = currently running across ALL users, plus a 1h grace window after
|
||||||
// freshly-finished sessions linger briefly instead of jumping to "Expired".
|
// the end so freshly-finished sessions linger briefly instead of jumping to "Expired".
|
||||||
const activeBookings = personalBookings.filter(b => {
|
const activeBookings = bookings.filter(b => {
|
||||||
|
if (b.status === 'cancelled') return false;
|
||||||
const start = new Date(b.startDateTime).getTime();
|
const start = new Date(b.startDateTime).getTime();
|
||||||
const end = new Date(b.endDateTime).getTime();
|
const end = new Date(b.endDateTime).getTime();
|
||||||
return start <= now.getTime() && end > now.getTime() - ONE_HOUR_MS;
|
return start <= now.getTime() && end > now.getTime() - ONE_HOUR_MS;
|
||||||
});
|
});
|
||||||
const upcomingBookings = personalBookings.filter(b => b.status === 'upcoming' && new Date(b.startDateTime) > now);
|
const upcomingBookings = bookings.filter(b => b.status === 'upcoming' && new Date(b.startDateTime) > now);
|
||||||
|
|
||||||
// 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)
|
||||||
@ -92,26 +95,26 @@ export default function Dashboard({
|
|||||||
<div className="space-y-6" id="dashboard-cockpit-root">
|
<div className="space-y-6" id="dashboard-cockpit-root">
|
||||||
|
|
||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-2xl p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="bg-card border border-line rounded-2xl p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h2 className="text-xl font-bold tracking-tight text-white font-sans">
|
<h2 className="text-xl font-bold tracking-tight text-fg font-sans">
|
||||||
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>
|
Welcome back, <span className="text-success">{currentUser.name}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 font-sans">
|
<p className="text-xs text-fg-muted font-sans">
|
||||||
Your lab management hub. Reserve hardware, track active sessions, and keep runbooks close.
|
Your lab management hub. Reserve hardware, track active sessions, and keep runbooks close.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<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-white font-sans font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
<Zap className="w-4 h-4 text-slate-950 fill-slate-950" />
|
<Zap className="w-4 h-4" />
|
||||||
Book Your Lab
|
Book Your Lab
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToDevices}
|
onClick={onNavigateToDevices}
|
||||||
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-800 hover:border-slate-700 rounded-lg text-xs font-semibold transition-all flex items-center gap-1 hover:cursor-pointer"
|
className="px-4 py-2 bg-inner hover:bg-card text-fg border border-line hover:border-line-strong rounded-lg text-xs font-semibold transition-all flex items-center gap-1 hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
Browse Inventory
|
Browse Inventory
|
||||||
</button>
|
</button>
|
||||||
@ -125,24 +128,25 @@ export default function Dashboard({
|
|||||||
<div className="lg:col-span-8 space-y-6">
|
<div className="lg:col-span-8 space-y-6">
|
||||||
|
|
||||||
{/* Active Sessions */}
|
{/* Active Sessions */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
|
<div className="bg-card border border-line 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-fg 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 h-4 text-emerald-400" />
|
<Clock className="w-4 h-4 text-success" />
|
||||||
Active Reservations
|
Active Reservations
|
||||||
</span>
|
</span>
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
|
<span className="w-2 h-2 rounded-full bg-success shrink-0" />
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{activeBookings.length === 0 ? (
|
{activeBookings.length === 0 ? (
|
||||||
<div className="text-center py-8 bg-slate-900/35 rounded-lg border border-slate-800 font-sans">
|
<div className="text-center py-8 bg-inner rounded-lg border border-line font-sans">
|
||||||
<PlayCircle className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
|
<PlayCircle className="w-7 h-7 text-fg-faint mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-xs text-slate-400">No active sessions.</p>
|
<p className="text-xs text-fg-muted">No active sessions.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 font-sans">
|
<div className="space-y-4 font-sans">
|
||||||
{activeBookings.map((booking) => {
|
{activeBookings.map((booking) => {
|
||||||
const lab = labs.find(l => l.id === booking.labId);
|
const lab = labs.find(l => l.id === booking.labId);
|
||||||
|
const booker = users.find(u => u.id === booking.userId);
|
||||||
const startDate = new Date(booking.startDateTime);
|
const startDate = new Date(booking.startDateTime);
|
||||||
const endDate = new Date(booking.endDateTime);
|
const endDate = new Date(booking.endDateTime);
|
||||||
const sameDay = startDate.toDateString() === endDate.toDateString();
|
const sameDay = startDate.toDateString() === endDate.toDateString();
|
||||||
@ -153,43 +157,48 @@ export default function Dashboard({
|
|||||||
? endDate.toLocaleTimeString('en-US', timeFmt)
|
? endDate.toLocaleTimeString('en-US', timeFmt)
|
||||||
: `${endDate.toLocaleDateString('en-US', dayFmt)}, ${endDate.toLocaleTimeString('en-US', timeFmt)}`;
|
: `${endDate.toLocaleDateString('en-US', dayFmt)}, ${endDate.toLocaleTimeString('en-US', timeFmt)}`;
|
||||||
return (
|
return (
|
||||||
<div key={booking.id} className="p-4 bg-slate-950/60 border border-emerald-500/30 rounded-xl relative overflow-hidden">
|
<div key={booking.id} className="p-4 bg-inner border border-success-line rounded-xl relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 bottom-0 w-1 bg-emerald-500" />
|
<div className="absolute top-0 right-0 bottom-0 w-1 bg-success" />
|
||||||
<div className="flex justify-between items-start mb-2 gap-2">
|
<div className="flex justify-between items-start mb-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-bold text-white font-sans">{lab?.name}</h4>
|
<h4 className="text-sm font-bold text-fg font-sans">{lab?.name}</h4>
|
||||||
<span className="text-[10px] text-slate-400 flex items-center gap-1 font-sans mt-0.5">
|
<span className="text-[10px] text-fg-muted flex items-center gap-1 font-sans mt-0.5">
|
||||||
<MapPin className="w-3.5 h-3.5 text-slate-500" />
|
<MapPin className="w-3.5 h-3.5 text-fg-faint" />
|
||||||
{lab?.location}
|
{lab?.location}
|
||||||
</span>
|
</span>
|
||||||
|
{booker && (
|
||||||
|
<span className="text-[10px] text-fg-faint font-sans mt-0.5 block">{booker.name}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Countdown Pill */}
|
{/* Countdown Pill */}
|
||||||
<span className="px-2.5 py-0.5 bg-emerald-950 border border-emerald-900 text-emerald-400 font-mono font-bold text-[10px] rounded-full">
|
<span className="px-2.5 py-0.5 bg-success-soft border border-success-line text-success font-mono font-bold text-[10px] rounded-full">
|
||||||
{getRemainingTimeText(booking.endDateTime)}
|
{getRemainingTimeText(booking.endDateTime)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800/60 flex justify-between items-center text-[10px]">
|
<div className="pt-3 border-t border-line flex justify-between items-center text-[10px]">
|
||||||
<span className="font-mono text-slate-400">
|
<span className="font-mono text-fg-muted">
|
||||||
{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-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"
|
className="px-3 py-1.5 bg-success-soft border border-success-line text-success hover:opacity-80 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
<button
|
{booking.userId === currentUser.id && (
|
||||||
onClick={() => {
|
<button
|
||||||
if (confirm('Release this reservation early?')) {
|
onClick={() => {
|
||||||
onCancelBooking(booking.id);
|
if (confirm('Release this reservation early?')) {
|
||||||
}
|
onCancelBooking(booking.id);
|
||||||
}}
|
}
|
||||||
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"
|
}}
|
||||||
>
|
className="px-3 py-1.5 bg-rose-soft border border-rose-line text-rose hover:opacity-80 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
|
||||||
Release
|
>
|
||||||
</button>
|
Release
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,65 +209,73 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Sessions */}
|
{/* Upcoming Sessions */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5">
|
||||||
<Calendar className="w-4 h-4 text-slate-400" />
|
<Calendar className="w-4 h-4 text-fg-muted" />
|
||||||
Upcoming ({upcomingBookings.length})
|
Upcoming ({upcomingBookings.length})
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{upcomingBookings.length === 0 ? (
|
{upcomingBookings.length === 0 ? (
|
||||||
<p className="text-xs text-slate-400 py-4 text-center">No upcoming reservations.</p>
|
<p className="text-xs text-fg-muted 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) => {
|
||||||
const lab = labs.find(l => l.id === booking.labId);
|
const lab = labs.find(l => l.id === booking.labId);
|
||||||
|
const booker = users.find(u => u.id === booking.userId);
|
||||||
const dayStr = new Date(booking.startDateTime).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
|
const dayStr = new Date(booking.startDateTime).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
|
||||||
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-900/30 border border-slate-800 hover:border-slate-700 rounded-lg flex flex-col justify-between">
|
<div key={booking.id} className="p-3 bg-inner border border-line hover:border-line-strong 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-400 bg-indigo-950/50 border border-indigo-900/50 px-2 py-0.5 rounded">
|
<span className="font-mono font-bold text-[10px] text-primary bg-primary-soft border border-primary-line 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-fg-faint">
|
||||||
{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-fg mt-1 font-sans">{lab?.name}</h4>
|
||||||
<p className="text-[10px] text-slate-400 line-clamp-1 mt-0.5 leading-normal">
|
{booker && (
|
||||||
|
<span className="text-[10px] text-fg-faint font-sans block mt-0.5">{booker.name}</span>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-fg-muted line-clamp-1 mt-0.5 leading-normal">
|
||||||
{booking.notes}
|
{booking.notes}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 mt-2 border-t border-slate-800 flex justify-end gap-1.5">
|
<div className="pt-2 mt-2 border-t border-line flex justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectBookingDetails(booking)}
|
onClick={() => onSelectBookingDetails(booking)}
|
||||||
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"
|
className="px-2.5 py-1 text-xs text-success hover:opacity-80 bg-success-soft border border-success-line rounded-lg font-semibold transition hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
<button
|
{booking.userId === currentUser.id && (
|
||||||
onClick={() => {
|
<button
|
||||||
if (confirm('Cancel this upcoming reservation?')) {
|
onClick={() => {
|
||||||
onCancelBooking(booking.id);
|
if (confirm('Cancel this upcoming reservation?')) {
|
||||||
}
|
onCancelBooking(booking.id);
|
||||||
}}
|
}
|
||||||
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"
|
}}
|
||||||
>
|
className="px-2.5 py-1 text-xs text-fg-muted hover:text-fg hover:bg-card rounded-lg border border-line hover:cursor-pointer transition"
|
||||||
Cancel
|
>
|
||||||
</button>
|
Cancel
|
||||||
<button
|
</button>
|
||||||
onClick={() => {
|
)}
|
||||||
if (confirm('Permanently delete this reservation?')) {
|
{currentUser.role.toLowerCase() === 'admin' && (
|
||||||
onDeleteBooking(booking.id);
|
<button
|
||||||
}
|
onClick={() => {
|
||||||
}}
|
if (confirm('Permanently delete this reservation?')) {
|
||||||
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"
|
onDeleteBooking(booking.id);
|
||||||
>
|
}
|
||||||
Purge
|
}}
|
||||||
</button>
|
className="px-2.5 py-1 text-xs text-rose hover:opacity-80 hover:bg-rose-soft rounded-lg border border-rose-line hover:cursor-pointer transition"
|
||||||
|
>
|
||||||
|
Purge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -273,9 +290,9 @@ export default function Dashboard({
|
|||||||
<div className="lg:col-span-4 space-y-6">
|
<div className="lg:col-span-4 space-y-6">
|
||||||
|
|
||||||
{/* Lab Checklist */}
|
{/* Lab Checklist */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5">
|
||||||
<ListTodo className="w-4 h-4 text-slate-400" />
|
<ListTodo className="w-4 h-4 text-fg-muted" />
|
||||||
Lab Checklist
|
Lab Checklist
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -284,15 +301,15 @@ export default function Dashboard({
|
|||||||
<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-900/40 hover:bg-slate-900 rounded-lg cursor-pointer transition-all border border-slate-800/60"
|
className="flex items-start gap-2.5 p-2 bg-inner hover:bg-card rounded-lg cursor-pointer transition-all border border-line"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
className="mt-0.5 rounded border-slate-700 text-emerald-500 w-3.5 h-3.5 shrink-0"
|
className="mt-0.5 rounded border-line-strong text-success w-3.5 h-3.5 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs leading-tight ${item.checked ? 'text-slate-500 line-through' : 'text-slate-300'}`}>
|
<span className={`text-xs leading-tight ${item.checked ? 'text-fg-faint line-through' : 'text-fg-muted'}`}>
|
||||||
{item.text}
|
{item.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -301,27 +318,27 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5 justify-between">
|
<h3 className="font-bold text-sm text-fg 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 h-4 text-slate-400" />
|
<LinkIcon className="w-4 h-4 text-fg-muted" />
|
||||||
Quick Links
|
Quick Links
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
onClick={onNavigateToLinks}
|
||||||
className="text-[10px] text-slate-400 hover:text-slate-200 font-semibold flex items-center gap-0.5 hover:cursor-pointer"
|
className="text-[10px] text-fg-muted hover:text-fg 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-800">
|
<div className="text-center py-6 bg-inner rounded-lg border border-line">
|
||||||
<Globe className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
|
<Globe className="w-7 h-7 text-fg-faint mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-xs text-slate-400">No shared links yet.</p>
|
<p className="text-xs text-fg-muted">No shared links yet.</p>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
onClick={onNavigateToLinks}
|
||||||
className="text-xs text-slate-400 font-semibold underline mt-1.5 hover:text-slate-200 hover:cursor-pointer"
|
className="text-xs text-fg-muted font-semibold underline mt-1.5 hover:text-fg hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
Add links
|
Add links
|
||||||
</button>
|
</button>
|
||||||
@ -338,23 +355,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-900/40 hover:bg-slate-900 rounded-lg border border-slate-800/60 hover:border-slate-700 transition-all"
|
className="group flex items-center gap-2.5 p-2 bg-inner hover:bg-card rounded-lg border border-line hover:border-line-strong 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-surface border border-line 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-xs font-semibold text-slate-200 group-hover:text-white truncate">{link.title}</span>
|
<span className="block text-xs font-semibold text-fg group-hover:text-fg truncate">{link.title}</span>
|
||||||
<span className={`block text-[10px] 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-400 shrink-0" />
|
<ExternalLink className="w-3.5 h-3.5 text-fg-faint group-hover:text-fg-muted 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-slate-300 pt-1.5 font-semibold hover:cursor-pointer"
|
className="w-full text-center text-[10px] text-fg-faint hover:text-fg-muted pt-1.5 font-semibold hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
+{links.length - 6} more links
|
+{links.length - 6} more links
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -15,8 +15,8 @@ const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'
|
|||||||
|
|
||||||
interface DeviceInventoryProps {
|
interface DeviceInventoryProps {
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
checkmkEnabled: boolean;
|
cmkEnabled: boolean;
|
||||||
checkmkBaseUrl: string;
|
cmkBaseUrl: 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;
|
||||||
@ -24,8 +24,8 @@ interface DeviceInventoryProps {
|
|||||||
|
|
||||||
export default function DeviceInventory({
|
export default function DeviceInventory({
|
||||||
devices,
|
devices,
|
||||||
checkmkEnabled,
|
cmkEnabled,
|
||||||
checkmkBaseUrl,
|
cmkBaseUrl,
|
||||||
onAddDevice,
|
onAddDevice,
|
||||||
onUpdateDevice,
|
onUpdateDevice,
|
||||||
onDeleteDevice,
|
onDeleteDevice,
|
||||||
@ -65,14 +65,14 @@ export default function DeviceInventory({
|
|||||||
|
|
||||||
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
|
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
|
||||||
const cmkHostUrl = (d: Device) =>
|
const cmkHostUrl = (d: Device) =>
|
||||||
checkmkEnabled && checkmkBaseUrl && d.cmkHostname
|
cmkEnabled && cmkBaseUrl && d.cmkHostname
|
||||||
? `${checkmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
|
? `${cmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
|
||||||
: null;
|
: 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-success-soft border-success-line text-success', dot: 'bg-success' };
|
||||||
if (s === 'offline') return { label: 'offline', badge: 'bg-rose-950/60 border-rose-900/80 text-rose-400', dot: 'bg-rose-400' };
|
if (s === 'offline') return { label: 'offline', badge: 'bg-rose-soft border-rose-line text-rose', dot: 'bg-rose' };
|
||||||
return { label: 'unknown', badge: 'bg-slate-800/60 border-slate-700 text-slate-400', dot: 'bg-slate-500' };
|
return { label: 'unknown', badge: 'bg-inner border-line text-fg-muted', dot: 'bg-fg-faint' };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtered devices list
|
// Filtered devices list
|
||||||
@ -160,31 +160,31 @@ export default function DeviceInventory({
|
|||||||
|
|
||||||
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
|
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
|
||||||
const renderEmergencySheetHtml = (text: string) => {
|
const renderEmergencySheetHtml = (text: string) => {
|
||||||
if (!text) return <p className="text-slate-400 italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
|
if (!text) return <p className="text-fg-muted italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
|
||||||
|
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
return lines.map((line, idx) => {
|
return lines.map((line, idx) => {
|
||||||
// Headers
|
// Headers
|
||||||
if (line.startsWith('### ')) {
|
if (line.startsWith('### ')) {
|
||||||
return <h4 key={idx} className="text-sm font-bold text-slate-100 mt-4 mb-2 border-b border-slate-800 pb-1 font-sans">{line.replace('### ', '')}</h4>;
|
return <h4 key={idx} className="text-sm font-bold text-fg mt-4 mb-2 border-b border-line pb-1 font-sans">{line.replace('### ', '')}</h4>;
|
||||||
}
|
}
|
||||||
if (line.startsWith('#### ')) {
|
if (line.startsWith('#### ')) {
|
||||||
return <h5 key={idx} className="text-xs font-bold text-emerald-400 mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
|
return <h5 key={idx} className="text-xs font-bold text-success mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
|
||||||
}
|
}
|
||||||
if (line.startsWith('**') && line.endsWith('**')) {
|
if (line.startsWith('**') && line.endsWith('**')) {
|
||||||
return <p key={idx} className="text-xs font-semibold text-slate-200 mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
|
return <p key={idx} className="text-xs font-semibold text-fg mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
|
||||||
}
|
}
|
||||||
// Bullet lists
|
// Bullet lists
|
||||||
if (line.startsWith('* ') || line.startsWith('- ')) {
|
if (line.startsWith('* ') || line.startsWith('- ')) {
|
||||||
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
|
return <div key={idx} className="flex gap-2 text-xs text-fg-muted ml-4 font-sans align-top mb-1">
|
||||||
<span className="text-emerald-500">•</span>
|
<span className="text-success">•</span>
|
||||||
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
|
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
// Numeric lists
|
// Numeric lists
|
||||||
if (/^\d+\s*\.\s/.test(line)) {
|
if (/^\d+\s*\.\s/.test(line)) {
|
||||||
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
|
return <div key={idx} className="flex gap-2 text-xs text-fg-muted ml-4 font-sans align-top mb-1">
|
||||||
<span className="text-emerald-400 font-bold">{line.match(/^\d+/)?.[0]}.</span>
|
<span className="text-success font-bold">{line.match(/^\d+/)?.[0]}.</span>
|
||||||
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
|
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@ -195,20 +195,20 @@ export default function DeviceInventory({
|
|||||||
if (line.trim() === '```bash' || line.trim() === '```') {
|
if (line.trim() === '```bash' || line.trim() === '```') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline formatting fallback
|
// Inline formatting fallback
|
||||||
if (line.includes('**')) {
|
if (line.includes('**')) {
|
||||||
return (
|
return (
|
||||||
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
|
<p key={idx} className="text-xs text-fg-muted my-1 font-sans">
|
||||||
{line.split('**').map((tok, ti) => {
|
{line.split('**').map((tok, ti) => {
|
||||||
return ti % 2 === 1 ? <strong key={ti} className="text-slate-100">{tok}</strong> : tok;
|
return ti % 2 === 1 ? <strong key={ti} className="text-fg">{tok}</strong> : tok;
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (line.includes('`')) {
|
if (line.includes('`')) {
|
||||||
return (
|
return (
|
||||||
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
|
<p key={idx} className="text-xs text-fg-muted my-1 font-sans">
|
||||||
{line.split('`').map((tok, ti) => {
|
{line.split('`').map((tok, ti) => {
|
||||||
return ti % 2 === 1 ? <code key={ti} className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-emerald-300 font-mono">{tok}</code> : tok;
|
return ti % 2 === 1 ? <code key={ti} className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-emerald-300 font-mono">{tok}</code> : tok;
|
||||||
})}
|
})}
|
||||||
@ -216,7 +216,7 @@ export default function DeviceInventory({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return line.trim() === '' ? <div key={idx} className="h-2" /> : <p key={idx} className="text-xs text-slate-300 my-0.5 font-sans">{line}</p>;
|
return line.trim() === '' ? <div key={idx} className="h-2" /> : <p key={idx} className="text-xs text-fg-muted my-0.5 font-sans">{line}</p>;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -224,41 +224,40 @@ export default function DeviceInventory({
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
|
||||||
|
|
||||||
{/* LEFT COLUMN: Device List & Controls */}
|
{/* LEFT COLUMN: Device List & Controls */}
|
||||||
<div className="lg:col-span-7 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
|
<div className="lg:col-span-7 bg-card border border-line rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2 font-sans">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2 font-sans">
|
||||||
<Server className="w-5 h-5 text-emerald-400" />
|
<Server className="w-5 h-5 text-success" />
|
||||||
Inventory
|
Inventory
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 font-sans">Every switch, firewall and AP we own. the source of truth, until someone forgets to update it.</p>
|
<p className="text-xs text-fg-muted font-sans">Every switch, firewall and AP we own. the source of truth, until someone forgets to update it.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenAdd}
|
onClick={handleOpenAdd}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-sans font-semibold rounded-lg text-xs transition-colors hover:cursor-pointer"
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white font-sans font-semibold rounded-lg text-xs transition-colors hover:cursor-pointer"
|
||||||
id="btn-add-device"
|
id="btn-add-device"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 text-slate-950 stroke-[3]" />
|
<Plus className="w-4 h-4 stroke-[3]" />
|
||||||
Add Device
|
Add Device
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Toolbar */}
|
{/* Filter Toolbar */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-800 rounded-lg">
|
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-inner border border-line rounded-lg">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-400">
|
<span className="absolute left-3 top-2.5 text-fg-faint">
|
||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</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-field text-fg border border-line-strong rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-success transition-colors placeholder:text-fg-faint"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
|
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
|
||||||
@ -268,8 +267,8 @@ export default function DeviceInventory({
|
|||||||
onClick={() => setTypeFilter(type)}
|
onClick={() => setTypeFilter(type)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
typeFilter === type
|
typeFilter === type
|
||||||
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
|
? 'bg-success-soft border border-success text-success'
|
||||||
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
|
: 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{type === 'all' ? 'All' : type}
|
{type === 'all' ? 'All' : type}
|
||||||
@ -281,7 +280,7 @@ export default function DeviceInventory({
|
|||||||
{/* Device Listing Card Table */}
|
{/* Device Listing Card Table */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
|
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
|
||||||
{filteredDevices.length === 0 ? (
|
{filteredDevices.length === 0 ? (
|
||||||
<div className="text-center py-12 text-slate-500 text-xs font-sans">
|
<div className="text-center py-12 text-fg-faint text-xs font-sans">
|
||||||
grep came back empty. no boxes match that filter.
|
grep came back empty. no boxes match that filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -293,29 +292,29 @@ export default function DeviceInventory({
|
|||||||
onClick={() => setSelectedDeviceId(device.id)}
|
onClick={() => setSelectedDeviceId(device.id)}
|
||||||
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
|
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-slate-900 border-emerald-500/80 shadow-[0_4px_12px_rgba(16,185,129,0.06)]'
|
? 'bg-card border-success'
|
||||||
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
: 'bg-inner border-line hover:border-line-strong hover:bg-card'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3.5">
|
<div className="flex items-start gap-3.5">
|
||||||
{/* Device Icon Circle */}
|
{/* Device Icon Circle */}
|
||||||
<div className={`p-2 rounded-lg border text-base ${
|
<div className={`p-2 rounded-lg border text-base ${
|
||||||
device.type === 'Firewall' ? 'bg-rose-950/20 border-rose-900/60 text-rose-400' :
|
device.type === 'Firewall' ? 'bg-rose-soft border-rose-line text-rose' :
|
||||||
device.type === 'Access-Point' ? 'bg-amber-950/20 border-amber-900/60 text-amber-400' :
|
device.type === 'Access-Point' ? 'bg-warning-soft border-warning-line text-warning' :
|
||||||
device.type === 'Controller' ? 'bg-cyan-950/20 border-cyan-900/60 text-cyan-400' :
|
device.type === 'Controller' ? 'bg-info-soft border-info-line text-info' :
|
||||||
'bg-teal-950/20 border-teal-900/60 text-teal-400'
|
'bg-success-soft border-success-line text-success'
|
||||||
}`}>
|
}`}>
|
||||||
<Server className="w-5 h-5" />
|
<Server className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono font-bold text-white text-sm">{device.hostname}</span>
|
<span className="font-mono font-bold text-fg text-sm">{device.hostname}</span>
|
||||||
<span className="text-[10px] px-2 py-0.5 font-sans rounded-full font-medium bg-slate-800 border border-slate-700 text-slate-400">{device.type}</span>
|
<span className="text-[10px] px-2 py-0.5 font-sans rounded-full font-medium bg-inner border border-line text-fg-muted">{device.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 mt-1 font-sans">
|
<div className="flex flex-col gap-0.5 mt-1 font-sans">
|
||||||
<span className="text-xs font-mono text-emerald-400">{device.ip}</span>
|
<span className="text-xs font-mono text-success">{device.ip}</span>
|
||||||
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
<span className="text-[10px] text-fg-muted flex items-center gap-1">
|
||||||
<MapPin className="w-3 h-3 text-slate-500" />
|
<MapPin className="w-3 h-3 text-fg-faint" />
|
||||||
{device.location}
|
{device.location}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -325,7 +324,7 @@ 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 Status Badge – only when CheckMK is enabled */}
|
{/* CheckMK Status Badge – only when CheckMK is enabled */}
|
||||||
{checkmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
|
{cmkEnabled && (() => { 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}`} />
|
||||||
@ -335,13 +334,13 @@ export default function DeviceInventory({
|
|||||||
); })()}
|
); })()}
|
||||||
|
|
||||||
{/* 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-line pl-3">
|
||||||
{cmkHostUrl(device) && (
|
{cmkHostUrl(device) && (
|
||||||
<a
|
<a
|
||||||
href={cmkHostUrl(device)!}
|
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-inner text-fg-muted hover:text-info transition-colors"
|
||||||
title="Open host in CheckMK"
|
title="Open host in CheckMK"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
@ -349,7 +348,7 @@ export default function DeviceInventory({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenEdit(device)}
|
onClick={() => handleOpenEdit(device)}
|
||||||
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
|
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-primary transition-colors"
|
||||||
title="Edit specifications"
|
title="Edit specifications"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
@ -360,7 +359,7 @@ export default function DeviceInventory({
|
|||||||
onDeleteDevice(device.id);
|
onDeleteDevice(device.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-rose-400 transition-colors"
|
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-rose transition-colors"
|
||||||
title="Delete device"
|
title="Delete device"
|
||||||
>
|
>
|
||||||
<Trash className="w-3.5 h-3.5" />
|
<Trash className="w-3.5 h-3.5" />
|
||||||
@ -380,34 +379,34 @@ export default function DeviceInventory({
|
|||||||
{selectedDevice ? (
|
{selectedDevice ? (
|
||||||
<>
|
<>
|
||||||
{/* Header Spec Block */}
|
{/* Header Spec Block */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm">
|
||||||
<span className="text-[10px] font-mono uppercase bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded text-amber-400 font-semibold">
|
<span className="text-[10px] font-mono uppercase bg-inner border border-line px-2.5 py-0.5 rounded text-warning font-semibold">
|
||||||
SPECS ID: {selectedDevice.id.toUpperCase()}
|
SPECS ID: {selectedDevice.id.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-lg font-bold text-slate-100 mt-2 font-mono flex items-center justify-between">
|
<h3 className="text-lg font-bold text-fg mt-2 font-mono flex items-center justify-between">
|
||||||
<span>{selectedDevice.hostname}</span>
|
<span>{selectedDevice.hostname}</span>
|
||||||
<span className="text-xs font-sans text-slate-400 font-normal">Active Link State</span>
|
<span className="text-xs font-sans text-fg-muted font-normal">Active Link State</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 font-mono mt-0.5 bg-slate-950 p-2.5 rounded border border-slate-850 mt-2 leading-relaxed">
|
<p className="text-xs text-fg-muted font-mono mt-0.5 bg-surface p-2.5 rounded border border-line mt-2 leading-relaxed">
|
||||||
Hostname: <span className="text-slate-200">{selectedDevice.hostname}</span><br />
|
Hostname: <span className="text-fg">{selectedDevice.hostname}</span><br />
|
||||||
IP Address: <span className="text-emerald-400 font-bold">{selectedDevice.ip}</span><br />
|
IP Address: <span className="text-success font-bold">{selectedDevice.ip}</span><br />
|
||||||
Location: <span className="text-slate-200">{selectedDevice.location}</span><br />
|
Location: <span className="text-fg">{selectedDevice.location}</span><br />
|
||||||
Node Class: <span className="text-slate-200">{selectedDevice.type}</span>
|
Node Class: <span className="text-fg">{selectedDevice.type}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 font-sans">
|
<div className="mt-4 font-sans">
|
||||||
<h4 className="text-xs font-semibold text-slate-300">Description & Technical Notes:</h4>
|
<h4 className="text-xs font-semibold text-fg-muted">Description & Technical Notes:</h4>
|
||||||
<div className="mt-1 bg-slate-900/50 rounded p-2.5 border border-slate-800/80 text-xs text-slate-300 leading-relaxed">
|
<div className="mt-1 bg-inner rounded p-2.5 border border-line text-xs text-fg-muted leading-relaxed">
|
||||||
{selectedDevice.notes || 'No description notes registered.'}
|
{selectedDevice.notes || 'No description notes registered.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CheckMK Monitoring Panel – only when CheckMK is enabled */}
|
{/* CheckMK Monitoring Panel – only when CheckMK is enabled */}
|
||||||
{checkmkEnabled && (
|
{cmkEnabled && (
|
||||||
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
|
<div className="mt-4 pt-4 border-t border-line 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-fg-muted font-sans font-medium flex items-center gap-1.5">
|
||||||
<Gauge className="w-3.5 h-3.5 text-cyan-400" />
|
<Gauge className="w-3.5 h-3.5 text-info" />
|
||||||
CheckMK Monitoring
|
CheckMK Monitoring
|
||||||
</span>
|
</span>
|
||||||
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
|
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
|
||||||
@ -422,14 +421,14 @@ export default function DeviceInventory({
|
|||||||
href={cmkHostUrl(selectedDevice)!}
|
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-inner border border-line text-fg hover:text-info hover:border-info rounded text-xs transition-colors font-mono"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
{selectedDevice.lastCheckedAt && (
|
{selectedDevice.lastCheckedAt && (
|
||||||
<p className="text-[10px] text-slate-500 font-mono">
|
<p className="text-[10px] text-fg-faint font-mono">
|
||||||
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
|
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -438,33 +437,33 @@ export default function DeviceInventory({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Emergency rescue guidelines sheet */}
|
{/* Emergency rescue guidelines sheet */}
|
||||||
<div className="bg-[#1D2432] border border-amber-900/40 rounded-xl p-5 shadow-sm overflow-hidden relative">
|
<div className="bg-warning-soft border border-warning-line rounded-xl p-5 shadow-sm overflow-hidden relative">
|
||||||
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-amber-500 to-rose-500" />
|
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-amber-500 to-rose-500" />
|
||||||
<div className="flex items-center justify-between border-b border-amber-900/30 pb-3 mb-4">
|
<div className="flex items-center justify-between border-b border-warning-line pb-3 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookOpen className="w-5 h-5 text-amber-500" />
|
<BookOpen className="w-5 h-5 text-warning" />
|
||||||
<h3 className="font-bold text-sm text-slate-100 font-sans">
|
<h3 className="font-bold text-sm text-fg font-sans">
|
||||||
Emergency Sheet & Disaster Recovery
|
Emergency Sheet & Disaster Recovery
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-mono font-bold bg-amber-950 text-amber-300 px-2 py-0.5 rounded border border-amber-900/50">
|
<span className="text-[9px] font-mono font-bold bg-warning-soft text-warning px-2 py-0.5 rounded border border-warning-line">
|
||||||
RESCUE SHEET
|
RESCUE SHEET
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Content box */}
|
{/* Markdown Content box */}
|
||||||
<div className="max-h-[350px] overflow-y-auto bg-slate-950/80 p-4 rounded-lg border border-slate-900 leading-relaxed font-sans scrollbar-thin">
|
<div className="max-h-[350px] overflow-y-auto bg-surface p-4 rounded-lg border border-line leading-relaxed font-sans scrollbar-thin">
|
||||||
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
|
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2 text-[10px] text-slate-400 bg-slate-900/60 p-2.5 rounded border border-slate-800">
|
<div className="mt-4 flex items-center gap-2 text-[10px] text-fg-muted bg-inner p-2.5 rounded border border-line">
|
||||||
<Info className="w-4 h-4 text-amber-400 shrink-0" />
|
<Info className="w-4 h-4 text-warning shrink-0" />
|
||||||
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
|
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-10 text-center text-slate-500 text-xs font-sans">
|
<div className="bg-card border border-line rounded-xl p-10 text-center text-fg-faint text-xs font-sans">
|
||||||
Pick a box from the list to see its specs and break-glass playbook.
|
Pick a box from the list to see its specs and break-glass playbook.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -472,16 +471,16 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
|
|
||||||
{/* FORM MODAL: Add / Edit Equipment */}
|
{/* FORM MODAL: Add / Edit Equipment */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-overlay backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-lg rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
<div className="bg-card border border-line-strong w-full max-w-lg rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||||
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans">
|
<div className="bg-inner px-5 py-4 border-b border-line flex items-center justify-between font-sans">
|
||||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<Server className="w-4 h-4 text-emerald-400" />
|
<Server className="w-4 h-4 text-success" />
|
||||||
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
|
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="text-slate-400 hover:text-white"
|
className="text-fg-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -490,43 +489,40 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
|
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Hostname</label>
|
<label className="block text-fg-muted font-semibold mb-1">Hostname</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
|
||||||
placeholder="SW-CORE-03"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">IP Address</label>
|
<label className="block text-fg-muted font-semibold mb-1">IP Address</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
|
||||||
placeholder="172.16.x.x"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
|
<label className="block text-fg-muted font-semibold mb-1">Physical Location</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
placeholder="Server Room R02, Rack C4..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Device Class</label>
|
<label className="block text-fg-muted font-semibold mb-1">Device Class</label>
|
||||||
<select
|
<select
|
||||||
value={isCustomType ? '__custom__' : formData.type}
|
value={isCustomType ? '__custom__' : formData.type}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -538,7 +534,7 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
setFormData({ ...formData, type: e.target.value as DeviceType });
|
setFormData({ ...formData, type: e.target.value as DeviceType });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
>
|
>
|
||||||
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
|
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
|
||||||
<option value="Firewall">Firewall / Security Appliance</option>
|
<option value="Firewall">Firewall / Security Appliance</option>
|
||||||
@ -553,48 +549,46 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
autoFocus
|
autoFocus
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Technical Notes / Patching Mappings</label>
|
<label className="block text-fg-muted font-semibold mb-1">Technical Notes / Patching Mappings</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
placeholder="Serial numbers, module slots, connected uplinks, license status..."
|
|
||||||
/>
|
/>
|
||||||
</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-fg-muted font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={6}
|
rows={6}
|
||||||
value={formData.emergencySheet}
|
value={formData.emergencySheet}
|
||||||
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-250 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono text-[11px] leading-tight"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono text-[11px] leading-tight"
|
||||||
placeholder="### EMERGENCY DETAILS..."
|
placeholder="### EMERGENCY DETAILS..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
<div className="pt-3 border-t border-line flex justify-end gap-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs"
|
className="px-4 py-2 bg-inner hover:bg-card text-fg-muted rounded font-semibold text-xs"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center gap-1.5"
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Save className="w-3.5 h-3.5 text-slate-950" />
|
<Save className="w-3.5 h-3.5" />
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ interface HeaderProps {
|
|||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
onThemeToggle: () => void;
|
onThemeToggle: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
isProduction: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
@ -22,6 +23,7 @@ export default function Header({
|
|||||||
theme,
|
theme,
|
||||||
onThemeToggle,
|
onThemeToggle,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
isProduction,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const [showMailInbox, setShowMailInbox] = useState(false);
|
const [showMailInbox, setShowMailInbox] = useState(false);
|
||||||
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
const [showBellDropdown, setShowBellDropdown] = useState(false);
|
||||||
@ -29,17 +31,17 @@ export default function Header({
|
|||||||
const userBookings = bookings.filter(b => b.userId === currentUser.id && b.emailSent);
|
const userBookings = bookings.filter(b => b.userId === currentUser.id && b.emailSent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-[#0F172A] border-b border-[#1E293B] text-slate-100 backdrop-blur-md bg-opacity-95 px-6 py-4 flex items-center justify-between" id="app-header">
|
<header className="sticky top-0 z-50 bg-header border-b border-line text-fg backdrop-blur-md bg-opacity-95 px-6 py-4 flex items-center justify-between" id="app-header">
|
||||||
{/* Brand Logo & Title */}
|
{/* Brand Logo & Title */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-1 bg-slate-950/90 border border-slate-800 rounded-xl shadow-[0_0_15px_rgba(6,182,212,0.15)] flex items-center justify-center text-white shrink-0 hover:border-cyan-550/50 transition-all duration-300" id="brand-logo">
|
<div className="p-1 bg-inner border border-line rounded-xl flex items-center justify-center text-fg shrink-0 hover:border-line-strong transition-all duration-300" id="brand-logo">
|
||||||
<GhostGridLogo className="w-10 h-10 animate-pulse" />
|
<GhostGridLogo className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-white">
|
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-fg">
|
||||||
GhostGrid
|
GhostGrid
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[9px] font-mono text-cyan-400 tracking-widest uppercase">it's not dns. there's no way it's dns. it was dns.</p>
|
<p className="text-[9px] font-mono text-info tracking-widest uppercase">it's not dns. there's no way it's dns. it was dns.</p>
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1 mt-1">
|
||||||
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
|
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
|
||||||
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
||||||
@ -55,16 +57,16 @@ export default function Header({
|
|||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onThemeToggle}
|
onClick={onThemeToggle}
|
||||||
className="p-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800/80 hover:text-white transition-all flex items-center justify-center cursor-pointer"
|
className="p-2.5 rounded-lg border border-line bg-inner text-fg-muted hover:bg-card hover:text-fg transition-all flex items-center justify-center cursor-pointer"
|
||||||
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="w-5 h-5 text-amber-400" /> : <Moon className="w-5 h-4.5 text-indigo-450" />}
|
{theme === 'dark' ? <Sun className="w-5 h-5 text-warning" /> : <Moon className="w-5 h-4.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 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-inner rounded-full border border-line text-xs font-mono text-fg-muted">
|
||||||
<span className={`w-2 h-2 rounded-full animate-pulse ${import.meta.env.PROD ? 'bg-emerald-500' : 'bg-amber-400'}`} />
|
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-success' : 'bg-warning'}`} />
|
||||||
<span>System: {import.meta.env.PROD ? 'Production' : 'Development'}</span>
|
<span>System: {isProduction ? 'Production' : 'Development'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mail Inbox */}
|
{/* Mail Inbox */}
|
||||||
@ -72,36 +74,36 @@ export default function Header({
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setShowMailInbox(!showMailInbox); setShowBellDropdown(false); }}
|
onClick={() => { setShowMailInbox(!showMailInbox); setShowBellDropdown(false); }}
|
||||||
className={`p-2.5 rounded-lg border transition-all relative ${
|
className={`p-2.5 rounded-lg border transition-all relative ${
|
||||||
showMailInbox ? 'bg-slate-800 border-emerald-500 text-emerald-400' : 'bg-slate-900 border-slate-800 text-slate-300 hover:bg-slate-800/80 hover:text-white'
|
showMailInbox ? 'bg-card border-success text-success' : 'bg-inner border-line text-fg-muted hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
title="E-Mail Inbox (Booking Confirmations)"
|
title="E-Mail Inbox (Booking Confirmations)"
|
||||||
>
|
>
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
{userBookings.length > 0 && (
|
{userBookings.length > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 bg-emerald-500 text-slate-950 font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 bg-success text-white font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||||
{userBookings.length}
|
{userBookings.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showMailInbox && (
|
{showMailInbox && (
|
||||||
<div className="absolute right-0 mt-3 w-96 bg-[#1E293B] border border-slate-700 rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="absolute right-0 mt-3 w-96 bg-card border border-line-strong rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
<div className="bg-inner px-4 py-3 border-b border-line flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm text-white flex items-center gap-2">
|
<h3 className="font-semibold text-sm text-fg flex items-center gap-2">
|
||||||
<Mail className="w-4 h-4 text-emerald-400" />
|
<Mail className="w-4 h-4 text-success" />
|
||||||
Mail Inbox: {currentUser.email}
|
Mail Inbox: {currentUser.email}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 font-sans">Automatic booking confirmations & dynamic alerts</p>
|
<p className="text-[10px] text-fg-muted font-sans">Automatic booking confirmations & dynamic alerts</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowMailInbox(false)} className="text-slate-400 hover:text-white text-xs font-sans">Close</button>
|
<button onClick={() => setShowMailInbox(false)} className="text-fg-muted hover:text-fg text-xs font-sans">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-slate-800 p-2 space-y-1">
|
<div className="max-h-[360px] overflow-y-auto divide-y divide-line p-2 space-y-1">
|
||||||
{userBookings.length === 0 ? (
|
{userBookings.length === 0 ? (
|
||||||
<div className="text-center py-8 text-slate-400 text-sm font-sans">
|
<div className="text-center py-8 text-fg-muted text-sm font-sans">
|
||||||
<Mail className="w-8 h-8 text-slate-600 mx-auto mb-2 opacity-50" />
|
<Mail className="w-8 h-8 text-fg-faint mx-auto mb-2 opacity-50" />
|
||||||
No emails in inbox.
|
No emails in inbox.
|
||||||
<p className="text-xs text-slate-500 mt-1">Book a lab to receive automated SMTP confirmations.</p>
|
<p className="text-xs text-fg-faint mt-1">Book a lab to receive automated SMTP confirmations.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
userBookings.map((booking) => {
|
userBookings.map((booking) => {
|
||||||
@ -109,22 +111,22 @@ export default function Header({
|
|||||||
const formattedStart = new Date(booking.startDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
|
const formattedStart = new Date(booking.startDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
const formattedEnd = new Date(booking.endDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
|
const formattedEnd = new Date(booking.endDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
return (
|
return (
|
||||||
<div key={booking.id} className="p-3 bg-slate-900/40 rounded-lg hover:bg-slate-900/80 transition-colors">
|
<div key={booking.id} className="p-3 bg-inner rounded-lg hover:bg-card transition-colors">
|
||||||
<div className="flex justify-between items-start mb-1 gap-1">
|
<div className="flex justify-between items-start mb-1 gap-1">
|
||||||
<span className="text-[11px] font-mono text-emerald-400 font-semibold bg-emerald-950/80 px-2 py-0.5 rounded border border-emerald-900/50">SMTP INCOMING</span>
|
<span className="text-[11px] font-mono text-success font-semibold bg-success-soft px-2 py-0.5 rounded border border-success-line">SMTP INCOMING</span>
|
||||||
<span className="text-[10px] font-mono text-slate-400">Just now</span>
|
<span className="text-[10px] font-mono text-fg-muted">Just now</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xs font-semibold text-white font-sans">Confirmation: Lab Booking for {lab?.name || 'Unknown'}</h4>
|
<h4 className="text-xs font-semibold text-fg font-sans">Confirmation: Lab Booking for {lab?.name || 'Unknown'}</h4>
|
||||||
<div className="mt-2 text-[11px] text-slate-300 leading-relaxed space-y-1.5 font-sans border-l-2 border-emerald-500/50 pl-2">
|
<div className="mt-2 text-[11px] text-fg-muted leading-relaxed space-y-1.5 font-sans border-l-2 border-success pl-2">
|
||||||
<p>Hello <strong>{currentUser.name}</strong>,</p>
|
<p>Hello <strong>{currentUser.name}</strong>,</p>
|
||||||
<p>Your booking request for lab <strong>{lab?.name}</strong> has been registered successfully.</p>
|
<p>Your booking request for lab <strong>{lab?.name}</strong> has been registered successfully.</p>
|
||||||
<div className="bg-slate-850 p-1.5 rounded font-mono text-[9px] text-slate-300 border border-slate-700/50">
|
<div className="bg-surface p-1.5 rounded font-mono text-[9px] text-fg-muted border border-line">
|
||||||
<strong>Lab Location:</strong> {lab?.location}<br />
|
<strong>Lab Location:</strong> {lab?.location}<br />
|
||||||
<strong>Start Time:</strong> {formattedStart}<br />
|
<strong>Start Time:</strong> {formattedStart}<br />
|
||||||
<strong>End Time:</strong> {formattedEnd}<br />
|
<strong>End Time:</strong> {formattedEnd}<br />
|
||||||
<strong>Notes:</strong> {booking.notes || 'None'}
|
<strong>Notes:</strong> {booking.notes || 'None'}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 italic">GhostGrid Automation Mailbot</p>
|
<p className="text-[10px] text-fg-faint italic">GhostGrid Automation Mailbot</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -140,39 +142,39 @@ export default function Header({
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setShowBellDropdown(!showBellDropdown); setShowMailInbox(false); }}
|
onClick={() => { setShowBellDropdown(!showBellDropdown); setShowMailInbox(false); }}
|
||||||
className={`p-2.5 rounded-lg border transition-all relative ${
|
className={`p-2.5 rounded-lg border transition-all relative ${
|
||||||
showBellDropdown ? 'bg-slate-800 border-amber-500 text-amber-400' : 'bg-slate-905 border-slate-800 text-slate-300 hover:bg-slate-800/80 hover:text-white'
|
showBellDropdown ? 'bg-card border-warning text-warning' : 'bg-inner border-line text-fg-muted hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
title="Interface & System Alerts"
|
title="Interface & System Alerts"
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-slate-950 font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 bg-warning text-white font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||||
{notifications.length}
|
{notifications.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showBellDropdown && (
|
{showBellDropdown && (
|
||||||
<div className="absolute right-0 mt-3 w-80 bg-[#1E293B] border border-slate-700 rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="absolute right-0 mt-3 w-80 bg-card border border-line-strong rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
<div className="bg-inner px-4 py-3 border-b border-line flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm text-white flex items-center gap-2 font-sans">
|
<h3 className="font-semibold text-sm text-fg flex items-center gap-2 font-sans">
|
||||||
<Bell className="w-4 h-4 text-amber-400" />
|
<Bell className="w-4 h-4 text-warning" />
|
||||||
Notifications ({notifications.length})
|
Notifications ({notifications.length})
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 font-sans">Booking lifecycles & countdowns</p>
|
<p className="text-[10px] text-fg-muted font-sans">Booking lifecycles & countdowns</p>
|
||||||
</div>
|
</div>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<button onClick={onClearNotifications} className="text-amber-400 hover:text-amber-300 text-xs font-semibold font-sans">Clear All</button>
|
<button onClick={onClearNotifications} className="text-warning hover:opacity-80 text-xs font-semibold font-sans">Clear All</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[300px] overflow-y-auto divide-y divide-slate-800 p-2">
|
<div className="max-h-[300px] overflow-y-auto divide-y divide-line p-2">
|
||||||
{notifications.length === 0 ? (
|
{notifications.length === 0 ? (
|
||||||
<div className="text-center py-6 text-slate-400 text-xs font-sans">No active system alerts.</div>
|
<div className="text-center py-6 text-fg-muted text-xs font-sans">No active system alerts.</div>
|
||||||
) : (
|
) : (
|
||||||
notifications.map((notif, index) => (
|
notifications.map((notif, index) => (
|
||||||
<div key={index} className="p-2.5 text-xs text-slate-200 flex gap-2 hover:bg-slate-905/40 rounded transition-colors mb-1 font-sans">
|
<div key={index} className="p-2.5 text-xs text-fg flex gap-2 hover:bg-inner rounded transition-colors mb-1 font-sans">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-warning shrink-0 mt-0.5" />
|
||||||
<p>{notif}</p>
|
<p>{notif}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@ -183,15 +185,15 @@ export default function Header({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Info + Logout */}
|
{/* User Info + Logout */}
|
||||||
<div className="flex items-center gap-2 pl-2 pr-1 py-1.5 bg-slate-900 border border-slate-800 rounded-lg text-slate-200">
|
<div className="flex items-center gap-2 pl-2 pr-1 py-1.5 bg-inner border border-line rounded-lg text-fg">
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<div className="text-xs font-semibold leading-3 text-white max-w-[120px] truncate">{currentUser.name}</div>
|
<div className="text-xs font-semibold leading-3 text-fg max-w-[120px] truncate">{currentUser.name}</div>
|
||||||
<div className="text-[9px] text-cyan-400 font-mono tracking-wider mt-0.5 max-w-[125px] truncate">{currentUser.email}</div>
|
<div className="text-[9px] text-info font-mono tracking-wider mt-0.5 max-w-[125px] truncate">{currentUser.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
title="Sign out"
|
title="Sign out"
|
||||||
className="ml-1 p-1.5 rounded-md text-slate-400 hover:text-red-400 hover:bg-red-950/40 transition-all"
|
className="ml-1 p-1.5 rounded-md text-fg-muted hover:text-danger hover:bg-danger-soft transition-all"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -4,25 +4,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { LabTemplate, Device, TopologyLink } from '../types';
|
import { LabTemplate, Device, TopologyLink, User } from '../types';
|
||||||
import TopologyPanel from './TopologyPanel';
|
import TopologyPanel from './TopologyPanel';
|
||||||
import {
|
import {
|
||||||
Server, Plus, Edit3, Trash, User, MapPin,
|
Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
|
||||||
Layers, ChevronRight, Save, X, Check, Pencil, Terminal,
|
Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LabTemplatesProps {
|
interface LabTemplatesProps {
|
||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void;
|
currentUser: User;
|
||||||
|
semaphoreEnabled: boolean;
|
||||||
|
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
|
||||||
onUpdateLab: (lab: LabTemplate) => void;
|
onUpdateLab: (lab: LabTemplate) => void;
|
||||||
onDeleteLab: (id: string) => void;
|
onDeleteLab: (id: string) => void;
|
||||||
onOpenDeviceDetails: (device: Device) => void;
|
onOpenDeviceDetails: (device: Device) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LabTemplates({
|
export default function LabTemplates({
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
|
currentUser,
|
||||||
|
semaphoreEnabled,
|
||||||
onAddLab,
|
onAddLab,
|
||||||
onUpdateLab,
|
onUpdateLab,
|
||||||
onDeleteLab,
|
onDeleteLab,
|
||||||
@ -49,6 +53,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
semaphoreSetupTemplateId: string;
|
semaphoreSetupTemplateId: string;
|
||||||
semaphoreTeardownTemplateId: string;
|
semaphoreTeardownTemplateId: string;
|
||||||
|
scope: 'global' | 'personal';
|
||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -57,6 +62,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: [],
|
deviceIds: [],
|
||||||
semaphoreSetupTemplateId: '',
|
semaphoreSetupTemplateId: '',
|
||||||
semaphoreTeardownTemplateId: '',
|
semaphoreTeardownTemplateId: '',
|
||||||
|
scope: 'global',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate filtered devices associated with selected lab
|
// Calculate filtered devices associated with selected lab
|
||||||
@ -75,6 +81,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: [],
|
deviceIds: [],
|
||||||
semaphoreSetupTemplateId: '',
|
semaphoreSetupTemplateId: '',
|
||||||
semaphoreTeardownTemplateId: '',
|
semaphoreTeardownTemplateId: '',
|
||||||
|
scope: 'global',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -91,6 +98,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: [...lab.deviceIds],
|
deviceIds: [...lab.deviceIds],
|
||||||
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
|
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
|
||||||
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
|
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
|
||||||
|
scope: lab.scope ?? 'global',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -137,105 +145,153 @@ export default function LabTemplates({
|
|||||||
topology: tempLinks,
|
topology: tempLinks,
|
||||||
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
|
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
|
||||||
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
|
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
|
||||||
|
scope: formData.scope,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (formMode === 'add') {
|
if (formMode === 'add') {
|
||||||
onAddLab(savedLabData);
|
onAddLab(savedLabData);
|
||||||
} else if (formMode === 'edit' && formData.id) {
|
} else if (formMode === 'edit' && formData.id) {
|
||||||
|
const existing = labs.find(l => l.id === formData.id);
|
||||||
onUpdateLab({
|
onUpdateLab({
|
||||||
...savedLabData,
|
...savedLabData,
|
||||||
id: formData.id
|
id: formData.id,
|
||||||
|
ownerId: existing?.ownerId ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAdmin = currentUser.role?.toLowerCase() === 'admin';
|
||||||
|
const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === '';
|
||||||
|
|
||||||
|
const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id);
|
||||||
|
const globalLabs = labs.filter(l => l.scope === 'global');
|
||||||
|
const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : [];
|
||||||
|
|
||||||
|
const renderLabCard = (lab: LabTemplate) => {
|
||||||
|
const isSelected = selectedLab?.id === lab.id;
|
||||||
|
const editable = canEdit(lab);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lab.id}
|
||||||
|
onClick={() => setSelectedLab(lab)}
|
||||||
|
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-card border-success'
|
||||||
|
: 'bg-inner border-line hover:border-line-strong hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h3 className="font-bold text-sm text-fg">{lab.name}</h3>
|
||||||
|
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(lab)}
|
||||||
|
className="text-fg-muted hover:text-primary 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-fg-muted hover:text-rose p-0.5"
|
||||||
|
title="Delete template"
|
||||||
|
>
|
||||||
|
<Trash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-fg-muted mt-1 line-clamp-2 leading-relaxed">
|
||||||
|
{lab.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-line grid grid-cols-2 gap-1 text-[10px] text-fg-muted">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<UserIcon className="w-3.5 h-3.5 text-fg-faint 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-fg-faint 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-success bg-success-soft px-2 py-0.5 rounded border border-success-line">
|
||||||
|
{lab.deviceIds.length} connected devices
|
||||||
|
</span>
|
||||||
|
{lab.scope === 'personal' ? (
|
||||||
|
<span className="text-[10px] font-mono text-primary bg-primary-soft px-2 py-0.5 rounded border border-primary-line flex items-center gap-1">
|
||||||
|
<Lock className="w-2.5 h-2.5" /> Personal
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-mono text-fg-muted bg-inner px-2 py-0.5 rounded border border-line flex items-center gap-1">
|
||||||
|
<Globe className="w-2.5 h-2.5" /> Global
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className={`w-4 h-4 text-fg-faint transition-transform ${isSelected ? 'translate-x-1 text-success' : ''}`} />
|
||||||
|
</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">
|
||||||
|
|
||||||
{/* LEFT COLUMN: Lab List */}
|
{/* LEFT COLUMN: Lab List */}
|
||||||
<div className="lg:col-span-4 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section">
|
<div className="lg:col-span-4 bg-card border border-line rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2">
|
||||||
<Layers className="w-5 h-5 text-emerald-400" />
|
<Layers className="w-5 h-5 text-success" />
|
||||||
Topology
|
Topology
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">Predefined architectural scenarios & wiring profiles.</p>
|
<p className="text-xs text-fg-muted">Predefined architectural scenarios & wiring profiles.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenAdd}
|
onClick={handleOpenAdd}
|
||||||
className="p-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded-lg text-xs flex items-center gap-1.5 transition-colors"
|
className="p-1.5 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition-colors"
|
||||||
title="Create new lab template"
|
title="Create new lab template"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 text-slate-950" />
|
<Plus className="w-4 h-4" />
|
||||||
New
|
New
|
||||||
</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-primary 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-fg-faint 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-fg-faint 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-fg-faint 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>
|
||||||
|
|
||||||
@ -244,33 +300,33 @@ export default function LabTemplates({
|
|||||||
{selectedLab ? (
|
{selectedLab ? (
|
||||||
<>
|
<>
|
||||||
{/* Template Card Meta */}
|
{/* Template Card Meta */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[9px] font-mono uppercase tracking-widest text-slate-400 bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded">
|
<span className="text-[9px] font-mono uppercase tracking-widest text-fg-muted bg-inner border border-line px-2.5 py-0.5 rounded">
|
||||||
TEMPLATE ID: {selectedLab.id.toUpperCase()}
|
TEMPLATE ID: {selectedLab.id.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-lg font-bold text-white mt-1.5">{selectedLab.name}</h3>
|
<h3 className="text-lg font-bold text-fg mt-1.5">{selectedLab.name}</h3>
|
||||||
</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-inner p-2 rounded-lg border border-line 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-fg-muted" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 leading-none">Primary Contact</p>
|
<p className="text-fg-muted leading-none">Primary Contact</p>
|
||||||
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
<p className="text-fg font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
|
<div className="bg-inner p-2 rounded-lg border border-line text-[10px] flex items-center gap-1">
|
||||||
<MapPin className="w-3.5 h-3.5 text-slate-400" />
|
<MapPin className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 leading-none">Testing Location</p>
|
<p className="text-fg-muted leading-none">Testing Location</p>
|
||||||
<p className="text-white font-semibold mt-0.5">{selectedLab.location}</p>
|
<p className="text-fg font-semibold mt-0.5">{selectedLab.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-slate-300 leading-relaxed bg-slate-900/30 p-3 rounded-lg border border-slate-800">
|
<p className="text-xs text-fg-muted leading-relaxed bg-inner p-3 rounded-lg border border-line">
|
||||||
{selectedLab.description}
|
{selectedLab.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -283,28 +339,28 @@ export default function LabTemplates({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sub-Devices components list */}
|
{/* Sub-Devices components list */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
|
||||||
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-widest font-mono mb-3">Associated Physical Hardware Map</h4>
|
<h4 className="text-xs font-bold text-fg-muted uppercase tracking-widest font-mono mb-3">Associated Physical Hardware Map</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{labDevices.map((device) => (
|
{labDevices.map((device) => (
|
||||||
<div
|
<div
|
||||||
key={device.id}
|
key={device.id}
|
||||||
onClick={() => onOpenDeviceDetails(device)}
|
onClick={() => onOpenDeviceDetails(device)}
|
||||||
className="p-3 bg-slate-900/40 border border-slate-800 hover:border-slate-700 hover:bg-slate-900 transition-colors rounded-lg cursor-pointer flex justify-between items-center"
|
className="p-3 bg-inner border border-line hover:border-line-strong hover:bg-card transition-colors rounded-lg cursor-pointer flex justify-between items-center"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 font-sans">
|
<div className="flex items-center gap-2.5 font-sans">
|
||||||
<div className={`p-1.5 rounded text-indigo-400 bg-indigo-950/30 border border-indigo-900/50`}>
|
<div className={`p-1.5 rounded text-primary bg-primary-soft border border-primary-line`}>
|
||||||
<Server className="w-4 h-4" />
|
<Server className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
|
<p className="text-xs font-mono font-bold text-fg leading-none">{device.hostname}</p>
|
||||||
<p className="text-[9px] font-mono text-emerald-400 mt-1">{device.ip}</p>
|
<p className="text-[9px] font-mono text-success mt-1">{device.ip}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 font-mono">
|
<div className="flex items-center gap-2 font-mono">
|
||||||
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-500' : 'bg-rose-500'}`} />
|
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-success' : 'bg-rose'}`} />
|
||||||
<span className="text-[10px] text-slate-400 capitalize">{device.status}</span>
|
<span className="text-[10px] text-fg-muted capitalize">{device.status}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -312,7 +368,7 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-16 text-center text-slate-500 text-xs font-sans">
|
<div className="bg-card border border-line rounded-xl p-16 text-center text-fg-faint text-xs font-sans">
|
||||||
Select a lab scenario template from the left directory column to inspect active port topology connections.
|
Select a lab scenario template from the left directory column to inspect active port topology connections.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -320,17 +376,17 @@ export default function LabTemplates({
|
|||||||
|
|
||||||
{/* FORM MODAL: Create or Edit Lab Template */}
|
{/* FORM MODAL: Create or Edit Lab Template */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-overlay backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
<div className="bg-card border border-line-strong w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
|
||||||
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans overflow-x-auto">
|
<div className="bg-inner px-5 py-4 border-b border-line flex items-center justify-between font-sans overflow-x-auto">
|
||||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<Layers className="w-5 h-5 text-emerald-400" />
|
<Layers className="w-5 h-5 text-success" />
|
||||||
{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}
|
{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="text-slate-400 hover:text-white"
|
className="text-fg-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -340,25 +396,23 @@ export default function LabTemplates({
|
|||||||
{/* Name & Location */}
|
{/* Name & Location */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Topology Name</label>
|
<label className="block text-fg-muted font-semibold mb-1">Topology Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
placeholder="e.g. Campus Core OSPF Backup Route"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
|
<label className="block text-fg-muted font-semibold mb-1">Physical Location</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
placeholder="e.g. Server Room R01, Cabinet B"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -366,34 +420,61 @@ export default function LabTemplates({
|
|||||||
{/* Description & Contact person */}
|
{/* Description & Contact person */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Caretaker / Owner</label>
|
<label className="block text-fg-muted font-semibold mb-1">Caretaker / Owner</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
placeholder="e.g. Jane Doe"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Description</label>
|
<label className="block text-fg-muted font-semibold mb-1">Description</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
placeholder="Purpose, VLAN mappings, target device models..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scope toggle */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<label className="block text-fg-muted 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-card border-line-strong text-fg'
|
||||||
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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-primary-soft border-primary text-primary'
|
||||||
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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-line pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
<label className="block text-fg-muted font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
||||||
<p className="text-[10px] text-slate-400 mb-2">Select the devices from the active hardware inventory associated with this testing group.</p>
|
<p className="text-[10px] text-fg-muted mb-2">Select the devices from the active hardware inventory associated with this testing group.</p>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 bg-slate-950/60 p-3 rounded-lg border border-slate-855">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 bg-inner p-3 rounded-lg border border-line">
|
||||||
{devices.map((dev) => {
|
{devices.map((dev) => {
|
||||||
const isChecked = formData.deviceIds.includes(dev.id);
|
const isChecked = formData.deviceIds.includes(dev.id);
|
||||||
return (
|
return (
|
||||||
@ -402,16 +483,16 @@ export default function LabTemplates({
|
|||||||
key={dev.id}
|
key={dev.id}
|
||||||
onClick={() => handleToggleDevice(dev.id)}
|
onClick={() => handleToggleDevice(dev.id)}
|
||||||
className={`p-2 rounded text-left border flex items-center justify-between transition-colors ${
|
className={`p-2 rounded text-left border flex items-center justify-between transition-colors ${
|
||||||
isChecked
|
isChecked
|
||||||
? 'bg-emerald-900/20 border-emerald-500 text-slate-100'
|
? 'bg-success-soft border-success text-fg'
|
||||||
: 'bg-slate-900 border-slate-800 hover:border-slate-700 text-slate-300'
|
: 'bg-card border-line hover:border-line-strong text-fg-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="truncate pr-1">
|
<div className="truncate pr-1">
|
||||||
<p className="font-bold text-[10px] font-mono leading-none">{dev.hostname}</p>
|
<p className="font-bold text-[10px] font-mono leading-none">{dev.hostname}</p>
|
||||||
<p className="text-[9px] font-mono text-slate-400 mt-1">{dev.ip}</p>
|
<p className="text-[9px] font-mono text-fg-muted mt-1">{dev.ip}</p>
|
||||||
</div>
|
</div>
|
||||||
{isChecked && <Check className="w-3.5 h-3.5 text-emerald-400 shrink-0" />}
|
{isChecked && <Check className="w-3.5 h-3.5 text-success shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -419,18 +500,18 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Physical/Logical topology builder link creator */}
|
{/* Physical/Logical topology builder link creator */}
|
||||||
<div className="border-t border-slate-800 pt-3">
|
<div className="border-t border-line pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5">2. Define Ports & Link Connections</label>
|
<label className="block text-fg-muted font-bold mb-1.5">2. Define Ports & Link Connections</label>
|
||||||
<p className="text-[10px] text-slate-400 mb-2">Model logical and physical patch cabling among the selected devices (e.g. Trunk aggregation profiles, LACP uplinks).</p>
|
<p className="text-[10px] text-fg-muted mb-2">Model logical and physical patch cabling among the selected devices (e.g. Trunk aggregation profiles, LACP uplinks).</p>
|
||||||
|
|
||||||
{/* Connection Inputs */}
|
{/* Connection Inputs */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2.5 p-2 bg-slate-1000 border border-slate-800 rounded-lg items-end mb-3">
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2.5 p-2 bg-inner border border-line rounded-lg items-end mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Source Node</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Source Node</label>
|
||||||
<select
|
<select
|
||||||
value={linkFrom}
|
value={linkFrom}
|
||||||
onChange={(e) => setLinkFrom(e.target.value)}
|
onChange={(e) => setLinkFrom(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
|
className="w-full bg-field text-fg border border-line-strong rounded p-1 font-mono text-[11px]"
|
||||||
>
|
>
|
||||||
<option value="">-- Choose --</option>
|
<option value="">-- Choose --</option>
|
||||||
{formData.deviceIds.map((id) => {
|
{formData.deviceIds.map((id) => {
|
||||||
@ -440,11 +521,11 @@ export default function LabTemplates({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Target Node</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Target Node</label>
|
||||||
<select
|
<select
|
||||||
value={linkTo}
|
value={linkTo}
|
||||||
onChange={(e) => setLinkTo(e.target.value)}
|
onChange={(e) => setLinkTo(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
|
className="w-full bg-field text-fg border border-line-strong rounded p-1 font-mono text-[11px]"
|
||||||
>
|
>
|
||||||
<option value="">-- Choose --</option>
|
<option value="">-- Choose --</option>
|
||||||
{formData.deviceIds.map((id) => {
|
{formData.deviceIds.map((id) => {
|
||||||
@ -454,11 +535,10 @@ export default function LabTemplates({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Link Identifier Description (Label)</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Link Identifier Description (Label)</label>
|
||||||
<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-field text-fg border border-line-strong 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)}
|
||||||
/>
|
/>
|
||||||
@ -481,8 +561,8 @@ export default function LabTemplates({
|
|||||||
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;
|
const isEditingThis = editingLinkIdx === idx;
|
||||||
return (
|
return (
|
||||||
<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">
|
<div key={idx} className="flex items-center gap-2 bg-inner px-3 py-1.5 rounded border border-line font-mono text-[10px] hover:border-line-strong">
|
||||||
<span className="text-slate-300 shrink-0"><strong>{fromDev}</strong> ────</span>
|
<span className="text-fg-muted shrink-0"><strong>{fromDev}</strong> ────</span>
|
||||||
{isEditingThis ? (
|
{isEditingThis ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -503,17 +583,17 @@ export default function LabTemplates({
|
|||||||
}
|
}
|
||||||
if (e.key === 'Escape') 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"
|
className="flex-1 min-w-0 bg-field text-fg border border-primary 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="flex-1 min-w-0 text-primary truncate">{link.type}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-slate-300 shrink-0">──── <strong>{toDev}</strong></span>
|
<span className="text-fg-muted shrink-0">──── <strong>{toDev}</strong></span>
|
||||||
<div className="flex gap-1.5 shrink-0 ml-auto font-sans">
|
<div className="flex gap-1.5 shrink-0 ml-auto font-sans">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditingLinkIdx(idx); setEditingLinkLabel(link.type); }}
|
onClick={() => { setEditingLinkIdx(idx); setEditingLinkLabel(link.type); }}
|
||||||
className="text-slate-400 hover:text-indigo-400 transition-colors"
|
className="text-fg-muted hover:text-primary transition-colors"
|
||||||
title="Edit label"
|
title="Edit label"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
@ -521,7 +601,7 @@ export default function LabTemplates({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveLink(idx)}
|
onClick={() => handleRemoveLink(idx)}
|
||||||
className="text-rose-500 hover:text-rose-400 font-bold"
|
className="text-rose hover:opacity-80 font-bold"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -531,57 +611,55 @@ export default function LabTemplates({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[10px] text-slate-500 italic">No interface connections formulated yet.</p>
|
<p className="text-[10px] text-fg-faint italic">No interface connections formulated yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ansible Semaphore Automation */}
|
{/* Ansible Semaphore Automation */}
|
||||||
<div className="border-t border-slate-800 pt-3">
|
{semaphoreEnabled && <div className="border-t border-line pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5 flex items-center gap-1.5">
|
<label className="block text-fg-muted font-bold mb-1.5 flex items-center gap-1.5">
|
||||||
<Terminal className="w-3.5 h-3.5 text-orange-400" />
|
<Terminal className="w-3.5 h-3.5 text-orange" />
|
||||||
3. Ansible Automation (optional)
|
3. Ansible Automation (optional)
|
||||||
</label>
|
</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>
|
<p className="text-[10px] text-fg-muted 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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Setup Template ID</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Setup Template ID</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.semaphoreSetupTemplateId}
|
value={formData.semaphoreSetupTemplateId}
|
||||||
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
|
||||||
placeholder="e.g. 3"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Teardown Template ID</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Teardown Template ID</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.semaphoreTeardownTemplateId}
|
value={formData.semaphoreTeardownTemplateId}
|
||||||
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
|
||||||
placeholder="e.g. 4"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Form submit handlers */}
|
{/* Form submit handlers */}
|
||||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
<div className="pt-3 border-t border-line flex justify-end gap-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs animate-none"
|
className="px-4 py-2 bg-inner hover:bg-card text-fg-muted rounded font-semibold text-xs animate-none"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
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-white font-bold rounded text-xs animate-none"
|
||||||
>
|
>
|
||||||
Save Lab Template
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -20,12 +20,12 @@ interface LinkDashboardProps {
|
|||||||
|
|
||||||
// Accent palette - keys are stored in the DB so they survive reloads.
|
// Accent palette - keys are stored in the DB so they survive reloads.
|
||||||
const ACCENTS: Record<string, { ring: string; text: string; bg: string; dot: string; bar: string }> = {
|
const ACCENTS: Record<string, { ring: string; text: string; bg: string; dot: string; bar: string }> = {
|
||||||
emerald: { ring: 'hover:border-emerald-500/60', text: 'text-emerald-400', bg: 'bg-emerald-950/40', dot: 'bg-emerald-500', bar: 'bg-emerald-500' },
|
emerald: { ring: 'hover:border-success', text: 'text-success', bg: 'bg-success-soft', dot: 'bg-success', bar: 'bg-success' },
|
||||||
cyan: { ring: 'hover:border-cyan-500/60', text: 'text-cyan-400', bg: 'bg-cyan-950/40', dot: 'bg-cyan-500', bar: 'bg-cyan-500' },
|
cyan: { ring: 'hover:border-info', text: 'text-info', bg: 'bg-info-soft', dot: 'bg-info', bar: 'bg-info' },
|
||||||
indigo: { ring: 'hover:border-indigo-500/60', text: 'text-indigo-400', bg: 'bg-indigo-950/40', dot: 'bg-indigo-500', bar: 'bg-indigo-500' },
|
indigo: { ring: 'hover:border-primary', text: 'text-primary', bg: 'bg-primary-soft', dot: 'bg-primary', bar: 'bg-primary' },
|
||||||
amber: { ring: 'hover:border-amber-500/60', text: 'text-amber-400', bg: 'bg-amber-950/40', dot: 'bg-amber-500', bar: 'bg-amber-500' },
|
amber: { ring: 'hover:border-warning', text: 'text-warning', bg: 'bg-warning-soft', dot: 'bg-warning', bar: 'bg-warning' },
|
||||||
rose: { ring: 'hover:border-rose-500/60', text: 'text-rose-400', bg: 'bg-rose-950/40', dot: 'bg-rose-500', bar: 'bg-rose-500' },
|
rose: { ring: 'hover:border-rose', text: 'text-rose', bg: 'bg-rose-soft', dot: 'bg-rose', bar: 'bg-rose' },
|
||||||
violet: { ring: 'hover:border-violet-500/60', text: 'text-violet-400', bg: 'bg-violet-950/40', dot: 'bg-violet-500', bar: 'bg-violet-500' },
|
violet: { ring: 'hover:border-violet', text: 'text-violet', bg: 'bg-violet-soft', dot: 'bg-violet', bar: 'bg-violet' },
|
||||||
};
|
};
|
||||||
const ACCENT_KEYS = Object.keys(ACCENTS);
|
const ACCENT_KEYS = Object.keys(ACCENTS);
|
||||||
const accent = (key: string) => ACCENTS[key] ?? ACCENTS.emerald;
|
const accent = (key: string) => ACCENTS[key] ?? ACCENTS.emerald;
|
||||||
@ -138,23 +138,23 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
<div className="space-y-6 font-sans" id="link-dashboard-root">
|
<div className="space-y-6 font-sans" id="link-dashboard-root">
|
||||||
|
|
||||||
{/* Header banner */}
|
{/* Header banner */}
|
||||||
<div className="bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm relative overflow-hidden">
|
<div className="bg-card border border-line rounded-2xl p-6 shadow-sm relative overflow-hidden">
|
||||||
<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="absolute top-0 right-0 p-8 text-fg-faint pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
|
||||||
LINKS
|
LINKS
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
<h2 className="text-2xl font-bold tracking-tight text-fg flex items-center gap-2.5">
|
||||||
<LinkIcon className="w-6 h-6 text-emerald-400" />
|
<LinkIcon className="w-6 h-6 text-success" />
|
||||||
Tooling & Quick Links
|
Tooling & Quick Links
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
<p className="text-xs text-fg-muted max-w-xl leading-relaxed">
|
||||||
The team's bookmark bar that doesn't live in one person's browser. CheckMK, Semaphore, Grafana, that one wiki page nobody can ever find again. Stored in SQLite, shared with everyone.
|
The team's bookmark bar that doesn't live in one person's browser. CheckMK, Semaphore, Grafana, that one wiki page nobody can ever find again. Stored in SQLite, shared with everyone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={openAdd}
|
onClick={openAdd}
|
||||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 shrink-0 hover:cursor-pointer"
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 shrink-0 hover:cursor-pointer"
|
||||||
id="btn-add-link">
|
id="btn-add-link">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Link
|
Add Link
|
||||||
@ -163,21 +163,20 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar: search + category filter */}
|
{/* Toolbar: search + category filter */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 p-3 bg-[#1E293B] border border-slate-800 rounded-xl">
|
<div className="flex flex-col sm:flex-row gap-3 p-3 bg-card border border-line rounded-xl">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<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-fg-faint"><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-field text-fg border border-line-strong rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 flex-wrap shrink-0">
|
<div className="flex gap-1 flex-wrap shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveCategory('all')}
|
onClick={() => setActiveCategory('all')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === 'all' ? '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'}`}
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === 'all' ? 'bg-success-soft border border-success text-success' : 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'}`}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
@ -185,7 +184,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setActiveCategory(cat)}
|
onClick={() => setActiveCategory(cat)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === cat ? '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'}`}
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === cat ? 'bg-success-soft border border-success text-success' : 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'}`}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
@ -195,30 +194,30 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{links.length === 0 ? (
|
{links.length === 0 ? (
|
||||||
<div className="text-center py-16 bg-[#1E293B] border border-dashed border-slate-700 rounded-2xl">
|
<div className="text-center py-16 bg-card border border-dashed border-line-strong rounded-2xl">
|
||||||
<Globe className="w-10 h-10 text-slate-600 mx-auto mb-3 opacity-60" />
|
<Globe className="w-10 h-10 text-fg-faint mx-auto mb-3 opacity-60" />
|
||||||
<h3 className="text-sm font-bold text-slate-200">404: links not found</h3>
|
<h3 className="text-sm font-bold text-fg">404: links not found</h3>
|
||||||
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
<p className="text-xs text-fg-muted mt-1 max-w-sm mx-auto">
|
||||||
Drop in the daily-driver tools - monitoring, automation, ticketing - so nobody has to ask "what's the URL for CheckMK again?" for the 40th time.
|
Drop in the daily-driver tools - monitoring, automation, ticketing - so nobody has to ask "what's the URL for CheckMK again?" for the 40th time.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={openAdd}
|
onClick={openAdd}
|
||||||
className="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg inline-flex items-center gap-1.5 hover:cursor-pointer"
|
className="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs rounded-lg inline-flex items-center gap-1.5 hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> Add your first link
|
<Plus className="w-4 h-4" /> Add your first link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<p className="text-center py-16 text-slate-500 text-xs">No links match your search.</p>
|
<p className="text-center py-16 text-fg-faint text-xs">No links match your search.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{grouped.map(([category, items]) => (
|
{grouped.map(([category, items]) => (
|
||||||
<section key={category}>
|
<section key={category}>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<FolderOpen className="w-4 h-4 text-slate-500" />
|
<FolderOpen className="w-4 h-4 text-fg-faint" />
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">{category}</h3>
|
<h3 className="text-xs font-bold uppercase tracking-wider text-fg-muted font-mono">{category}</h3>
|
||||||
<span className="text-[10px] text-slate-600 font-mono">({items.length})</span>
|
<span className="text-[10px] text-fg-faint font-mono">({items.length})</span>
|
||||||
<div className="flex-1 h-px bg-slate-850 ml-2" />
|
<div className="flex-1 h-px bg-line ml-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
@ -227,12 +226,12 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={link.id}
|
key={link.id}
|
||||||
className={`group relative bg-[#1E293B] border border-slate-800 rounded-xl p-4 transition-all ${a.ring} hover:shadow-lg`}
|
className={`group relative bg-card border border-line rounded-xl p-4 transition-all ${a.ring} hover:shadow-lg`}
|
||||||
>
|
>
|
||||||
<span className={`absolute top-0 left-0 bottom-0 w-1 rounded-l-xl ${a.bar} opacity-70`} />
|
<span className={`absolute top-0 left-0 bottom-0 w-1 rounded-l-xl ${a.bar} opacity-70`} />
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-slate-800 flex items-center justify-center shrink-0 overflow-hidden`}>
|
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-line flex items-center justify-center shrink-0 overflow-hidden`}>
|
||||||
<Globe className={`w-5 h-5 ${a.text}`} />
|
<Globe className={`w-5 h-5 ${a.text}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -241,7 +240,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm font-bold text-white hover:underline flex items-center gap-1.5 truncate"
|
className="text-sm font-bold text-fg hover:underline flex items-center gap-1.5 truncate"
|
||||||
title={link.title}
|
title={link.title}
|
||||||
>
|
>
|
||||||
<span className="truncate">{link.title}</span>
|
<span className="truncate">{link.title}</span>
|
||||||
@ -262,14 +261,13 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitDescEdit(link); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitDescEdit(link); }
|
||||||
if (e.key === 'Escape') { setEditingDescId(null); }
|
if (e.key === 'Escape') { setEditingDescId(null); }
|
||||||
}}
|
}}
|
||||||
className="w-full mt-3 bg-slate-950 text-slate-200 text-[11px] border border-emerald-600 rounded px-2 py-1 resize-none focus:outline-none leading-relaxed"
|
className="w-full mt-3 bg-field text-fg text-[11px] border border-success rounded px-2 py-1 resize-none focus:outline-none leading-relaxed"
|
||||||
placeholder="Add a description…"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p
|
<p
|
||||||
onClick={() => startDescEdit(link)}
|
onClick={() => startDescEdit(link)}
|
||||||
title="Click to edit description"
|
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`}
|
className={`text-[11px] leading-relaxed mt-3 line-clamp-2 cursor-text ${link.description ? 'text-fg-muted hover:text-fg' : 'text-fg-faint italic hover:text-fg-muted'} transition-colors`}
|
||||||
>
|
>
|
||||||
{link.description || 'Add a description…'}
|
{link.description || 'Add a description…'}
|
||||||
</p>
|
</p>
|
||||||
@ -280,7 +278,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
<button
|
<button
|
||||||
onClick={() => openEdit(link)}
|
onClick={() => openEdit(link)}
|
||||||
title="Edit link"
|
title="Edit link"
|
||||||
className="p-1.5 rounded-md bg-slate-900/90 border border-slate-800 text-slate-400 hover:text-emerald-400 hover:border-emerald-600 transition-all hover:cursor-pointer"
|
className="p-1.5 rounded-md bg-card border border-line text-fg-muted hover:text-success hover:border-success transition-all hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -289,7 +287,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
if (confirm(`Remove "${link.title}" from the shared link dashboard?`)) onDeleteLink(link.id);
|
if (confirm(`Remove "${link.title}" from the shared link dashboard?`)) onDeleteLink(link.id);
|
||||||
}}
|
}}
|
||||||
title="Delete link"
|
title="Delete link"
|
||||||
className="p-1.5 rounded-md bg-slate-900/90 border border-slate-800 text-slate-400 hover:text-rose-400 hover:border-rose-600 transition-all hover:cursor-pointer"
|
className="p-1.5 rounded-md bg-card border border-line text-fg-muted hover:text-rose hover:border-rose transition-all hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -305,50 +303,47 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
|
|
||||||
{/* Add / Edit modal */}
|
{/* Add / Edit modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-150" onClick={closeForm}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-overlay backdrop-blur-sm p-4 animate-in fade-in duration-150" onClick={closeForm}>
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-md bg-[#1E293B] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150"
|
className="w-full max-w-md bg-card border border-line-strong rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="bg-slate-900 px-5 py-3.5 border-b border-slate-800 flex items-center justify-between">
|
<div className="bg-inner px-5 py-3.5 border-b border-line flex items-center justify-between">
|
||||||
<h3 className="font-bold text-sm text-white flex items-center gap-2">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2">
|
||||||
<Star className="w-4 h-4 text-emerald-400" />
|
<Star className="w-4 h-4 text-success" />
|
||||||
{editingId ? 'Edit Link' : 'New Quick Link'}
|
{editingId ? 'Edit Link' : 'New Quick Link'}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={closeForm} className="text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
|
<button onClick={closeForm} className="text-fg-muted hover:text-fg"><X className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
|
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Title *</label>
|
<label className="block text-fg-muted font-semibold mb-1">Title *</label>
|
||||||
<input
|
<input
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">URL *</label>
|
<label className="block text-fg-muted font-semibold mb-1">URL *</label>
|
||||||
<input
|
<input
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Category</label>
|
<label className="block text-fg-muted font-semibold mb-1">Category</label>
|
||||||
<input
|
<input
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
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">
|
||||||
{categories.map(c => <option key={c} value={c} />)}
|
{categories.map(c => <option key={c} value={c} />)}
|
||||||
@ -356,25 +351,24 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Description</label>
|
<label className="block text-fg-muted font-semibold mb-1">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1.5">Accent</label>
|
<label className="block text-fg-muted font-semibold mb-1.5">Accent</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{ACCENT_KEYS.map(key => (
|
{ACCENT_KEYS.map(key => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setDraft({ ...draft, color: key })}
|
onClick={() => setDraft({ ...draft, color: key })}
|
||||||
className={`w-7 h-7 rounded-full ${accent(key).dot} transition-all hover:cursor-pointer ${draft.color === key ? 'ring-2 ring-offset-2 ring-offset-[#1E293B] ring-white scale-110' : 'opacity-70 hover:opacity-100'}`}
|
className={`w-7 h-7 rounded-full ${accent(key).dot} transition-all hover:cursor-pointer ${draft.color === key ? 'ring-2 ring-offset-2 ring-offset-card ring-fg scale-110' : 'opacity-70 hover:opacity-100'}`}
|
||||||
title={key}
|
title={key}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -382,10 +376,10 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button type="button" onClick={closeForm} className="flex-1 py-2 bg-slate-900 border border-slate-800 text-slate-300 hover:text-white rounded font-semibold transition-colors hover:cursor-pointer">
|
<button type="button" onClick={closeForm} className="flex-1 py-2 bg-inner border border-line text-fg-muted hover:text-fg rounded font-semibold transition-colors hover:cursor-pointer">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="flex-1 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded flex items-center justify-center gap-1.5 transition-colors hover:cursor-pointer">
|
<button type="submit" className="flex-1 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded flex items-center justify-center gap-1.5 transition-colors hover:cursor-pointer">
|
||||||
<Save className="w-3.5 h-3.5" />
|
<Save className="w-3.5 h-3.5" />
|
||||||
{editingId ? 'Save Changes' : 'Add Link'}
|
{editingId ? 'Save Changes' : 'Add Link'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -20,8 +20,8 @@ interface LogbookProps {
|
|||||||
|
|
||||||
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
|
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('non-system');
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
|
|
||||||
// Custom Maintenance Log state
|
// Custom Maintenance Log state
|
||||||
const [showAddLog, setShowAddLog] = useState(false);
|
const [showAddLog, setShowAddLog] = useState(false);
|
||||||
const [targetDeviceId, setTargetDeviceId] = useState('');
|
const [targetDeviceId, setTargetDeviceId] = useState('');
|
||||||
@ -35,7 +35,6 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
|
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
const matchesType =
|
const matchesType =
|
||||||
typeFilter === 'all' ? true :
|
typeFilter === 'all' ? true :
|
||||||
typeFilter === 'non-system' ? log.type !== 'system' :
|
|
||||||
log.type === typeFilter;
|
log.type === typeFilter;
|
||||||
return matchesSearch && matchesType;
|
return matchesSearch && matchesType;
|
||||||
});
|
});
|
||||||
@ -67,14 +66,14 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
const getLogTypeBadge = (type: string) => {
|
const getLogTypeBadge = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'maintenance':
|
case 'maintenance':
|
||||||
return 'bg-amber-950 border border-amber-900/60 text-amber-400';
|
return 'bg-warning-soft border border-warning-line text-warning';
|
||||||
case 'booking':
|
case 'booking':
|
||||||
return 'bg-emerald-950 border border-emerald-900/60 text-emerald-400';
|
return 'bg-success-soft border border-success-line text-success';
|
||||||
case 'status':
|
case 'status':
|
||||||
return 'bg-cyan-950 border border-cyan-900/60 text-cyan-400';
|
return 'bg-info-soft border border-info-line text-info';
|
||||||
case 'system':
|
case 'system':
|
||||||
default:
|
default:
|
||||||
return 'bg-slate-900 border border-slate-800 text-slate-350';
|
return 'bg-inner border border-line text-fg-muted';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -91,42 +90,41 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-sans" id="logbook-dashboard">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-sans" id="logbook-dashboard">
|
||||||
|
|
||||||
{/* LEFT COLUMN: Chronological Log List */}
|
{/* LEFT COLUMN: Chronological Log List */}
|
||||||
<div className="lg:col-span-8 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="logbook-ledger-card">
|
<div className="lg:col-span-8 bg-card border border-line rounded-xl p-5 flex flex-col h-full" id="logbook-ledger-card">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2">
|
||||||
<History className="w-5 h-5 text-emerald-400" />
|
<History className="w-5 h-5 text-success" />
|
||||||
Audit Log & Maintenance Journal
|
Audit Log & Maintenance Journal
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">Append-only history of who touched what. git blame, but for the lab.</p>
|
<p className="text-xs text-fg-muted">Append-only history of who touched what. git blame, but for the lab.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddLog(!showAddLog)}
|
onClick={() => setShowAddLog(!showAddLog)}
|
||||||
className="px-3 py-1.5 bg-slate-900 border border-slate-800 text-slate-200 hover:text-emerald-400 hover:border-emerald-500 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all font-sans hover:cursor-pointer"
|
className="px-3 py-1.5 bg-inner border border-line text-fg hover:text-success hover:border-success rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all font-sans hover:cursor-pointer"
|
||||||
id="btn-toggle-add-log"
|
id="btn-toggle-add-log"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 text-emerald-400" />
|
<Plus className="w-4 h-4 text-success" />
|
||||||
File Maintenance Report
|
File Maintenance Report
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters toolbar */}
|
{/* Search and Filters toolbar */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-850 rounded-lg">
|
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-inner border border-line rounded-lg">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-550">
|
<span className="absolute left-3 top-2.5 text-fg-faint">
|
||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</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-field text-fg border border-line-strong 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 flex-wrap">
|
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
|
||||||
{[
|
{[
|
||||||
{ key: 'non-system', label: 'All' },
|
{ key: 'all', label: 'All' },
|
||||||
{ key: 'booking', label: 'Booking' },
|
{ key: 'booking', label: 'Booking' },
|
||||||
{ key: 'maintenance',label: 'Maintenance' },
|
{ key: 'maintenance',label: 'Maintenance' },
|
||||||
{ key: 'status', label: 'Status' },
|
{ key: 'status', label: 'Status' },
|
||||||
@ -137,8 +135,8 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
onClick={() => setTypeFilter(key)}
|
onClick={() => setTypeFilter(key)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
typeFilter === key
|
typeFilter === key
|
||||||
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
|
? 'bg-success-soft border border-success text-success'
|
||||||
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
|
: 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@ -150,7 +148,7 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
{/* Audit Log Sheet */}
|
{/* Audit Log Sheet */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
|
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
|
||||||
{filteredLogs.length === 0 ? (
|
{filteredLogs.length === 0 ? (
|
||||||
<p className="text-center py-16 text-slate-500 text-xs">
|
<p className="text-center py-16 text-fg-faint text-xs">
|
||||||
No audit records match the selected filtering rules.
|
No audit records match the selected filtering rules.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@ -163,29 +161,29 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={log.id} className="p-3 bg-slate-900/40 border border-slate-855 rounded-xl hover:border-slate-800 hover:bg-slate-900/70 transition-all flex items-start gap-3.5">
|
<div key={log.id} className="p-3 bg-inner border border-line rounded-xl hover:border-line-strong hover:bg-card transition-all flex items-start gap-3.5">
|
||||||
<div className="flex flex-col items-center gap-1.5 shrink-0 pt-0.5 min-w-[80px]">
|
<div className="flex flex-col items-center gap-1.5 shrink-0 pt-0.5 min-w-[80px]">
|
||||||
<span className={`text-[9px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded font-sans scale-90 text-center block w-full ${getLogTypeBadge(log.type)}`}>
|
<span className={`text-[9px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded font-sans scale-90 text-center block w-full ${getLogTypeBadge(log.type)}`}>
|
||||||
{getLogTypeLabel(log.type)}
|
{getLogTypeLabel(log.type)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] font-mono text-slate-500 leading-none">
|
<span className="text-[9px] font-mono text-fg-faint leading-none">
|
||||||
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-xs text-slate-200 leading-relaxed font-sans">{log.message}</p>
|
<p className="text-xs text-fg leading-relaxed font-sans">{log.message}</p>
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-[10px] font-mono text-slate-500 pt-1.5 border-t border-slate-900/40">
|
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-[10px] font-mono text-fg-faint pt-1.5 border-t border-line">
|
||||||
<span>Calendar Time: {timestampFormatted}</span>
|
<span>Calendar Time: {timestampFormatted}</span>
|
||||||
{user && (
|
{user && (
|
||||||
<span className="flex items-center gap-1 text-slate-400">
|
<span className="flex items-center gap-1 text-fg-muted">
|
||||||
<UserIcon className="w-3 h-3 text-slate-500" />
|
<UserIcon className="w-3 h-3 text-fg-faint" />
|
||||||
Operator: {user.name}
|
Operator: {user.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{dev && (
|
{dev && (
|
||||||
<span className="flex items-center gap-1 text-emerald-450 font-semibold">
|
<span className="flex items-center gap-1 text-success font-semibold">
|
||||||
<Server className="w-3 h-3 text-slate-500" />
|
<Server className="w-3 h-3 text-fg-faint" />
|
||||||
Node: {dev.hostname}
|
Node: {dev.hostname}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -201,15 +199,15 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
|
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
|
||||||
<div className="lg:col-span-4" id="logbook-forms-side">
|
<div className="lg:col-span-4" id="logbook-forms-side">
|
||||||
{showAddLog ? (
|
{showAddLog ? (
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm space-y-4 animate-in fade-in slide-in-from-right-3 duration-200 font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm space-y-4 animate-in fade-in slide-in-from-right-3 duration-200 font-sans">
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800">
|
<div className="flex items-center justify-between pb-2 border-b border-line">
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-1.5">
|
||||||
<Hammer className="w-4 h-4 text-amber-500" />
|
<Hammer className="w-4 h-4 text-warning" />
|
||||||
Journal Maintenance Work
|
Journal Maintenance Work
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddLog(false)}
|
onClick={() => setShowAddLog(false)}
|
||||||
className="text-slate-400 hover:text-white"
|
className="text-fg-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@ -217,11 +215,11 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
|
|
||||||
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
|
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Target Network Host (Optional)</label>
|
<label className="block text-fg-muted font-semibold mb-1">Target Network Host (Optional)</label>
|
||||||
<select
|
<select
|
||||||
value={targetDeviceId}
|
value={targetDeviceId}
|
||||||
onChange={(e) => setTargetDeviceId(e.target.value)}
|
onChange={(e) => setTargetDeviceId(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-field text-fg border border-line-strong rounded p-2 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">-- Complete Lab Cluster / General Event --</option>
|
<option value="">-- Complete Lab Cluster / General Event --</option>
|
||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
@ -233,25 +231,24 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Documented Actions / Findings</label>
|
<label className="block text-fg-muted font-semibold mb-1">Documented Actions / Findings</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
rows={4}
|
rows={4}
|
||||||
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-field text-fg border border-line-strong rounded p-2 focus:outline-none"
|
||||||
placeholder="Describe SFP fiber replacements, port trunking alterations, routing table diagnostic evaluations..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-900 border border-slate-800 p-2.5 rounded text-[11px] text-slate-450 leading-normal flex gap-2">
|
<div className="bg-inner border border-line p-2.5 rounded text-[11px] text-fg-muted leading-normal flex gap-2">
|
||||||
<Info className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
<Info className="w-4 h-4 text-success shrink-0 mt-0.5" />
|
||||||
<span>This writes straight to the shared ledger - everyone on the team sees it, so spell it like the next on-call has to read it at 3am.</span>
|
<span>This writes straight to the shared ledger - everyone on the team sees it, so spell it like the next on-call has to read it at 3am.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center justify-center gap-1.5 transition-colors"
|
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs flex items-center justify-center gap-1.5 transition-colors"
|
||||||
>
|
>
|
||||||
<Save className="w-3.5 h-3.5" />
|
<Save className="w-3.5 h-3.5" />
|
||||||
Publish to Shared Log Book
|
Publish to Shared Log Book
|
||||||
@ -259,15 +256,15 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 shadow-sm text-xs text-slate-400 font-sans leading-relaxed">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm text-xs text-fg-muted font-sans leading-relaxed">
|
||||||
<h3 className="font-bold text-white mb-2 text-sm flex items-center gap-2">
|
<h3 className="font-bold text-fg mb-2 text-sm flex items-center gap-2">
|
||||||
<ChevronRight className="w-4 h-4 text-emerald-400 shrink-0" />
|
<ChevronRight className="w-4 h-4 text-success shrink-0" />
|
||||||
Shared Audit & Fault Logging
|
Shared Audit & Fault Logging
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
Labs drift. If you hit an STP block, a bridging loop or that one port that mysteriously err-disables itself, write down how you dug out. future-you (and the next on-call) will thank you.
|
Labs drift. If you hit an STP block, a bridging loop or that one port that mysteriously err-disables itself, write down how you dug out. future-you (and the next on-call) will thank you.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 p-3 bg-amber-950/20 border border-amber-900/40 rounded-lg text-amber-300 font-mono text-[10px]">
|
<div className="mt-4 p-3 bg-warning-soft border border-warning-line rounded-lg text-warning font-mono text-[10px]">
|
||||||
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
|
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -52,21 +52,21 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
|
||||||
{/* Logo & Brand */}
|
{/* Logo & Brand */}
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<div className="inline-flex p-3 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)]">
|
<div className="inline-flex p-3 bg-card border border-line rounded-2xl shadow-lg">
|
||||||
<GhostGridLogo className="w-14 h-14" />
|
<GhostGridLogo className="w-14 h-14" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-fg">GhostGrid</h1>
|
||||||
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
|
<p className="text-[10px] font-mono text-info tracking-widest uppercase mt-0.5">
|
||||||
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-1.5 mt-2">
|
<div className="flex items-center justify-center gap-1.5 mt-2">
|
||||||
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
|
<span className="text-[9px] text-fg-faint font-sans">A product by</span>
|
||||||
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
|
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
|
||||||
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
||||||
AirITSystems
|
AirITSystems
|
||||||
@ -76,22 +76,22 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Card */}
|
{/* Login Card */}
|
||||||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
|
<div className="bg-card border border-line rounded-2xl p-8 shadow-2xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Sign in</h2>
|
<h2 className="text-lg font-semibold text-fg">Sign in</h2>
|
||||||
<p className="text-xs text-slate-400 mt-1">Enter your credentials to access the platform.</p>
|
<p className="text-xs text-fg-muted mt-1">Enter your credentials to access the platform.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 bg-red-950/60 border border-red-800/60 rounded-lg px-3 py-2.5 text-xs text-red-300">
|
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2.5 text-xs text-danger">
|
||||||
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
|
<AlertCircle className="w-4 h-4 shrink-0 text-danger" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="email">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="email">
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -101,13 +101,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
required
|
required
|
||||||
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-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
|
||||||
placeholder="user@airit.rocks"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="password">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="password">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -118,13 +117,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
required
|
required
|
||||||
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-field border border-line-strong rounded-lg px-3 py-2.5 pr-10 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(v => !v)}
|
onClick={() => setShowPassword(v => !v)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted hover:text-fg transition-colors"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
@ -135,7 +133,7 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg shadow-cyan-900/30"
|
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-line-strong disabled:text-fg-faint text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
@ -149,14 +147,14 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
{azureEnabled && (
|
{azureEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 h-px bg-slate-800" />
|
<div className="flex-1 h-px bg-line" />
|
||||||
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-widest">or</span>
|
<span className="text-[10px] font-mono text-fg-faint uppercase tracking-widest">or</span>
|
||||||
<div className="flex-1 h-px bg-slate-800" />
|
<div className="flex-1 h-px bg-line" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { window.location.href = '/api/auth/azure'; }}
|
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"
|
className="w-full flex items-center justify-center gap-3 bg-inner hover:bg-card border border-line hover:border-line-strong text-fg font-semibold text-sm py-2.5 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
{/* Microsoft M logo */}
|
{/* 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">
|
<svg width="18" height="18" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
@ -170,11 +168,11 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-center text-xs text-slate-400">
|
<p className="text-center text-xs text-fg-muted">
|
||||||
No account yet?{' '}
|
No account yet?{' '}
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToRegister}
|
onClick={onNavigateToRegister}
|
||||||
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
|
className="text-info hover:opacity-80 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Create one
|
Create one
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -56,21 +56,21 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
|
||||||
{/* Logo & Brand */}
|
{/* Logo & Brand */}
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<div className="inline-flex p-3 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)]">
|
<div className="inline-flex p-3 bg-card border border-line rounded-2xl shadow-lg">
|
||||||
<GhostGridLogo className="w-14 h-14" />
|
<GhostGridLogo className="w-14 h-14" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-fg">GhostGrid</h1>
|
||||||
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
|
<p className="text-[10px] font-mono text-info tracking-widest uppercase mt-0.5">
|
||||||
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-1.5 mt-2">
|
<div className="flex items-center justify-center gap-1.5 mt-2">
|
||||||
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
|
<span className="text-[9px] text-fg-faint font-sans">A product by</span>
|
||||||
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
|
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
|
||||||
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
||||||
AirIT Systems
|
AirIT Systems
|
||||||
@ -80,22 +80,22 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Register Card */}
|
{/* Register Card */}
|
||||||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
|
<div className="bg-card border border-line rounded-2xl p-8 shadow-2xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Create account</h2>
|
<h2 className="text-lg font-semibold text-fg">Create account</h2>
|
||||||
<p className="text-xs text-slate-400 mt-1">Register to gain access to the platform.</p>
|
<p className="text-xs text-fg-muted mt-1">Register to gain access to the platform.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 bg-red-950/60 border border-red-800/60 rounded-lg px-3 py-2.5 text-xs text-red-300">
|
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2.5 text-xs text-danger">
|
||||||
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
|
<AlertCircle className="w-4 h-4 shrink-0 text-danger" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-name">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-name">
|
||||||
Full name
|
Full name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -105,13 +105,12 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
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-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
|
||||||
placeholder="Max Mustermann"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-email">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-email">
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -121,13 +120,12 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
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-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-password">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-password">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -138,20 +136,19 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
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-field border border-line-strong rounded-lg px-3 py-2.5 pr-10 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
|
||||||
placeholder="Min. 8 characters"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(v => !v)}
|
onClick={() => setShowPassword(v => !v)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted hover:text-fg transition-colors"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{password.length > 0 && (
|
{password.length > 0 && (
|
||||||
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-emerald-400' : 'text-amber-400'}`}>
|
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-success' : 'text-warning'}`}>
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
{passwordStrong ? 'Password meets requirements' : 'At least 8 characters required'}
|
{passwordStrong ? 'Password meets requirements' : 'At least 8 characters required'}
|
||||||
</div>
|
</div>
|
||||||
@ -159,7 +156,7 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-confirm">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-confirm">
|
||||||
Confirm password
|
Confirm password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -169,19 +166,18 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
className={`w-full bg-slate-900 border rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 transition-all ${
|
className={`w-full bg-field border rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 transition-all ${
|
||||||
confirmPassword.length > 0 && confirmPassword !== password
|
confirmPassword.length > 0 && confirmPassword !== password
|
||||||
? 'border-red-700 focus:ring-red-500/50'
|
? 'border-danger focus:ring-danger/50'
|
||||||
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
|
: 'border-line-strong focus:ring-info/50 focus:border-info'
|
||||||
}`}
|
}`}
|
||||||
placeholder="Repeat password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg shadow-cyan-900/30"
|
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-line-strong disabled:text-fg-faint text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
@ -192,11 +188,11 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-xs text-slate-400">
|
<p className="text-center text-xs text-fg-muted">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLogin}
|
onClick={onNavigateToLogin}
|
||||||
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
|
className="text-info hover:opacity-80 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -100,60 +100,60 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
|
|||||||
const getDeviceIcon = (type: string) => {
|
const getDeviceIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'Firewall':
|
case 'Firewall':
|
||||||
return <Shield className="w-5 h-5 text-rose-400" />;
|
return <Shield className="w-5 h-5 text-rose" />;
|
||||||
case 'Access-Point':
|
case 'Access-Point':
|
||||||
return <Wifi className="w-5 h-5 text-amber-400" />;
|
return <Wifi className="w-5 h-5 text-warning" />;
|
||||||
case 'Controller':
|
case 'Controller':
|
||||||
return <Cpu className="w-5 h-5 text-cyan-400" />;
|
return <Cpu className="w-5 h-5 text-info" />;
|
||||||
case 'Switch':
|
case 'Switch':
|
||||||
default:
|
default:
|
||||||
return <Server className="w-5 h-5 text-teal-400" />;
|
return <Server className="w-5 h-5 text-success" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDeviceColorClass = (type: string) => {
|
const getDeviceColorClass = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'Firewall':
|
case 'Firewall':
|
||||||
return 'border-rose-500/60 bg-rose-950/20 text-rose-200';
|
return 'border-rose-line bg-rose-soft text-rose';
|
||||||
case 'Access-Point':
|
case 'Access-Point':
|
||||||
return 'border-amber-500/60 bg-amber-950/20 text-amber-200';
|
return 'border-warning-line bg-warning-soft text-warning';
|
||||||
case 'Controller':
|
case 'Controller':
|
||||||
return 'border-cyan-500/60 bg-cyan-950/20 text-cyan-200';
|
return 'border-info-line bg-info-soft text-info';
|
||||||
case 'Switch':
|
case 'Switch':
|
||||||
default:
|
default:
|
||||||
return 'border-teal-500/60 bg-teal-950/20 text-teal-200';
|
return 'border-success-line bg-success-soft text-success';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-inner" id="topology-panel">
|
<div className="bg-card border border-line rounded-xl p-4 shadow-inner" id="topology-panel">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-slate-100 flex items-center gap-2 font-sans">
|
<h3 className="text-sm font-semibold text-fg flex items-center gap-2 font-sans">
|
||||||
<Activity className="w-4 h-4 text-emerald-400 animate-pulse" />
|
<Activity className="w-4 h-4 text-success" />
|
||||||
Interactive Topology Diagram (Physical & Logical Links)
|
Interactive Topology Diagram (Physical & Logical Links)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-slate-400 font-sans">Hover links to inspect port details. Click host nodes to view operational recovery playbooks.</p>
|
<p className="text-[11px] text-fg-muted font-sans">Hover links to inspect port details. Click host nodes to view operational recovery playbooks.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-[10px] font-mono">
|
<div className="flex gap-2 text-[10px] font-mono">
|
||||||
<span className="flex items-center gap-1 text-teal-400 bg-teal-950/40 px-2 py-0.5 rounded border border-teal-900/50">
|
<span className="flex items-center gap-1 text-success bg-success-soft px-2 py-0.5 rounded border border-success-line">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-teal-500"></span> Switch
|
<span className="w-1.5 h-1.5 rounded-full bg-success"></span> Switch
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 text-rose-400 bg-rose-950/40 px-2 py-0.5 rounded border border-rose-900/50">
|
<span className="flex items-center gap-1 text-rose bg-rose-soft px-2 py-0.5 rounded border border-rose-line">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500"></span> Firewall
|
<span className="w-1.5 h-1.5 rounded-full bg-rose"></span> Firewall
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 text-amber-400 bg-amber-950/40 px-2 py-0.5 rounded border border-amber-900/50">
|
<span className="flex items-center gap-1 text-warning bg-warning-soft px-2 py-0.5 rounded border border-warning-line">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500"></span> AP
|
<span className="w-1.5 h-1.5 rounded-full bg-warning"></span> AP
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 text-cyan-400 bg-cyan-950/40 px-2 py-0.5 rounded border border-cyan-900/50">
|
<span className="flex items-center gap-1 text-info bg-info-soft px-2 py-0.5 rounded border border-info-line">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500"></span> WLC
|
<span className="w-1.5 h-1.5 rounded-full bg-info"></span> WLC
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative overflow-auto border border-slate-800 rounded-lg bg-slate-950/60 flex justify-center items-center">
|
<div className="relative overflow-auto border border-line rounded-lg bg-inner flex justify-center items-center">
|
||||||
{devices.length === 0 ? (
|
{devices.length === 0 ? (
|
||||||
<div className="py-20 text-center text-slate-500 text-xs font-sans">
|
<div className="py-20 text-center text-fg-faint text-xs font-sans">
|
||||||
No active hardware nodes assigned. Build or update a lab template to associate physical equipment.
|
No active hardware nodes assigned. Build or update a lab template to associate physical equipment.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -246,8 +246,8 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
|
|||||||
key={`badge-${idx}`}
|
key={`badge-${idx}`}
|
||||||
className={`absolute -translate-x-1/2 -translate-y-1/2 px-2 py-0.5 rounded text-[9px] font-mono transition-all z-20 shadow pointer-events-none ${
|
className={`absolute -translate-x-1/2 -translate-y-1/2 px-2 py-0.5 rounded text-[9px] font-mono transition-all z-20 shadow pointer-events-none ${
|
||||||
isHovered
|
isHovered
|
||||||
? 'bg-emerald-500 text-slate-950 scale-110 font-bold border border-emerald-400 z-30'
|
? 'bg-success text-white scale-110 font-bold border border-success z-30'
|
||||||
: 'bg-slate-800 text-slate-400 border border-slate-700'
|
: 'bg-inner text-fg-muted border border-line'
|
||||||
}`}
|
}`}
|
||||||
style={{ left: layout.apexX, top: layout.apexY }}
|
style={{ left: layout.apexX, top: layout.apexY }}
|
||||||
>
|
>
|
||||||
@ -265,25 +265,25 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
|
|||||||
<button
|
<button
|
||||||
key={device.id}
|
key={device.id}
|
||||||
onClick={() => onSelectDevice && onSelectDevice(device)}
|
onClick={() => onSelectDevice && onSelectDevice(device)}
|
||||||
className={`absolute group -translate-x-1/2 -translate-y-1/2 p-3 border-2 rounded-xl flex flex-col items-center gap-1.5 transition-all text-center z-20 ${getDeviceColorClass(device.type)} hover:bg-slate-900/90 hover:scale-105 hover:border-emerald-500/80 hover:shadow-[0_0_15px_rgba(16,185,129,0.15)]`}
|
className={`absolute group -translate-x-1/2 -translate-y-1/2 p-3 border-2 rounded-xl flex flex-col items-center gap-1.5 transition-all text-center z-20 ${getDeviceColorClass(device.type)} hover:bg-card hover:scale-105 hover:border-success hover:shadow-lg`}
|
||||||
style={{ left: pos.x, top: pos.y }}
|
style={{ left: pos.x, top: pos.y }}
|
||||||
title={`${device.hostname} (${device.ip}) - Click for troubleshooting details`}
|
title={`${device.hostname} (${device.ip}) - Click for troubleshooting details`}
|
||||||
>
|
>
|
||||||
{/* 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-card ${
|
||||||
device.status === 'online' ? 'bg-emerald-500' :
|
device.status === 'online' ? 'bg-success' :
|
||||||
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
|
device.status === 'offline' ? 'bg-rose' : 'bg-fg-faint'
|
||||||
}`} />
|
}`} />
|
||||||
|
|
||||||
<div className="p-1.5 bg-slate-950/60 rounded-lg border border-slate-800/40">
|
<div className="p-1.5 bg-inner rounded-lg border border-line">
|
||||||
{getDeviceIcon(device.type)}
|
{getDeviceIcon(device.type)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="leading-none">
|
<div className="leading-none">
|
||||||
<p className="text-[11px] font-mono font-bold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
|
<p className="text-[11px] font-mono font-bold tracking-tight text-fg group-hover:text-success transition-colors">
|
||||||
{device.hostname}
|
{device.hostname}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[9px] font-mono text-slate-400 group-hover:text-slate-300 mt-0.5">
|
<p className="text-[9px] font-mono text-fg-muted group-hover:text-fg mt-0.5">
|
||||||
{device.ip}
|
{device.ip}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { User, Booking } from '../types';
|
import { User, Booking } from '../types';
|
||||||
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle } from 'lucide-react';
|
import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle, ShieldCheck, Shield } from 'lucide-react';
|
||||||
|
|
||||||
interface UserDirectoryProps {
|
interface UserDirectoryProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
@ -8,6 +8,7 @@ interface UserDirectoryProps {
|
|||||||
bookings: Booking[];
|
bookings: Booking[];
|
||||||
onDeleteUser: (id: string) => Promise<void>;
|
onDeleteUser: (id: string) => Promise<void>;
|
||||||
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
|
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
|
||||||
|
onSetRole: (id: string, role: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AVATAR_COLORS = [
|
const AVATAR_COLORS = [
|
||||||
@ -59,53 +60,53 @@ function EditModal({ user, onClose, onSave }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/70 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-overlay backdrop-blur-sm">
|
||||||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl shadow-2xl w-full max-w-md">
|
<div className="bg-card border border-line 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">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-line">
|
||||||
<h3 className="text-sm font-semibold text-white">Edit User</h3>
|
<h3 className="text-sm font-semibold text-fg">Edit User</h3>
|
||||||
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all">
|
<button onClick={onClose} className="p-1 rounded-lg text-fg-muted hover:text-fg hover:bg-inner transition-all">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
{error && (
|
{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">
|
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2 text-xs text-danger">
|
||||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Name</label>
|
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
required
|
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"
|
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Email address</label>
|
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Email address</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
required
|
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"
|
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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"
|
className="px-4 py-2 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-inner transition-all"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
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"
|
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-line-strong disabled:text-fg-faint 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 ? <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'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
@ -117,10 +118,13 @@ function EditModal({ user, onClose, onSave }: EditModalProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser }: UserDirectoryProps) {
|
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser, onSetRole }: UserDirectoryProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [togglingRoleId, setTogglingRoleId] = useState<string | null>(null);
|
||||||
|
const [roleError, setRoleError] = useState<string | null>(null);
|
||||||
|
const isCurrentUserAdmin = currentUser.role.toLowerCase() === 'admin';
|
||||||
|
|
||||||
const bookingCount = useMemo(() => {
|
const bookingCount = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
@ -152,6 +156,18 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
try { await onDeleteUser(id); } finally { setDeletingId(null); }
|
try { await onDeleteUser(id); } finally { setDeletingId(null); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleRole(user: User) {
|
||||||
|
setTogglingRoleId(user.id);
|
||||||
|
setRoleError(null);
|
||||||
|
try {
|
||||||
|
await onSetRole(user.id, user.role.toLowerCase() === 'admin' ? 'User' : 'admin');
|
||||||
|
} catch (err: any) {
|
||||||
|
setRoleError(err.message || 'Failed to change role.');
|
||||||
|
} finally {
|
||||||
|
setTogglingRoleId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveEdit(name: string, email: string) {
|
async function handleSaveEdit(name: string, email: string) {
|
||||||
if (!editingUser) return;
|
if (!editingUser) return;
|
||||||
await onUpdateUser(editingUser.id, name, email);
|
await onUpdateUser(editingUser.id, name, email);
|
||||||
@ -161,50 +177,57 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
<div className="space-y-6 font-sans" id="user-directory-root">
|
<div className="space-y-6 font-sans" id="user-directory-root">
|
||||||
|
|
||||||
{/* Header banner */}
|
{/* Header banner */}
|
||||||
<div className="bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm relative overflow-hidden">
|
<div className="bg-card border border-line rounded-2xl p-6 shadow-sm relative overflow-hidden">
|
||||||
<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="absolute top-0 right-0 p-8 text-fg-faint pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
|
||||||
TEAM
|
TEAM
|
||||||
</div>
|
</div>
|
||||||
<div className="relative space-y-1.5">
|
<div className="relative space-y-1.5">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
<h2 className="text-2xl font-bold tracking-tight text-fg flex items-center gap-2.5">
|
||||||
<Users className="w-6 h-6 text-emerald-400" />
|
<Users className="w-6 h-6 text-success" />
|
||||||
Registered Operators
|
Registered Operators
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
<p className="text-xs text-fg-muted max-w-xl leading-relaxed">
|
||||||
Everyone with an account on this box. Booking counts come straight from the shared reservation pool.
|
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-inner border border-line rounded-lg px-3 py-1.5 text-[11px] text-fg-muted">
|
||||||
<Users className="w-3.5 h-3.5 text-emerald-400" />
|
<Users className="w-3.5 h-3.5 text-success" />
|
||||||
<strong className="text-white font-mono">{users.length}</strong> registered
|
<strong className="text-fg font-mono">{users.length}</strong> registered
|
||||||
</span>
|
</span>
|
||||||
<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-inner border border-line rounded-lg px-3 py-1.5 text-[11px] text-fg-muted">
|
||||||
<Calendar className="w-3.5 h-3.5 text-indigo-400" />
|
<Calendar className="w-3.5 h-3.5 text-primary" />
|
||||||
<strong className="text-white font-mono">{bookings.length}</strong> total bookings
|
<strong className="text-fg font-mono">{bookings.length}</strong> total bookings
|
||||||
</span>
|
</span>
|
||||||
<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-inner border border-line rounded-lg px-3 py-1.5 text-[11px] text-fg-muted">
|
||||||
<Activity className="w-3.5 h-3.5 text-emerald-400" />
|
<Activity className="w-3.5 h-3.5 text-success" />
|
||||||
<strong className="text-white font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
|
<strong className="text-fg font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{roleError && (
|
||||||
|
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2 text-xs text-danger">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{roleError}
|
||||||
|
<button onClick={() => setRoleError(null)} className="ml-auto text-danger hover:text-fg"><X className="w-3 h-3" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span>
|
<span className="absolute left-3 top-2.5 text-fg-faint"><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-card text-fg border border-line rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User grid */}
|
{/* User grid */}
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="text-center py-16 text-slate-500 text-xs">No operators match your search.</p>
|
<p className="text-center py-16 text-fg-faint text-xs">No operators match your search.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filtered.map(user => {
|
{filtered.map(user => {
|
||||||
@ -215,10 +238,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className={`relative bg-[#1E293B] border rounded-xl p-5 transition-all hover:shadow-lg ${isMe ? 'border-emerald-500/50 shadow-[0_0_20px_rgba(16,185,129,0.08)]' : 'border-slate-800 hover:border-slate-700'}`}
|
className={`relative bg-card border rounded-xl p-5 transition-all hover:shadow-lg ${isMe ? 'border-success shadow-lg' : 'border-line hover:border-line-strong'}`}
|
||||||
>
|
>
|
||||||
{isMe && (
|
{isMe && (
|
||||||
<span className="absolute top-3 right-3 text-[9px] font-bold uppercase tracking-wider text-emerald-400 bg-emerald-950/60 border border-emerald-900/60 px-2 py-0.5 rounded-full">
|
<span className="absolute top-3 right-3 text-[9px] font-bold uppercase tracking-wider text-success bg-success-soft border border-success-line px-2 py-0.5 rounded-full">
|
||||||
You
|
You
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -228,10 +251,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
{initials(user.name)}
|
{initials(user.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-sm font-bold text-white truncate">{user.name}</h3>
|
<h3 className="text-sm font-bold text-fg truncate">{user.name}</h3>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${user.email}`}
|
href={`mailto:${user.email}`}
|
||||||
className="text-[11px] text-slate-400 hover:text-emerald-400 truncate flex items-center gap-1 mt-0.5 w-fit max-w-full"
|
className="text-[11px] text-fg-muted hover:text-success truncate flex items-center gap-1 mt-0.5 w-fit max-w-full"
|
||||||
>
|
>
|
||||||
<Mail className="w-3 h-3 shrink-0" />
|
<Mail className="w-3 h-3 shrink-0" />
|
||||||
<span className="truncate">{user.email}</span>
|
<span className="truncate">{user.email}</span>
|
||||||
@ -239,26 +262,43 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
<div className="mt-4 pt-3 border-t border-line 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-warning uppercase tracking-wider"><ShieldCheck className="w-3 h-3" />Admin</span>
|
||||||
|
: <span className="text-[10px] font-mono text-fg-faint uppercase tracking-wider">User</span>
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
|
<div className="flex items-center gap-3 text-[10px] font-mono text-fg-muted">
|
||||||
<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-primary" />
|
||||||
{total}
|
{total}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1" title="Active / upcoming bookings">
|
<span className="flex items-center gap-1" title="Active / upcoming bookings">
|
||||||
<Activity className="w-3 h-3 text-emerald-400" />
|
<Activity className="w-3 h-3 text-success" />
|
||||||
{active}
|
{active}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-1 ml-1">
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
{isCurrentUserAdmin && !isMe && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleRole(user)}
|
||||||
|
disabled={togglingRoleId === user.id}
|
||||||
|
className={`p-1.5 rounded-lg transition-all disabled:opacity-40 ${user.role.toLowerCase() === 'admin' ? 'text-warning hover:text-fg-muted hover:bg-inner' : 'text-fg-faint hover:text-warning hover:bg-inner'}`}
|
||||||
|
title={user.role.toLowerCase() === 'admin' ? 'Remove admin' : 'Make admin'}
|
||||||
|
>
|
||||||
|
{togglingRoleId === user.id
|
||||||
|
? <span className="w-3.5 h-3.5 border-2 border-line-strong border-t-warning rounded-full animate-spin inline-block" />
|
||||||
|
: user.role.toLowerCase() === 'admin'
|
||||||
|
? <ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
: <Shield className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingUser(user)}
|
onClick={() => setEditingUser(user)}
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"
|
className="p-1.5 rounded-lg text-fg-faint hover:text-info hover:bg-inner transition-all"
|
||||||
title="Edit name / email"
|
title="Edit name / email"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
@ -267,11 +307,11 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(user.id)}
|
onClick={() => handleDelete(user.id)}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-slate-900 transition-all disabled:opacity-40"
|
className="p-1.5 rounded-lg text-fg-faint hover:text-rose hover:bg-inner transition-all disabled:opacity-40"
|
||||||
title="Delete user"
|
title="Delete user"
|
||||||
>
|
>
|
||||||
{isDeleting
|
{isDeleting
|
||||||
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-rose-400 rounded-full animate-spin inline-block" />
|
? <span className="w-3.5 h-3.5 border-2 border-line-strong border-t-rose rounded-full animate-spin inline-block" />
|
||||||
: <Trash2 className="w-3.5 h-3.5" />}
|
: <Trash2 className="w-3.5 h-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
913
src/index.css
913
src/index.css
@ -6,6 +6,38 @@
|
|||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Semantic design tokens ────────────────────────────────────────
|
||||||
|
Wired with `inline` so each utility references the CSS var directly
|
||||||
|
(e.g. `.bg-card { background: var(--bg-card) }`). The values live in
|
||||||
|
:root / :root.light below, so theme switching is a pure var swap —
|
||||||
|
no per-utility light-mode overrides needed. */
|
||||||
|
@theme inline {
|
||||||
|
/* neutrals */
|
||||||
|
--color-surface: var(--bg);
|
||||||
|
--color-header: var(--bg-header);
|
||||||
|
--color-card: var(--bg-card);
|
||||||
|
--color-inner: var(--bg-inner);
|
||||||
|
--color-field: var(--bg-input);
|
||||||
|
--color-line: var(--border);
|
||||||
|
--color-line-strong: var(--border-muted);
|
||||||
|
--color-fg: var(--text);
|
||||||
|
--color-fg-muted: var(--text-muted);
|
||||||
|
--color-fg-faint: var(--text-faint);
|
||||||
|
--color-overlay: var(--overlay);
|
||||||
|
|
||||||
|
/* accents — triplet per hue: vivid (text/icon) / soft (chip bg) / line (chip border) */
|
||||||
|
--color-success: var(--success); --color-success-soft: var(--success-soft); --color-success-line: var(--success-line);
|
||||||
|
--color-info: var(--info); --color-info-soft: var(--info-soft); --color-info-line: var(--info-line);
|
||||||
|
--color-primary: var(--primary); --color-primary-soft: var(--primary-soft); --color-primary-line: var(--primary-line);
|
||||||
|
--color-warning: var(--warning); --color-warning-soft: var(--warning-soft); --color-warning-line: var(--warning-line);
|
||||||
|
--color-danger: var(--danger); --color-danger-soft: var(--danger-soft); --color-danger-line: var(--danger-line);
|
||||||
|
--color-rose: var(--rose); --color-rose-soft: var(--rose-soft); --color-rose-line: var(--rose-line);
|
||||||
|
--color-violet: var(--violet); --color-violet-soft: var(--violet-soft); --color-violet-line: var(--violet-line);
|
||||||
|
--color-sky: var(--sky); --color-sky-soft: var(--sky-soft); --color-sky-line: var(--sky-line);
|
||||||
|
--color-orange: var(--orange); --color-orange-soft: var(--orange-soft); --color-orange-line: var(--orange-line);
|
||||||
|
--color-blue: var(--blue); --color-blue-soft: var(--blue-soft); --color-blue-line: var(--blue-line);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── AirIT brand tokens ────────────────────────────────────────── */
|
/* ── AirIT brand tokens ────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--airit-navy: #003A70;
|
--airit-navy: #003A70;
|
||||||
@ -18,21 +50,37 @@
|
|||||||
--airit-border: #D6DADF;
|
--airit-border: #D6DADF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CSS custom properties ─────────────────────────────────────── */
|
/* ── Theme values: DARK (default) ──────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
|
/* neutrals */
|
||||||
--bg: #0b0f19;
|
--bg: #0b0f19;
|
||||||
--bg-header: #0f172a;
|
--bg-header: #0f172a;
|
||||||
--bg-card: #1e293b;
|
--bg-card: #181f2b;
|
||||||
--bg-inner: #090d16;
|
--bg-inner: #0c1119;
|
||||||
--bg-input: #020408;
|
--bg-input: #0a0e16;
|
||||||
--border: #1e293b;
|
--border: #283142;
|
||||||
--border-muted:#334155;
|
--border-muted:#3a4659;
|
||||||
--text: #f1f5f9;
|
--text: #e9eef5;
|
||||||
--text-muted: #94a3b8;
|
--text-muted: #94a3b8;
|
||||||
--text-label: #cbd5e1;
|
--text-faint: #64748b;
|
||||||
|
--overlay: rgba(2, 6, 12, 0.6);
|
||||||
|
|
||||||
|
/* accents — calmer, slightly desaturated; soft chips use alpha tints */
|
||||||
|
--success: #34d399; --success-soft: rgba(52,211,153,0.12); --success-line: rgba(52,211,153,0.26);
|
||||||
|
--info: #22d3ee; --info-soft: rgba(34,211,238,0.12); --info-line: rgba(34,211,238,0.26);
|
||||||
|
--primary: #818cf8; --primary-soft: rgba(129,140,248,0.14); --primary-line: rgba(129,140,248,0.28);
|
||||||
|
--warning: #fbbf24; --warning-soft: rgba(251,191,36,0.13); --warning-line: rgba(251,191,36,0.28);
|
||||||
|
--danger: #f87171; --danger-soft: rgba(248,113,113,0.13); --danger-line: rgba(248,113,113,0.30);
|
||||||
|
--rose: #fb7185; --rose-soft: rgba(251,113,133,0.13); --rose-line: rgba(251,113,133,0.30);
|
||||||
|
--violet: #a78bfa; --violet-soft: rgba(167,139,250,0.14); --violet-line: rgba(167,139,250,0.28);
|
||||||
|
--sky: #38bdf8; --sky-soft: rgba(56,189,248,0.13); --sky-line: rgba(56,189,248,0.28);
|
||||||
|
--orange: #fb923c; --orange-soft: rgba(251,146,60,0.13); --orange-line: rgba(251,146,60,0.28);
|
||||||
|
--blue: #60a5fa; --blue-soft: rgba(96,165,250,0.13); --blue-line: rgba(96,165,250,0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Theme values: LIGHT ───────────────────────────────────────── */
|
||||||
:root.light {
|
:root.light {
|
||||||
|
/* neutrals */
|
||||||
--bg: #f1f5f9;
|
--bg: #f1f5f9;
|
||||||
--bg-header: #ffffff;
|
--bg-header: #ffffff;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
@ -42,833 +90,40 @@
|
|||||||
--border-muted:#cbd5e1;
|
--border-muted:#cbd5e1;
|
||||||
--text: #0f172a;
|
--text: #0f172a;
|
||||||
--text-muted: #475569;
|
--text-muted: #475569;
|
||||||
--text-label: #334155;
|
--text-faint: #94a3b8;
|
||||||
}
|
--overlay: rgba(15, 23, 42, 0.45);
|
||||||
|
|
||||||
/* ── LIGHT MODE OVERRIDES ─────────────────────────────────────── */
|
/* accents */
|
||||||
|
--success: #059669; --success-soft: #d1fae5; --success-line: #6ee7b7;
|
||||||
/* Root / body */
|
--info: #0891b2; --info-soft: #cffafe; --info-line: #67e8f9;
|
||||||
:root.light body,
|
--primary: #4f46e5; --primary-soft: #e0e7ff; --primary-line: #a5b4fc;
|
||||||
:root.light #main-root {
|
--warning: #d97706; --warning-soft: #fef3c7; --warning-line: #fde68a;
|
||||||
background-color: var(--bg) !important;
|
--danger: #dc2626; --danger-soft: #fee2e2; --danger-line: #fca5a5;
|
||||||
color: var(--text) !important;
|
--rose: #be123c; --rose-soft: #ffe4e6; --rose-line: #fca5a5;
|
||||||
}
|
--violet: #7c3aed; --violet-soft: #ede9fe; --violet-line: #c4b5fd;
|
||||||
|
--sky: #0284c7; --sky-soft: #e0f2fe; --sky-line: #7dd3fc;
|
||||||
/* ── Backgrounds: all dark hex variants → card/inner */
|
--orange: #ea580c; --orange-soft: #fff7ed; --orange-line: #fdba74;
|
||||||
:root.light .bg-\[\#0B0F19\],
|
--blue: #1d4ed8; --blue-soft: #dbeafe; --blue-line: #93c5fd;
|
||||||
:root.light .bg-\[\#0b0f19\] {
|
}
|
||||||
background-color: var(--bg) !important;
|
|
||||||
}
|
/* ── Base element styling ──────────────────────────────────────────
|
||||||
|
The component layer now consumes the semantic tokens above directly,
|
||||||
:root.light .bg-\[\#0F172A\],
|
so the old ~260-rule `:root.light` utility-override block is gone.
|
||||||
:root.light .bg-\[\#0f172a\] {
|
Only genuinely global, theme-agnostic rules remain here. */
|
||||||
background-color: var(--bg-header) !important;
|
|
||||||
border-color: var(--border) !important;
|
/* AirIT badge - always white text on navy, regardless of theme */
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-\[\#1E293B\],
|
|
||||||
:root.light .bg-\[\#1e293b\] {
|
|
||||||
background-color: var(--bg-card) !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* BookingCalendar "Quick Booking" green-tinted card */
|
|
||||||
:root.light .bg-\[\#1D2535\],
|
|
||||||
:root.light .bg-\[\#1d2535\] {
|
|
||||||
background-color: #f0fdf4 !important;
|
|
||||||
border-color: #bbf7d0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header & nav ─────────────────────────────────────────────── */
|
|
||||||
:root.light header,
|
|
||||||
:root.light #app-header {
|
|
||||||
background-color: var(--bg-header) !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light aside,
|
|
||||||
:root.light #nav-sidebar {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Slate utility backgrounds ────────────────────────────────── */
|
|
||||||
:root.light .bg-slate-950,
|
|
||||||
:root.light .bg-slate-900 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-slate-800 {
|
|
||||||
background-color: #e2e8f0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* opacity variants */
|
|
||||||
:root.light .bg-slate-950\/10,
|
|
||||||
:root.light .bg-slate-950\/20,
|
|
||||||
:root.light .bg-slate-950\/30,
|
|
||||||
:root.light .bg-slate-950\/40,
|
|
||||||
:root.light .bg-slate-950\/60,
|
|
||||||
:root.light .bg-slate-950\/80 {
|
|
||||||
background-color: rgba(241, 245, 249, 0.85) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-slate-900\/10,
|
|
||||||
:root.light .bg-slate-900\/35,
|
|
||||||
:root.light .bg-slate-900\/40,
|
|
||||||
:root.light .bg-slate-900\/60,
|
|
||||||
:root.light .bg-slate-900\/80 {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-slate-800\/50,
|
|
||||||
:root.light .bg-slate-800\/60,
|
|
||||||
:root.light .bg-slate-800\/80 {
|
|
||||||
background-color: #e9ecf0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dashboard / UserDirectory banner gradients (dark hex only) ── */
|
|
||||||
/* Targets only the dark-themed banners, not coloured avatar gradients */
|
|
||||||
:root.light .bg-gradient-to-br.from-\[\#1E293B\],
|
|
||||||
:root.light .bg-gradient-to-br.from-\[\#1e293b\] {
|
|
||||||
background-image: linear-gradient(to bottom right, #ffffff, #f1f5f9) !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Inputs, selects, textareas ───────────────────────────────── */
|
|
||||||
:root.light input,
|
|
||||||
:root.light select,
|
|
||||||
:root.light textarea {
|
|
||||||
background-color: var(--bg-input) !important;
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light option {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light input:focus,
|
|
||||||
:root.light select:focus,
|
|
||||||
:root.light textarea:focus {
|
|
||||||
border-color: #6366f1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Borders ──────────────────────────────────────────────────── */
|
|
||||||
:root.light .border-slate-900,
|
|
||||||
:root.light .border-slate-800,
|
|
||||||
:root.light .border-slate-850,
|
|
||||||
:root.light .border-slate-855,
|
|
||||||
:root.light .border-slate-700,
|
|
||||||
:root.light .border-\[\#1E293B\],
|
|
||||||
:root.light .border-\[\#1e293b\] {
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .border-red-900\/50,
|
|
||||||
:root.light .border-red-900\/40,
|
|
||||||
:root.light .border-red-900\/30 {
|
|
||||||
border-color: #fca5a5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .divide-slate-800 > *,
|
|
||||||
:root.light .divide-slate-850 > * {
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Text colours ─────────────────────────────────────────────── */
|
|
||||||
:root.light .text-white,
|
|
||||||
:root.light .text-white\/90,
|
|
||||||
:root.light .text-white\/80,
|
|
||||||
:root.light .text-white\/70,
|
|
||||||
:root.light .text-slate-100,
|
|
||||||
:root.light .text-slate-200 {
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-slate-300 {
|
|
||||||
color: #334155 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-slate-400 {
|
|
||||||
color: #64748b !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-slate-500 {
|
|
||||||
color: #94a3b8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accent colours - slightly darkened for readability on white */
|
|
||||||
:root.light .text-emerald-400 {
|
|
||||||
color: #059669 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-cyan-400 {
|
|
||||||
color: #0891b2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-indigo-400 {
|
|
||||||
color: #4f46e5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-amber-400,
|
|
||||||
:root.light .text-amber-500 {
|
|
||||||
color: #d97706 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-rose-400,
|
|
||||||
:root.light .text-rose-450 {
|
|
||||||
color: #be123c !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Accent / status badge backgrounds ───────────────────────── */
|
|
||||||
:root.light .bg-emerald-950\/60,
|
|
||||||
:root.light .bg-emerald-950\/50,
|
|
||||||
:root.light .bg-emerald-950\/40,
|
|
||||||
:root.light .bg-emerald-950\/20,
|
|
||||||
:root.light .bg-emerald-950\/80 {
|
|
||||||
background-color: #d1fae5 !important;
|
|
||||||
border-color: #6ee7b7 !important;
|
|
||||||
color: #065f46 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-indigo-950\/60,
|
|
||||||
:root.light .bg-indigo-950\/50,
|
|
||||||
:root.light .bg-indigo-950\/40 {
|
|
||||||
background-color: #e0e7ff !important;
|
|
||||||
border-color: #a5b4fc !important;
|
|
||||||
color: #3730a3 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-rose-950\/60,
|
|
||||||
:root.light .bg-rose-950\/40,
|
|
||||||
:root.light .bg-rose-950\/20 {
|
|
||||||
background-color: #ffe4e6 !important;
|
|
||||||
border-color: #fca5a5 !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 {
|
|
||||||
background-color: #cffafe !important;
|
|
||||||
border-color: #67e8f9 !important;
|
|
||||||
color: #155e75 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-amber-950\/40,
|
|
||||||
:root.light .bg-amber-900\/30 {
|
|
||||||
background-color: #fef3c7 !important;
|
|
||||||
border-color: #fde68a !important;
|
|
||||||
color: #92400e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Nav sidebar active item ──────────────────────────────────── */
|
|
||||||
:root.light #nav-sidebar button {
|
|
||||||
color: #475569 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light #nav-sidebar button:hover:not(.bg-gradient-to-r) {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sidebar telemetry box ────────────────────────────────────── */
|
|
||||||
:root.light #nav-sidebar .bg-slate-950 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dropdown panels (mail, bell) ─────────────────────────────── */
|
|
||||||
:root.light .bg-\[\#1E293B\].rounded-xl,
|
|
||||||
:root.light .shadow-2xl.rounded-xl {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Table internals ──────────────────────────────────────────── */
|
|
||||||
:root.light table {
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light thead {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light tbody tr:hover {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-\[\#0f172a\]\/60,
|
|
||||||
:root.light tr.bg-\[\#0f172a\] {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dashed empty states ──────────────────────────────────────── */
|
|
||||||
:root.light .border-dashed {
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── DeviceInventory ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Emergency Sheet container (amber-tinted dark card) */
|
|
||||||
:root.light .bg-\[\#1D2432\],
|
|
||||||
:root.light .bg-\[\#1d2432\] {
|
|
||||||
background-color: #fffbeb !important;
|
|
||||||
border-color: #fde68a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Emergency sheet markdown content area - light in light mode */
|
|
||||||
:root.light .bg-\[\#1D2432\] .bg-slate-950\/80,
|
|
||||||
:root.light .bg-\[\#1d2432\] .bg-slate-950\/80 {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-\[\#1D2432\] .bg-slate-950\/80 *,
|
|
||||||
:root.light .bg-\[\#1d2432\] .bg-slate-950\/80 * {
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep emerald headings readable */
|
|
||||||
:root.light .bg-\[\#1D2432\] .bg-slate-950\/80 h5,
|
|
||||||
:root.light .bg-\[\#1d2432\] .bg-slate-950\/80 h5 {
|
|
||||||
color: #059669 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device type icon pill backgrounds */
|
|
||||||
:root.light .bg-rose-950\/20 { background-color: #fff1f2 !important; border-color: #fecdd3 !important; }
|
|
||||||
:root.light .bg-amber-950\/20 { background-color: #fffbeb !important; border-color: #fde68a !important; }
|
|
||||||
:root.light .bg-cyan-950\/20 { background-color: #ecfeff !important; border-color: #a5f3fc !important; }
|
|
||||||
:root.light .bg-teal-950\/20 { background-color: #f0fdfa !important; border-color: #99f6e4 !important; }
|
|
||||||
|
|
||||||
/* Filter toolbar type-filter buttons */
|
|
||||||
:root.light .bg-slate-850,
|
|
||||||
:root.light .hover\:bg-slate-850:hover {
|
|
||||||
background-color: #e2e8f0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device card selected state */
|
|
||||||
:root.light #inventory-list-container .bg-slate-900.border-emerald-500\/80 {
|
|
||||||
background-color: #f0fdf4 !important;
|
|
||||||
border-color: #10b981 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device card unselected/hover */
|
|
||||||
:root.light #inventory-list-container .bg-slate-900\/40 {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light #inventory-list-container .hover\:bg-slate-900\/60:hover {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SPECS ID badge and code block inside right panel */
|
|
||||||
:root.light #inventory-details-container .bg-slate-950 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light #inventory-details-container .bg-slate-900\/50,
|
|
||||||
:root.light #inventory-details-container .bg-slate-900\/60 {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Amber rescue badge */
|
|
||||||
:root.light .bg-amber-950 {
|
|
||||||
background-color: #fef3c7 !important;
|
|
||||||
border-color: #fde68a !important;
|
|
||||||
color: #92400e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dashboard "NET" watermark: invisible in light mode ───────── */
|
|
||||||
:root.light #dashboard-cockpit-root .text-slate-800 {
|
|
||||||
color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal overlays ───────────────────────────────────────────── */
|
|
||||||
:root.light .fixed.inset-0 {
|
|
||||||
background-color: rgba(15, 23, 42, 0.45) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .fixed.inset-0 > div,
|
|
||||||
:root.light .bg-\[\#0F172A\].rounded-2xl {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Lab Template Modal internals ─────────────────────────────── */
|
|
||||||
|
|
||||||
/* Modal header bar */
|
|
||||||
:root.light .fixed.inset-0 .bg-slate-900.border-b {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal form body */
|
|
||||||
:root.light .fixed.inset-0 form {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device-toggle buttons inside modal */
|
|
||||||
:root.light .fixed.inset-0 .bg-slate-900.border-slate-800 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
color: var(--text-label) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .fixed.inset-0 .bg-slate-900.border-slate-850 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device grid area */
|
|
||||||
:root.light .bg-slate-950\/60 {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Link builder row */
|
|
||||||
:root.light .bg-slate-1000,
|
|
||||||
:root.light .bg-slate-1000\/40 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Existing link row badges */
|
|
||||||
:root.light .fixed.inset-0 .bg-slate-900\/40 {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* border-slate-700 in modal context */
|
|
||||||
:root.light .border-slate-700 {
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Login / Register pages ───────────────────────────────────── */
|
|
||||||
:root.light .min-h-screen.bg-\[\#0B0F19\] {
|
|
||||||
background-color: var(--bg) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-slate-950\/80 {
|
|
||||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Code / terminal blocks - always dark ─────────────────────── */
|
|
||||||
:root.light pre,
|
|
||||||
:root.light code,
|
|
||||||
:root.light .font-mono.bg-slate-950 {
|
|
||||||
background-color: #0d1117 !important;
|
|
||||||
color: #00f0ff !important;
|
|
||||||
border-color: #1f242c !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light pre *,
|
|
||||||
:root.light code * {
|
|
||||||
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 {
|
.airit-badge {
|
||||||
color: #ffffff !important;
|
color: #ffffff;
|
||||||
background-color: var(--airit-navy) !important;
|
background-color: var(--airit-navy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Text selection ───────────────────────────────────────────── */
|
/* Text selection - subtle brand tint in both themes */
|
||||||
:root.light ::selection {
|
::selection {
|
||||||
background-color: rgba(5, 150, 105, 0.2) !important;
|
background-color: rgba(16, 185, 129, 0.25);
|
||||||
color: #047857 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reservation Details Modal (BookingDetailsModal) ──────────── */
|
/* Date picker calendar icon - invert to dark so it reads on light inputs */
|
||||||
|
|
||||||
/* Device node cards inside right panel */
|
|
||||||
:root.light #booking-details-modal .bg-slate-950\/65 {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JSON REST Response panel – GitHub Light style in light mode */
|
|
||||||
:root.light #booking-details-modal .font-mono.bg-slate-950 {
|
|
||||||
background-color: #f6f8fa !important;
|
|
||||||
color: #24292f !important;
|
|
||||||
border-color: #d0d7de !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The <pre> inside inherits the dark pre-rule; override explicitly */
|
|
||||||
:root.light #booking-details-modal .font-mono.bg-slate-950 pre {
|
|
||||||
background-color: transparent !important;
|
|
||||||
color: #24292f !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header bar inside the JSON panel */
|
|
||||||
:root.light #booking-details-modal .font-mono.bg-slate-950 .bg-slate-900 {
|
|
||||||
background-color: #eaeef2 !important;
|
|
||||||
border-color: #d0d7de !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Title label and icon in panel header */
|
|
||||||
:root.light #booking-details-modal .font-mono.bg-slate-950 .text-indigo-400 {
|
|
||||||
color: #6366f1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Copy button inside panel */
|
|
||||||
:root.light #booking-details-modal .font-mono.bg-slate-950 button {
|
|
||||||
background-color: #eaeef2 !important;
|
|
||||||
border-color: #d0d7de !important;
|
|
||||||
color: #57606a !important;
|
|
||||||
}
|
|
||||||
:root.light #booking-details-modal .font-mono.bg-slate-950 button:hover {
|
|
||||||
background-color: #d0d7de !important;
|
|
||||||
color: #24292f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ansible status card – orange accent in light mode */
|
|
||||||
:root.light #booking-details-modal .bg-orange-950\/10 {
|
|
||||||
background-color: #fff7ed !important;
|
|
||||||
}
|
|
||||||
:root.light #booking-details-modal .border-orange-900\/40 {
|
|
||||||
border-color: #fdba74 !important;
|
|
||||||
}
|
|
||||||
:root.light .text-orange-400 {
|
|
||||||
color: #ea580c !important;
|
|
||||||
}
|
|
||||||
:root.light .bg-orange-950\/60,
|
|
||||||
:root.light .bg-orange-900\/40 {
|
|
||||||
background-color: #fed7aa !important;
|
|
||||||
border-color: #fb923c !important;
|
|
||||||
color: #9a3412 !important;
|
|
||||||
}
|
|
||||||
:root.light .border-orange-800\/50 {
|
|
||||||
border-color: #fb923c !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* bg-slate-900/40 rows (notice box etc.) – light in light mode */
|
|
||||||
:root.light #booking-details-modal .bg-slate-900\/40 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reserve Slot selects & date pickers (BookingCalendar) ───── */
|
|
||||||
:root.light #booking-actions-card select,
|
|
||||||
:root.light #booking-actions-card input[type="text"],
|
|
||||||
:root.light #booking-actions-card input[type="date"],
|
|
||||||
:root.light #booking-actions-card textarea {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date picker calendar icon - invert to dark in light mode */
|
|
||||||
:root.light input[type="date"]::-webkit-calendar-picker-indicator {
|
:root.light input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
filter: invert(0.6);
|
filter: invert(0.6);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Define Ports dropdowns & Link Builder (LabTemplates modal) ── */
|
|
||||||
|
|
||||||
/* Non-standard text/border classes used in the link builder */
|
|
||||||
:root.light .text-slate-250 {
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .border-slate-805 {
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selects and inputs inside any fixed modal overlay */
|
|
||||||
:root.light .fixed.inset-0 select,
|
|
||||||
:root.light .fixed.inset-0 input[type="text"],
|
|
||||||
:root.light .fixed.inset-0 input[type="email"],
|
|
||||||
:root.light .fixed.inset-0 input[type="password"],
|
|
||||||
:root.light .fixed.inset-0 input[type="date"],
|
|
||||||
:root.light .fixed.inset-0 textarea {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Link row items inside modal */
|
|
||||||
:root.light .fixed.inset-0 .bg-slate-950.font-mono {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Add Link" button - keep readable white label on indigo in light mode
|
|
||||||
(the global :root.light .text-white override would otherwise darken it) */
|
|
||||||
:root.light #add-link-btn {
|
|
||||||
background-color: #4f46e5 !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light #add-link-btn:hover {
|
|
||||||
background-color: #6366f1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────────────────────────── */
|
|
||||||
/* EXTENDED LIGHT MODE OVERRIDES */
|
|
||||||
/* ─────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* ── Solid (no-opacity) color backgrounds ────────────────────────── */
|
|
||||||
/* These are used in Logbook type badges and Dashboard countdown pill */
|
|
||||||
|
|
||||||
:root.light .bg-emerald-950 {
|
|
||||||
background-color: #d1fae5 !important;
|
|
||||||
border-color: #6ee7b7 !important;
|
|
||||||
color: #065f46 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-cyan-950 {
|
|
||||||
background-color: #cffafe !important;
|
|
||||||
border-color: #67e8f9 !important;
|
|
||||||
color: #155e75 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-indigo-950 {
|
|
||||||
background-color: #e0e7ff !important;
|
|
||||||
border-color: #a5b4fc !important;
|
|
||||||
color: #3730a3 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .bg-rose-950 {
|
|
||||||
background-color: #ffe4e6 !important;
|
|
||||||
border-color: #fca5a5 !important;
|
|
||||||
color: #9f1239 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── emerald-900 opacity variants (quick booking modal tabs) ──────── */
|
|
||||||
:root.light .bg-emerald-900\/50,
|
|
||||||
:root.light .bg-emerald-900\/40,
|
|
||||||
:root.light .bg-emerald-900\/30 {
|
|
||||||
background-color: #d1fae5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Violet accent (LinkDashboard) ────────────────────────────────── */
|
|
||||||
:root.light .bg-violet-950\/60,
|
|
||||||
:root.light .bg-violet-950\/40,
|
|
||||||
:root.light .bg-violet-950\/20 {
|
|
||||||
background-color: #ede9fe !important;
|
|
||||||
border-color: #c4b5fd !important;
|
|
||||||
color: #5b21b6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-violet-400 {
|
|
||||||
color: #7c3aed !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 300-level text – near-invisible on white backgrounds ────────── */
|
|
||||||
:root.light .text-amber-300 {
|
|
||||||
color: #b45309 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-emerald-300 {
|
|
||||||
color: #059669 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-cyan-300 {
|
|
||||||
color: #0891b2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-rose-300 {
|
|
||||||
color: #be123c !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .text-indigo-300 {
|
|
||||||
color: #4338ca !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Missing border opacity variants ─────────────────────────────── */
|
|
||||||
|
|
||||||
/* slate-700 with opacity */
|
|
||||||
:root.light .border-slate-700\/40,
|
|
||||||
:root.light .border-slate-700\/50,
|
|
||||||
:root.light .border-slate-700\/60 {
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* slate-900 opacity (section dividers, row separators) */
|
|
||||||
:root.light .border-slate-900\/30,
|
|
||||||
:root.light .border-slate-900\/40,
|
|
||||||
:root.light .border-slate-900\/60 {
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* emerald borders – solid and opacity variants */
|
|
||||||
:root.light .border-emerald-900,
|
|
||||||
:root.light .border-emerald-900\/30,
|
|
||||||
:root.light .border-emerald-900\/40,
|
|
||||||
:root.light .border-emerald-900\/60 {
|
|
||||||
border-color: #6ee7b7 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .border-emerald-800\/50,
|
|
||||||
:root.light .border-emerald-800\/60 {
|
|
||||||
border-color: #a7f3d0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* cyan borders */
|
|
||||||
:root.light .border-cyan-900\/50,
|
|
||||||
:root.light .border-cyan-900\/60 {
|
|
||||||
border-color: #67e8f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* amber borders */
|
|
||||||
:root.light .border-amber-900\/40,
|
|
||||||
:root.light .border-amber-900\/60 {
|
|
||||||
border-color: #fde68a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .border-amber-800\/50,
|
|
||||||
:root.light .border-amber-800\/60 {
|
|
||||||
border-color: #fde68a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* rose borders */
|
|
||||||
:root.light .border-rose-900\/30,
|
|
||||||
:root.light .border-rose-900\/50,
|
|
||||||
:root.light .border-rose-900\/60 {
|
|
||||||
border-color: #fca5a5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* indigo borders */
|
|
||||||
:root.light .border-indigo-900,
|
|
||||||
:root.light .border-indigo-900\/40,
|
|
||||||
:root.light .border-indigo-900\/50 {
|
|
||||||
border-color: #a5b4fc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* violet borders */
|
|
||||||
:root.light .border-violet-900\/50,
|
|
||||||
:root.light .border-violet-950\/40 {
|
|
||||||
border-color: #c4b5fd !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Missing bg-slate-900 opacity variants ───────────────────────── */
|
|
||||||
:root.light .bg-slate-900\/30,
|
|
||||||
:root.light .bg-slate-900\/50,
|
|
||||||
:root.light .bg-slate-900\/70,
|
|
||||||
:root.light .bg-slate-900\/90 {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── bg-slate-800 additional variant ────────────────────────────── */
|
|
||||||
:root.light .bg-slate-800\/40 {
|
|
||||||
background-color: #e2e8f0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Hover-state overrides for dark bg classes ───────────────────── */
|
|
||||||
/* Without these the hover flashes a dark background in light mode. */
|
|
||||||
|
|
||||||
:root.light .hover\:bg-slate-900:hover {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .hover\:bg-slate-900\/35:hover,
|
|
||||||
:root.light .hover\:bg-slate-900\/40:hover,
|
|
||||||
:root.light .hover\:bg-slate-900\/60:hover,
|
|
||||||
:root.light .hover\:bg-slate-900\/70:hover,
|
|
||||||
:root.light .hover\:bg-slate-900\/80:hover {
|
|
||||||
background-color: #f1f5f9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .hover\:bg-slate-800:hover {
|
|
||||||
background-color: #e2e8f0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .hover\:bg-slate-800\/80:hover {
|
|
||||||
background-color: #e2e8f0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .hover\:bg-slate-950\/30:hover,
|
|
||||||
:root.light .hover\:bg-slate-950\/40:hover {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Coloured hover states */
|
|
||||||
:root.light .hover\:bg-emerald-900\/40:hover,
|
|
||||||
:root.light .hover\:bg-emerald-900\/60:hover {
|
|
||||||
background-color: #a7f3d0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .hover\:bg-rose-900\/60:hover {
|
|
||||||
background-color: #fecdd3 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .hover\:bg-red-950\/40:hover {
|
|
||||||
background-color: #fee2e2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Border-dashed empty slots (calendar grid) ───────────────────── */
|
|
||||||
:root.light .border-slate-800\/40 {
|
|
||||||
border-color: var(--border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .hover\:border-slate-700\/60:hover {
|
|
||||||
border-color: var(--border-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Hover text colors – prevent near-white text on light backgrounds */
|
|
||||||
:root.light .hover\:text-white:hover,
|
|
||||||
:root.light .hover\:text-slate-100:hover,
|
|
||||||
:root.light .hover\:text-slate-200:hover {
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .group:hover .group-hover\:text-white {
|
|
||||||
color: var(--text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light .group:hover .group-hover\:text-slate-300 {
|
|
||||||
color: var(--text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@ -36,6 +36,8 @@ export interface LabTemplate {
|
|||||||
topology: TopologyLink[];
|
topology: TopologyLink[];
|
||||||
semaphoreSetupTemplateId?: string;
|
semaphoreSetupTemplateId?: string;
|
||||||
semaphoreTeardownTemplateId?: string;
|
semaphoreTeardownTemplateId?: string;
|
||||||
|
scope: 'global' | 'personal';
|
||||||
|
ownerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
@ -48,10 +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;
|
semaphoreSetupTriggered?: boolean;
|
||||||
ansibleTeardownTriggered?: boolean;
|
semaphoreTeardownTriggered?: boolean;
|
||||||
ansibleSetupJobId?: string;
|
semaphoreSetupJobId?: string;
|
||||||
ansibleTeardownJobId?: string;
|
semaphoreTeardownJobId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
|
|||||||
Reference in New Issue
Block a user