Compare commits

...

64 Commits

Author SHA1 Message Date
f1d46e7f56 refactor(ui): semantic token theming + cleaner SaaS palette
Replace the brittle 266-rule `:root.light` `!important` override block with a
Tailwind v4 `@theme inline` semantic token system (surface/header/card/inner/
field/line/fg/fg-muted/fg-faint + success/info/primary/warning/danger/rose/
violet/sky/orange/blue, each with vivid/soft/line). Migrate all 14 components
and App.tsx off hardcoded slate/hex utilities onto the tokens, so dark/light
is now a pure CSS-variable swap with no per-utility overrides.

- index.css ~984 -> ~150 lines; CSS bundle 145 -> 98 kB
- calmer, desaturated accents; removed gratuitous glows and constant pulsing
- branding, playful copy and intentionally-dark code blocks preserved

Also wires `requireAdmin` onto settings, bookings-delete, database, checkmk,
semaphore and caddy routes.
2026-06-17 15:27:32 +02:00
8e24487172 fix(realtime): remove duplicate state updates from handlers
SSE broadcasts the authoritative state before the HTTP response returns,
so local setState calls in handlers caused every entry to appear twice.
Handlers now only call the API and show notifications; SSE drives all
state. Added a useEffect to keep selectedBookingForDetails in sync with
the SSE-updated bookings list.
2026-06-16 17:00:03 +02:00
e6e6c4d43a feat(realtime): replace device polling with SSE push for all shared data 2026-06-16 16:37:47 +02:00
150557ce2c refactor(naming): unify service abbreviations (cmk, semaphore)
Standardise CheckMK variables to the cmk prefix (checkMkUrl -> cmkUrl,
checkmk* -> cmk*) and resolve the ansible/semaphore split by renaming
all booking fields to semaphore*. Includes DB migrations 0001/0002 for
existing databases.
2026-06-10 17:06:17 +02:00
e0fd19f471 feat(topology): hide Ansible Automation section when Semaphore is disabled 2026-06-10 16:37:50 +02:00
5c7ad3140a feat(db): add lightweight migration system
Introduce server-migrations.ts with a named-migration runner that tracks
applied migrations in a _migrations table. runMigrations(db) is called at
startup before routes, so additive schema changes (ALTER TABLE, new settings)
are applied once and skipped on subsequent restarts.

Update ARCHITECTURE.md: five inline edits + new §4.4 documenting the convention.
2026-06-10 16:30:44 +02:00
c3931e7f36 style(ui): remove placeholder text from all input fields
Strips example/hint placeholder attributes across all components for a cleaner, less cluttered form UX.
2026-06-10 16:25:28 +02:00
d78ade4629 docs(architecture): reflect personal/global topology scope feature 2026-06-10 16:20:42 +02:00
84bad8c0e6 feat(auth): admin role management with logbook entries 2026-06-10 16:05:08 +02:00
08a4df5503 feat(topology): add personal/global scope to lab templates
Labs can now be marked as Personal or Global when creating or editing.
Personal topologies are visible only to the owner and admins; others
cannot see, book, or edit them. Global topologies are visible to all
but editable only by the creator, admins, or legacy (migrated) labs.

- DB: idempotent ALTER TABLE adds scope + ownerId columns to labs
- API: POST sets ownerId from JWT; PUT/DELETE enforce ownership (403 for
  unauthorized edits; legacy ownerId='' remains freely editable)
- Types: LabTemplate extended with scope and ownerId fields
- LabTemplates UI: sectioned list (My / Global / Others' Personal),
  Personal/Global toggle in form, Lock/Globe badges on cards,
  edit+delete buttons hidden for non-owners
- BookingCalendar: personal labs filtered from selects/quick booking,
  optgroup grouping for Global vs Personal in topology dropdown
- Light mode: add missing bg-slate-950/50 and border-slate-800/50
  overrides so the Global badge renders correctly
2026-06-10 15:51:53 +02:00
cb36caff2e fix(auth): log Entra login events to logbook 2026-06-10 15:15:23 +02:00
be007791dc refactor(db): rename redirect_path→redirect, add uid/addLog helpers, simplify Caddy CRUD
- Rename caddy.redirect_path to caddy.redirect across schema, server, frontend and docs
- Remove obsolete ALTER TABLE migration (fresh-install model has no migrations)
- Move uid() from server.ts to server-db.ts for shared use
- Add addLog() general helper (prepared statement, shared timestamp support) and
  replace ~24 inline INSERT INTO logs calls throughout server.ts
- Caddy CRUD now takes CaddyRouteInput object instead of positional arguments;
  add/update reuse getCaddyRouteById() to avoid duplicate SELECT
2026-06-10 15:08:35 +02:00
515052fbda refactor: replace CADDY_MANAGER with DEPLOY_ENV for instance-role awareness
DEPLOY_ENV=production now marks the primary instance globally - used for
Caddy ownership, the Dev/Prod header badge, and Caddy UI gating. Removes
build-time VITE_DEPLOY_ENV/import.meta.env.DEV from the header in favour
of the runtime API response (isProduction field in /api/auth/config).
2026-06-10 14:43:31 +02:00
49cd0ae4f6 feat(caddy): optional root redirect per route
Add a redirect_path column to the caddy table and an optional 'root redirect'
field in the route form. When set, buildCaddyfile emits 'redir / <path>' so the
bare host (e.g. checkmk.domain.local/) redirects to a sub-path (e.g.
/monitoring/check_mk/) while every other path still passes through to the
backend — the safe pattern for apps like CheckMK that bake their site path into
absolute URLs. Defensive ALTER TABLE keeps existing databases working.
2026-06-10 10:22:39 +02:00
a2d515992c fix(logbook): 'All' filter shows every log including system entries
Drop the 'non-system' default filter; 'All' now means all log types.
2026-06-09 13:09:04 +02:00
2a2902d5bc feat(ui): distinguish dev/prod via VITE_DEPLOY_ENV
Both instances run with NODE_ENV=production, so import.meta.env.PROD was
always true and the header always showed 'Production'. deploy.sh now passes
VITE_DEPLOY_ENV=<branch> into the build and Header reads it to label the
system indicator dev vs prod correctly.
2026-06-09 13:09:03 +02:00
ac1cf8fec7 docs(architecture): sync Caddy manager gate in first-start + ownership invariant 2026-06-09 13:09:01 +02:00
e0332b05ad feat(caddy): single owner via CADDY_MANAGER env flag
One Caddy serves the whole container and POST /load replaces the entire
config, so two instances pushing would clobber each other. Now only the
instance with CADDY_MANAGER=true (production) pushes, seeds routes from the
Caddyfile, and accepts route mutations (others get 403). /api/auth/config
exposes caddyManaged so the non-owner Settings UI shows the Caddy section
read-only. The installer sets the flag on the production .env only.
2026-06-09 12:47:20 +02:00
bc677ff805 feat(caddy): add standard forwarding headers to every reverse_proxy
Every generated reverse_proxy block now emits header_up for
X-Forwarded-Proto, X-Real-IP and Host. Caddy already sets the X-Forwarded-*
family and Host by default; this makes them explicit and adds X-Real-IP
(nginx convention) for backends that expect it. The https:// transport block
is preserved alongside the headers.
2026-06-09 11:39:45 +02:00
1dba721a9a feat(ui): light-mode sky palette for Caddy card, favicon, doc sync
- index.css: add :root.light overrides for the sky-* accent used only by the
  Caddy settings card (buttons, badges, hovers) + the missing red-950/30 hover
- favicon: add public/favicon.svg (GhostGrid logo) and link it in index.html
- ARCHITECTURE.md: GET /caddy/routes returns a plain array, document the Caddy
  startup import, https:// upstream, favicon/public dir, and the SPA-catch-all-last
  + Cache-Control: no-store invariant
2026-06-08 14:51:36 +02:00
f6263ad2f3 feat(caddy): support HTTPS upstreams via https:// prefix
When a route's upstream starts with https://, buildCaddyfile emits a
transport http { tls_insecure_skip_verify } block so Caddy connects over TLS
and accepts the self-signed certificate typical of backends like Semaphore.
Added a UI hint explaining the https:// prefix.
2026-06-08 14:43:29 +02:00
6f621067b9 fix(server): register SPA catch-all last so /api GET routes are reachable
The static/SPA fallback (app.get('*')) was registered before the Caddy
routes, so every GET /api/caddy/* request was swallowed by the catch-all and
returned index.html instead of JSON. POST/PUT/DELETE still worked because
app.get('*') only matches GET — which is why adding routes worked but the
list was always empty. Move the static block to just before app.listen, and
add Cache-Control: no-store on /api so stale HTML can't be served via 304.
2026-06-08 14:37:33 +02:00
d429b2d252 refactor(caddy): flatten routes to a plain array like bookings
GET /api/caddy/routes now returns the route array directly instead of
{ system, custom }. Frontend state is CaddyRoute[] initialised to [],
rendered with a simple .map() and an empty-state message — mirroring how
bookings are loaded and displayed.
2026-06-08 14:08:57 +02:00
1526d25144 fix(caddy): decouple status check from routes fetch, use useEffect for load trigger
Routes now load immediately from DB without waiting for the Caddy Admin API
status check (which can take up to 2s timeout). A dedicated useEffect on
caddyEnabled replaces the unreliable fire-and-forget call inside loadSettings.
2026-06-08 13:59:03 +02:00
2857040803 docs(architecture): remove revision history, add PUT /caddy/routes/{id} to API reference 2026-06-08 13:40:36 +02:00
acadf8db7c fix(caddy): prevent duplicate routes and make status/routes fetches independent
POST /api/caddy/routes now returns 409 if the hostname already exists,
preventing duplicate DB entries that cause Caddy's "ambiguous site definition" error.

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

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

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

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

1
.gitignore vendored
View File

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

895
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,895 @@
# GhostGrid
## Architecture Reference
**Status:** Living document — single source of truth for the codebase
> Use this document as the starting context for any future task on GhostGrid. It describes the whole application: purpose, stack, file layout, data model, REST API, frontend structure, integrations, background jobs, security, and deployment.
---
## 1. Executive Summary
**GhostGrid** is an internal, offline-capable **network-lab and device-inventory tool** for managing hardware lab environments. Teams use it to keep a device inventory with live status, define lab templates (devices + topology), book labs for a time window, and automatically run Ansible playbooks at booking start/end. It pulls live device status from CheckMK and can manage Caddy reverse-proxy routes and Microsoft Entra ID SSO from its own UI.
### Key Design Decisions
| Aspect | Choice | Rationale |
|--------|--------|-----------|
| Scope | Single-tenant internal tool | Small team / lab operations, not multi-tenant SaaS |
| Process model | **One Node.js process** serving API + frontend | Simple to deploy, no orchestration needed |
| Backend | Express 4 + TypeScript | Minimal, well-understood, fast to iterate |
| Frontend | React 19 + Vite 6 | Modern SPA, no router dependency (tab state) |
| Database | **SQLite** (`better-sqlite3`, WAL) | Zero-ops, single file, synchronous, perfect for air-gapped LAN |
| Styling | Tailwind CSS v4 | Utility-first, dark/light theme via class toggle |
| Auth | Local JWT + optional Azure Entra ID OAuth | Self-contained, SSO optional |
| Offline | Fonts bundled via `@fontsource` | No CDN / external runtime assets |
| Integrations | CheckMK, Ansible Semaphore, Caddy | All configured at runtime in the Settings UI (stored in DB) |
| Deployment | Proxmox LXC + systemd, two instances | Manual `git pull && build && restart` model |
**Core constraint:** runs **fully offline**. No external code, assets, or CDN resources are loaded at runtime.
---
## 2. System Architecture
### 2.1 High-Level Architecture
```
+-----------------------------------------------------------------------------+
| GHOSTGRID PLATFORM |
+-----------------------------------------------------------------------------+
| +---------------------------------------------------------------------+ |
| | PRESENTATION LAYER | |
| | +-----------------------------+ +----------------------------+ | |
| | | React 19 SPA (Vite) | | Browser localStorage | | |
| | | - Tab-based navigation | | - ghostgrid_token (JWT) | | |
| | | - Tailwind dark/light | | - ghostgrid_user | | |
| | +-----------------------------+ +----------------------------+ | |
| +---------------------------------------------------------------------+ |
| | authFetch > Bearer <JWT> |
| +---------------------------------------------------------------------+ |
| | APPLICATION LAYER (server.ts) | |
| | Single Express process — serves API + frontend | |
| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | |
| | | Auth | | Devices | | Labs | | Bookings | | Logs | | |
| | | (JWT/MSAL)| | CRUD | | CRUD | | CRUD | | | | |
| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | |
| | +-----------+ +-----------+ +-----------+ +-----------+ | |
| | | Users | | Links | | Settings | | Caddy | | |
| | +-----------+ +-----------+ +-----------+ +-----------+ | |
| | +---------------------------------------------------------------+ | |
| | | Background jobs (self-rescheduling setTimeout loops) | | |
| | | - CheckMK status sync (default 60s) | | |
| | | - Semaphore setup/teardown trigger (30s) | | |
| | +---------------------------------------------------------------+ | |
| +---------------------------------------------------------------------+ |
| | |
| +---------------------------------------------------------------------+ |
| | DATA LAYER (server-db.ts) | |
| | +-------------------------------------------------------------+ | |
| | | SQLite — ghostgrid.db (better-sqlite3, WAL) | | |
| | | users · devices · labs · bookings · logs · links | | |
| | | settings · caddy | | |
| | +-------------------------------------------------------------+ | |
| +---------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------+
| EXTERNAL INTEGRATIONS |
| +-------------+ +------------------+ +-----------+ +----------------+ |
| | CheckMK | | Ansible Semaphore| | Caddy | | Microsoft | |
| | REST API | | REST API | | Admin API | | Entra ID (MSAL)| |
| | (status) | | (playbook tasks) | | (/load) | | (OAuth 2.0) | |
| +-------------+ +------------------+ +-----------+ +----------------+ |
+-----------------------------------------------------------------------------+
```
### 2.2 Component Breakdown
#### 2.2.1 Presentation Layer
| Component | Technology | Purpose |
|-----------|------------|---------|
| Web UI | React 19 + TypeScript | Dashboard, booking calendar, inventory, topology, settings |
| Build/dev server | Vite 6 | Bundles the SPA; mounted as Express middleware in dev |
| Session store | Browser `localStorage` | Persists JWT + user between reloads |
#### 2.2.2 Application Layer (`server.ts` — single process)
| Route group | Responsibility |
|---------|----------------|
| Auth | Local register/login (JWT), Azure Entra ID OAuth, `/me`, public `/config` |
| Devices | Inventory CRUD; delete also scrubs the device from labs |
| Labs | Lab-template CRUD; `deviceIds`/`topology` stored as JSON |
| Bookings | Reservation CRUD; cancellation can trigger Semaphore teardown |
| Logs | Audit/maintenance journal (read + manual create) |
| Users | Team list, edit, delete (self/last-user guarded) |
| Links | Shared quick-links dashboard CRUD |
| Settings | Integration config (Azure, CheckMK, Semaphore, Caddy); secrets masked |
| CheckMK | Manual sync trigger |
| Semaphore | Template-list proxy, manual setup/teardown trigger |
| Caddy | Status, route CRUD, Caddyfile push |
| Background jobs | CheckMK sync loop + Semaphore trigger loop |
| Static serving | Vite middleware (dev) / static `dist/` + SPA fallback (prod) |
#### 2.2.3 Data Layer (`server-db.ts`)
| Component | Technology | Purpose |
|-----------|------------|---------|
| Database | SQLite via `better-sqlite3` | Single file `ghostgrid.db`, WAL journal mode, synchronous queries |
| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | Baseline 8 tables created on boot via `server-db.ts`; additive changes to live DBs go in `server-migrations.ts` (see §4.4) |
| Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` |
---
## 3. Technology Stack
### 3.1 Backend Stack
```
Node.js 20 LTS (TypeScript ~5.8, ES modules)
+-- Web Framework
| +-- express 4.21 (HTTP server, routing, JSON middleware)
| +-- vite 6 (createServer) (dev middleware mode, SPA)
+-- Auth & Security
| +-- jsonwebtoken 9 (JWT sign/verify, 24h expiry)
| +-- bcryptjs 2.4 (password hashing, cost 10)
| +-- @azure/msal-node 5 (Entra ID OAuth 2.0 auth-code flow)
+-- Data Access
| +-- better-sqlite3 12 (synchronous SQLite driver, WAL)
+-- Utilities
| +-- dotenv 17 (.env loading)
| +-- (global fetch) (CheckMK / Semaphore / Caddy HTTP calls)
+-- Build / Run
+-- tsx 4 (dev: run server.ts directly)
+-- esbuild 0.25 (bundle server > dist/server.cjs)
```
### 3.2 Frontend Stack
```
React 19 + TypeScript
+-- State Management
| +-- React hooks only (useState/useEffect in App.tsx — no Redux/Zustand)
| +-- localStorage (token + user persistence via src/lib/auth.ts)
+-- UI / Styling
| +-- tailwindcss 4 (@tailwindcss/vite plugin)
| +-- lucide-react 0.546 (icon set)
+-- Fonts (self-hosted, offline)
| +-- @fontsource/inter
| +-- @fontsource/jetbrains-mono
+-- Networking
| +-- fetch + authFetch() (thin wrapper injecting Authorization header)
+-- Build Tools
+-- vite 6 + @vitejs/plugin-react
```
### 3.3 Data Layer
```
SQLite (single file: ghostgrid.db, WAL mode)
+-- users (local + Azure-provisioned accounts)
+-- devices (inventory + CheckMK-synced status)
+-- labs (templates: deviceIds[] + topology[] as JSON)
+-- bookings (reservations + Ansible trigger flags/jobs)
+-- logs (audit/maintenance journal)
+-- links (shared quick-links dashboard)
+-- settings (key/value runtime config for integrations)
+-- caddy (custom reverse-proxy routes)
```
### 3.4 Infrastructure
```
Deployment
+-- Proxmox LXC (Debian 12, unprivileged)
+-- systemd services (ghostgrid + ghostgrid-dev)
+-- Two parallel instances (main:3000, dev:3001), separate DBs
+-- deploy/proxmox-ghostgrid.sh (one-shot installer)
+-- deploy/deploy.sh <branch> (git pull + build + restart)
Networking (optional, managed in-app)
+-- Caddy reverse proxy (local_certs, tls internal)
+-- Caddyfile pushed to Caddy Admin API /load
```
---
## 4. Database Schema Design
SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Baseline tables are created idempotently on boot (`CREATE TABLE IF NOT EXISTS` in `server-db.ts`). Additive changes to live DBs (new columns, tables, default settings) are applied by the migration runner in `server-migrations.ts`. See §4.4.
### 4.1 Schema (as created in `server-db.ts`)
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'User',
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL -- bcrypt; '' for Azure-provisioned users
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
ip TEXT NOT NULL,
location TEXT NOT NULL,
notes TEXT,
type TEXT NOT NULL, -- Switch | Firewall | Access-Point | Controller | custom
status TEXT NOT NULL, -- online | offline | unknown
emergencySheet TEXT NOT NULL, -- markdown
lastCheckedAt TEXT,
checkMkUrl TEXT NOT NULL DEFAULT '',
cmkHostname TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS labs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
contactPerson TEXT NOT NULL,
location TEXT NOT NULL,
deviceIds TEXT NOT NULL, -- JSON string: string[]
topology TEXT NOT NULL, -- JSON string: TopologyLink[]
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'personal'
ownerId TEXT NOT NULL DEFAULT '' -- userId of creator; '' = legacy (pre-migration)
);
-- scope/ownerId were added after initial release via idempotent ALTER TABLE in server-db.ts
CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY,
labId TEXT NOT NULL,
userId TEXT NOT NULL,
startDateTime TEXT NOT NULL,
endDateTime TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL, -- active | upcoming | completed | cancelled
notified INTEGER NOT NULL DEFAULT 0,
emailSent INTEGER NOT NULL DEFAULT 0,
ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0,
ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0,
ansibleSetupJobId TEXT NOT NULL DEFAULT '',
ansibleTeardownJobId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
type TEXT NOT NULL, -- maintenance | booking | status | system
message TEXT NOT NULL,
deviceId TEXT,
userId TEXT
);
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'emerald',
createdBy TEXT,
createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS caddy (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1,
redirect TEXT NOT NULL DEFAULT '', -- optional 'redir / <path>' for the bare root
created_at TEXT DEFAULT (datetime('now'))
);
```
### 4.2 Data-Model Notes
| Topic | Detail |
|-------|--------|
| IDs | App-generated strings: `` `${prefix}-${Date.now()}-${rand}` `` (`dev-…`, `lab-…`, `book-…`, `log-…`, `u-…`, `link-…`); `caddy` uses an autoincrement integer |
| JSON columns | `labs.deviceIds` and `labs.topology` are JSON strings, parsed in the API layer |
| Booleans | Booking flags (`notified`, `emailSent`, `ansible*Triggered`) are `INTEGER` 0/1, mapped to JS booleans on read |
| Cascade | No FK cascades; referential cleanup is done in code (e.g. deleting a device scrubs it from every lab's `deviceIds`/`topology`) |
| Schema changes | Baseline schema in `server-db.ts` (`CREATE TABLE IF NOT EXISTS`). New columns / tables / default settings for live DBs go in `server-migrations.ts` as an appended migration object. See §4.4. |
### 4.3 Settings (key/value config)
Seeded with `INSERT OR IGNORE` (defaults below). Secret keys are never returned raw — `maskSettings()` replaces them with the `__SET__` sentinel.
| Group | Keys (default) |
|-------|----------------|
| Azure | `azure_enabled`(false), `azure_client_id`, `azure_tenant_id`, `azure_client_secret`🔒, `azure_redirect_uri`, `azure_allowed_group` |
| CheckMK | `checkmk_enabled`(false), `checkmk_api_url`, `checkmk_api_user`(automation), `checkmk_api_secret`🔒, `checkmk_sync_interval_ms`(60000) |
| Semaphore | `semaphore_enabled`(false), `semaphore_api_url`, `semaphore_api_token`🔒, `semaphore_project_id` |
| Caddy | `caddy_enabled`(false), `caddy_admin_url`(http://localhost:2019) |
🔒 = in `SECRET_KEYS`, masked on read, only updated when a non-`__SET__` value is sent.
### 4.4 Migration System
Additive schema changes to live databases are handled by `server-migrations.ts`. The runner executes once at the start of `startServer()`, before routes or background jobs, and is synchronous (`better-sqlite3`).
**How it works:**
1. Creates (idempotently) a `_migrations` table: `id TEXT PRIMARY KEY, applied_at TEXT`.
2. Iterates the `migrations` array in order. For each entry whose `id` is not in `_migrations`, runs `up(db)` inside a transaction and records the id on success.
3. If `up()` throws, the transaction rolls back and the id is not recorded — the migration retries on the next start.
4. Already-applied migrations are skipped forever.
**Rules for adding a migration:**
- Append to the end of the `migrations` array in `server-migrations.ts`. **Never reorder or remove entries.**
- Use a unique, descriptive `id` — format: `NNNN_short_description` (e.g. `0001_bookings_add_color`).
- Only additive DDL: `ALTER TABLE … ADD COLUMN`, `CREATE TABLE IF NOT EXISTS`, `INSERT OR IGNORE INTO settings`.
- New default settings for **live DBs** go here. Day-0 seeds (fresh install) stay in `DEFAULT_SETTINGS` in `server-db.ts`.
- New settings still need allow-listing in `PUT /api/settings` (and `SECRET_KEYS` if secret) — the migration only seeds the value.
**Fresh-install behavior:** `server-db.ts` creates all baseline tables first (at module import). `runMigrations` then applies every migration in the array — safe because all migrations are additive over the baseline.
**`_migrations` is excluded** from `/api/database/import` and `/api/database/info` (not in their explicit table lists) — migration state belongs to the current install, not the imported data.
---
## 5. API Design
All `/api/*` routes return JSON. Every route except the public auth/config endpoints requires `requireAuth` (JWT bearer).
### 5.1 REST API Structure
```
/api
+-- /auth
| +-- POST /register # Create local account > { token, user } [public]
| +-- POST /login # Authenticate > { token, user } [public]
| +-- GET /me # Current user from token [auth]
| +-- GET /config # { azureEnabled, effectiveRedirectUri, [public]
| | # checkmkEnabled, checkmkBaseUrl }
| +-- GET /azure # Start Azure OAuth (redirect to Microsoft) [public]
| +-- GET /azure/callback # OAuth callback > redirect /?token=… [public]
|
+-- /settings
| +-- GET / # All settings (secrets masked as __SET__) [auth]
| +-- PUT / # Update allow-listed keys; re-push Caddy [auth]
|
+-- /users
| +-- GET / # List users [auth]
| +-- PUT /{id} # Update name/email (dupe-email guarded) [auth]
| +-- DELETE /{id} # Delete (not self, not last user) [auth]
|
+-- /devices
| +-- GET / # List devices [auth]
| +-- POST / # Create device (+ maintenance log) [auth]
| +-- PUT /{id} # Update device (+ maintenance log) [auth]
| +-- DELETE /{id} # Delete + scrub from all labs [auth]
|
+-- /labs
| +-- GET / # List labs (parses deviceIds/topology JSON) [auth]
| +-- POST / # Create lab; sets ownerId=req.user [auth]
| +-- PUT /{id} # Update lab; 403 if not owner/admin/legacy [auth]
| +-- DELETE /{id} # Delete + cancel upcoming bookings; same 403 guard [auth]
|
+-- /bookings
| +-- GET / # List bookings (int flags > booleans) [auth]
| +-- POST / # Create booking (+ log, alertGenerated) [auth]
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
| +-- DELETE /{id} # Delete booking [auth]
|
+-- /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
| +-- GET / # All logs, newest first [auth]
| +-- POST / # Manual log entry [auth]
|
+-- /links
| +-- GET / # Quick links (ordered category, title) [auth]
| +-- POST / # Create link [auth]
| +-- PUT /{id} # Update link [auth]
| +-- DELETE /{id} # Delete link [auth]
|
+-- /checkmk
| +-- POST /sync # Trigger CheckMK status sync now [auth]
|
+-- /semaphore
| +-- GET /templates # Proxy Semaphore task-template list [auth]
| +-- POST /trigger/{bookingId} # Manual setup|teardown for a booking [auth]
|
+-- /caddy
+-- GET /status # Caddy admin API reachable? [auth]
+-- GET /routes # Custom routes (plain array) [auth]
+-- POST /routes # Add custom route + push config [auth]
+-- PUT /routes/{id} # Update custom route + push config [auth]
+-- DELETE /routes/{id} # Remove custom route + push config [auth]
```
### 5.2 Authentication & Authorization
```
Auth model
+-- Token: JWT (HS256, secret = JWT_SECRET), payload { userId, email }, expiry 24h
+-- Transport: Authorization: Bearer <jwt> (no cookies > no CSRF surface)
+-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user)
+-- Middleware
| +-- requireAuth — verifies JWT, sets req.user; applied to all data routes
| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED to routes
+-- Roles: role column defaults to 'User'
+-- Lab ownership: PUT/DELETE /api/labs/:id enforce inline ownership check
| (owner || admin || legacy-lab with ownerId=''); 403 otherwise
```
**Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user.
**Azure Entra ID (OAuth 2.0 auth-code flow):**
```
1. GET /api/auth/config > frontend learns azureEnabled, shows SSO button
2. GET /api/auth/azure > MSAL getAuthCodeUrl > 302 to Microsoft
3. GET /api/auth/azure/callback> acquireTokenByCode
> optional azure_allowed_group membership check
> upsert user (auto-provision, empty password)
> 302 /?token=<jwt>
4. App.tsx reads ?token= / ?auth_error=, verifies via /api/auth/me, persists
```
---
## 6. Integrations & Background Jobs
All three integrations are configured at runtime via the Settings UI (stored in the `settings` table). The background loops re-read settings each cycle, so CheckMK interval changes take effect without a restart.
### 6.1 CheckMK — Device Status Sync
```
Loop: scheduleSync() > syncCheckMkStatuses() > setTimeout(checkmk_sync_interval_ms)
(default 60s; skipped entirely if checkmk_enabled !== 'true')
Auth header: Authorization: Bearer <user> <secret>
Step 1 GET /domain-types/host_config/collections/all
> build IP > hostname map (checks attributes + effective_attributes)
Step 2 for each device:
- no CheckMK host for its IP > status 'unknown'
- GET /objects/host/{name}?columns=state…
state 0 > online
state 1 | 2 > offline
else > unknown
- update devices.status, lastCheckedAt, cmkHostname
- on change: write a 'status' log
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint)
After each cycle: broadcastDevices() + broadcastLogs() > SSE push to all clients
```
### 6.2 Ansible Semaphore — Playbook Automation
```
Loop: scheduleSemaphoreCheck() > checkAndTriggerAnsibleTasks() > setTimeout(30s)
(skipped if semaphore_enabled !== 'true')
Setup bookings WHERE startDateTime <= now AND ansibleSetupTriggered=0
AND status != 'cancelled' > trigger lab.semaphoreSetupTemplateId
Teardown bookings WHERE endDateTime <= now AND ansibleTeardownTriggered=0
AND status != 'cancelled' > trigger lab.semaphoreTeardownTemplateId
(also triggered immediately when a started booking is cancelled)
triggerSemaphoreTask(templateId, extraVars):
POST {apiUrl}/api/project/{projectId}/tasks
body { template_id, environment: JSON.stringify(extraVars) }
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
> store returned job id on booking; log success/failure
(a booking with no template id is marked triggered > not retried)
After each cycle: broadcastBookings() + broadcastLogs() > SSE push to all clients
Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' }
GET /api/semaphore/templates (proxy for UI dropdowns)
```
### 6.3 Caddy — Reverse Proxy
```
buildCaddyfile():
{ local_certs } # global block
per custom route { [encode] [tls internal] [redir / <redirect>] reverse_proxy <upstream> { … } }
redirect set → `redir / <path>` redirects only the bare root '/'
(other paths pass through; e.g. CheckMK served at /<site>/check_mk/)
every reverse_proxy block carries standard forwarding headers:
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
header_up Host {host}
upstream prefixed with https:// → block also gets a
transport http { tls_insecure_skip_verify } block
(for self-signed TLS backends like Semaphore)
importCaddyfileRoutes(): reads /etc/caddy/Caddyfile on first Caddy enable
parses hostname/upstream blocks → seeds caddy table as custom routes
(no-op if caddy table already has entries or file not found)
pushCaddyConfig(): POST <caddy_admin_url>/load (Content-Type: text/caddyfile)
called on startup, after settings save, after route add/delete
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true'
or if this instance is not the Caddy manager)
Ownership — one Caddy serves the whole container (admin API on :2019); POST /load
replaces the ENTIRE config. Only the instance with env CADDY_MANAGER=true (production)
pushes, seeds routes, and accepts route edits (POST/PUT/DELETE → 403 otherwise). The
other instance shows the Caddy section read-only (/api/auth/config → caddyManaged:false)
and never pushes — otherwise its own (partial) config would clobber the owner's. The
owner's caddy table therefore holds ALL routes (both GhostGrid domains + every service).
```
---
### 6.4 First-start Initialization
Runs in `startServer()` on every startup — each step is idempotent.
```
Default admin user (only on a blank database):
if users table is empty:
INSERT user (name='admin', role='admin', email='admin@ghostgrid.local', password=bcrypt('admin'))
→ log "[Init] Default admin user created"
Caddy route import (re-deploy safety net, Caddy manager only):
if CADDY_MANAGER === 'true' AND caddy_enabled === 'true' AND caddy table is empty:
importCaddyfileRoutes() → seed routes from /etc/caddy/Caddyfile
(also runs in PUT /api/settings on the disabled → enabled transition)
Default settings:
INSERT OR IGNORE all DEFAULT_SETTINGS keys from server-db.ts
→ existing values in the settings table are never overwritten
```
---
## 7. Frontend Architecture
### 7.1 Application Structure
```
src/
+-- main.tsx # React entry: imports fonts + index.css, renders <App/>
+-- App.tsx # Stateful root: auth gate, data loading, tab routing, all handlers
+-- index.css # Tailwind + theme tokens
+-- types.ts # Shared interfaces (Device, LabTemplate, Booking, …) — see §8
+-- vite-env.d.ts
+-- lib/
| +-- auth.ts # localStorage token/user, authFetch() wrapper, session helpers
+-- components/
+-- Header.tsx # Top bar; exports GhostGridLogo; notifications; theme/logout
+-- Dashboard.tsx # Active/upcoming bookings + quick-links widget
+-- BookingCalendar.tsx # Day-offset grid; create/cancel; conflict + online checks
+-- BookingDetailsModal.tsx # Booking detail; manual Semaphore trigger; cancel/delete
+-- DeviceInventory.tsx # List/detail; CRUD; markdown emergency sheet; CheckMK link
+-- LabTemplates.tsx # Lab CRUD + topology editor; embeds TopologyPanel
+-- TopologyPanel.tsx # Pure SVG (800x400) node/link renderer
+-- Logbook.tsx # Sorted/filtered log list + manual entry
+-- LinkDashboard.tsx # Quick-link CRUD; 6 accent colors; category grouping
+-- UserDirectory.tsx # Team list; avatar colors; edit/delete modal
+-- LoginPage.tsx # Local login + Azure SSO button (if enabled)
+-- RegisterPage.tsx # Self-registration form
+-- Settings.tsx # Integration config cards (Azure, CheckMK, Semaphore, Caddy)
```
### 7.2 State & Data Flow
`App.tsx` is the single stateful root (no router, no global store):
```
+-- Auth state: currentUser (from localStorage), authView (login|register), authChecked
+-- App data: users, devices, labs, bookings, logs, links (loaded in one Promise.all)
+-- UI state: activeTab, navCollapsed*, theme* (dark|light), notifications,
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
+-- Effects:
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
| +-- Load data on login (one Promise.all; initial seed before SSE connects)
| +-- 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)
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
handleAddLogManually — call API via authFetch, update local state
(SSE pushes the authoritative state to all tabs within ~1s)
(* persisted to localStorage)
```
**Navigation** is a plain `activeTab` switch. Groups: Dashboard / Lab Management (Booking, Inventory, Topology) / Resources (Quick Links, Team) / Audit (Logbook) / System (Settings).
### 7.3 Key UI Components
```
Dashboard
+-- Active / upcoming booking cards
+-- Quick-links widget
+-- Navigation shortcuts (to calendar, devices, labs, links)
Booking Calendar
+-- Day-offset grid
+-- Create booking with conflict detection + device-online validation
+-- (devices in 'unknown' status are not bookable when CheckMK enabled)
Device Inventory
+-- Searchable list + detail panel
+-- CRUD; class presets (Switch/Firewall/Access-Point/Controller) + free-form
+-- Markdown emergency sheet; optional CheckMK deep-link
Lab Templates + Topology
+-- Lab CRUD; Semaphore setup/teardown template selection
+-- Scope toggle (Global / Personal) per lab; Personal labs visible only to owner + admins
+-- List sectioned: "My Topologies" / "Global Topologies" / "Others' Personal" (admin only)
+-- Edit/Delete buttons hidden for labs the current user cannot modify
+-- Topology link editor (fromDevice > toDevice, link type)
+-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular)
Settings
+-- Microsoft Entra ID (OAuth SSO, redirect-URI helper, allowed group)
+-- CheckMK (API URL/user/secret, sync interval, "Run sync now")
+-- Ansible Semaphore (API URL/token/project, "Test connection")
+-- Caddy (admin URL, custom route management;
auto-seeded from /etc/caddy/Caddyfile on first enable;
https:// upstream → TLS proxy, certificate not verified)
+-- Secret inputs use the __SET__ sentinel (blank = keep existing)
```
---
## 8. Shared Types (`src/types.ts`)
The single contract between frontend and backend — imported by **both** `server.ts` and the React components.
| Type | Notes |
|------|-------|
| `DeviceType` | `'Switch' \| 'Access-Point' \| 'Firewall' \| 'Controller' \| (string & {})` — presets + free-form |
| `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` |
| `TopologyLink` | `{ fromDevice, toDevice, type }` (e.g. `LACP-Trunk`, `Uplink`, `OOB-Management`) |
| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs; `scope: 'global' \| 'personal'`, `ownerId: string` (userId of creator, `''` for legacy rows) |
| `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs |
| `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` |
| `User` | `{ id, name, role, email }` (never password on the client) |
| `QuickLink` | `{ id, title, url, description, category, color, createdBy?, createdAt }` |
---
## 9. Deployment Architecture
### 9.1 Process & Build Model
```
Dev : npm run dev > tsx server.ts
Express + Vite middleware (HMR) on :3000
Build : npm run build
vite build > dist/ (frontend)
esbuild server.ts --bundle --platform=node --format=cjs
--packages=external > dist/server.cjs
Prod : NODE_ENV=production node dist/server.cjs
Express serves static dist/ + SPA fallback (GET * > dist/index.html)
```
### 9.2 Proxmox LXC + systemd (two instances)
```
+----------------------- Proxmox LXC (Debian 12) -----------------------+
| |
| Production Staging |
| +-------------------------+ +-----------------------------+ |
| | branch : main | | branch : dev | |
| | dir : /opt/ghostgrid| | dir : /opt/ghostgrid-dev | |
| | port : 3000 | | port : 3001 | |
| | service : ghostgrid | | service : ghostgrid-dev | |
| | db : ghostgrid.db | | db : ghostgrid.db (own) | |
| | .env : own JWT_SECRET| | .env : own JWT_SECRET | |
| +-------------------------+ +-----------------------------+ |
| |
| Both exposed directly on the LAN (no reverse proxy / TLS by default; |
| the in-app Caddy feature can add this). |
+-----------------------------------------------------------------------+
Install : deploy/proxmox-ghostgrid.sh (creates LXC, Node 20, clones both
branches, builds, configures services)
Update : deploy/deploy.sh <branch> (git pull + npm run build + systemctl restart;
defaults to main)
Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
```
### 9.3 Environment Variables
| Var | Default | Purpose |
|-----|---------|---------|
| `JWT_SECRET` | insecure fallback | Sign/verify JWTs — **must be set in production** |
| `APP_URL` | `http://localhost:<PORT>` | Base URL for deriving the Azure redirect URI |
| `PORT` | `3000` | HTTP listen port |
| `NODE_ENV` | — | `production` switches to static `dist/` serving |
| `CADDY_MANAGER` | unset | `true` makes this instance the sole Caddy owner (push/seed/edit). Set on production only — one Caddy per container |
| `CHECKMK_API_URL` / `CHECKMK_API_USER` / `CHECKMK_API_SECRET` | — | Fallbacks if not set in the Settings UI |
---
## 10. Security Architecture
```
+-------------------------------------------------------------+
| SECURITY OVERVIEW |
+-------------------------------------------------------------+
| Authentication |
| +-- Local users: bcrypt password hashing (cost 10) |
| +-- JWT (HS256), 24h expiry, no refresh tokens |
| +-- Token in Authorization header (not cookies) |
| +-- Optional Azure Entra ID SSO (MSAL), group restriction |
+-------------------------------------------------------------+
| Authorization |
| +-- requireAuth on all data routes |
| +-- role column ('User'/'admin') exists |
| +-- ⚠ requireAdmin defined but NOT applied — any |
| authenticated user can read/write settings + users |
| +-- Lab ownership enforced on PUT/DELETE /api/labs/:id |
| (owner || admin || legacy ownerId=''); 403 otherwise |
+-------------------------------------------------------------+
| Secret Handling |
| +-- Integration secrets stored in settings table |
| +-- Masked as __SET__ on read (SECRET_KEYS) |
| +-- Only overwritten when a non-sentinel value is sent |
+-------------------------------------------------------------+
| Notable gaps / accepted risks |
| +-- JWT_SECRET has an insecure fallback if unset |
| +-- POST /api/bookings trusts client-supplied userId |
| (does not force req.user.userId) |
| +-- No rate limiting on auth endpoints |
| +-- Secrets at rest are plaintext in SQLite (file perms |
| are the protection boundary) |
+-------------------------------------------------------------+
```
---
## 11. Project Structure
```
GhostGrid/
+-- server.ts # Express app: all routes, auth, integrations, background jobs
+-- server-db.ts # SQLite connection, full schema, settings/Caddy/log helpers (uid, addLog)
+-- index.html # Vite HTML entry (#root > src/main.tsx); links /favicon.svg
+-- public/
| +-- favicon.svg # app favicon (GhostGrid logo; served at site root by Vite)
+-- vite.config.ts # Vite + React + Tailwind; '@' alias > repo root
+-- tsconfig.json # noEmit, react-jsx, bundler resolution
+-- package.json # scripts + deps (package name "react-example" is vestigial)
+-- .env.example # JWT_SECRET, APP_URL
+-- metadata.json # app name/description metadata
+-- README.md # user-facing overview
+-- DEPLOY.md # Proxmox LXC / systemd deployment guide
+-- ARCHITECTURE.md # ← this file
+-- deploy/
| +-- deploy.sh # git pull + build + systemctl restart (arg: branch)
| +-- ghostgrid.service # systemd unit — production (main, :3000)
| +-- ghostgrid-dev.service # systemd unit — staging (dev, :3001)
| +-- proxmox-ghostgrid.sh # one-shot LXC installer (Proxmox VE helper-script style)
+-- src/
+-- main.tsx # React entry
+-- App.tsx # root component (state, routing, handlers)
+-- index.css # Tailwind + theme
+-- types.ts # shared TS interfaces
+-- vite-env.d.ts
+-- lib/auth.ts # token storage + authFetch
+-- components/ # 14 components (see §7.1)
# Runtime artifacts (gitignored):
# ghostgrid.db, ghostgrid.db-wal, ghostgrid.db-shm, dist/, node_modules/
```
---
## 12. Key Technical Decisions Summary
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Process model** | Single Express process serves API + SPA | No orchestration; trivial LXC deployment |
| **Database** | SQLite (`better-sqlite3`, synchronous, WAL) | Zero-ops, single file, ideal for LAN/air-gap |
| **Auth** | JWT in `localStorage` + optional Azure MSAL | Self-contained; SSO optional, header-based (no CSRF) |
| **Frontend state** | React hooks only (no Redux/Zustand/router) | App is small; one stateful root is enough |
| **Runtime config** | Integration settings in DB, edited in Settings UI | No redeploy to change CheckMK/Semaphore/Caddy/Azure |
| **Background jobs** | Self-rescheduling `setTimeout` loops | Picks up settings changes each cycle; no cron/queue dep |
| **Styling** | Tailwind v4 + class-based dark/light | Utility-first, theme toggled on `<html>` |
| **Offline** | Fonts bundled via `@fontsource` | No CDN / external runtime fetches |
| **Build** | Vite (frontend) + esbuild (server bundle) | Fast, single `dist/server.cjs` output |
---
## 13. Operational Notes & Risk Mitigation
| Risk / Concern | Mitigation / Status |
|----------------|---------------------|
| Missing `JWT_SECRET` in prod | Documented in `.env.example`/DEPLOY.md; **set per instance** (installer generates a random one) |
| No admin RBAC enforced | `requireAdmin` exists — wire it onto `/api/settings` and `/api/users` if stricter control is needed |
| Client-supplied `userId` on booking create | Force `req.user.userId` server-side if spoofing is a concern |
| CheckMK/Semaphore outage | Integration loops catch errors, log them, and retry next cycle; non-fatal |
| Caddy admin API unreachable | `pushCaddyConfig()` failures are logged as warnings; routes apply when Caddy starts |
| Data loss | Back up `ghostgrid.db` + `-wal`/`-shm`; each instance has its own DB |
| Schema evolution | For fresh installs, edit the `CREATE TABLE` block in `server-db.ts`. For live DBs (new columns, tables, default settings), append a migration to `server-migrations.ts` (see §4.4). New settings still need allow-listing in `PUT /api/settings` (+ `SECRET_KEYS` if secret). |
---
## 14. Dependencies and Libraries
### 14.1 Backend (`dependencies`)
```
@azure/msal-node ^5.2.2 # Entra ID OAuth
@fontsource/inter ^5.2.8 # self-hosted font (used by frontend build)
@fontsource/jetbrains-mono ^5.2.8
@tailwindcss/vite ^4.1.14 # Tailwind v4 Vite plugin
@vitejs/plugin-react ^5.0.4
bcryptjs ^2.4.3 # password hashing
better-sqlite3 ^12.10.0 # SQLite driver
dotenv ^17.2.3 # .env loading
express ^4.21.2 # HTTP server
jsonwebtoken ^9.0.2 # JWT
lucide-react ^0.546.0 # icons
react / react-dom ^19.0.1
vite ^6.2.3
```
### 14.2 Dev / Build (`devDependencies`)
```
@types/bcryptjs, @types/better-sqlite3, @types/express,
@types/jsonwebtoken, @types/node # type definitions
esbuild ^0.25.0 # bundle server.ts > dist/server.cjs
tailwindcss ^4.1.14
tsx ^4.21.0 # run TS directly in dev
typescript ~5.8.2
```
### 14.3 npm Scripts
| Script | Command | Purpose |
|--------|---------|---------|
| `dev` | `tsx server.ts` | Dev server (Express + Vite middleware) on :3000 |
| `build` | `vite build && esbuild server.ts … --outfile=dist/server.cjs` | Build frontend + bundle server |
| `start` | `node dist/server.cjs` | Run production build (`NODE_ENV=production`) |
| `clean` | `rm -rf dist server.js` | Remove build artifacts |
| `lint` | `tsc --noEmit` | Type check |
---
## 15. Quick Mental Model (for future prompts)
```
Browser (React SPA, localStorage JWT)
│ authFetch > Authorization: Bearer <jwt>
Express (server.ts) ──► better-sqlite3 (ghostgrid.db, WAL)
├─ /api/auth/* (JWT local + Azure MSAL)
├─ /api/{devices,labs,bookings,logs,links,users,settings}
├─ /api/checkmk/* ── background: ~60s status sync
├─ /api/semaphore/* ── background: 30s setup/teardown trigger
└─ /api/caddy/* ── pushes Caddyfile to Caddy admin API
└─ serves frontend: Vite middleware (dev) / static dist/ (prod)
```
**Invariants to remember when editing:**
- Frontend and backend share `src/types.ts` — change both sides together.
- `labs.deviceIds` / `labs.topology` are JSON strings in SQLite, parsed in the API.
- Booking boolean flags are 0/1 integers in SQLite, mapped on read.
- A new settings key must be: **seeded** in `server-db.ts`, **allow-listed** in `PUT /api/settings`, and (if secret) added to `SECRET_KEYS`.
- Baseline schema in `server-db.ts`. Additive schema changes (new columns, tables, default settings) go in `server-migrations.ts` as an appended migration object — never edit existing entries (see §4.4).
- The SPA catch-all (`app.get('*')`) + static serving are registered **last** in `startServer()`, after every `/api` route — otherwise GET `/api/*` falls through to `index.html`. All `/api` responses carry `Cache-Control: no-store`.
- One Caddy per container; `POST /load` replaces the whole config. Only the `CADDY_MANAGER=true` instance may push/seed/edit routes — never let the non-manager push.
- All user-facing strings are in **English**.
---
*Generated from the GhostGrid codebase. Keep this document in sync when the data model, API surface, or integrations change.*

View File

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

View File

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

View File

@ -183,12 +183,15 @@ fi
run "sudo -u ghostgrid git clone --branch ${REPO_BRANCH} '${CLONE_URL}' ${APP_DIR}" run "sudo -u ghostgrid git clone --branch ${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)"

View File

@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#0b0f19" />
<title>GhostGrid</title> <title>GhostGrid</title>
</head> </head>
<body> <body>

131
package-lock.json generated
View File

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

View File

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

24
public/favicon.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#0b0f19"/>
<!-- ghost body -->
<path d="M 24,78 C 18,65 14,35 34,22 C 48,12 62,15 68,26" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
<path d="M 24,78 C 26,83 31,81 35,74 C 38,68 41,74 45,77 C 48,79 50,70 52,65" stroke="#06b6d4" stroke-width="4" stroke-linecap="round" fill="none"/>
<!-- eyes -->
<rect x="38" y="32" width="6" height="13" rx="3" fill="#00f0ff"/>
<rect x="52" y="32" width="6" height="13" rx="3" fill="#00f0ff"/>
<!-- network grid -->
<line x1="56" y1="38" x2="88" y2="38" stroke="#06b6d4" stroke-width="2.5"/>
<line x1="46" y1="62" x2="84" y2="62" stroke="#06b6d4" stroke-width="2.5"/>
<line x1="56" y1="20" x2="56" y2="80" stroke="#06b6d4" stroke-width="2.5"/>
<line x1="68" y1="15" x2="68" y2="76" stroke="#0891b2" stroke-width="2"/>
<line x1="80" y1="26" x2="80" y2="62" stroke="#06b6d4" stroke-width="2"/>
<!-- nodes -->
<circle cx="56" cy="26" r="4" fill="#00f0ff"/>
<circle cx="68" cy="26" r="4" fill="#00f0ff"/>
<circle cx="80" cy="26" r="4" fill="#00f0ff"/>
<circle cx="56" cy="38" r="4" fill="#00f0ff"/>
<circle cx="80" cy="38" r="5" fill="#38bdf8"/>
<circle cx="88" cy="38" r="4" fill="#00f0ff"/>
<circle cx="68" cy="62" r="5" fill="#38bdf8"/>
<circle cx="80" cy="62" r="4" fill="#00f0ff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,7 +1,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);
@ -10,77 +13,196 @@ db.pragma('journal_mode = WAL');
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'User', role TEXT NOT NULL DEFAULT 'User',
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL password_hash TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
hostname TEXT NOT NULL, hostname TEXT NOT NULL,
ip TEXT NOT NULL, ip TEXT NOT NULL,
location TEXT NOT NULL, location TEXT NOT NULL,
notes TEXT, notes TEXT,
type TEXT NOT NULL, type TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
emergencySheet TEXT NOT NULL, emergencySheet TEXT NOT NULL,
lastCheckedAt TEXT lastCheckedAt TEXT,
cmkUrl TEXT NOT NULL DEFAULT '',
cmkHostname TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS labs ( CREATE TABLE IF NOT EXISTS labs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
contactPerson TEXT NOT NULL, contactPerson TEXT NOT NULL,
location TEXT NOT NULL, location TEXT NOT NULL,
deviceIds TEXT NOT NULL, deviceIds TEXT NOT NULL,
topology TEXT NOT NULL topology TEXT NOT NULL,
semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '',
semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS bookings ( CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
labId TEXT NOT NULL, labId TEXT NOT NULL,
userId TEXT NOT NULL, userId TEXT NOT NULL,
startDateTime TEXT NOT NULL, startDateTime TEXT NOT NULL,
endDateTime TEXT NOT NULL, endDateTime TEXT NOT NULL,
notes TEXT, notes TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
notified INTEGER DEFAULT 0, notified INTEGER NOT NULL DEFAULT 0,
emailSent INTEGER DEFAULT 0 emailSent INTEGER NOT NULL DEFAULT 0,
semaphoreSetupTriggered INTEGER NOT NULL DEFAULT 0,
semaphoreTeardownTriggered INTEGER NOT NULL DEFAULT 0,
semaphoreSetupJobId TEXT NOT NULL DEFAULT '',
semaphoreTeardownJobId TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS logs ( CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
deviceId TEXT, deviceId TEXT,
userId TEXT userId TEXT
); );
CREATE TABLE IF NOT EXISTS links ( CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '', category TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'emerald', color TEXT NOT NULL DEFAULT 'emerald',
createdBy TEXT, createdBy TEXT,
createdAt TEXT NOT NULL createdAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS caddy (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1,
redirect TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
); );
`); `);
// Lightweight migrations for columns added after the initial release. // Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run.
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add them by hand. try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {}
function ensureColumn(table: string, column: string, ddl: string) { try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {}
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!cols.some(c => c.name === column)) { // Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); const DEFAULT_SETTINGS: Record<string, string> = {
} azure_enabled: 'false',
azure_client_id: '',
azure_tenant_id: '',
azure_client_secret: '',
azure_redirect_uri: '',
azure_allowed_group: '',
checkmk_enabled: 'false',
checkmk_api_url: '',
checkmk_api_user: 'automation',
checkmk_api_secret: '',
checkmk_sync_interval_ms: '60000',
semaphore_enabled: 'false',
semaphore_api_url: '',
semaphore_api_token: '',
semaphore_project_id: '',
caddy_enabled: 'false',
caddy_admin_url: 'http://127.0.0.1:2019',
};
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) seedSetting.run(key, value);
export function getSetting(key: string): string {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? '';
} }
ensureColumn('devices', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''"); export function setSetting(key: string, value: string): void {
db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))").run(key, value);
}
export function getAllSettings(): Record<string, string> {
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
return Object.fromEntries(rows.map(r => [r.key, r.value]));
}
const insertLog = db.prepare(
'INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)',
);
/**
* Append a logbook entry. `deviceId`/`userId` default to NULL; `timestamp`
* defaults to now (pass one to share a single timestamp across a batch).
* Returns the generated log id.
*/
export function addLog(
type: string,
message: string,
opts: { deviceId?: string | null; userId?: string | null; timestamp?: string } = {},
): string {
const id = uid('log');
insertLog.run(id, opts.timestamp ?? new Date().toISOString(), type, message, opts.deviceId ?? null, opts.userId ?? null);
return id;
}
/** A reverse-proxy route as stored (booleans are SQLite 0/1 integers). */
export interface CaddyRoute {
id: number;
hostname: string;
upstream: string;
tls: number;
compress: number;
redirect: string;
created_at: string;
}
/** Fields a caller supplies to create or update a route (JS booleans). */
export interface CaddyRouteInput {
hostname: string;
upstream: string;
tls: boolean;
compress: boolean;
redirect?: string;
}
export function getCaddyRoutes(): CaddyRoute[] {
return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[];
}
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
}
export function addCaddyRoute(route: CaddyRouteInput): CaddyRoute {
const { lastInsertRowid } = db.prepare(
'INSERT INTO caddy (hostname, upstream, tls, compress, redirect) VALUES (?, ?, ?, ?, ?)',
).run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '');
return getCaddyRouteById(Number(lastInsertRowid))!;
}
export function updateCaddyRoute(id: number, route: CaddyRouteInput): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect = ? WHERE id = ?')
.run(route.hostname, route.upstream, route.tls ? 1 : 0, route.compress ? 1 : 0, route.redirect ?? '', id);
return getCaddyRouteById(id)!;
}
export function deleteCaddyRoute(id: number): void {
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
}
export default db; export default db;

48
server-migrations.ts Normal file
View 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}`);
}
}

1081
server.ts

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { User, Device, LabTemplate, Booking, LogEntry, QuickLink } from './types'; import { User, Device, LabTemplate, Booking, LogEntry, QuickLink } from './types';
import { authFetch, getToken, getStoredUser, clearSession } from './lib/auth'; import { authFetch, getToken, getStoredUser, clearSession, saveSession } from './lib/auth';
import Header, { GhostGridLogo } from './components/Header'; import Header, { GhostGridLogo } from './components/Header';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import BookingCalendar from './components/BookingCalendar'; import BookingCalendar from './components/BookingCalendar';
@ -12,10 +12,11 @@ import UserDirectory from './components/UserDirectory';
import BookingDetailsModal from './components/BookingDetailsModal'; import BookingDetailsModal from './components/BookingDetailsModal';
import LoginPage from './components/LoginPage'; import LoginPage from './components/LoginPage';
import RegisterPage from './components/RegisterPage'; import RegisterPage from './components/RegisterPage';
import Settings from './components/Settings';
import { import {
LayoutDashboard, Calendar, Server, Layers, History, Link as LinkIcon, Users, LayoutDashboard, Calendar, Server, Layers, History, Link as LinkIcon, Users,
PanelLeftClose, PanelLeftOpen, PanelLeftClose, PanelLeftOpen, Settings2,
} from 'lucide-react'; } from 'lucide-react';
type AuthView = 'login' | 'register'; type AuthView = 'login' | 'register';
@ -47,10 +48,15 @@ export default function App() {
localStorage.setItem('ghostgrid_nav_collapsed', navCollapsed ? '1' : '0'); localStorage.setItem('ghostgrid_nav_collapsed', navCollapsed ? '1' : '0');
}, [navCollapsed]); }, [navCollapsed]);
const [oauthError, setOauthError] = useState('');
const [notifications, setNotifications] = useState<string[]>([]); const [notifications, setNotifications] = useState<string[]>([]);
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set()); const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null); const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
const [cmkEnabled, setCmkEnabled] = useState(false);
const [cmkBaseUrl, setCmkBaseUrl] = useState('');
const [isProduction, setIsProduction] = useState(false);
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@ -59,14 +65,45 @@ export default function App() {
localStorage.setItem('ghostgrid_theme', theme); localStorage.setItem('ghostgrid_theme', theme);
}, [theme]); }, [theme]);
// Verify stored token on startup // Verify stored token on startup + handle OAuth callback (?token= / ?auth_error=)
useEffect(() => { useEffect(() => {
async function verifyToken() { async function verifyToken() {
const token = getToken(); // Handle OAuth redirect params first
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
const urlError = params.get('auth_error');
if (urlToken || urlError) {
window.history.replaceState({}, '', '/');
}
if (urlError) {
setOauthError(decodeURIComponent(urlError));
setAuthChecked(true);
return;
}
const token = urlToken || getToken();
if (!token) { if (!token) {
setAuthChecked(true); setAuthChecked(true);
return; return;
} }
if (urlToken) {
// Token came from OAuth callback verify and persist
try {
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${urlToken}` } });
if (res.ok) {
const user = await res.json();
saveSession(urlToken, user);
setCurrentUser(user);
} else {
setOauthError('Login failed. Please try again.');
}
} catch {
setOauthError('Server unreachable. Please try again.');
} finally {
setAuthChecked(true);
}
return;
}
try { try {
const res = await authFetch('/api/auth/me'); const res = await authFetch('/api/auth/me');
if (res.ok) { if (res.ok) {
@ -92,13 +129,14 @@ export default function App() {
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
try { try {
const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes] = await Promise.all([ const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes, configRes] = await Promise.all([
authFetch('/api/users'), authFetch('/api/users'),
authFetch('/api/devices'), authFetch('/api/devices'),
authFetch('/api/labs'), authFetch('/api/labs'),
authFetch('/api/bookings'), authFetch('/api/bookings'),
authFetch('/api/logs'), authFetch('/api/logs'),
authFetch('/api/links'), authFetch('/api/links'),
fetch('/api/auth/config'),
]); ]);
if (usersRes.ok) setUsers(await usersRes.json()); if (usersRes.ok) setUsers(await usersRes.json());
@ -107,6 +145,7 @@ export default function App() {
if (bookingsRes.ok) setBookings(await bookingsRes.json()); if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json()); if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.json()); if (linksRes.ok) setLinks(await linksRes.json());
if (configRes.ok) { const cfg = await configRes.json(); 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 {
@ -116,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;
@ -180,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); }
}; };
@ -196,139 +262,108 @@ 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); }
}; };
// User handlers
const handleDeleteUser = async (id: string) => {
try {
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
} catch (err: any) { throw err; }
};
const handleUpdateUser = async (id: string, name: string, email: string) => {
try {
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
} catch (err: any) { throw err; }
};
const handleSetUserRole = async (id: string, role: string) => {
try {
const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) });
if (res.ok) {
const updated: User = await res.json();
// 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); }
} catch (err: any) { throw err; }
};
// Quick-link handlers (shared link dashboard) // Quick-link handlers (shared link dashboard)
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => { const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
try { try {
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); }
}; };
@ -365,17 +400,23 @@ export default function App() {
{ id: 'logs', label: 'Logbook', icon: <History className="w-4 h-4 shrink-0" /> }, { id: 'logs', label: 'Logbook', icon: <History className="w-4 h-4 shrink-0" /> },
], ],
}, },
{
label: 'System',
items: [
{ id: 'settings', label: 'Settings', icon: <Settings2 className="w-4 h-4 shrink-0" /> },
],
},
]; ];
// Startup check not done yet // Startup check not done yet
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>
); );
@ -386,22 +427,22 @@ export default function App() {
if (authView === 'register') { if (authView === 'register') {
return <RegisterPage onLogin={handleLogin} onNavigateToLogin={() => setAuthView('login')} />; return <RegisterPage onLogin={handleLogin} onNavigateToLogin={() => setAuthView('login')} />;
} }
return <LoginPage onLogin={handleLogin} onNavigateToRegister={() => setAuthView('register')} />; return <LoginPage onLogin={handleLogin} onNavigateToRegister={() => setAuthView('register')} authError={oauthError} />;
} }
// Loading data after login // Loading data after login
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>
@ -411,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}
@ -422,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 (
@ -464,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}
@ -478,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.checkMkUrl && 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">
@ -496,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}
@ -512,6 +555,8 @@ export default function App() {
labs={labs} labs={labs}
devices={devices} devices={devices}
currentUser={currentUser} currentUser={currentUser}
users={users}
cmkEnabled={cmkEnabled}
onAddBooking={handleAddBooking} onAddBooking={handleAddBooking}
onCancelBooking={handleCancelBooking} onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking} onDeleteBooking={handleDeleteBooking}
@ -521,6 +566,8 @@ export default function App() {
{activeTab === 'devices' && ( {activeTab === 'devices' && (
<DeviceInventory <DeviceInventory
devices={devices} devices={devices}
cmkEnabled={cmkEnabled}
cmkBaseUrl={cmkBaseUrl}
onAddDevice={handleAddDevice} onAddDevice={handleAddDevice}
onUpdateDevice={handleUpdateDevice} onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice} onDeleteDevice={handleDeleteDevice}
@ -530,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}
@ -550,6 +599,9 @@ export default function App() {
users={users} users={users}
currentUser={currentUser} currentUser={currentUser}
bookings={bookings} bookings={bookings}
onDeleteUser={handleDeleteUser}
onUpdateUser={handleUpdateUser}
onSetRole={handleSetUserRole}
/> />
)} )}
{activeTab === 'logs' && ( {activeTab === 'logs' && (
@ -561,6 +613,9 @@ export default function App() {
onAddLog={handleAddLogManually} onAddLog={handleAddLogManually}
/> />
)} )}
{activeTab === 'settings' && currentUser.role.toLowerCase() === 'admin' && (
<Settings currentUser={currentUser} />
)}
</main> </main>
</div> </div>

View File

@ -1,15 +1,14 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { Booking, LabTemplate, Device, User } from '../types'; import { Booking, LabTemplate, Device, User } from '../types';
import { import {
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database, Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
X, Layers, Server, Clock, ChevronDown X, Layers, Server, Clock, ChevronDown
} from 'lucide-react'; } from 'lucide-react';
/** A device can only be reserved when CheckMK reports it online. */
function effectiveStatus(d: Device): 'online' | 'offline' | 'unknown' { function effectiveStatus(d: Device): 'online' | 'offline' | 'unknown' {
return d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown'; return d.status;
} }
function isBookable(d: Device): boolean { function isOnline(d: Device): boolean {
return effectiveStatus(d) === 'online'; return effectiveStatus(d) === 'online';
} }
@ -18,6 +17,8 @@ interface BookingCalendarProps {
labs: LabTemplate[]; labs: LabTemplate[];
devices: Device[]; devices: Device[];
currentUser: User; currentUser: User;
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,11 +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,
users,
cmkEnabled,
onAddBooking, onAddBooking,
onCancelBooking, onCancelBooking,
onDeleteBooking, onDeleteBooking,
@ -156,11 +165,11 @@ export default function BookingCalendar({
return { hasConflict: false }; return { hasConflict: false };
} }
// Devices in the current selection that CheckMK does not report as online - these block the booking. // Devices in the current selection that CheckMK does not report as online - shown as a warning only.
function blockingDevices(deviceIds: string[]): Device[] { function offlineDevices(deviceIds: string[]): Device[] {
return deviceIds return deviceIds
.map(id => devices.find(d => d.id === id)) .map(id => devices.find(d => d.id === id))
.filter((d): d is Device => !!d && !isBookable(d)); .filter((d): d is Device => !!d && !isOnline(d));
} }
// ── available-now helpers for Quick Booking ──────────────────────────── // ── available-now helpers for Quick Booking ────────────────────────────
@ -171,17 +180,26 @@ export default function BookingCalendar({
return { startMs: start.getTime(), endMs: end.getTime(), start, end }; return { startMs: start.getTime(), endMs: end.getTime(), start, end };
}, [quickDuration]); }, [quickDuration]);
// A lab is quick-bookable only when every device is free AND reported online by CheckMK. const bookableLabs = useMemo(() => labs.filter(l =>
const availableLabs = useMemo(() => labs.filter(lab => l.scope === 'global' ||
l.ownerId === currentUser.id ||
currentUser.role?.toLowerCase() === 'admin'
), [labs, currentUser.id, currentUser.role]);
useEffect(() => {
if (selectedLabId && !bookableLabs.find(l => l.id === selectedLabId)) {
setSelectedLabId(bookableLabs[0]?.id || '');
}
}, [bookableLabs]);
// A lab is quick-bookable when every device is free (regardless of online status).
const availableLabs = useMemo(() => bookableLabs.filter(lab =>
lab.deviceIds.length > 0 && lab.deviceIds.length > 0 &&
lab.deviceIds.every(dId => { lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
const dev = devices.find(d => d.id === dId); ), [bookableLabs, devices, bookings, quickWindow]);
return !!dev && isBookable(dev) && !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs);
})
), [labs, devices, bookings, quickWindow]);
const availableDevices = useMemo(() => devices.filter(dev => const availableDevices = useMemo(() => devices.filter(dev =>
isBookable(dev) && !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs) !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
), [devices, bookings, quickWindow]); ), [devices, bookings, quickWindow]);
// ── booking actions ──────────────────────────────────────────────────── // ── booking actions ────────────────────────────────────────────────────
@ -191,12 +209,6 @@ export default function BookingCalendar({
const deviceIds = targetDeviceIds(); const deviceIds = targetDeviceIds();
if (deviceIds.length === 0) { alert('Please select a resource to reserve.'); return; } if (deviceIds.length === 0) { alert('Please select a resource to reserve.'); return; }
const blocked = blockingDevices(deviceIds);
if (blocked.length > 0) {
alert(`Not bookable: ${blocked.map(d => `"${d.hostname}" (${effectiveStatus(d)})`).join(', ')} ${blocked.length === 1 ? 'is' : 'are'} not online in CheckMK.`);
return;
}
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime); const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (conflict.hasConflict) { alert(conflict.message); return; } if (conflict.hasConflict) { alert(conflict.message); return; }
@ -225,14 +237,8 @@ export default function BookingCalendar({
}; };
const handleQuickBookDevice = (device: Device) => { const handleQuickBookDevice = (device: Device) => {
if (!isBookable(device)) {
alert(`"${device.hostname}" is ${effectiveStatus(device)} in CheckMK and cannot be reserved.`);
return;
}
// Find or pick a lab that contains this device; fall back to device ID as labId marker
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),
@ -250,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
@ -275,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' })} &gt;{' '}
{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>
@ -293,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)
@ -301,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)
@ -312,16 +318,22 @@ export default function BookingCalendar({
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2"> <div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
{quickTab === 'labs' ? ( {quickTab === 'labs' ? (
availableLabs.length === 0 ? ( availableLabs.length === 0 ? (
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free and fully online for {quickDuration}h right now. all boxes either leased or not reporting in.</p> <p className="text-xs text-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;
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 && (
<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
</p>
)}
</div> </div>
<button <button
onClick={() => handleQuickBookLab(lab)} onClick={() => handleQuickBookLab(lab)}
@ -338,32 +350,32 @@ export default function BookingCalendar({
const status = effectiveStatus(device); const status = effectiveStatus(device);
const online = status === 'online'; const online = status === 'online';
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs); const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
const bookable = online && free;
return ( return (
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${ <div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
bookable ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60' free ? 'bg-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 && (
<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}
</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>
{bookable ? ( {free ? (
<button <button
onClick={() => handleQuickBookDevice(device)} onClick={() => handleQuickBookDevice(device)}
className="shrink-0 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition-all" className="shrink-0 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition-all"
> >
Book Book
</button> </button>
) : !online ? (
<span className="shrink-0 flex items-center gap-1 text-[10px] text-amber-400 font-mono font-semibold capitalize" title="Not online in CheckMK - cannot be reserved">
<AlertTriangle className="w-3 h-3" />{status}
</span>
) : ( ) : (
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span> <span className="shrink-0 text-[10px] text-rose font-mono font-semibold">Busy</span>
)} )}
</div> </div>
); );
@ -375,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>
@ -408,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);
@ -442,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>
); );
} }
@ -458,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>
); );
@ -487,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>
@ -501,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>
@ -516,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>
@ -525,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
@ -565,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
@ -576,28 +595,39 @@ 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}>
{isBookable(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip}) {isOnline(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip})
</option> </option>
))} ))}
</select> </select>
@ -606,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}
@ -619,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>
@ -658,41 +688,43 @@ 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>
{(() => { {(() => {
const deviceIds = targetDeviceIds(); const deviceIds = targetDeviceIds();
const blocked = blockingDevices(deviceIds); const offline = offlineDevices(deviceIds);
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime); const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (blocked.length > 0) { if (conflict.hasConflict) {
return ( return (
<div className="bg-amber-950/40 p-2.5 rounded border border-amber-800/60 flex gap-2 text-amber-300 text-[11px] leading-normal"> <div className="bg-rose-soft p-2.5 rounded border border-rose-line flex gap-2 text-rose text-[11px] leading-normal">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" /> <AlertCircle className="w-4 h-4 text-rose shrink-0" />
<span>{conflict.message}</span>
</div>
);
}
if (cmkEnabled && offline.length > 0) {
return (
<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-warning shrink-0" />
<span> <span>
Not bookable - {blocked.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {blocked.length === 1 ? 'is' : 'are'} not online in CheckMK. Hardware must be reachable before it can be reserved. 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>
</div> </div>
); );
} }
return conflict.hasConflict ? ( 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-success-soft p-2.5 rounded border border-success-line flex gap-2 text-success text-[11px] leading-normal">
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" /> <CheckCircle2 className="w-4 h-4 text-success shrink-0" />
<span>{conflict.message}</span> <span>Timeframe is available.</span>
</div>
) : (
<div className="bg-emerald-950/20 p-2.5 rounded border border-emerald-900/40 flex gap-2 text-emerald-300 text-[11px] leading-normal">
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
<span>Online & free. Timeframe is available.</span>
</div> </div>
); );
})()} })()}
@ -700,13 +732,12 @@ export default function BookingCalendar({
{(() => { {(() => {
const deviceIds = targetDeviceIds(); const deviceIds = targetDeviceIds();
const disabled = deviceIds.length === 0 const disabled = deviceIds.length === 0
|| blockingDevices(deviceIds).length > 0
|| checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict; || checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict;
return ( return (
<button <button
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>
@ -717,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)}
@ -725,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>
@ -754,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:');
@ -764,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>

View File

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

View File

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

View File

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

View File

@ -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 bg-emerald-500 animate-pulse"></span> <span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-success' : 'bg-warning'}`} />
<span>System: Online (Simulated)</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>

View File

@ -4,17 +4,19 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { LabTemplate, Device, TopologyLink } from '../types'; import { LabTemplate, Device, TopologyLink, User } from '../types';
import TopologyPanel from './TopologyPanel'; import TopologyPanel from './TopologyPanel';
import { import {
Server, Plus, Edit3, Trash, User, MapPin, Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
Layers, ChevronRight, Save, X, Check Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
} from 'lucide-react'; } from 'lucide-react';
interface LabTemplatesProps { interface LabTemplatesProps {
labs: LabTemplate[]; labs: LabTemplate[];
devices: Device[]; devices: Device[];
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void; currentUser: User;
semaphoreEnabled: boolean;
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
onUpdateLab: (lab: LabTemplate) => void; onUpdateLab: (lab: LabTemplate) => void;
onDeleteLab: (id: string) => void; onDeleteLab: (id: string) => void;
onOpenDeviceDetails: (device: Device) => void; onOpenDeviceDetails: (device: Device) => void;
@ -23,6 +25,8 @@ interface LabTemplatesProps {
export default function LabTemplates({ export default function LabTemplates({
labs, labs,
devices, devices,
currentUser,
semaphoreEnabled,
onAddLab, onAddLab,
onUpdateLab, onUpdateLab,
onDeleteLab, onDeleteLab,
@ -37,6 +41,8 @@ export default function LabTemplates({
const [linkFrom, setLinkFrom] = useState(''); const [linkFrom, setLinkFrom] = useState('');
const [linkTo, setLinkTo] = useState(''); const [linkTo, setLinkTo] = useState('');
const [linkType, setLinkType] = useState('Trunk Uplink'); const [linkType, setLinkType] = useState('Trunk Uplink');
const [editingLinkIdx, setEditingLinkIdx] = useState<number | null>(null);
const [editingLinkLabel, setEditingLinkLabel] = useState('');
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
id?: string; id?: string;
@ -45,12 +51,18 @@ export default function LabTemplates({
contactPerson: string; contactPerson: string;
location: string; location: string;
deviceIds: string[]; deviceIds: string[];
semaphoreSetupTemplateId: string;
semaphoreTeardownTemplateId: string;
scope: 'global' | 'personal';
}>({ }>({
name: '', name: '',
description: '', description: '',
contactPerson: '', contactPerson: '',
location: '', location: '',
deviceIds: [] deviceIds: [],
semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '',
scope: 'global',
}); });
// Calculate filtered devices associated with selected lab // Calculate filtered devices associated with selected lab
@ -66,7 +78,10 @@ export default function LabTemplates({
description: '', description: '',
contactPerson: '', contactPerson: '',
location: '', location: '',
deviceIds: [] deviceIds: [],
semaphoreSetupTemplateId: '',
semaphoreTeardownTemplateId: '',
scope: 'global',
}); });
setIsEditing(true); setIsEditing(true);
}; };
@ -80,7 +95,10 @@ export default function LabTemplates({
description: lab.description, description: lab.description,
contactPerson: lab.contactPerson, contactPerson: lab.contactPerson,
location: lab.location, location: lab.location,
deviceIds: [...lab.deviceIds] deviceIds: [...lab.deviceIds],
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
scope: lab.scope ?? 'global',
}); });
setIsEditing(true); setIsEditing(true);
}; };
@ -124,106 +142,156 @@ export default function LabTemplates({
contactPerson: formData.contactPerson, contactPerson: formData.contactPerson,
location: formData.location, location: formData.location,
deviceIds: formData.deviceIds, deviceIds: formData.deviceIds,
topology: tempLinks topology: tempLinks,
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
scope: formData.scope,
}; };
if (formMode === 'add') { if (formMode === 'add') {
onAddLab(savedLabData); onAddLab(savedLabData);
} else if (formMode === 'edit' && formData.id) { } else if (formMode === 'edit' && formData.id) {
const existing = labs.find(l => l.id === formData.id);
onUpdateLab({ onUpdateLab({
...savedLabData, ...savedLabData,
id: formData.id id: formData.id,
ownerId: existing?.ownerId ?? '',
}); });
} }
setIsEditing(false); setIsEditing(false);
}; };
const isAdmin = currentUser.role?.toLowerCase() === 'admin';
const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === '';
const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id);
const globalLabs = labs.filter(l => l.scope === 'global');
const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : [];
const renderLabCard = (lab: LabTemplate) => {
const isSelected = selectedLab?.id === lab.id;
const editable = canEdit(lab);
return (
<div
key={lab.id}
onClick={() => setSelectedLab(lab)}
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
isSelected
? 'bg-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>
@ -232,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>
@ -271,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>
))} ))}
@ -300,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>
)} )}
@ -308,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>
@ -328,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>
@ -354,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 (
@ -391,15 +484,15 @@ export default function LabTemplates({
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>
); );
})} })}
@ -407,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) => {
@ -428,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) => {
@ -442,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)}
/> />
@ -467,41 +559,107 @@ export default function LabTemplates({
{tempLinks.map((link, idx) => { {tempLinks.map((link, idx) => {
const fromDev = devices.find(d => d.id === link.fromDevice)?.hostname || link.fromDevice; const fromDev = devices.find(d => d.id === link.fromDevice)?.hostname || link.fromDevice;
const toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice; const toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice;
const isEditingThis = editingLinkIdx === idx;
return ( return (
<div key={idx} className="flex justify-between items-center bg-slate-950 px-3 py-1.5 rounded border border-slate-855 font-mono text-[10px] hover:border-slate-700"> <div key={idx} className="flex items-center gap-2 bg-inner px-3 py-1.5 rounded border border-line font-mono text-[10px] hover:border-line-strong">
<span className="text-slate-300"> <span className="text-fg-muted shrink-0"><strong>{fromDev}</strong> ────</span>
<strong>{fromDev}</strong> {link.type} <strong>{toDev}</strong> {isEditingThis ? (
</span> <input
<button autoFocus
type="button" value={editingLinkLabel}
onClick={() => handleRemoveLink(idx)} onChange={(e) => setEditingLinkLabel(e.target.value)}
className="text-rose-500 hover:text-rose-450 font-sans font-bold" onBlur={() => {
> if (editingLinkLabel.trim()) {
Remove setTempLinks(tempLinks.map((l, i) => i === idx ? { ...l, type: editingLinkLabel.trim() } : l));
</button> }
setEditingLinkIdx(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (editingLinkLabel.trim()) {
setTempLinks(tempLinks.map((l, i) => i === idx ? { ...l, type: editingLinkLabel.trim() } : l));
}
setEditingLinkIdx(null);
}
if (e.key === 'Escape') setEditingLinkIdx(null);
}}
className="flex-1 min-w-0 bg-field text-fg border border-primary rounded px-1.5 py-0.5 focus:outline-none"
/>
) : (
<span className="flex-1 min-w-0 text-primary truncate">{link.type}</span>
)}
<span className="text-fg-muted shrink-0"> <strong>{toDev}</strong></span>
<div className="flex gap-1.5 shrink-0 ml-auto font-sans">
<button
type="button"
onClick={() => { setEditingLinkIdx(idx); setEditingLinkLabel(link.type); }}
className="text-fg-muted hover:text-primary transition-colors"
title="Edit label"
>
<Pencil className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => handleRemoveLink(idx)}
className="text-rose hover:opacity-80 font-bold"
>
<X className="w-3 h-3" />
</button>
</div>
</div> </div>
); );
})} })}
</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 */}
{semaphoreEnabled && <div className="border-t border-line pt-3">
<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" />
3. Ansible Automation (optional)
</label>
<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>
<label className="block text-[10px] text-fg-muted mb-1">Setup Template ID</label>
<input
type="text"
inputMode="numeric"
value={formData.semaphoreSetupTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
/>
</div>
<div>
<label className="block text-[10px] text-fg-muted mb-1">Teardown Template ID</label>
<input
type="text"
inputMode="numeric"
value={formData.semaphoreTeardownTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
/>
</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>

View File

@ -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;
@ -43,6 +43,8 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT); const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [activeCategory, setActiveCategory] = useState<string>('all'); const [activeCategory, setActiveCategory] = useState<string>('all');
const [editingDescId, setEditingDescId] = useState<string | null>(null);
const [descDraft, setDescDraft] = useState('');
const categories = useMemo(() => { const categories = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@ -97,6 +99,16 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
setDraft(EMPTY_DRAFT); setDraft(EMPTY_DRAFT);
}; };
const startDescEdit = (link: QuickLink) => {
setEditingDescId(link.id);
setDescDraft(link.description);
};
const commitDescEdit = (link: QuickLink) => {
onUpdateLink({ ...link, description: descDraft.trim() });
setEditingDescId(null);
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const title = draft.title.trim(); const title = draft.title.trim();
@ -126,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
@ -151,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>
@ -173,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>
@ -183,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">
@ -215,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>
@ -229,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>
@ -239,8 +250,27 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
</div> </div>
</div> </div>
{link.description && ( {editingDescId === link.id ? (
<p className="text-[11px] text-slate-400 leading-relaxed mt-3 line-clamp-2">{link.description}</p> <textarea
autoFocus
rows={2}
value={descDraft}
onChange={(e) => setDescDraft(e.target.value)}
onBlur={() => commitDescEdit(link)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitDescEdit(link); }
if (e.key === 'Escape') { setEditingDescId(null); }
}}
className="w-full mt-3 bg-field text-fg text-[11px] border border-success rounded px-2 py-1 resize-none focus:outline-none leading-relaxed"
/>
) : (
<p
onClick={() => startDescEdit(link)}
title="Click to edit description"
className={`text-[11px] leading-relaxed mt-3 line-clamp-2 cursor-text ${link.description ? 'text-fg-muted hover:text-fg' : 'text-fg-faint italic hover:text-fg-muted'} transition-colors`}
>
{link.description || 'Add a description…'}
</p>
)} )}
{/* Hover actions */} {/* Hover actions */}
@ -248,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>
@ -257,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>
@ -273,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} />)}
@ -324,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}
/> />
))} ))}
@ -350,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>

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { GhostGridLogo } from './Header'; import { GhostGridLogo } from './Header';
import { authFetch, saveSession } from '../lib/auth'; import { authFetch, saveSession } from '../lib/auth';
import { User } from '../types'; import { User } from '../types';
@ -7,14 +7,23 @@ import { LogIn, Eye, EyeOff, AlertCircle } from 'lucide-react';
interface LoginPageProps { interface LoginPageProps {
onLogin: (user: User) => void; onLogin: (user: User) => void;
onNavigateToRegister: () => void; onNavigateToRegister: () => void;
authError?: string;
} }
export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPageProps) { export default function LoginPage({ onLogin, onNavigateToRegister, authError }: LoginPageProps) {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState(authError || '');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [azureEnabled, setAzureEnabled] = useState(false);
useEffect(() => {
fetch('/api/auth/config')
.then(r => r.json())
.then(d => setAzureEnabled(Boolean(d.azureEnabled)))
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -43,21 +52,21 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
}; };
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
@ -67,22 +76,22 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
</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
@ -92,13 +101,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
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">
@ -109,13 +117,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
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" />}
@ -126,7 +133,7 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
<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" />
@ -137,11 +144,35 @@ export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPagePr
</button> </button>
</form> </form>
<p className="text-center text-xs text-slate-400"> {azureEnabled && (
<>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-line" />
<span className="text-[10px] font-mono text-fg-faint uppercase tracking-widest">or</span>
<div className="flex-1 h-px bg-line" />
</div>
<button
type="button"
onClick={() => { window.location.href = '/api/auth/azure'; }}
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 */}
<svg width="18" height="18" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
<rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
</svg>
Sign In with Entra ID
</button>
</>
)}
<p className="text-center text-xs text-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>

View File

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

1220
src/components/Settings.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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,26 +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.checkMkUrl ? 'bg-slate-500' : device.status === 'online' ? 'bg-success' :
device.status === 'online' ? 'bg-emerald-500' : device.status === 'offline' ? 'bg-rose' : 'bg-fg-faint'
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
}`} /> }`} />
<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>

View File

@ -1,19 +1,16 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { User, Booking } from '../types'; import { User, Booking } from '../types';
import { Users, Search, Mail, Calendar, Activity } from 'lucide-react'; import { Users, Search, Mail, Calendar, Activity, Trash2, Pencil, X, Save, AlertCircle, ShieldCheck, Shield } from 'lucide-react';
interface UserDirectoryProps { interface UserDirectoryProps {
users: User[]; users: User[];
currentUser: User; currentUser: User;
bookings: Booking[]; bookings: Booking[];
onDeleteUser: (id: string) => Promise<void>;
onUpdateUser: (id: string, name: string, email: string) => Promise<void>;
onSetRole: (id: string, role: string) => Promise<void>;
} }
// Deterministic accent so a given user always renders the same colour.
const AVATAR_COLORS = [ const AVATAR_COLORS = [
'from-emerald-500 to-teal-600', 'from-emerald-500 to-teal-600',
'from-cyan-500 to-blue-600', 'from-cyan-500 to-blue-600',
@ -35,8 +32,99 @@ function initials(name: string): string {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
} }
export default function UserDirectory({ users, currentUser, bookings }: UserDirectoryProps) { interface EditModalProps {
user: User;
onClose: () => void;
onSave: (name: string, email: string) => Promise<void>;
}
function EditModal({ user, onClose, onSave }: EditModalProps) {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
setSaving(true);
setError('');
try {
await onSave(name.trim(), email.trim().toLowerCase());
onClose();
} catch (err: any) {
setError(err.message || 'Failed to save.');
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-overlay backdrop-blur-sm">
<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-line">
<h3 className="text-sm font-semibold text-fg">Edit User</h3>
<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" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<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" />
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
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 className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Email address</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
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 className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-inner transition-all"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 disabled:bg-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 ? 'Saving…' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}
export default function UserDirectory({ users, currentUser, bookings, onDeleteUser, onUpdateUser, onSetRole }: UserDirectoryProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingRoleId, setTogglingRoleId] = useState<string | null>(null);
const [roleError, setRoleError] = useState<string | null>(null);
const isCurrentUserAdmin = currentUser.role.toLowerCase() === 'admin';
const bookingCount = useMemo(() => { const bookingCount = useMemo(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
@ -63,68 +151,97 @@ export default function UserDirectory({ users, currentUser, bookings }: UserDire
); );
}, [users, search]); }, [users, search]);
async function handleDelete(id: string) {
setDeletingId(id);
try { await onDeleteUser(id); } finally { setDeletingId(null); }
}
async function handleToggleRole(user: User) {
setTogglingRoleId(user.id);
setRoleError(null);
try {
await onSetRole(user.id, user.role.toLowerCase() === 'admin' ? 'User' : 'admin');
} catch (err: any) {
setRoleError(err.message || 'Failed to change role.');
} finally {
setTogglingRoleId(null);
}
}
async function handleSaveEdit(name: string, email: string) {
if (!editingUser) return;
await onUpdateUser(editingUser.id, name, email);
}
return ( return (
<div className="space-y-6 font-sans" id="user-directory-root"> <div className="space-y-6 font-sans" id="user-directory-root">
{/* 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 - no shadow IT here. Everyone with an account on this box. Booking counts come straight from the shared reservation pool.
</p> </p>
<div className="flex flex-wrap gap-2 pt-3"> <div className="flex flex-wrap gap-2 pt-3">
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300"> <span className="inline-flex items-center gap-1.5 bg-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-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 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 => {
const isMe = user.id === currentUser.id; const isMe = user.id === currentUser.id;
const total = bookingCount.get(user.id) ?? 0; const total = bookingCount.get(user.id) ?? 0;
const active = activeCount.get(user.id) ?? 0; const active = activeCount.get(user.id) ?? 0;
const isDeleting = deletingId === user.id;
return ( return (
<div <div
key={user.id} key={user.id}
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>
)} )}
@ -134,10 +251,10 @@ Everyone with an account on this box. booking counts come straight from the shar
{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>
@ -145,18 +262,60 @@ Everyone with an account on this box. booking counts come straight from the shar
</div> </div>
</div> </div>
<div className="mt-4 pt-3 border-t border-slate-850 flex items-center justify-between"> <div className="mt-4 pt-3 border-t border-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-3 text-[10px] font-mono text-slate-400"> <div className="flex items-center gap-2">
<span className="flex items-center gap-1" title="Total bookings"> <div className="flex items-center gap-3 text-[10px] font-mono text-fg-muted">
<Calendar className="w-3 h-3 text-indigo-400" /> <span className="flex items-center gap-1" title="Total bookings">
{total} <Calendar className="w-3 h-3 text-primary" />
</span> {total}
<span className="flex items-center gap-1" title="Active / upcoming bookings"> </span>
<Activity className="w-3 h-3 text-emerald-400" /> <span className="flex items-center gap-1" title="Active / upcoming bookings">
{active} <Activity className="w-3 h-3 text-success" />
</span> {active}
</span>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 ml-1">
{isCurrentUserAdmin && !isMe && (
<button
onClick={() => handleToggleRole(user)}
disabled={togglingRoleId === user.id}
className={`p-1.5 rounded-lg transition-all disabled:opacity-40 ${user.role.toLowerCase() === 'admin' ? 'text-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
onClick={() => setEditingUser(user)}
className="p-1.5 rounded-lg text-fg-faint hover:text-info hover:bg-inner transition-all"
title="Edit name / email"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{!isMe && (
<button
onClick={() => handleDelete(user.id)}
disabled={isDeleting}
className="p-1.5 rounded-lg text-fg-faint hover:text-rose hover:bg-inner transition-all disabled:opacity-40"
title="Delete user"
>
{isDeleting
? <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" />}
</button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -164,6 +323,14 @@ Everyone with an account on this box. booking counts come straight from the shar
})} })}
</div> </div>
)} )}
{editingUser && (
<EditModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSave={handleSaveEdit}
/>
)}
</div> </div>
); );
} }

View File

@ -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,518 +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;
}
/* Amber-tinted warning/safety card used in Dashboard */
:root.light .bg-\[\#1D2535\],
:root.light .bg-\[\#1d2535\] {
background-color: #fffbeb !important;
border-color: #fde68a !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 banner gradient ────────────────────────────────── */
:root.light .bg-gradient-to-br {
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-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 .divide-slate-800 > *,
:root.light .divide-slate-850 > * {
border-color: var(--border) !important;
}
/* ── Text colours ─────────────────────────────────────────────── */
:root.light .text-white,
: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-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;
}
/* ── 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;
}
/* Developer panel wrapper - restore dark terminal feel */
:root.light #booking-details-modal .font-mono.bg-slate-950 {
background-color: #0d1117 !important;
color: #c9d1d9 !important;
border-color: #30363d !important;
}
/* Terminal output area bg-slate-1000 */
:root.light #booking-details-modal .bg-slate-1000 {
background-color: #0d1117 !important;
color: #c9d1d9 !important;
border-color: #30363d !important;
}
/* Ansible/terminal button trigger row bg-slate-900/40 - keep readable */
: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;
}

View File

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

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

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