Initial commit

This commit is contained in:
Brückner
2026-06-03 15:20:06 +02:00
commit eed01b9665
34 changed files with 11921 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
JWT_SECRET="change-this-to-a-long-random-secret-in-production"
APP_URL="http://localhost:3000"

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
# Normalize line endings; force LF for shell scripts and unit files
* text=auto eol=lf
*.sh text eol=lf
*.service text eol=lf

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
# local SQLite database
ghostgrid.db
ghostgrid.db-shm
ghostgrid.db-wal

394
DEPLOY.md Normal file
View File

@ -0,0 +1,394 @@
# GhostGrid Deployment
This document describes how to deploy GhostGrid inside a Proxmox LXC container.
GhostGrid runs as a single Node.js process. In production mode, the Express backend serves both the built frontend from `dist/` and the API on `0.0.0.0`.
The container runs two GhostGrid instances in parallel. Each instance has its own working directory, SQLite database, port, and systemd service.
| Instance | Branch | Directory | Port | systemd Service |
| --- | --- | --- | --- | --- |
| Production | `main` | `/opt/ghostgrid` | `3000` | `ghostgrid` |
| Staging | `dev` | `/opt/ghostgrid-dev` | `3001` | `ghostgrid-dev` |
## Deployment Model
GhostGrid uses a manual deployment model:
```bash
git pull
npm run build
systemctl restart <service>
```
The helper script `deploy/deploy.sh` automates this flow. It accepts the target branch as an argument:
```bash
deploy/deploy.sh main
deploy/deploy.sh dev
```
If no argument is provided, the script defaults to `main`.
## Access
Both instances are exposed directly in the LAN:
| Instance | URL |
| --- | --- |
| Production | `http://<lxc-ip>:3000` |
| Staging | `http://<lxc-ip>:3001` |
There is currently no reverse proxy and no TLS termination.
## Recommended Setup: Automated Proxmox Installer
The recommended installation method is the standalone helper script:
```text
deploy/proxmox-ghostgrid.sh
```
It follows the style of the Proxmox VE helper scripts and performs the full setup automatically.
The installer creates the LXC container, installs Node.js 20, clones both branches, builds both instances, and configures two systemd services:
| Service | Branch | Port |
| --- | --- | --- |
| `ghostgrid` | `main` | `3000` |
| `ghostgrid-dev` | `dev` | `3001` |
Each instance receives its own randomly generated `JWT_SECRET`.
Run the installer on the Proxmox host as `root`.
Example using the script directly from the repository:
```bash
bash <(curl -fsSL https://git.airit.rocks/jbrueckner/GhostGrid/raw/branch/main/deploy/proxmox-ghostgrid.sh)
```
Alternatively, copy the script locally and run it:
```bash
bash proxmox-ghostgrid.sh
```
The script asks for the container ID, hostname, resources, network settings, and a read-only Gitea access token.
The token is only required while the repository is private. If the repository is public, the token can be left empty.
At the end of the installation, the script prints both application URLs:
```text
http://<lxc-ip>:3000
http://<lxc-ip>:3001
```
Updates after the initial installation are handled through:
```bash
deploy/deploy.sh main
deploy/deploy.sh dev
```
## Manual Installation
The following steps describe a manual installation of the production instance on branch `main`.
The staging instance follows the same pattern, using branch `dev`, directory `/opt/ghostgrid-dev`, service `ghostgrid-dev`, and port `3001`.
## 1. Create the LXC Container
Create the container on the Proxmox host.
Recommended baseline:
- Debian 12 template
- Unprivileged container
- 12 vCPU
- 1 GB RAM
- 8 GB disk
- Static IP or DHCP reservation in the LAN
Example using the Proxmox CLI:
```bash
pct create <vmid> local:vztmpl/debian-12-standard_*.tar.zst \
--hostname ghostgrid \
--cores 2 \
--memory 1024 \
--rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--unprivileged 1
pct start <vmid>
pct enter <vmid>
```
Replace `<vmid>` with the desired container ID.
## 2. Install Base Packages and Node.js 20 LTS
Run the following commands inside the container:
```bash
apt update
apt install -y curl git ca-certificates build-essential python3
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
node --version
```
The expected Node.js version is `v20.x`.
`build-essential` and `python3` are fallback dependencies in case `better-sqlite3` cannot use a prebuilt binary and needs to compile locally.
## 3. Create the Service User and Clone the Repository
Create a dedicated system user:
```bash
useradd --system \
--create-home \
--home-dir /opt/ghostgrid \
--shell /usr/sbin/nologin \
ghostgrid
```
Repository:
```text
https://git.airit.rocks/jbrueckner/GhostGrid.git
```
For unattended deployments, the `ghostgrid` user needs read-only repository access.
There are two recommended options.
### Option A: SSH Deploy Key
Create an SSH key for the service user:
```bash
sudo -u ghostgrid ssh-keygen -t ed25519 -N "" -f /opt/ghostgrid/.ssh/id_ed25519
sudo -u ghostgrid cat /opt/ghostgrid/.ssh/id_ed25519.pub
```
Add the public key in Gitea:
```text
Repository → Settings → Deploy Keys → Add Deploy Key
```
Keep the deploy key read-only.
Accept the host key and clone the repository via SSH:
```bash
sudo -u ghostgrid ssh-keyscan git.airit.rocks >> /opt/ghostgrid/.ssh/known_hosts
sudo -u ghostgrid git clone git@git.airit.rocks:jbrueckner/GhostGrid.git /opt/ghostgrid
```
If Gitea uses a non-default SSH port, configure it either with `-p <port>` or through `/opt/ghostgrid/.ssh/config`.
When the repository is cloned via SSH, `deploy/deploy.sh` can use the configured Git remote without further changes.
### Option B: HTTPS with Read-Only Access Token
Create a Gitea access token with read-only repository permissions, for example with the `read:repository` scope.
Clone the repository using the token:
```bash
sudo -u ghostgrid git clone \
https://oauth2:<READ_ONLY_TOKEN>@git.airit.rocks/jbrueckner/GhostGrid.git \
/opt/ghostgrid
```
Protect the Git config because the token is stored in `.git/config`:
```bash
chmod 600 /opt/ghostgrid/.git/config
```
## 4. Configure the Application Secret
Create `/opt/ghostgrid/.env`.
The application loads this file through `dotenv`.
```bash
printf 'JWT_SECRET="%s"\n' "$(openssl rand -hex 32)" > /opt/ghostgrid/.env
chown ghostgrid:ghostgrid /opt/ghostgrid/.env
chmod 600 /opt/ghostgrid/.env
```
The `JWT_SECRET` must be unique and must not be committed to Git.
## 5. Build the Application and Enable the systemd Service
```bash
cd /opt/ghostgrid
sudo -u ghostgrid npm ci
sudo -u ghostgrid npm run build
cp deploy/ghostgrid.service /etc/systemd/system/ghostgrid.service
systemctl daemon-reload
systemctl enable --now ghostgrid
systemctl status ghostgrid
```
The service should be `active (running)`.
## 6. Allow Controlled Service Restarts for Deployments
The unprivileged `ghostgrid` user needs permission to restart the GhostGrid services during deployment.
Create the sudoers file:
```bash
nano /etc/sudoers.d/ghostgrid
```
Add the following rule:
```text
ghostgrid ALL=(root) NOPASSWD: /usr/bin/systemctl restart ghostgrid, /usr/bin/systemctl restart ghostgrid-dev
```
Validate the sudoers file if desired:
```bash
visudo -c
```
## 7. Future Deployments
Deploy production:
```bash
sudo -u ghostgrid /opt/ghostgrid/deploy/deploy.sh main
```
Deploy staging:
```bash
sudo -u ghostgrid /opt/ghostgrid-dev/deploy/deploy.sh dev
```
Running `deploy.sh` without an argument deploys `main`.
## Verification
After installation or deployment, verify the following:
1. Both services are running:
```bash
systemctl status ghostgrid ghostgrid-dev
```
2. Recent logs do not show crashes:
```bash
journalctl -u ghostgrid -n 50
journalctl -u ghostgrid-dev -n 50
```
3. Both applications are reachable in the browser:
```text
http://<lxc-ip>:3000
http://<lxc-ip>:3001
```
4. Login or registration loads correctly.
5. Create a device and reload the page. The device should remain available, confirming SQLite persistence.
6. Restart the service or reboot the LXC container. Login and data should still be available.
7. Verify the deployment flow:
```text
local change → push to dev → deploy.sh dev → test → merge to main → deploy.sh main
```
## Manual Setup of the Staging Instance
The automated installer creates the staging instance automatically.
For a manual setup, repeat the production steps with the following changes:
| Setting | Production | Staging |
| --- | --- | --- |
| Branch | `main` | `dev` |
| Directory | `/opt/ghostgrid` | `/opt/ghostgrid-dev` |
| Port | `3000` | `3001` |
| Service | `ghostgrid` | `ghostgrid-dev` |
| Database | `/opt/ghostgrid/ghostgrid.db` | `/opt/ghostgrid-dev/ghostgrid.db` |
Example:
```bash
sudo -u ghostgrid git clone --branch dev <REPO-URL> /opt/ghostgrid-dev
printf 'JWT_SECRET="%s"\n' "$(openssl rand -hex 32)" > /opt/ghostgrid-dev/.env
chown ghostgrid:ghostgrid /opt/ghostgrid-dev/.env
chmod 600 /opt/ghostgrid-dev/.env
cd /opt/ghostgrid-dev
sudo -u ghostgrid npm ci
sudo -u ghostgrid npm run build
cp deploy/ghostgrid-dev.service /etc/systemd/system/ghostgrid-dev.service
systemctl daemon-reload
systemctl enable --now ghostgrid-dev
```
## Notes
Each instance uses its own SQLite database:
| Instance | Database |
| --- | --- |
| Production | `/opt/ghostgrid/ghostgrid.db` |
| Staging | `/opt/ghostgrid-dev/ghostgrid.db` |
The database files are intentionally not tracked in Git.
For backups, include the SQLite database and its related WAL/SHM files if present:
```text
ghostgrid.db
ghostgrid.db-wal
ghostgrid.db-shm
```
Ports `3000` and `3001` are exposed directly in the LAN.
If a reverse proxy or TLS termination is required later, add it separately, for example with nginx or Caddy forwarding to:
```text
127.0.0.1:3000
127.0.0.1:3001
```
Device status is provided by CheckMK.
To enable periodic status synchronization, configure the following variables in the relevant `.env` file:
```env
CHECKMK_API_URL=
CHECKMK_API_SECRET=
```
If these variables are not set, CheckMK synchronization is disabled. Devices remain in status `unknown` and are not available for booking.

113
README.md Normal file
View File

@ -0,0 +1,113 @@
# GhostGrid
GhostGrid is an internal network lab and device inventory tool for managing hardware lab environments.
The application uses an Express backend and a Vite/React frontend running in a single Node.js process. Data is stored locally in a SQLite database using `better-sqlite3`.
GhostGrid is designed to run fully offline. Fonts are bundled locally through `@fontsource`, and the application does not load external code, assets, or CDN resources at runtime.
## Features
- **Dashboard**
Overview of active and upcoming reservations, plus a quick links widget for frequently used tools.
- **Bookings**
Reserve complete lab topologies or individual devices for a defined time period. Bookings include conflict detection and device status validation.
- **Inventory**
Manage lab devices, including emergency and rescue information. Live device status is retrieved from CheckMK.
- **Topology**
Visualize devices and connections for each lab environment.
- **Quick Links**
Shared link dashboard for internal tools such as CheckMK, Semaphore, documentation, or jump hosts.
- **Team**
Overview of all registered users.
- **Logbook**
Shared audit and maintenance journal for lab activity, operational notes, and troubleshooting history.
## Device Status
Device reachability is monitored in CheckMK and imported through the CheckMK API.
Each device can be linked to CheckMK using the **CheckMK Host URL** field. If no CheckMK binding is configured, the device status is set to `unknown`.
Devices with an `unknown` status are not available for booking.
## Local Development
### Requirements
- Node.js 20 LTS
### Setup
```bash
npm install
npm run dev
```
The application will be available at:
```text
http://localhost:3000
```
The development server runs the Express backend with Vite middleware.
## Available Scripts
```bash
npm run dev
```
Start the local development server.
```bash
npm run build
```
Build the frontend into `dist/` and bundle the server as `dist/server.cjs`.
```bash
npm start
```
Start the built production server. This expects `NODE_ENV=production`.
```bash
npm run lint
```
Run the TypeScript type check using `tsc --noEmit`.
## Configuration
Configuration is loaded from environment variables or a local `.env` file. See `.env.example` for reference.
| Variable | Description |
| --- | --- |
| `JWT_SECRET` | Secret used to sign JSON Web Tokens. This must be set explicitly in production. Without a value, the application falls back to an insecure default. |
| `PORT` | HTTP port used by the application. Defaults to `3000`. |
## Deployment
Production deployment is intended to run inside a Proxmox LXC container using systemd.
For detailed deployment instructions, see:
```text
DEPLOY.md
```
The container runs both branches in parallel:
| Branch | Purpose | Port |
| --- | --- | --- |
| `main` | Production | `3000` |
| `dev` | Staging | `3001` |
Each branch runs as a separate systemd service and uses its own database.

22
deploy/deploy.sh Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Pull latest, rebuild, bounce the service. Works for both instances:
# deploy.sh -> main (prod, /opt/ghostgrid, port 3000)
# deploy.sh dev -> dev (stage, /opt/ghostgrid-dev, port 3001)
set -euo pipefail
BRANCH="${1:-main}"
case "$BRANCH" in
main) DIR=/opt/ghostgrid; SVC=ghostgrid ;;
dev) DIR=/opt/ghostgrid-dev; SVC=ghostgrid-dev ;;
*) echo "usage: deploy.sh [main|dev]"; exit 1 ;;
esac
cd "$DIR"
git fetch --prune origin
git checkout "$BRANCH"
git pull --ff-only origin "$BRANCH"
npm ci
npm run build
sudo systemctl restart "$SVC"
echo "Deployed $BRANCH ($SVC). Status:"
systemctl --no-pager status "$SVC" | head -n 5

View File

@ -0,0 +1,19 @@
[Unit]
Description=GhostGrid Development
After=network.target
[Service]
Type=simple
User=ghostgrid
Group=ghostgrid
WorkingDirectory=/opt/ghostgrid-dev
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=3001
EnvironmentFile=-/opt/ghostgrid-dev/.env
ExecStart=/usr/bin/node dist/server.cjs
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

19
deploy/ghostgrid.service Normal file
View File

@ -0,0 +1,19 @@
[Unit]
Description=GhostGrid Production
After=network.target
[Service]
Type=simple
User=ghostgrid
Group=ghostgrid
WorkingDirectory=/opt/ghostgrid
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=3000
EnvironmentFile=-/opt/ghostgrid/.env
ExecStart=/usr/bin/node dist/server.cjs
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

284
deploy/proxmox-ghostgrid.sh Normal file
View File

@ -0,0 +1,284 @@
#!/usr/bin/env bash
# GhostGrid - Proxmox LXC Installer
#
# Run on the Proxmox host as root:
# bash proxmox-ghostgrid.sh
#
# Creates an unprivileged Debian 12 LXC, installs Node.js 20 and Caddy,
# deploys two GhostGrid instances and exposes them through Caddy with
# internally generated TLS certificates:
# main -> ghostgrid.service -> 127.0.0.1:3000 -> https://<main-dns>
# dev -> ghostgrid-dev.service -> 127.0.0.1:3001 -> https://<dev-dns>
set -euo pipefail
# ── Defaults ───────────────────────────────────────────────────────────
APP="GhostGrid"
REPO_URL_DEFAULT="https://git.airit.rocks/jbrueckner/GhostGrid.git"
REPO_BRANCH="main"
DEV_BRANCH="dev"
HOSTNAME_DEFAULT="ghostgrid"
DISK_DEFAULT="8"
CPU_DEFAULT="2"
RAM_DEFAULT="1024"
BRIDGE_DEFAULT="vmbr0"
ROOTFS_STORAGE_DEFAULT="local-lvm"
TEMPLATE_STORAGE_DEFAULT="local"
APP_PORT="3000"
DEV_PORT="3001"
APP_DIR="/opt/ghostgrid"
DEV_DIR="/opt/ghostgrid-dev"
PROD_DNS_DEFAULT="ghostgrid.local"
DEV_DNS_DEFAULT="ghostgrid-dev.local"
DNS_RESOLVER_DEFAULT="1.1.1.1"
# ── Output helpers ─────────────────────────────────────────────────────
YW=$'\033[33m'; GN=$'\033[1;92m'; RD=$'\033[01;31m'; BL=$'\033[36m'; CL=$'\033[m'
CM="${GN}${CL}"; CROSS="${RD}${CL}"; INFO="${BL}${CL}"
msg_info() { echo -e " ${YW}${CL} $1"; }
msg_ok() { echo -e " ${CM} $1"; }
msg_err() { echo -e " ${CROSS} ${RD}$1${CL}"; }
header() {
echo -e "${BL}"
echo " ____ _ _ ____ _ _ "
echo " / ___| |__ ___ ___| |_ / ___|_ __(_) __| |"
echo " | | _| '_ \\ / _ \\/ __| __| | _| '__| |/ _\` |"
echo " | |_| | | | | (_) \\__ \\ |_| |_| | | | | (_| |"
echo " \\____|_| |_|\\___/|___/\\__|\\____|_| |_|\\__,_|"
echo -e " Proxmox LXC Installer${CL}\n"
}
trap 'msg_err "Abort in line $LINENO."; exit 1' ERR
ask_input() {
local title="$1"
local prompt="$2"
local default_value="${3:-}"
whiptail --inputbox "$prompt" 8 72 "$default_value" --title "$title" 3>&1 1>&2 2>&3
}
ask_password() {
local title="$1"
local prompt="$2"
whiptail --passwordbox "$prompt" 9 72 "" --title "$title" 3>&1 1>&2 2>&3 || true
}
require_value() {
local name="$1"
local value="$2"
[[ -n "$value" ]] || { msg_err "$name must not be empty."; exit 1; }
}
# ── Prerequisites ──────────────────────────────────────────────────────
header
[[ "$(id -u)" -eq 0 ]] || { msg_err "Please run this script as root on the Proxmox host."; exit 1; }
command -v pct >/dev/null 2>&1 || { msg_err "'pct' was not found. Are you running this on a Proxmox VE host?"; exit 1; }
command -v whiptail >/dev/null 2>&1 || { msg_err "'whiptail' was not found."; exit 1; }
# ── Interactive configuration ──────────────────────────────────────────
NEXTID="$(pvesh get /cluster/nextid 2>/dev/null || echo 100)"
CTID=$(ask_input "$APP - LXC" "Container-ID (CTID)" "$NEXTID")
CT_HOSTNAME=$(ask_input "$APP - LXC" "LXC hostname" "$HOSTNAME_DEFAULT")
CPU=$(ask_input "$APP - Resources" "vCPU cores" "$CPU_DEFAULT")
RAM=$(ask_input "$APP - Resources" "RAM in MB" "$RAM_DEFAULT")
DISK=$(ask_input "$APP - Resources" "Disk in GB" "$DISK_DEFAULT")
ROOTFS_STORAGE=$(ask_input "$APP - Storage" "rootfs-Storage" "$ROOTFS_STORAGE_DEFAULT")
TEMPLATE_STORAGE=$(ask_input "$APP - Storage" "Template-Storage" "$TEMPLATE_STORAGE_DEFAULT")
BRIDGE=$(ask_input "$APP - Network" "Network bridge" "$BRIDGE_DEFAULT")
if whiptail --title "$APP - Network" --yesno "Should the container use DHCP?\n\nNo = configure a static IP address during installation." 10 72; then
NET="dhcp"
DNS_RESOLVER=""
else
NET="static"
IPADDR=$(ask_input "$APP - Network" "Static IP in CIDR notation, for example 172.16.66.50/24" "")
GATEWAY=$(ask_input "$APP - Network" "Gateway, for example 172.16.66.1" "")
DNS_RESOLVER=$(ask_input "$APP - Network" "DNS resolver for the container" "$DNS_RESOLVER_DEFAULT")
require_value "Static IP" "$IPADDR"
require_value "Gateway" "$GATEWAY"
fi
PROD_DNS=$(ask_input "$APP - Caddy" "DNS name for main / production" "$PROD_DNS_DEFAULT")
DEV_DNS=$(ask_input "$APP - Caddy" "DNS name for dev / staging" "$DEV_DNS_DEFAULT")
require_value "Production DNS-Name" "$PROD_DNS"
require_value "Development DNS-Name" "$DEV_DNS"
if [[ "$PROD_DNS" == "$DEV_DNS" ]]; then
msg_err "Production and development DNS names must not be identical."
exit 1
fi
REPO_URL=$(ask_input "$APP - Git" "Git-Repository (HTTPS)" "$REPO_URL_DEFAULT")
ACCESS_TOKEN=$(ask_password "$APP - Git" "Gitea read-only access token\nLeave empty if the repository is public.")
ROOT_PW=$(ask_password "$APP - LXC" "Root password for the container\nEmpty = no password; access only through 'pct enter ${CTID}'.")
require_value "Git-Repository" "$REPO_URL"
# ── Ensure template ────────────────────────────────────────────────────
msg_info "Updating template list"
pveam update >/dev/null 2>&1 || true
TEMPLATE="$(pveam available --section system 2>/dev/null | awk '/debian-12-standard/{print $2}' | sort -V | tail -n1)"
[[ -n "$TEMPLATE" ]] || { msg_err "No Debian 12 standard template found."; exit 1; }
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then
msg_info "Downloading template $TEMPLATE"
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null
fi
msg_ok "Template ready: $TEMPLATE"
# ── Create container ───────────────────────────────────────────────────
if [[ "$NET" == "dhcp" ]]; then
NETCONF="name=eth0,bridge=${BRIDGE},ip=dhcp"
else
NETCONF="name=eth0,bridge=${BRIDGE},ip=${IPADDR},gw=${GATEWAY}"
fi
msg_info "Creating LXC $CTID ($CT_HOSTNAME)"
CREATE_OPTS=(
--hostname "$CT_HOSTNAME"
--cores "$CPU"
--memory "$RAM"
--rootfs "${ROOTFS_STORAGE}:${DISK}"
--net0 "$NETCONF"
--unprivileged 1
--features keyctl=1,nesting=1
--onboot 1
)
[[ -n "$ROOT_PW" ]] && CREATE_OPTS+=(--password "$ROOT_PW")
[[ "$NET" == "static" && -n "$DNS_RESOLVER" ]] && CREATE_OPTS+=(--nameserver "$DNS_RESOLVER")
pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" "${CREATE_OPTS[@]}" >/dev/null
msg_ok "Container created"
msg_info "Starting container"
pct start "$CTID" >/dev/null
# Wait for basic network/DNS availability.
for _ in $(seq 1 45); do
pct exec "$CTID" -- getent hosts deb.nodesource.com >/dev/null 2>&1 && break
sleep 2
done
msg_ok "Container is running"
# ── In-container helper ────────────────────────────────────────────────
run() { pct exec "$CTID" -- bash -c "export LANG=C.UTF-8 LC_ALL=C.UTF-8; $1"; }
msg_info "Installing base packages, Node.js 20 LTS, and Caddy"
run "export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq curl git ca-certificates build-essential python3 sudo gnupg debian-keyring debian-archive-keyring apt-transport-https caddy >/dev/null"
run "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 && apt-get install -y -qq nodejs >/dev/null"
msg_ok "Node.js $(run 'node --version') installed"
msg_ok "Caddy installed"
msg_info "Creating service user and cloning both branches"
run "id ghostgrid >/dev/null 2>&1 || useradd --system --home-dir ${APP_DIR} --shell /usr/sbin/nologin ghostgrid"
run "mkdir -p ${APP_DIR} ${DEV_DIR} && chown ghostgrid:ghostgrid ${APP_DIR} ${DEV_DIR}"
if [[ -n "$ACCESS_TOKEN" ]]; then
CLONE_URL="$(echo "$REPO_URL" | sed -E "s#https://#https://oauth2:${ACCESS_TOKEN}@#")"
else
CLONE_URL="$REPO_URL"
fi
run "sudo -u ghostgrid git clone --branch ${REPO_BRANCH} '${CLONE_URL}' ${APP_DIR}"
run "sudo -u ghostgrid git clone --branch ${DEV_BRANCH} '${CLONE_URL}' ${DEV_DIR}"
run "chmod 600 ${APP_DIR}/.git/config ${DEV_DIR}/.git/config"
msg_ok "Repositories cloned (main + dev)"
msg_info "Creating .env file for each instance"
for d in "${APP_DIR}" "${DEV_DIR}"; do
SECRET="$(openssl rand -hex 32)"
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
done
msg_ok ".env files created (main + dev)"
msg_info "Installing dependencies and building both instances"
for d in "${APP_DIR}" "${DEV_DIR}"; do
run "cd ${d} && sudo -u ghostgrid npm ci --no-audit --no-fund >/dev/null 2>&1 && sudo -u ghostgrid npm run build >/dev/null 2>&1"
done
msg_ok "Build completed (main + dev)"
msg_info "Configuring systemd services"
run "cp ${APP_DIR}/deploy/ghostgrid.service /etc/systemd/system/ghostgrid.service"
run "cp ${DEV_DIR}/deploy/ghostgrid-dev.service /etc/systemd/system/ghostgrid-dev.service"
run "printf 'ghostgrid ALL=(root) NOPASSWD: /usr/bin/systemctl restart ghostgrid, /usr/bin/systemctl restart ghostgrid-dev\n' > /etc/sudoers.d/ghostgrid && chmod 440 /etc/sudoers.d/ghostgrid"
run "systemctl daemon-reload && systemctl enable --now ghostgrid ghostgrid-dev >/dev/null 2>&1"
msg_ok "Services are active (ghostgrid + ghostgrid-dev)"
msg_info "Configuring Caddy with internally generated TLS certificates"
run "cat > /etc/caddy/Caddyfile <<'CADDYEOF'
{
local_certs
}
${PROD_DNS} {
encode zstd gzip
tls internal
reverse_proxy 127.0.0.1:${APP_PORT}
}
${DEV_DNS} {
encode zstd gzip
tls internal
reverse_proxy 127.0.0.1:${DEV_PORT}
}
CADDYEOF
caddy fmt --overwrite /etc/caddy/Caddyfile >/dev/null
caddy validate --config /etc/caddy/Caddyfile >/dev/null
systemctl enable --now caddy >/dev/null 2>&1
systemctl reload caddy >/dev/null 2>&1"
msg_ok "Caddy is active (${PROD_DNS}, ${DEV_DNS})"
# ── Final output ───────────────────────────────────────────────────────
IP="$(pct exec "$CTID" -- ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 | head -n1)"
msg_info "Writing Proxmox notes"
pct set "$CTID" --description "# ${APP}
This container runs two GhostGrid instances in parallel: main (production) and dev (staging), each with its own SQLite database.
## Caddy HTTPS
- Production: https://${PROD_DNS}
- Development: https://${DEV_DNS}
- Caddy uses internally generated certificates through 'tls internal'.
- Both DNS names must internally resolve to ${IP}.
- Caddy root CA inside the container: /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
## Production (main)
- Branch: ${REPO_BRANCH}
- Directory: ${APP_DIR}
- Service: ghostgrid
- Internal upstream: 127.0.0.1:${APP_PORT}
## Development (dev)
- Branch: ${DEV_BRANCH}
- Directory: ${DEV_DIR}
- Service: ghostgrid-dev
- Internal upstream: 127.0.0.1:${DEV_PORT}
## Gitea
- Repo: ${REPO_URL}
## Update from Proxmox host
- Prod: pct exec ${CTID} -- sudo -u ghostgrid ${APP_DIR}/deploy/deploy.sh main
- Dev: pct exec ${CTID} -- sudo -u ghostgrid ${DEV_DIR}/deploy/deploy.sh dev
Installed via deploy/proxmox-ghostgrid.sh" >/dev/null
msg_ok "Notes written"
echo
msg_ok "${GN}${APP} has been installed!${CL}"
echo -e " ${INFO} CTID: ${BL}${CTID}${CL}"
echo -e " ${INFO} LXC IP: ${BL}${IP}${CL}"
echo -e " ${INFO} Prod (main): ${BL}https://${PROD_DNS}${CL}"
echo -e " ${INFO} Dev (dev): ${BL}https://${DEV_DNS}${CL}"
echo -e " ${INFO} DNS note: ${BL}${PROD_DNS} and ${DEV_DNS} must resolve to ${IP}.${CL}"
echo -e " ${INFO} Caddy Root CA: ${BL}/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt${CL}"
echo -e " ${INFO} Update prod: ${BL}pct exec ${CTID} -- sudo -u ghostgrid ${APP_DIR}/deploy/deploy.sh main${CL}"
echo -e " ${INFO} Update dev: ${BL}pct exec ${CTID} -- sudo -u ghostgrid ${DEV_DIR}/deploy/deploy.sh dev${CL}"
echo

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GhostGrid</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
metadata.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "GhostGrid — Build and control invisible infrastructure.",
"description": "A platform with a SQLite backend and REST API for scheduled booking of lab environments and hardware inventory for IT teams.",
"requestFramePermissions": [],
"majorCapabilities": []
}

4491
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx server.ts",
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
"start": "node dist/server.cjs",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@azure/msal-node": "^5.2.2",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.10.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

86
server-db.ts Normal file
View File

@ -0,0 +1,86 @@
import Database from 'better-sqlite3';
import path from 'path';
const DB_FILE = path.join(process.cwd(), 'ghostgrid.db');
console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`);
const db = new Database(DB_FILE);
db.pragma('journal_mode = WAL');
db.exec(`
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
);
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,
status TEXT NOT NULL,
emergencySheet TEXT NOT NULL,
lastCheckedAt TEXT
);
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,
topology TEXT NOT NULL
);
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,
notified INTEGER DEFAULT 0,
emailSent INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
type TEXT NOT NULL,
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
);
`);
// Lightweight migrations for columns added after the initial release.
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add them by hand.
function ensureColumn(table: string, column: string, ddl: string) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!cols.some(c => c.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
}
ensureColumn('devices', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''");
export default db;

546
server.ts Normal file
View File

@ -0,0 +1,546 @@
import 'dotenv/config';
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import { createServer as createViteServer } from 'vite';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import db from './server-db';
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
const JWT_EXPIRY = '24h';
interface JwtPayload {
userId: string;
email: string;
}
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Authentication required.' });
return;
}
try {
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token.' });
}
}
async function startServer() {
const app = express();
const PORT = Number(process.env.PORT) || 3000;
app.use(express.json());
// -------------------------------------------------------------
// AUTH API
// -------------------------------------------------------------
app.post('/api/auth/register', (req, res) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Name, email and password are required.' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
}
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
const passwordHash = bcrypt.hashSync(password, 10);
const id = uid("u");
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
.run(id, name, 'User', email, passwordHash);
const user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
const token = jwt.sign({ userId: id, email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
res.status(201).json({ token, user });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/auth/login', (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required.' });
}
const row = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as (User & { password_hash: string }) | undefined;
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
return res.status(401).json({ error: 'Invalid email or password.' });
}
const token = jwt.sign({ userId: row.id, email: row.email }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
const user: User = { id: row.id, name: row.name, role: row.role, email: row.email };
res.json({ token, user });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/auth/me', requireAuth, (req, res) => {
try {
const user = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(req.user!.userId) as User | undefined;
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
res.json(user);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Users
// -------------------------------------------------------------
app.get('/api/users', requireAuth, (_req, res) => {
try {
const users = db.prepare('SELECT id, name, role, email FROM users').all() as User[];
res.json(users);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Devices / Inventory
// -------------------------------------------------------------
app.get('/api/devices', requireAuth, (_req, res) => {
try {
const devices = db.prepare('SELECT * FROM devices').all() as Device[];
res.json(devices);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/devices', requireAuth, (req, res) => {
try {
const { hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt } = req.body;
if (!hostname || !ip || !type) {
return res.status(400).json({ error: 'Missing required device specifications.' });
}
const id = uid("dev");
db.prepare(`
INSERT INTO devices (id, hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, hostname, ip, location || '', notes || '', type, status || 'unknown', emergencySheet || '', checkMkUrl || '', lastCheckedAt || null);
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'maintenance',
`Provisioned a new host candidate "${hostname}" (${ip}) inside location ${location || 'Inventory'}.`,
id, req.user!.userId);
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
res.status(201).json(device);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/devices/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl, lastCheckedAt, operatorName } = req.body;
db.prepare(`
UPDATE devices SET hostname = ?, ip = ?, location = ?, notes = ?, type = ?, status = ?, emergencySheet = ?, checkMkUrl = ?, lastCheckedAt = ?
WHERE id = ?
`).run(hostname, ip, location, notes, type, status, emergencySheet, checkMkUrl ?? '', lastCheckedAt ?? null, id);
const logId = uid("log");
const operatorText = operatorName ? `${operatorName} finished ` : 'Updated ';
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'maintenance',
`${operatorText}refining the device specifications for "${hostname}".`, id, req.user!.userId);
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
res.json(device);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/devices/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const dev = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
if (!dev) return res.status(404).json({ error: 'Device not found.' });
db.prepare('DELETE FROM devices WHERE id = ?').run(id);
const labs = db.prepare('SELECT * FROM labs').all() as any[];
const updateLabStmt = db.prepare('UPDATE labs SET deviceIds = ?, topology = ? WHERE id = ?');
for (const lab of labs) {
const deviceIds: string[] = JSON.parse(lab.deviceIds);
const topology: any[] = JSON.parse(lab.topology);
updateLabStmt.run(
JSON.stringify(deviceIds.filter(dId => dId !== id)),
JSON.stringify(topology.filter(t => t.fromDevice !== id && t.toDevice !== id)),
lab.id
);
}
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'maintenance',
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
null, req.user!.userId);
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Lab Templates
// -------------------------------------------------------------
app.get('/api/labs', requireAuth, (_req, res) => {
try {
const rows = db.prepare('SELECT * FROM labs').all() as any[];
const labs: LabTemplate[] = rows.map(r => ({
id: r.id, name: r.name, description: r.description,
contactPerson: r.contactPerson, location: r.location,
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology)
}));
res.json(labs);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/labs', requireAuth, (req, res) => {
try {
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
}
const id = uid("lab");
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []));
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'maintenance',
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
req.user!.userId);
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology) });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/labs/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ? WHERE id = ?`)
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), id);
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'maintenance',
`Modified the active topology mapping schema for the "${name}" lab template.`, req.user!.userId);
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology) });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/labs/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const lab = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
if (!lab) return res.status(404).json({ error: 'Lab template not found.' });
db.prepare('DELETE FROM labs WHERE id = ?').run(id);
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'booking',
`Withdrew the lab testing template "${lab.name || id}".`, req.user!.userId);
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Bookings / Reservations
// -------------------------------------------------------------
app.get('/api/bookings', requireAuth, (_req, res) => {
try {
const rows = db.prepare('SELECT * FROM bookings').all() as any[];
const bookings: Booking[] = rows.map(r => ({
id: r.id, labId: r.labId, userId: r.userId,
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
notes: r.notes || '', status: r.status as any,
notified: r.notified === 1, emailSent: r.emailSent === 1
}));
res.json(bookings);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/bookings', requireAuth, (req, res) => {
try {
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
if (!labId || !userId || !startDateTime || !endDateTime) {
return res.status(400).json({ error: 'Missing reservation timestamps or laboratory ID.' });
}
const id = uid("book");
db.prepare(`INSERT INTO bookings (id, labId, userId, startDateTime, endDateTime, notes, status, notified, emailSent) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1)`)
.run(id, labId, userId, startDateTime, endDateTime, notes || '', status || 'upcoming');
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(labId) as { name: string } | undefined;
const logId = uid("log");
const operatorText = operatorName || 'An operator';
const startF = new Date(startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const endF = new Date(endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'booking',
`${operatorText} booked the lab template "${lab?.name || 'Unknown'}" from ${startF} to ${endF}.`, userId);
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
res.status(201).json({
booking: { id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 },
alertGenerated: `Reservation successfully approved: Lab "${lab?.name || 'Scenario'}" is reserved for your exclusive usage during this window.`
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/bookings/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { status, operatorName } = req.body;
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
if (!booking) return res.status(404).json({ error: 'Reservation not found.' });
db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id);
if (status === 'cancelled') {
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'booking',
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
req.user!.userId);
}
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
res.json({ id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/bookings/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
if (!booking) return res.status(404).json({ error: 'Reservation not found.' });
db.prepare('DELETE FROM bookings WHERE id = ?').run(id);
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'booking',
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`, req.user!.userId);
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Logs
// -------------------------------------------------------------
app.get('/api/logs', requireAuth, (_req, res) => {
try {
const logs = db.prepare('SELECT * FROM logs ORDER BY timestamp DESC').all() as LogEntry[];
res.json(logs);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/logs', requireAuth, (req, res) => {
try {
const { type, message, deviceId, userId } = req.body;
if (!message || !type) {
return res.status(400).json({ error: 'Missing log message or classification type.' });
}
const id = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)`)
.run(id, new Date().toISOString(), type, message, deviceId || null, userId || null);
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
res.status(201).json(log);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// RESTFUL API: Quick Links (shared link dashboard)
// -------------------------------------------------------------
app.get('/api/links', requireAuth, (_req, res) => {
try {
const links = db.prepare('SELECT * FROM links ORDER BY category ASC, title ASC').all() as QuickLink[];
res.json(links);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/links', requireAuth, (req, res) => {
try {
const { title, url, description, category, color } = req.body;
if (!title || !url) {
return res.status(400).json({ error: 'A title and a URL are required.' });
}
const id = uid("link");
const createdAt = new Date().toISOString();
db.prepare(`INSERT INTO links (id, title, url, description, category, color, createdBy, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, title, url, description || '', category || '', color || 'emerald', req.user!.userId, createdAt);
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
res.status(201).json(link);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/links/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { title, url, description, category, color } = req.body;
const existing = db.prepare('SELECT id FROM links WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Link not found.' });
db.prepare(`UPDATE links SET title = ?, url = ?, description = ?, category = ?, color = ? WHERE id = ?`)
.run(title, url, description || '', category || '', color || 'emerald', id);
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
res.json(link);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/links/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const existing = db.prepare('SELECT id FROM links WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Link not found.' });
db.prepare('DELETE FROM links WHERE id = ?').run(id);
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// -------------------------------------------------------------
// VITE / STATIC SERVING
// -------------------------------------------------------------
if (process.env.NODE_ENV !== 'production') {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'spa',
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
app.get('*', (_req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
}
// -------------------------------------------------------------
// CYCLIC CHECKMK STATUS SYNC
// The device status shown in the UI is owned by CheckMK, not the app.
// This job runs on an interval and reconciles each *linked* device's status
// from the CheckMK REST API. The frontend additionally polls /api/devices,
// so anything written here surfaces in the inventory & booking screens.
// Until CHECKMK_API_URL/SECRET are configured, devices stay 'unknown' (and
// therefore not bookable) - which is the intended safe default.
// -------------------------------------------------------------
const CHECKMK_SYNC_INTERVAL_MS = Number(process.env.CHECKMK_SYNC_INTERVAL_MS) || 60_000;
const CHECKMK_API_URL = process.env.CHECKMK_API_URL; // e.g. https://checkmk.internal/<site>/check_mk/api/1.0
const CHECKMK_API_SECRET = process.env.CHECKMK_API_SECRET; // automation user secret
async function syncCheckMkStatuses() {
if (!CHECKMK_API_URL || !CHECKMK_API_SECRET) return; // not configured - leave statuses as 'unknown'
const rows = db.prepare("SELECT id, hostname, checkMkUrl FROM devices WHERE checkMkUrl != ''")
.all() as { id: string; hostname: string; checkMkUrl: string }[];
for (const dev of rows) {
try {
// TODO(checkmk): query the host's hard state from the CheckMK API using the
// automation secret, map 0 (UP) -> 'online' and anything else -> 'offline':
// const res = await fetch(`${CHECKMK_API_URL}/objects/host/${host}/actions/...`,
// { headers: { Authorization: `Bearer automation ${CHECKMK_API_SECRET}` } });
// const state = (await res.json()).extensions.state === 0 ? 'online' : 'offline';
// db.prepare('UPDATE devices SET status = ?, lastCheckedAt = ? WHERE id = ?')
// .run(state, new Date().toISOString(), dev.id);
} catch (err) {
console.error(`[CheckMK] Status sync failed for ${dev.hostname}:`, err);
}
}
}
setInterval(syncCheckMkStatuses, CHECKMK_SYNC_INTERVAL_MS);
syncCheckMkStatuses();
app.listen(PORT, '0.0.0.0', () => {
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
});
}
startServer().catch(err => {
console.error('[Server] Critical Crash during bootstrap:', err);
});

582
src/App.tsx Normal file
View File

@ -0,0 +1,582 @@
import React, { useState, useEffect } from 'react';
import { User, Device, LabTemplate, Booking, LogEntry, QuickLink } from './types';
import { authFetch, getToken, getStoredUser, clearSession } from './lib/auth';
import Header, { GhostGridLogo } from './components/Header';
import Dashboard from './components/Dashboard';
import BookingCalendar from './components/BookingCalendar';
import DeviceInventory from './components/DeviceInventory';
import LabTemplates from './components/LabTemplates';
import Logbook from './components/Logbook';
import LinkDashboard from './components/LinkDashboard';
import UserDirectory from './components/UserDirectory';
import BookingDetailsModal from './components/BookingDetailsModal';
import LoginPage from './components/LoginPage';
import RegisterPage from './components/RegisterPage';
import {
LayoutDashboard, Calendar, Server, Layers, History, Link as LinkIcon, Users,
PanelLeftClose, PanelLeftOpen,
} from 'lucide-react';
type AuthView = 'login' | 'register';
export default function App() {
// Auth state
const [currentUser, setCurrentUser] = useState<User | null>(() => getStoredUser());
const [authView, setAuthView] = useState<AuthView>('login');
const [authChecked, setAuthChecked] = useState(false);
// App data
const [users, setUsers] = useState<User[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [labs, setLabs] = useState<LabTemplate[]>([]);
const [bookings, setBookings] = useState<Booking[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [links, setLinks] = useState<QuickLink[]>([]);
const [loading, setLoading] = useState(false);
const [selectedBookingForDetails, setSelectedBookingForDetails] = useState<Booking | null>(null);
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
return (localStorage.getItem('ghostgrid_theme') as 'dark' | 'light') || 'dark';
});
const [activeTab, setActiveTab] = useState<string>('dashboard');
const [navCollapsed, setNavCollapsed] = useState<boolean>(() => localStorage.getItem('ghostgrid_nav_collapsed') === '1');
useEffect(() => {
localStorage.setItem('ghostgrid_nav_collapsed', navCollapsed ? '1' : '0');
}, [navCollapsed]);
const [notifications, setNotifications] = useState<string[]>([]);
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
useEffect(() => {
const root = document.documentElement;
if (theme === 'light') root.classList.add('light');
else root.classList.remove('light');
localStorage.setItem('ghostgrid_theme', theme);
}, [theme]);
// Verify stored token on startup
useEffect(() => {
async function verifyToken() {
const token = getToken();
if (!token) {
setAuthChecked(true);
return;
}
try {
const res = await authFetch('/api/auth/me');
if (res.ok) {
const user = await res.json();
setCurrentUser(user);
} else {
clearSession();
setCurrentUser(null);
}
} catch {
// Server unreachable - keep stored user, will fail on data load
} finally {
setAuthChecked(true);
}
}
verifyToken();
}, []);
// Load data once authenticated
useEffect(() => {
if (!currentUser) return;
async function loadData() {
setLoading(true);
try {
const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes] = await Promise.all([
authFetch('/api/users'),
authFetch('/api/devices'),
authFetch('/api/labs'),
authFetch('/api/bookings'),
authFetch('/api/logs'),
authFetch('/api/links'),
]);
if (usersRes.ok) setUsers(await usersRes.json());
if (devicesRes.ok) setDevices(await devicesRes.json());
if (labsRes.ok) setLabs(await labsRes.json());
if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.json());
} catch (err) {
console.error('[App] Failed to load data:', err);
} finally {
setLoading(false);
}
}
loadData();
}, [currentUser]);
// Cyclic device-status check: poll the inventory every 30s so CheckMK-driven
// status changes (online/offline) surface without a manual reload. The backend
// is the source of truth - it syncs each device's status from the CheckMK API.
useEffect(() => {
if (!currentUser) return;
const refreshDevices = async () => {
try {
const res = await authFetch('/api/devices');
if (res.ok) setDevices(await res.json());
} catch {
// transient network/server hiccup - keep last known state, retry next tick
}
};
const id = setInterval(refreshDevices, 30_000);
return () => clearInterval(id);
}, [currentUser]);
// Upcoming-booking reminder - checks every 60s, fires once per booking
useEffect(() => {
if (!currentUser || bookings.length === 0) return;
const check = () => {
const now = Date.now();
bookings
.filter(b => b.userId === currentUser.id && b.status === 'upcoming')
.forEach(b => {
const startsIn = new Date(b.startDateTime).getTime() - now;
if (startsIn > 0 && startsIn <= 30 * 60_000 && !remindedBookings.has(b.id)) {
const labName = labs.find(l => l.id === b.labId)?.name ?? 'your lab';
const mins = Math.ceil(startsIn / 60_000);
setNotifications(prev => [`Reminder: "${labName}" starts in ${mins} min.`, ...prev]);
setRemindedBookings(prev => new Set([...prev, b.id]));
}
});
};
check();
const id = setInterval(check, 60_000);
return () => clearInterval(id);
}, [bookings, currentUser, labs, remindedBookings]);
const handleLogin = (user: User) => {
setCurrentUser(user);
};
const handleLogout = () => {
clearSession();
setCurrentUser(null);
setUsers([]);
setDevices([]);
setLabs([]);
setBookings([]);
setLogs([]);
setLinks([]);
setActiveTab('dashboard');
};
// Booking handlers
const handleAddBooking = async (newB: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => {
try {
const res = await authFetch('/api/bookings', {
method: 'POST',
body: JSON.stringify({ ...newB, operatorName: currentUser!.name }),
});
if (res.ok) {
const data = await res.json();
setBookings(prev => [data.booking, ...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); }
};
const handleCancelBooking = async (bookingId: string) => {
try {
const booking = bookings.find(b => b.id === bookingId);
const res = await authFetch(`/api/bookings/${bookingId}`, {
method: 'PUT',
body: JSON.stringify({ status: 'cancelled', operatorName: currentUser!.name }),
});
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';
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); }
};
const handleDeleteBooking = async (bookingId: string) => {
try {
const res = 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); }
};
// Device handlers
const handleAddDevice = async (newDev: Omit<Device, 'id'>) => {
try {
const res = 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); }
};
const handleUpdateDevice = async (updatedDev: Device) => {
try {
const res = await authFetch(`/api/devices/${updatedDev.id}`, {
method: 'PUT',
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); }
};
const handleDeleteDevice = async (id: string) => {
try {
const res = 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); }
};
// Lab handlers
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => {
try {
const res = 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); }
};
const handleUpdateLab = async (updatedLab: LabTemplate) => {
try {
const res = 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); }
};
const handleDeleteLab = async (id: string) => {
try {
const res = 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); }
};
const handleAddLogManually = async (newLogEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
try {
const res = 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); }
};
// Quick-link handlers (shared link dashboard)
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
try {
const res = 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); }
};
const handleUpdateLink = async (updated: QuickLink) => {
try {
const res = 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); }
};
const handleDeleteLink = async (id: string) => {
try {
const res = 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); }
};
const handleOpenDeviceDetailsFromTopology = (dev: Device) => {
setInventoryHighlightDevice(dev);
setActiveTab('devices');
};
const navigationGroups: { label: string | null; items: { id: string; label: string; icon: React.ReactNode }[] }[] = [
{
label: null,
items: [
{ id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4 shrink-0" /> },
],
},
{
label: 'Lab Management',
items: [
{ id: 'calendar', label: 'Booking', icon: <Calendar className="w-4 h-4 shrink-0" /> },
{ id: 'devices', label: 'Inventory', icon: <Server className="w-4 h-4 shrink-0" /> },
{ id: 'labs', label: 'Topology', icon: <Layers className="w-4 h-4 shrink-0" /> },
],
},
{
label: 'Resources',
items: [
{ id: 'links', label: 'Quick Links', icon: <LinkIcon className="w-4 h-4 shrink-0" /> },
{ id: 'users', label: 'Team', icon: <Users className="w-4 h-4 shrink-0" /> },
],
},
{
label: 'Audit',
items: [
{ id: 'logs', label: 'Logbook', icon: <History className="w-4 h-4 shrink-0" /> },
],
},
];
// Startup check not done yet
if (!authChecked) {
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-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">
<GhostGridLogo className="w-16 h-16 animate-pulse" />
</div>
<p className="text-xs text-slate-400 font-mono">booting...</p>
</div>
</div>
);
}
// Not logged in
if (!currentUser) {
if (authView === 'register') {
return <RegisterPage onLogin={handleLogin} onNavigateToLogin={() => setAuthView('login')} />;
}
return <LoginPage onLogin={handleLogin} onNavigateToRegister={() => setAuthView('register')} />;
}
// Loading data after login
if (loading) {
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
<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">
<GhostGridLogo className="w-20 h-20 animate-pulse" />
</div>
<div className="space-y-2">
<h2 className="text-base font-bold tracking-tight text-white">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>
<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">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-ping"></span>
SQLITE DATABASE HYDRATION ONGOING
</div>
</div>
</div>
</div>
);
}
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">
<Header
currentUser={currentUser}
bookings={bookings}
labs={labs}
notifications={notifications}
onClearNotifications={() => setNotifications([])}
theme={theme}
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
onLogout={handleLogout}
/>
<div className="flex-1 flex flex-col md:flex-row">
<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'}`}
id="nav-sidebar"
>
<div className="space-y-5">
{/* Collapse toggle */}
<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>
<button
onClick={() => setNavCollapsed(c => !c)}
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"
>
{navCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
<nav className="space-y-4">
{navigationGroups.map((group, gi) => (
<div key={gi} className="space-y-1">
{group.label && (
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-slate-600 px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
{group.label}
</span>
)}
{/* 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.items.map((item) => {
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => {
setActiveTab(item.id);
if (item.id !== 'devices') setInventoryHighlightDevice(null);
}}
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' : ''} ${
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)]'
: 'text-slate-400 hover:text-white hover:bg-slate-900'
}`}
>
{item.icon}
<span className={navCollapsed ? 'md:hidden' : ''}>{item.label}</span>
</button>
);
})}
</div>
))}
</nav>
</div>
<div className={`bg-slate-950 p-4 border border-slate-900 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>
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
<div>Active: <span className="text-emerald-400 font-semibold font-mono">{bookings.filter(b => b.status === 'active').length}</span></div>
<div>Upcoming: <span className="text-indigo-400 font-semibold font-mono">{bookings.filter(b => b.status === 'upcoming').length}</span></div>
<div>Online: <span className="text-white font-semibold font-mono">{devices.filter(d => d.checkMkUrl && d.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
<div>Labs: <span className="text-white font-semibold font-mono">{labs.length}</span> configured</div>
</div>
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full animate-pulse mt-2" /></div>
</aside>
<main className="flex-1 p-6 md:p-8 overflow-y-auto" id="main-content-display">
{activeTab === 'dashboard' && (
<Dashboard
currentUser={currentUser}
bookings={bookings}
labs={labs}
devices={devices}
links={links}
onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking}
onSelectBookingDetails={setSelectedBookingForDetails}
onNavigateToCalendar={() => setActiveTab('calendar')}
onNavigateToDevices={() => setActiveTab('devices')}
onNavigateToLabs={() => setActiveTab('labs')}
onNavigateToLinks={() => setActiveTab('links')}
/>
)}
{activeTab === 'calendar' && (
<BookingCalendar
bookings={bookings}
labs={labs}
devices={devices}
currentUser={currentUser}
onAddBooking={handleAddBooking}
onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking}
onSelectBookingDetails={setSelectedBookingForDetails}
/>
)}
{activeTab === 'devices' && (
<DeviceInventory
devices={devices}
onAddDevice={handleAddDevice}
onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice}
/>
)}
{activeTab === 'labs' && (
<LabTemplates
labs={labs}
devices={devices}
onAddLab={handleAddLab}
onUpdateLab={handleUpdateLab}
onDeleteLab={handleDeleteLab}
onOpenDeviceDetails={handleOpenDeviceDetailsFromTopology}
/>
)}
{activeTab === 'links' && (
<LinkDashboard
links={links}
currentUser={currentUser}
onAddLink={handleAddLink}
onUpdateLink={handleUpdateLink}
onDeleteLink={handleDeleteLink}
/>
)}
{activeTab === 'users' && (
<UserDirectory
users={users}
currentUser={currentUser}
bookings={bookings}
/>
)}
{activeTab === 'logs' && (
<Logbook
logs={logs}
devices={devices}
users={users}
currentUser={currentUser}
onAddLog={handleAddLogManually}
/>
)}
</main>
</div>
{selectedBookingForDetails && (
<BookingDetailsModal
booking={selectedBookingForDetails}
labs={labs}
devices={devices}
users={users}
currentUser={currentUser}
onClose={() => setSelectedBookingForDetails(null)}
onCancel={handleCancelBooking}
onDelete={handleDeleteBooking}
onAddLog={handleAddLogManually}
/>
)}
</div>
);
}

View File

@ -0,0 +1,811 @@
import React, { useState, useMemo } from 'react';
import { Booking, LabTemplate, Device, User } from '../types';
import {
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
X, Layers, Server, Clock, ChevronDown
} from 'lucide-react';
/** A device can only be reserved when CheckMK reports it online. */
function effectiveStatus(d: Device): 'online' | 'offline' | 'unknown' {
return d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
}
function isBookable(d: Device): boolean {
return effectiveStatus(d) === 'online';
}
interface BookingCalendarProps {
bookings: Booking[];
labs: LabTemplate[];
devices: Device[];
currentUser: User;
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
onCancelBooking: (id: string) => void;
onDeleteBooking: (id: string) => void;
onSelectBookingDetails: (booking: Booking) => void;
}
// ── helpers ────────────────────────────────────────────────────────────────
/** Midnight of today + offset days in LOCAL time */
function dayBase(offset: number): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + offset);
return d;
}
/** 'YYYY-MM-DD' string in LOCAL time (avoids UTC rollover in getTime()) */
function localDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/** 'YYYY-MM-DDTHH:MM:SS' in LOCAL time - no Z suffix, consistent with form input */
function toLocalISO(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${localDateStr(d)}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** Slot boundary as UTC-ms using LOCAL clock (avoids mixed-zone comparison) */
function slotMs(offset: number, hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
const d = dayBase(offset);
d.setHours(h, m, 0, 0);
return d.getTime();
}
function fmtDate(offset: number) {
return dayBase(offset).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
}
const TIME_SLOTS = Array.from({ length: 12 }, (_, i) => {
const h = 8 + i;
const start = `${String(h).padStart(2, '0')}:00`;
const end = `${String(h + 1).padStart(2, '0')}:00`;
return { start, end, label: start };
});
const TIME_OPTIONS = ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'];
// ── component ──────────────────────────────────────────────────────────────
export default function BookingCalendar({
bookings,
labs,
devices,
currentUser,
onAddBooking,
onCancelBooking,
onDeleteBooking,
onSelectBookingDetails,
}: BookingCalendarProps) {
// Calendar navigation - 0 = today, positive = future days
const [dayOffset, setDayOffset] = useState(0);
// Standard booking form
const [resourceType, setResourceType] = useState<'lab' | 'device'>('lab');
const [selectedLabId, setSelectedLabId] = useState<string>(labs[0]?.id || '');
const [selectedDeviceId, setSelectedDeviceId] = useState<string>(devices[0]?.id || '');
const [bookingNotes, setBookingNotes] = useState('');
const [startDate, setStartDate] = useState(localDateStr(new Date()));
const [endDate, setEndDate] = useState(localDateStr(new Date()));
const [startTime, setStartTime] = useState('08:00');
const [endTime, setEndTime] = useState('12:00');
// Quick Booking modal state
const [showQuickPanel, setShowQuickPanel] = useState(false);
const [quickDuration, setQuickDuration] = useState(2);
const [quickTab, setQuickTab] = useState<'labs' | 'devices'>('labs');
// Reservation registry is collapsed by default to keep the page tidy
const [showReservations, setShowReservations] = useState(false);
// ── availability helpers ───────────────────────────────────────────────
function bookingCoversDevice(b: Booking, deviceId: string): boolean {
if (b.labId.startsWith('device:')) return b.labId === `device:${deviceId}`;
return !!labs.find(l => l.id === b.labId)?.deviceIds.includes(deviceId);
}
function isDeviceBooked(deviceId: string, startMs: number, endMs: number): boolean {
return bookings.some(b => {
if (b.status === 'cancelled' || b.status === 'completed') return false;
if (!bookingCoversDevice(b, deviceId)) return false;
const bStart = new Date(b.startDateTime).getTime();
const bEnd = new Date(b.endDateTime).getTime();
return startMs < bEnd && endMs > bStart;
});
}
function getBookingForDeviceInSlot(device: Device, offset: number, slotStart: string, slotEnd: string): Booking | undefined {
const sMs = slotMs(offset, slotStart);
const eMs = slotMs(offset, slotEnd);
return bookings.find(b => {
if (b.status === 'cancelled' || b.status === 'completed') return false;
if (!bookingCoversDevice(b, device.id)) return false;
const bStart = new Date(b.startDateTime).getTime();
const bEnd = new Date(b.endDateTime).getTime();
return sMs < bEnd && eMs > bStart;
});
}
// Resolve which physical devices a standard booking would occupy.
function targetDeviceIds(): string[] {
if (resourceType === 'device') return selectedDeviceId ? [selectedDeviceId] : [];
return labs.find(l => l.id === selectedLabId)?.deviceIds ?? [];
}
function checkConflict(deviceIds: string[], sDate: string, sTime: string, eDate: string, eTime: string) {
const reqStart = new Date(`${sDate}T${sTime}:00`).getTime();
const reqEnd = new Date(`${eDate}T${eTime}:00`).getTime();
if (reqEnd <= reqStart) return { hasConflict: true, message: 'End date/time must be after start.' };
for (const dId of deviceIds) {
if (isDeviceBooked(dId, reqStart, reqEnd)) {
const confLab = bookings
.filter(b => b.status !== 'cancelled' && b.status !== 'completed')
.find(b => {
if (!bookingCoversDevice(b, dId)) return false;
const bS = new Date(b.startDateTime).getTime();
const bE = new Date(b.endDateTime).getTime();
return reqStart < bE && reqEnd > bS;
});
const devName = devices.find(x => x.id === dId)?.hostname || dId;
const lName = confLab ? labs.find(l => l.id === confLab.labId)?.name : undefined;
return { hasConflict: true, message: `Hardware Conflict: "${devName}" is already allocated${lName ? ` by "${lName}"` : ''} during this timeframe.` };
}
}
return { hasConflict: false };
}
// Devices in the current selection that CheckMK does not report as online - these block the booking.
function blockingDevices(deviceIds: string[]): Device[] {
return deviceIds
.map(id => devices.find(d => d.id === id))
.filter((d): d is Device => !!d && !isBookable(d));
}
// ── available-now helpers for Quick Booking ────────────────────────────
const quickWindow = useMemo(() => {
const start = new Date();
const end = new Date(start.getTime() + quickDuration * 3600_000);
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
}, [quickDuration]);
// A lab is quick-bookable only when every device is free AND reported online by CheckMK.
const availableLabs = useMemo(() => labs.filter(lab =>
lab.deviceIds.length > 0 &&
lab.deviceIds.every(dId => {
const dev = devices.find(d => d.id === dId);
return !!dev && isBookable(dev) && !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs);
})
), [labs, devices, bookings, quickWindow]);
const availableDevices = useMemo(() => devices.filter(dev =>
isBookable(dev) && !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
), [devices, bookings, quickWindow]);
// ── booking actions ────────────────────────────────────────────────────
const handleCreateBooking = (e: React.FormEvent) => {
e.preventDefault();
const deviceIds = targetDeviceIds();
if (deviceIds.length === 0) { alert('Please select a resource to reserve.'); return; }
const blocked = blockingDevices(deviceIds);
if (blocked.length > 0) {
alert(`Not bookable: ${blocked.map(d => `"${d.hostname}" (${effectiveStatus(d)})`).join(', ')} ${blocked.length === 1 ? 'is' : 'are'} not online in CheckMK.`);
return;
}
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (conflict.hasConflict) { alert(conflict.message); return; }
onAddBooking({
labId: resourceType === 'device' ? `device:${selectedDeviceId}` : selectedLabId,
userId: currentUser.id,
startDateTime: `${startDate}T${startTime}:00`,
endDateTime: `${endDate}T${endTime}:00`,
notes: bookingNotes,
status: 'upcoming',
});
setBookingNotes('');
};
const handleQuickBookLab = (lab: LabTemplate) => {
onAddBooking({
labId: lab.id,
userId: currentUser.id,
startDateTime: toLocalISO(quickWindow.start),
endDateTime: toLocalISO(quickWindow.end),
notes: `Quick Booking - ${lab.name} [${quickDuration}h]`,
status: 'active',
});
setShowQuickPanel(false);
setDayOffset(0);
};
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({
labId: hostLab?.id ?? `device:${device.id}`,
userId: currentUser.id,
startDateTime: toLocalISO(quickWindow.start),
endDateTime: toLocalISO(quickWindow.end),
notes: `Quick Device Reservation - ${device.hostname} [${quickDuration}h]`,
status: 'active',
});
setShowQuickPanel(false);
setDayOffset(0);
};
// ── render ─────────────────────────────────────────────────────────────
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="calendar-dashboard-root">
{/* ── Quick Booking Modal ── */}
{showQuickPanel && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg bg-[#0F172A] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
<h3 className="font-bold text-sm text-white font-sans">Quick Booking</h3>
</div>
<button onClick={() => setShowQuickPanel(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Duration Selector */}
<div className="px-5 pt-4 space-y-1">
<p className="text-[11px] text-slate-400 font-sans">Duration starting now:</p>
<div className="flex gap-2">
{[1, 2, 4, 8].map(h => (
<button
key={h}
onClick={() => setQuickDuration(h)}
className={`flex-1 py-2 rounded-lg text-xs font-semibold font-sans border transition-all ${
quickDuration === h
? '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'
}`}
>
{h}h
</button>
))}
</div>
<p className="text-[10px] text-slate-500 font-mono">
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {' '}
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
{/* Tabs */}
<div className="flex gap-1 px-5 pt-3">
<button
onClick={() => setQuickTab('labs')}
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'
}`}
>
<Layers className="w-3.5 h-3.5" /> Lab Topologies ({availableLabs.length} available)
</button>
<button
onClick={() => setQuickTab('devices')}
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'
}`}
>
<Server className="w-3.5 h-3.5" /> Individual Devices ({availableDevices.length} free)
</button>
</div>
{/* Content */}
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
{quickTab === 'labs' ? (
availableLabs.length === 0 ? (
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free and fully online for {quickDuration}h right now. all boxes either leased or not reporting in.</p>
) : (
availableLabs.map(lab => {
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
return (
<div key={lab.id} className="flex items-center justify-between gap-3 p-3 bg-slate-900/60 border border-slate-800 rounded-lg hover:border-emerald-800/50 transition-all">
<div className="min-w-0">
<p className="text-xs font-bold text-white truncate">{lab.name}</p>
<p className="text-[10px] text-slate-400 font-mono">{lab.location} · {labDevices.length} device{labDevices.length !== 1 ? 's' : ''}</p>
<p className="text-[9px] text-slate-500 truncate mt-0.5">{labDevices.map(d => d.hostname).join(', ')}</p>
</div>
<button
onClick={() => handleQuickBookLab(lab)}
className="shrink-0 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition-all"
>
Book
</button>
</div>
);
})
)
) : (
devices.map(device => {
const status = effectiveStatus(device);
const online = status === 'online';
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
const bookable = online && free;
return (
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
bookable ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60'
}`}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${online ? 'bg-emerald-400' : status === 'offline' ? 'bg-rose-400' : 'bg-slate-500'}`} />
<p className="text-xs font-bold text-white font-mono">{device.hostname}</p>
</div>
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
<p className="text-[9px] text-slate-500">{device.location}</p>
</div>
{bookable ? (
<button
onClick={() => handleQuickBookDevice(device)}
className="shrink-0 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition-all"
>
Book
</button>
) : !online ? (
<span className="shrink-0 flex items-center gap-1 text-[10px] text-amber-400 font-mono font-semibold capitalize" title="Not online in CheckMK - cannot be reserved">
<AlertTriangle className="w-3 h-3" />{status}
</span>
) : (
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span>
)}
</div>
);
})
)}
</div>
</div>
</div>
)}
{/* ── 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="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Calendar className="text-emerald-400 w-5 h-5" />
Bookings
</h2>
<p className="text-xs text-slate-400">Who has which box, and until when. mutex for hardware, basically.</p>
</div>
{/* Day navigation */}
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-lg border border-slate-805 shrink-0">
<button
onClick={() => setDayOffset(dayOffset - 1)}
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"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<div className="text-xs font-semibold px-2.5 text-center text-slate-200 min-w-[130px] font-mono select-none">
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
</div>
<button
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"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Matrix Grid */}
<div className="flex-1 overflow-x-auto rounded-lg border border-slate-850 p-1 bg-slate-950/40">
<div style={{ minWidth: '860px' }}>
{/* Header row */}
<div
className="border-b border-slate-800 pb-1"
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>
{TIME_SLOTS.map((slot, i) => (
<div key={i} className="text-center py-1 border-l border-slate-855">
<span className="text-[9px] font-mono text-slate-300 leading-none">{slot.label}</span>
</div>
))}
</div>
{/* Device rows */}
<div className="divide-y divide-slate-850 max-h-[460px] overflow-y-auto">
{devices.map((device) => (
<div
key={device.id}
className="items-center group hover:bg-slate-900/35"
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
>
<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="text-[9px] font-mono text-slate-500 mt-0.5 leading-none">{device.type}</p>
</div>
{TIME_SLOTS.map((slot, sIdx) => {
const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end);
const prev = sIdx > 0 ? getBookingForDeviceInSlot(device, dayOffset, TIME_SLOTS[sIdx - 1].start, TIME_SLOTS[sIdx - 1].end) : undefined;
const next = sIdx < TIME_SLOTS.length - 1 ? getBookingForDeviceInSlot(device, dayOffset, TIME_SLOTS[sIdx + 1].start, TIME_SLOTS[sIdx + 1].end) : undefined;
if (!cur) {
return (
<div key={sIdx} className="px-0.5 py-1.5 h-10 flex items-center border-l border-slate-850">
<div className="w-full h-full rounded border border-dashed border-slate-800/40 hover:border-slate-700/60 transition-all" />
</div>
);
}
const isMe = cur.userId === currentUser.id;
const isFirst = !prev || prev.id !== cur.id;
const isLast = !next || next.id !== cur.id;
const lab = labs.find(l => l.id === cur.labId);
const radius = isFirst && isLast ? 'rounded'
: isFirst ? 'rounded-l'
: isLast ? 'rounded-r'
: '';
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-indigo-500/25 border-indigo-500/50 hover:bg-indigo-500/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`;
return (
<div
key={sIdx}
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-slate-850`}
>
<div
onClick={() => onSelectBookingDetails(cur)}
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}`}
>
{isFirst && (
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-white/90">
{lab?.name ?? 'Device'}
</span>
)}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
</div>
{/* 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="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-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 border border-dashed border-slate-800/40" /> Available</span>
</div>
<p className="italic">Double-bookings get rejected at commit time. no race conditions on your watch.</p>
</div>
</div>
{/* ── RIGHT: Booking Form ── */}
<div className="lg:col-span-4 space-y-6" id="booking-actions-card">
{/* Quick Booking Trigger */}
<div className="bg-[#1D2535] border border-emerald-900/30 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" />
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5 mb-1.5 font-sans">
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
Quick Booking
</h3>
<p className="text-[11px] text-slate-400 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.
</p>
<div className="grid grid-cols-4 gap-2 mb-3">
{[1, 2, 4, 8].map(h => (
<button
key={h}
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"
>
{h}h
</button>
))}
</div>
<button
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"
>
<Clock className="w-3.5 h-3.5" />
Show Available Now
</button>
<div className="mt-3 flex items-center gap-3 text-[10px] text-slate-500 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-cyan-400" />{availableDevices.length} devices free</span>
</div>
</div>
{/* Standard Booking Form */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3">
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
Reserve Slot
</h3>
<form onSubmit={handleCreateBooking} className="space-y-4 text-xs">
{/* Resource type toggle: whole lab topology or a single device */}
<div>
<label className="block text-slate-300 font-semibold mb-1">Reserve</label>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => setResourceType('lab')}
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
resourceType === 'lab'
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Layers className="w-3.5 h-3.5" /> Topology
</button>
<button
type="button"
onClick={() => setResourceType('device')}
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
resourceType === 'device'
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Server className="w-3.5 h-3.5" /> Single Device
</button>
</div>
</div>
{resourceType === 'lab' ? (
<div>
<label className="block text-slate-300 font-semibold mb-1">Topology</label>
<select
value={selectedLabId}
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"
>
{labs.map((l) => (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-slate-300 font-semibold mb-1">Device</label>
<select
value={selectedDeviceId}
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"
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{isBookable(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip})
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Start Date</label>
<input
type="date"
value={startDate}
min={localDateStr(new Date())}
onChange={(e) => {
setStartDate(e.target.value);
// Navigate calendar to selected date
const today = dayBase(0).getTime();
const sel = new Date(e.target.value + 'T00:00:00').getTime();
setDayOffset(Math.round((sel - today) / 86_400_000));
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"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">End Date</label>
<input
type="date"
value={endDate}
min={startDate}
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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Start</label>
<select
value={startTime}
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"
>
{TIME_OPTIONS.slice(0, -1).map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">End</label>
<select
value={endTime}
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"
>
{TIME_OPTIONS.slice(1).map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Notes / Objective</label>
<textarea
required
rows={3}
placeholder="e.g. Validating STP failover convergence times..."
value={bookingNotes}
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"
/>
</div>
{(() => {
const deviceIds = targetDeviceIds();
const blocked = blockingDevices(deviceIds);
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (blocked.length > 0) {
return (
<div className="bg-amber-950/40 p-2.5 rounded border border-amber-800/60 flex gap-2 text-amber-300 text-[11px] leading-normal">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<span>
Not bookable - {blocked.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {blocked.length === 1 ? 'is' : 'are'} not online in CheckMK. Hardware must be reachable before it can be reserved.
</span>
</div>
);
}
return conflict.hasConflict ? (
<div className="bg-rose-950/40 p-2.5 rounded border border-rose-900/60 flex gap-2 text-rose-300 text-[11px] leading-normal">
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
<span>{conflict.message}</span>
</div>
) : (
<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>
);
})()}
{(() => {
const deviceIds = targetDeviceIds();
const disabled = deviceIds.length === 0
|| blockingDevices(deviceIds).length > 0
|| checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict;
return (
<button
type="submit"
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"
>
Confirm Reservation
</button>
);
})()}
</form>
</div>
</div>
{/* ── 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">
<button
type="button"
onClick={() => setShowReservations(s => !s)}
className="w-full flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 text-left hover:cursor-pointer"
aria-expanded={showReservations}
>
<div>
<h3 className="text-sm font-bold text-slate-100 flex items-center gap-2">
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${showReservations ? '' : '-rotate-90'}`} />
<Database className="w-4 h-4 text-emerald-400" />
Reservations
</h3>
<p className="text-xs text-slate-400 pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
</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">
DATABASE SELECT: {bookings.length} RECORDS
</span>
</button>
{!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">
No active reservation structures currently exist inside the database.
</p>
) : (
<div className="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/10">
<table className="w-full text-xs text-left text-slate-300 divide-y divide-slate-800">
<thead className="bg-[#0f172a]/60 text-slate-400 font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">Topology / Resource</th>
<th className="px-4 py-3">Scheduled Window</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Notes</th>
<th className="px-4 py-3 text-right font-sans">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800 bg-slate-950/10 font-sans">
{bookings.map((b) => {
const lab = labs.find(l => l.id === b.labId);
const isDeviceBooking = b.labId?.startsWith('device:');
const deviceId = isDeviceBooking ? b.labId.replace('device:', '') : null;
const device = deviceId ? devices.find(d => d.id === deviceId) : null;
const day = new Date(b.startDateTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
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' });
return (
<tr key={b.id} className="hover:bg-slate-900/40 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">
<span className="text-slate-100 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>
</td>
<td className="px-4 py-3.5 font-mono">
<span className="block text-slate-200">{day}</span>
<span className="text-[10px] text-slate-400">{tStart} - {tEnd}</span>
</td>
<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 ${
b.status === 'active' ? 'bg-emerald-950/60 border-emerald-500/30 text-emerald-400' :
b.status === 'upcoming' ? 'bg-indigo-950/60 border-indigo-500/30 text-indigo-400' :
b.status === 'completed' ? 'bg-slate-900 border-slate-800 text-slate-400' :
'bg-rose-950/60 border-rose-500/20 text-rose-400 font-bold'
}`}>{b.status}</span>
</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-right space-x-1 whitespace-nowrap">
<button
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"
>
Details
</button>
<button
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"
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,457 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Booking, LabTemplate, Device, User } from '../types';
import {
X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check,
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive
} from 'lucide-react';
interface BookingDetailsModalProps {
booking: Booking;
labs: LabTemplate[];
devices: Device[];
users: User[];
currentUser: User;
onClose: () => void;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onAddLog: (log: { type: 'system' | 'maintenance' | 'booking'; message: string; userId?: string }) => void;
}
export default function BookingDetailsModal({
booking,
labs,
devices,
users,
currentUser,
onClose,
onCancel,
onDelete,
onAddLog
}: BookingDetailsModalProps) {
const lab = labs.find(l => l.id === booking.labId);
const creator = users.find(u => u.id === booking.userId) || currentUser;
// Find devices mapped to this booking
const mappedDevices = devices.filter(d => lab?.deviceIds.includes(d.id));
// Developer panel tabs ('rest', 'ansible', 'terminal')
const [activeTab, setActiveTab] = useState<'rest' | 'ansible' | 'terminal'>('ansible');
const [isCopied, setIsCopied] = useState(false);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationLogs, setSimulationLogs] = useState<string[]>([]);
const [simStep, setSimStep] = useState(0);
const startFormatted = new Date(booking.startDateTime).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
});
const endFormatted = new Date(booking.endDateTime).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
});
// Dynamic Ansible playbook string based on active nodes
const ipList = mappedDevices.map(d => d.ip);
const ansiblePlaybook = `---
- name: Reset GhostGrid Infrastructure Post-Reservation
hosts: localhost
gather_facts: false
vars:
target_nodes:
${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targets"'}
backup_repo: "https://git.ghostgrid.io/topology-configs"
tasks:
- name: Audit out-of-band diagnostic link states
ansible.builtin.ping:
register: ping_result
- name: Fetch designated golden config profile
ansible.builtin.get_url:
url: "{{ backup_repo }}/golden/${booking.labId}.cfg"
dest: "/tmp/golden_${booking.id}.cfg"
- name: Commit golden parameters & purge current stack
ansible.netcommon.net_config:
src: "/tmp/golden_${booking.id}.cfg"
replace: block
when: ping_result is succeeded
`;
// Dynamic REST Response
const mockJsonResponse = JSON.stringify({
retrievedAt: new Date().toISOString(),
apiEndpoint: `/api/bookings/${booking.id}`,
payload: {
...booking,
expandedLab: {
id: lab?.id,
name: lab?.name,
location: lab?.location,
hardwareTotal: mappedDevices.length,
devices: mappedDevices.map(d => ({ hostname: d.hostname, ip: d.ip, type: d.type }))
}
}
}, null, 2);
const handleCopyText = (text: string) => {
navigator.clipboard.writeText(text);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
// Ansible Terminal simulation execution
const runAnsibleSimulation = () => {
if (isSimulating) return;
setIsSimulating(true);
setSimStep(1);
setSimulationLogs([
`[ansible-playbook -i localhost] Starting playbook: "Reset GhostGrid Infrastructure"`,
`[ansible-playbook] Configured target node list: ${ipList.join(', ') || 'None'}`
]);
setTimeout(() => {
setSimStep(2);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Audit out-of-band diagnostic link states] **********************`,
...mappedDevices.map(d => `ok: [${d.hostname} (${d.ip})] ping_state=SUCCESS latency=1.2ms`)
]);
}, 1000);
setTimeout(() => {
setSimStep(3);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Fetch designated golden config profile] *************************`,
`changed: [localhost] fetched golden profile for lab ID "${booking.labId}"`
]);
}, 2200);
setTimeout(() => {
setSimStep(4);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Commit golden parameters & purge current stack] ******************`,
...mappedDevices.map(d => `changed: [${d.hostname}] configuration synced - cache invalidated - interfaces reset`),
`PLAY RECAP *************************************************************************`,
`localhost : ok=4 changed=2 unreachable=0 failed=0`
]);
setIsSimulating(false);
onAddLog({
type: 'maintenance',
message: `System Worker triggered an automated Ansible Golden Reset on reservation ${booking.id} (${lab?.name || 'Unknown'}). Checked ${mappedDevices.length} hosts.`
});
}, 3800);
};
return (
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" id="booking-details-modal">
<div className="bg-[#1E293B] border border-slate-705 w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]">
{/* 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="flex items-center gap-3">
<div className="p-1.5 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-400">
<HardDrive className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<span>Reservation Details</span>
<span className="text-slate-500 font-mono font-normal">#{booking.id}</span>
</h3>
<p className="text-[11px] text-slate-400">Inspect allocation status and diagnostic automation APIs</p>
</div>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white p-1 hover:bg-slate-800 rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body Scroll Container */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{/* Main info row: Split details and targets */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* 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>
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-slate-500">Scheduled Blueprint</span>
<h4 className="text-base font-bold text-white 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"
style={{
backgroundColor:
booking.status === 'active' ? 'rgba(16,185,129,0.1)' :
booking.status === 'upcoming' ? 'rgba(99,102,241,0.1)' :
booking.status === 'completed' ? 'rgba(100,116,139,0.1)' : 'rgba(239,68,68,0.1)',
borderColor:
booking.status === 'active' ? 'rgba(16,185,129,0.5)' :
booking.status === 'upcoming' ? 'rgba(99,102,241,0.5)' :
booking.status === 'completed' ? 'rgba(100,116,139,0.5)' : 'rgba(239,68,68,0.5)',
color:
booking.status === 'active' ? '#10B981' :
booking.status === 'upcoming' ? '#818CF8' :
booking.status === 'completed' ? '#94A3B8' : '#F87171',
}}>
<span className={`w-1.5 h-1.5 rounded-full ${
booking.status === 'active' ? 'bg-emerald-400' :
booking.status === 'upcoming' ? 'bg-indigo-400' :
booking.status === 'completed' ? 'bg-slate-400' : 'bg-rose-400'
}`} />
{booking.status}
</div>
</div>
{/* Time blocks */}
<div className="space-y-2.5 font-sans">
<div className="flex gap-2.5 text-xs text-slate-300">
<Calendar className="w-4.5 h-4.5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Start Time</span>
<span className="font-mono text-slate-200">{startFormatted}</span>
</div>
</div>
<div className="flex gap-2.5 text-xs text-slate-300">
<Clock className="w-4.5 h-4.5 text-indigo-400 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Terminations On</span>
<span className="font-mono text-slate-200">{endFormatted}</span>
</div>
</div>
<div className="flex gap-2.5 text-xs text-slate-300">
<UserIcon className="w-4.5 h-4.5 text-slate-500 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Reserved Operator</span>
<span className="text-slate-200">{creator.name}</span>
</div>
</div>
</div>
{/* Operator Notes */}
<div className="pt-3 border-t border-slate-850/80 font-sans text-xs">
<span className="font-semibold block text-slate-400 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">
"{booking.notes || 'No objectives specified.'}"
</p>
</div>
</div>
{/* 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>
<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] text-slate-500 font-mono">Location: {lab?.location}</span>
</div>
{mappedDevices.length === 0 ? (
<p className="text-xs text-slate-400 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">
{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 className="flex items-center gap-2.5">
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-400' : 'bg-rose-400'}`} />
<div>
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
<p className="text-[9px] text-slate-400 mt-1 font-mono leading-none">{device.type} {device.location}</p>
</div>
</div>
<span className="text-xs font-mono font-bold text-emerald-400">{device.ip}</span>
</div>
))}
</div>
)}
</div>
{/* 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">
<HelpCircle className="w-4 h-4 text-emerald-400 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>
</div>
</div>
</div>
{/* BELOW BLOCK: Restful API & Automation Integration developer panel */}
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950 font-mono">
{/* Panel Tabs Header */}
<div className="bg-slate-900 border-b border-slate-850 px-4 py-2 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<span className="text-[10px] uppercase tracking-wider font-bold text-indigo-400 font-sans flex items-center gap-2">
<Terminal className="w-4 h-4 text-emerald-400" />
Developer Restful API & Ansible Integration
</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>
{/* Panel Content Box */}
<div className="p-4 bg-slate-950 text-xs leading-normal font-mono relative overflow-x-auto min-h-[180px] max-h-[300px] overflow-y-auto">
{/* Copy Overlay button */}
{activeTab !== 'terminal' && (
<button
onClick={() => handleCopyText(activeTab === 'ansible' ? ansiblePlaybook : mockJsonResponse)}
className="absolute top-4 right-4 bg-slate-900 border border-slate-800 hover:border-slate-700 hover:bg-slate-850 p-1.5 text-slate-400 hover:text-white rounded flex items-center gap-1 text-[10px] font-sans transition-all"
>
{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 key={lIdx} className={`${
logLine.includes('failed=0') || logLine.includes('sync') ? 'text-emerald-400 font-semibold' :
logLine.includes('TASK') ? 'text-indigo-400 font-semibold mt-3' :
logLine.includes('Starting') ? 'text-slate-400' : 'text-slate-300'
}`}>
{logLine}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* 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="flex gap-2">
{/* Delete button option */}
<button
onClick={() => {
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
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"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Purge Entry (SQLite DELETE)</span>
</button>
{/* Cancel Status Toggle */}
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
<button
onClick={() => {
if (confirm('Do you plan to release scheduled device holds? This will notify your colleagues.')) {
onCancel(booking.id);
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"
>
<Ban className="w-3.5 h-3.5" />
<span>Cancel Reservation</span>
</button>
)}
</div>
<button
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"
>
Acknowledge Specs
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,420 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Booking, LabTemplate, Device, User, QuickLink } from '../types';
import {
Zap, Clock, PlayCircle, MapPin, ListTodo, Calendar,
Link as LinkIcon, ExternalLink, Globe, ArrowRight
} from 'lucide-react';
interface DashboardProps {
currentUser: User;
bookings: Booking[];
labs: LabTemplate[];
devices: Device[];
links: QuickLink[];
onCancelBooking: (id: string) => void;
onDeleteBooking: (id: string) => void;
onSelectBookingDetails: (booking: Booking) => void;
onNavigateToCalendar: () => void;
onNavigateToDevices: () => void;
onNavigateToLabs: () => void;
onNavigateToLinks: () => void;
}
const LINK_ACCENT: Record<string, string> = {
emerald: 'text-emerald-400', cyan: 'text-cyan-400', indigo: 'text-indigo-400',
amber: 'text-amber-400', rose: 'text-rose-400', violet: 'text-violet-400',
};
export default function Dashboard({
currentUser,
bookings,
labs,
devices,
links,
onCancelBooking,
onDeleteBooking,
onSelectBookingDetails,
onNavigateToCalendar,
onNavigateToDevices,
onNavigateToLabs,
onNavigateToLinks
}: DashboardProps) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const ONE_HOUR_MS = 60 * 60 * 1000;
const personalBookings = bookings.filter(b => b.userId === currentUser.id && b.status !== 'cancelled');
// "Active" = currently running, plus a 1h grace window after the end so
// freshly-finished sessions linger briefly instead of jumping to "Expired".
const activeBookings = personalBookings.filter(b => {
const start = new Date(b.startDateTime).getTime();
const end = new Date(b.endDateTime).getTime();
return start <= now.getTime() && end > now.getTime() - ONE_HOUR_MS;
});
const upcomingBookings = personalBookings.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!
// (kept deliberately nerdy - morale is a critical infrastructure dependency)
const [todoList, setTodoList] = useState([
{ id: 't1', text: 'Ping 8.8.8.8 to confirm the Internet still exists', checked: false },
{ id: 't2', text: 'Coffee level nominal ☕ - packets route faster on caffeine', checked: true },
{ id: 't3', text: "Rule out DNS first (narrator: it was, in fact, always DNS)", checked: false },
{ id: 't4', text: 'Layer-1 check: is it actually plugged in? (PEBKAC defense)', checked: false }
]);
const toggleTodo = (id: string) => {
setTodoList(todoList.map(t => t.id === id ? { ...t, checked: !t.checked } : t));
};
const getRemainingTimeText = (endTimeStr: string) => {
const diffMs = new Date(endTimeStr).getTime() - now.getTime();
if (diffMs <= 0) {
// Within the 1h grace window - wrapping up rather than "expired".
const agoMin = Math.max(1, Math.ceil(-diffMs / (1000 * 60)));
return `Ended ${agoMin}m ago`;
}
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const secs = Math.floor((diffMs % (1000 * 60)) / 1000);
return hours > 0 ? `${hours}h ${mins}m remaining` : `${mins}m ${secs}s remaining`;
};
return (
<div className="space-y-6" id="dashboard-cockpit-root">
{/* Banner Card Grid */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm overflow-hidden relative">
<div className="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
NET
</div>
<div className="md:col-span-8 space-y-4">
<h2 className="text-2xl font-bold tracking-tight text-white leading-tight font-sans">
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>!
</h2>
<p className="text-xs text-slate-300 leading-relaxed font-sans max-w-xl">
Your lab cockpit. Grab some hardware, block a time slot, and keep the rescue runbooks one click away for when a switch decides to packet-storm itself at 16:59 on a Friday. root@ghostgrid:~# have fun, break things (in the lab).
</p>
<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 className="md:col-span-4 bg-slate-950/60 p-4 rounded-xl border border-slate-850 flex flex-col justify-between font-sans">
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-slate-500 block">System Time</span>
<div className="text-2xl font-mono text-emerald-400 font-bold mt-1 tabular-nums">
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<p className="text-[10px] text-slate-400 font-sans mt-0.5">
{now.toLocaleDateString([], { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
<div className="mt-4 pt-4 border-t border-slate-850 grid grid-cols-2 gap-2 text-center text-[10px] text-slate-350">
<div className="bg-slate-900 p-2 rounded border border-slate-850">
<span className="block font-bold text-slate-100 font-mono">{devices.length}</span>
<span>Hardware Nodes</span>
</div>
<div className="bg-slate-900 p-2 rounded border border-slate-850">
<span className="block font-bold text-slate-100 font-mono">{labs.length}</span>
<span>Available Labs</span>
</div>
</div>
</div>
</div>
{/* Main Grid Content */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT COMPONENT: Active / Upcoming Bookings */}
<div className="lg:col-span-8 space-y-6">
{/* Active Sessions */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-4 font-sans justify-between">
<span className="flex items-center gap-2">
<Clock className="w-4.5 h-4.5 text-emerald-400" />
Active Reservations (your boxes, right now)
</span>
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono font-bold text-emerald-400 bg-emerald-950/40 border border-emerald-900/50 rounded-full px-2.5 py-0.5">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
LIVE
</span>
</h3>
{activeBookings.length === 0 ? (
<div className="text-center py-10 bg-slate-900/35 rounded-lg border border-slate-850 p-6 font-sans">
<PlayCircle className="w-8 h-8 text-slate-700 mx-auto mb-2 opacity-50" />
<p className="text-xs text-slate-400">No boxes checked out right now. idle hands, idle hardware.</p>
<button
onClick={onNavigateToCalendar}
className="text-xs text-emerald-400 font-semibold underline mt-2 hover:text-emerald-300"
>
grab a slot -&gt;
</button>
</div>
) : (
<div className="space-y-4 font-sans">
{activeBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
const startDate = new Date(booking.startDateTime);
const endDate = new Date(booking.endDateTime);
const sameDay = startDate.toDateString() === endDate.toDateString();
const dayFmt: Intl.DateTimeFormatOptions = { weekday: 'short', day: 'numeric', month: 'short' };
const timeFmt: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
const startF = `${startDate.toLocaleDateString('en-US', dayFmt)}, ${startDate.toLocaleTimeString('en-US', timeFmt)}`;
const endF = sameDay
? endDate.toLocaleTimeString('en-US', timeFmt)
: `${endDate.toLocaleDateString('en-US', dayFmt)}, ${endDate.toLocaleTimeString('en-US', timeFmt)}`;
return (
<div key={booking.id} className="p-4 bg-slate-950/60 border border-emerald-500/30 rounded-xl relative overflow-hidden">
<div className="absolute top-0 right-0 bottom-0 w-1 bg-emerald-500" />
<div className="flex justify-between items-start mb-2 gap-2">
<div>
<h4 className="text-sm font-bold text-white font-sans">{lab?.name}</h4>
<span className="text-[10px] text-slate-400 flex items-center gap-1 font-sans mt-0.5">
<MapPin className="w-3.5 h-3.5 text-slate-500" />
{lab?.location}
</span>
</div>
{/* 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">
{getRemainingTimeText(booking.endDateTime)}
</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed font-sans mb-3 italic">
"{booking.notes || 'no notes - running blind'}"
</p>
<div className="pt-3 border-t border-slate-900/60 flex justify-between items-center text-[10px]">
<span className="font-mono text-slate-400">
Active window: {startF} - {endF}
</span>
<div className="flex gap-2">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-2.5 py-1 bg-emerald-950/80 border border-emerald-500/30 text-emerald-400 hover:text-emerald-300 rounded flex items-center gap-1 transition-all font-semibold hover:cursor-pointer"
>
Inspect Details (Rest / Ansible)
</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>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Upcoming Sessions */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5">
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
Upcoming in the Queue ({upcomingBookings.length})
</h3>
{upcomingBookings.length === 0 ? (
<p className="text-xs text-slate-400 py-4 italic text-center">Queue is empty. crontab clean, nothing scheduled.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{upcomingBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
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 endF = new Date(booking.endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
return (
<div key={booking.id} className="p-3 bg-slate-905/30 border border-slate-850 hover:border-slate-800 rounded-lg flex flex-col justify-between">
<div>
<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">
{dayStr}
</span>
<span className="text-[10px] font-mono text-slate-500">
{startF} - {endF}
</span>
</div>
<h4 className="text-xs font-bold text-slate-200 mt-1 font-sans">{lab?.name}</h4>
<p className="text-[10px] text-slate-400 line-clamp-1 mt-0.5 leading-normal">
{booking.notes}
</p>
</div>
<div className="pt-2 mt-2 border-t border-slate-850 flex justify-end gap-1.5 pt-2">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-2.5 py-1 text-[9px] text-emerald-400 hover:text-emerald-350 bg-emerald-950/40 border border-emerald-990/30 rounded font-semibold transition hover:cursor-pointer"
>
Specs / REST API
</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>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* RIGHT COLUMN: Checklist and simulated action panel */}
<div className="lg:col-span-4 space-y-6">
{/* Workflows Checklist */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5">
<ListTodo className="w-4.5 h-4.5 text-amber-500" />
Pre-Flight Checklist (before you blame the network)
</h3>
<div className="space-y-2.5">
{todoList.map((item) => (
<div
key={item.id}
onClick={() => toggleTodo(item.id)}
className="flex items-start gap-2.5 p-2 bg-slate-1000/40 hover:bg-slate-900 rounded-lg cursor-pointer transition-all border border-slate-850/60"
>
<input
type="checkbox"
checked={item.checked}
onChange={() => {}}
className="mt-0.5 rounded border-slate-800 text-emerald-500 focus:ring-emerald-450 w-3.5 h-3.5 shrink-0"
/>
<span className={`text-[11px] leading-tight ${item.checked ? 'text-slate-500 line-through' : 'text-slate-200'}`}>
{item.text}
</span>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-slate-850 text-[10px] text-slate-450 text-center">
Works on my machine (TM). check the boxes anyway.
</div>
</div>
{/* Quick Links - shortcut into the shared tooling dashboard */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5 justify-between">
<span className="flex items-center gap-2">
<LinkIcon className="w-4.5 h-4.5 text-cyan-400" />
Quick Links
</span>
<button
onClick={onNavigateToLinks}
className="text-[10px] text-cyan-400 hover:text-cyan-300 font-semibold flex items-center gap-0.5 hover:cursor-pointer"
>
Manage <ArrowRight className="w-3 h-3" />
</button>
</h3>
{links.length === 0 ? (
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-850 p-5">
<Globe className="w-7 h-7 text-slate-700 mx-auto mb-2 opacity-50" />
<p className="text-[11px] text-slate-400">No shared links yet.</p>
<button
onClick={onNavigateToLinks}
className="text-[11px] text-cyan-400 font-semibold underline mt-1.5 hover:text-cyan-300 hover:cursor-pointer"
>
Add CheckMK, Semaphore & co.
</button>
</div>
) : (
<div className="space-y-1.5">
{links.slice(0, 6).map(link => {
let host = link.url;
try { host = new URL(link.url).host; } catch { /* keep raw */ }
const accent = LINK_ACCENT[link.color] ?? LINK_ACCENT.emerald;
return (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2.5 p-2 bg-slate-1000/40 hover:bg-slate-900 rounded-lg border border-slate-850/60 hover:border-slate-800 transition-all"
>
<span className={`w-7 h-7 rounded-md bg-slate-950 border border-slate-800 flex items-center justify-center shrink-0 ${accent}`}>
<Globe className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-semibold text-slate-200 group-hover:text-white truncate">{link.title}</span>
<span className={`block text-[9px] font-mono truncate ${accent}`}>{host}</span>
</span>
<ExternalLink className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-300 shrink-0" />
</a>
);
})}
{links.length > 6 && (
<button
onClick={onNavigateToLinks}
className="w-full text-center text-[10px] text-slate-500 hover:text-cyan-400 pt-1.5 font-semibold hover:cursor-pointer"
>
+{links.length - 6} more links
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,629 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { Device, DeviceType } from '../types';
import {
Server, Search, Plus, Trash, Edit2, MapPin, Info,
BookOpen, Save, X, ExternalLink, Gauge
} from 'lucide-react';
// Built-in device class presets shown in the dropdown.
const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'];
interface DeviceInventoryProps {
devices: Device[];
onAddDevice: (device: Omit<Device, 'id'>) => void;
onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void;
}
export default function DeviceInventory({
devices,
onAddDevice,
onUpdateDevice,
onDeleteDevice,
}: DeviceInventoryProps) {
// Filters & State
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(devices[0]?.id || null);
// Always derived from prop so edits reflect immediately in the detail panel
const selectedDevice = useMemo(
() => devices.find(d => d.id === selectedDeviceId) ?? null,
[devices, selectedDeviceId]
);
// Create / Edit modal state
const [isEditing, setIsEditing] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
// True when the user is defining a device class outside the presets.
const [isCustomType, setIsCustomType] = useState(false);
const [formData, setFormData] = useState<{
id?: string;
hostname: string;
ip: string;
location: string;
notes: string;
type: DeviceType;
emergencySheet: string;
checkMkUrl: string;
}>({
hostname: '',
ip: '',
location: '',
notes: '',
type: 'Switch',
emergencySheet: '',
checkMkUrl: ''
});
// Effective status: nothing is known until CheckMK is linked and reports a state.
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' =>
d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
const 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 === 'offline') return { label: 'offline', badge: 'bg-rose-950/60 border-rose-900/80 text-rose-400', dot: 'bg-rose-400' };
return { label: 'unknown', badge: 'bg-slate-800/60 border-slate-700 text-slate-400', dot: 'bg-slate-500' };
};
// Filtered devices list
const filteredDevices = devices.filter(dev => {
const matchesSearch =
dev.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
dev.ip.includes(searchTerm) ||
dev.location.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || dev.type === typeFilter;
return matchesSearch && matchesType;
});
const handleOpenAdd = () => {
setFormMode('add');
setIsCustomType(false);
setFormData({
hostname: '',
ip: '172.16.',
location: '',
notes: '',
type: 'Switch',
checkMkUrl: '',
emergencySheet: `### EMERGENCY MANUAL [HOSTNAME]
**Device Type:** [Enter Model]
**Serial Number:** [Enter Serial Number]
#### 1. Out-of-Band Console Connection
* **Baud Rate:** 115200
* **Data Bits:** 8
* **Parity:** None
* **Stop Bits:** 1
#### 2. Recovery / Hard Reset
1. Press and hold down the physical reset micro-button on the front panel.
2. Cycle power, wait 10 seconds, then release.`
});
setIsEditing(true);
};
const handleOpenEdit = (dev: Device) => {
setFormMode('edit');
setIsCustomType(!DEVICE_CLASS_PRESETS.includes(dev.type));
setFormData({
id: dev.id,
hostname: dev.hostname,
ip: dev.ip,
location: dev.location,
notes: dev.notes,
type: dev.type,
checkMkUrl: dev.checkMkUrl ?? '',
emergencySheet: dev.emergencySheet
});
setIsEditing(true);
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.hostname.trim() || !formData.ip.trim() || !formData.type.trim()) return;
if (formMode === 'add') {
onAddDevice({
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
status: 'unknown',
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
} else if (formMode === 'edit' && formData.id) {
const match = devices.find(d => d.id === formData.id);
if (match) {
onUpdateDevice({
...match,
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
}
}
setIsEditing(false);
};
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
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>;
const lines = text.split('\n');
return lines.map((line, idx) => {
// Headers
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>;
}
if (line.startsWith('#### ')) {
return <h5 key={idx} className="text-xs font-bold text-emerald-400 mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
}
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>;
}
// Bullet lists
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">
<span className="text-emerald-500"></span>
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
</div>;
}
// Numeric lists
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">
<span className="text-emerald-400 font-bold">{line.match(/^\d+/)?.[0]}.</span>
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
</div>;
}
// Codeblocks
if (line.startsWith('`') && line.endsWith('`')) {
return <code key={idx} className="block bg-slate-950 p-2 rounded text-[10px] font-mono text-emerald-300 my-2 border border-slate-900 overflow-x-auto">{line.replace(/\`/g, '')}</code>;
}
if (line.trim() === '```bash' || line.trim() === '```') {
return null;
}
// Inline formatting fallback
if (line.includes('**')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('**').map((tok, ti) => {
return ti % 2 === 1 ? <strong key={ti} className="text-slate-100">{tok}</strong> : tok;
})}
</p>
);
}
if (line.includes('`')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{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;
})}
</p>
);
}
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 (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
{/* 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">
{/* Title */}
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2 font-sans">
<Server className="w-5 h-5 text-emerald-400" />
Inventory
</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>
</div>
<div className="flex items-center gap-2">
<button
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"
id="btn-add-device"
>
<Plus className="w-4 h-4 text-slate-950 stroke-[3]" />
Add Device
</button>
</div>
</div>
{/* 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="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-400">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Search by hostname, IP address, rack location..."
value={searchTerm}
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"
/>
</div>
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
{['all', 'Switch', 'Firewall', 'Access-Point', 'Controller'].map((type) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === type
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : type}
</button>
))}
</div>
</div>
{/* Device Listing Card Table */}
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
{filteredDevices.length === 0 ? (
<div className="text-center py-12 text-slate-500 text-xs font-sans">
grep came back empty. no boxes match that filter.
</div>
) : (
filteredDevices.map((device) => {
const isSelected = selectedDevice?.id === device.id;
return (
<div
key={device.id}
onClick={() => setSelectedDeviceId(device.id)}
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
isSelected
? 'bg-slate-900 border-emerald-500/80 shadow-[0_4px_12px_rgba(16,185,129,0.06)]'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex items-start gap-3.5">
{/* Device Icon Circle */}
<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 === 'Access-Point' ? 'bg-amber-950/20 border-amber-900/60 text-amber-400' :
device.type === 'Controller' ? 'bg-cyan-950/20 border-cyan-900/60 text-cyan-400' :
'bg-teal-950/20 border-teal-900/60 text-teal-400'
}`}>
<Server className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-white 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>
</div>
<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-[10px] text-slate-400 flex items-center gap-1">
<MapPin className="w-3 h-3 text-slate-500" />
{device.location}
</span>
</div>
</div>
</div>
{/* Right: Actions and Status */}
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
{/* CheckMK Monitoring Badge */}
{(() => { const m = statusMeta(effectiveStatus(device)); return (
<div className="flex flex-col items-end gap-1 font-sans">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
<span className="text-[9px] font-mono text-slate-500">{device.checkMkUrl ? 'via CheckMK' : 'not linked'}</span>
</div>
); })()}
{/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{device.checkMkUrl && (
<a
href={device.checkMkUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Open in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
<button
onClick={() => handleOpenEdit(device)}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
title="Edit specifications"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to permanently delete device node "${device.hostname}" from the inventory? Existing topology mappings will become invalid.`)) {
onDeleteDevice(device.id);
}
}}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-rose-400 transition-colors"
title="Delete device"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* RIGHT COLUMN: Notfallhandbuch & Technical Specs Details */}
<div className="lg:col-span-5 flex flex-col gap-6" id="inventory-details-container">
{selectedDevice ? (
<>
{/* Header Spec Block */}
<div className="bg-[#1E293B] border border-slate-800 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">
SPECS ID: {selectedDevice.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-slate-100 mt-2 font-mono flex items-center justify-between">
<span>{selectedDevice.hostname}</span>
<span className="text-xs font-sans text-slate-400 font-normal">Active Link State</span>
</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">
Hostname: <span className="text-slate-200">{selectedDevice.hostname}</span><br />
IP Address: <span className="text-emerald-400 font-bold">{selectedDevice.ip}</span><br />
Location: <span className="text-slate-200">{selectedDevice.location}</span><br />
Node Class: <span className="text-slate-200">{selectedDevice.type}</span>
</p>
<div className="mt-4 font-sans">
<h4 className="text-xs font-semibold text-slate-300">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">
{selectedDevice.notes || 'No description notes registered.'}
</div>
</div>
{/* CheckMK Monitoring Panel */}
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-cyan-400" />
CheckMK Monitoring
</span>
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
); })()}
</div>
{selectedDevice.checkMkUrl ? (
<a
href={selectedDevice.checkMkUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-700 text-slate-200 hover:text-cyan-400 hover:border-cyan-500 rounded text-xs transition-colors font-mono"
>
<ExternalLink className="w-3.5 h-3.5" />
Open host in CheckMK
</a>
) : (
<p className="text-[11px] text-slate-500 italic bg-slate-900/50 border border-slate-800 rounded p-2 leading-relaxed">
No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API.
</p>
)}
</div>
</div>
{/* Emergency rescue guidelines sheet */}
<div className="bg-[#1D2432] border border-amber-900/40 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="flex items-center justify-between border-b border-amber-900/30 pb-3 mb-4">
<div className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-amber-500" />
<h3 className="font-bold text-sm text-slate-100 font-sans">
Emergency Sheet & Disaster Recovery
</h3>
</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">
RESCUE SHEET
</span>
</div>
{/* 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">
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
</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">
<Info className="w-4 h-4 text-amber-400 shrink-0" />
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
</div>
</div>
</>
) : (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-10 text-center text-slate-500 text-xs font-sans">
Pick a box from the list to see its specs and break-glass playbook.
</div>
)}
</div>
{/* FORM MODAL: Add / Edit Equipment */}
{isEditing && (
<div className="fixed inset-0 bg-slate-950/80 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-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-400" />
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Hostname</label>
<input
type="text"
required
value={formData.hostname}
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"
placeholder="SW-CORE-03"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">IP Address</label>
<input
type="text"
required
value={formData.ip}
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"
placeholder="172.16.x.x"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
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"
placeholder="Server Room R02, Rack C4..."
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Device Class</label>
<select
value={isCustomType ? '__custom__' : formData.type}
onChange={(e) => {
if (e.target.value === '__custom__') {
setIsCustomType(true);
setFormData({ ...formData, type: '' });
} else {
setIsCustomType(false);
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"
>
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
<option value="Firewall">Firewall / Security Appliance</option>
<option value="Access-Point">Access-Point (WLAN Node)</option>
<option value="Controller">Wireless Controller (WLC Engine)</option>
<option value="__custom__">+ Define new class</option>
</select>
{isCustomType && (
<input
type="text"
required
autoFocus
value={formData.type}
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"
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
/>
)}
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Technical Notes / Patching Mappings</label>
<textarea
rows={2}
value={formData.notes}
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"
placeholder="Serial numbers, module slots, connected uplinks, license status..."
/>
</div>
{/* CheckMK Monitoring integration */}
<div className="bg-slate-900/60 border border-cyan-900/40 rounded-lg p-3.5 space-y-3">
<div className="flex items-center gap-2 text-cyan-400 font-semibold">
<Gauge className="w-4 h-4" />
CheckMK Monitoring
</div>
<p className="text-[11px] text-slate-400 leading-relaxed flex gap-1.5">
<Info className="w-3.5 h-3.5 text-cyan-400 shrink-0 mt-0.5" />
Link this host to CheckMK. Connectivity is monitored there and pulled in via the CheckMK API - no in-app ping checks.
</p>
<div>
<label className="block text-slate-300 font-semibold mb-1">CheckMK Host URL</label>
<input
type="text"
value={formData.checkMkUrl}
onChange={(e) => setFormData({ ...formData, checkMkUrl: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-cyan-500 font-mono"
placeholder="https://checkmk.internal/site/check_mk/index.py?host=SW-CORE-03"
/>
<p className="text-[10px] text-slate-500 mt-1.5 italic">Without a linked CheckMK host the status stays unknown.</p>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<textarea
rows={6}
value={formData.emergencySheet}
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"
placeholder="### EMERGENCY DETAILS..."
/>
</div>
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs"
>
Cancel
</button>
<button
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"
>
<Save className="w-3.5 h-3.5 text-slate-950" />
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

249
src/components/Header.tsx Normal file
View File

@ -0,0 +1,249 @@
import React, { useState } from 'react';
import { User, Booking, LabTemplate } from '../types';
import { Mail, Bell, AlertTriangle, Sun, Moon, LogOut } from 'lucide-react';
interface HeaderProps {
currentUser: User;
bookings: Booking[];
labs: LabTemplate[];
notifications: string[];
onClearNotifications: () => void;
theme: 'dark' | 'light';
onThemeToggle: () => void;
onLogout: () => void;
}
export default function Header({
currentUser,
bookings,
labs,
notifications,
onClearNotifications,
theme,
onThemeToggle,
onLogout,
}: HeaderProps) {
const [showMailInbox, setShowMailInbox] = useState(false);
const [showBellDropdown, setShowBellDropdown] = useState(false);
const userBookings = bookings.filter(b => b.userId === currentUser.id && b.emailSent);
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">
{/* Brand Logo & Title */}
<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">
<GhostGridLogo className="w-10 h-10 animate-pulse" />
</div>
<div>
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-white">
GhostGrid
</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>
<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="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
AirITSystems
</span>
</div>
</div>
</div>
{/* Header Actions */}
<div className="flex items-center gap-4">
{/* Theme Toggle */}
<button
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"
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" />}
</button>
{/* System Indicator */}
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span>System: Online (Simulated)</span>
</div>
{/* Mail Inbox */}
<div className="relative">
<button
onClick={() => { setShowMailInbox(!showMailInbox); setShowBellDropdown(false); }}
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'
}`}
title="E-Mail Inbox (Booking Confirmations)"
>
<Mail className="w-5 h-5" />
{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">
{userBookings.length}
</span>
)}
</button>
{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="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm text-white flex items-center gap-2">
<Mail className="w-4 h-4 text-emerald-400" />
Mail Inbox: {currentUser.email}
</h3>
<p className="text-[10px] text-slate-400 font-sans">Automatic booking confirmations & dynamic alerts</p>
</div>
<button onClick={() => setShowMailInbox(false)} className="text-slate-400 hover:text-white text-xs font-sans">Close</button>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-slate-800 p-2 space-y-1">
{userBookings.length === 0 ? (
<div className="text-center py-8 text-slate-400 text-sm font-sans">
<Mail className="w-8 h-8 text-slate-600 mx-auto mb-2 opacity-50" />
No emails in inbox.
<p className="text-xs text-slate-500 mt-1">Book a lab to receive automated SMTP confirmations.</p>
</div>
) : (
userBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
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' });
return (
<div key={booking.id} className="p-3 bg-slate-900/40 rounded-lg hover:bg-slate-900/80 transition-colors">
<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-[10px] font-mono text-slate-400">Just now</span>
</div>
<h4 className="text-xs font-semibold text-white 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">
<p>Hello <strong>{currentUser.name}</strong>,</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">
<strong>Lab Location:</strong> {lab?.location}<br />
<strong>Start Time:</strong> {formattedStart}<br />
<strong>End Time:</strong> {formattedEnd}<br />
<strong>Notes:</strong> {booking.notes || 'None'}
</div>
<p className="text-[10px] text-slate-500 italic">GhostGrid Automation Mailbot</p>
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
{/* Notifications Bell */}
<div className="relative">
<button
onClick={() => { setShowBellDropdown(!showBellDropdown); setShowMailInbox(false); }}
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'
}`}
title="Interface & System Alerts"
>
<Bell className="w-5 h-5" />
{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">
{notifications.length}
</span>
)}
</button>
{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="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm text-white flex items-center gap-2 font-sans">
<Bell className="w-4 h-4 text-amber-400" />
Notifications ({notifications.length})
</h3>
<p className="text-[10px] text-slate-400 font-sans">Booking lifecycles & countdowns</p>
</div>
{notifications.length > 0 && (
<button onClick={onClearNotifications} className="text-amber-400 hover:text-amber-300 text-xs font-semibold font-sans">Clear All</button>
)}
</div>
<div className="max-h-[300px] overflow-y-auto divide-y divide-slate-800 p-2">
{notifications.length === 0 ? (
<div className="text-center py-6 text-slate-400 text-xs font-sans">No active system alerts.</div>
) : (
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">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<p>{notif}</p>
</div>
))
)}
</div>
</div>
)}
</div>
{/* 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="hidden sm:block">
<div className="text-xs font-semibold leading-3 text-white 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>
<button
onClick={onLogout}
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"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</header>
);
}
export function GhostGridLogo({ className = 'w-8 h-8' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="ghost-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
<filter id="dot-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<path d="M 24,78 C 18,65 14,35 34,22 C 48,12 62,15 68,26" stroke="#06b6d4" strokeWidth="3.5" strokeLinecap="round" filter="url(#ghost-glow)" />
<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" strokeWidth="3.5" strokeLinecap="round" filter="url(#ghost-glow)" />
<rect x="38" y="32" width="6" height="13" rx="3" fill="#00f0ff" filter="url(#ghost-glow)" />
<rect x="52" y="32" width="6" height="13" rx="3" fill="#00f0ff" filter="url(#ghost-glow)" />
<line x1="48" y1="26" x2="80" y2="26" stroke="#0891b2" strokeWidth="2" filter="url(#ghost-glow)" strokeDasharray="1 1" />
<line x1="56" y1="38" x2="88" y2="38" stroke="#06b6d4" strokeWidth="2.5" filter="url(#ghost-glow)" />
<line x1="48" y1="50" x2="80" y2="50" stroke="#0891b2" strokeWidth="2" filter="url(#ghost-glow)" />
<line x1="46" y1="62" x2="84" y2="62" stroke="#06b6d4" strokeWidth="2.5" filter="url(#ghost-glow)" />
<line x1="48" y1="74" x2="74" y2="74" stroke="#0891b2" strokeWidth="2" strokeDasharray="1 1" />
<line x1="56" y1="20" x2="56" y2="80" stroke="#06b6d4" strokeWidth="2.5" filter="url(#ghost-glow)" />
<line x1="68" y1="15" x2="68" y2="76" stroke="#0891b2" strokeWidth="2" filter="url(#ghost-glow)" />
<line x1="80" y1="26" x2="80" y2="62" stroke="#06b6d4" strokeWidth="2" filter="url(#ghost-glow)" />
<circle cx="56" cy="26" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="26" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="80" cy="26" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="38" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="38" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="80" cy="38" r="4.5" fill="#38bdf8" filter="url(#dot-glow)" />
<circle cx="88" cy="38" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="50" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="50" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="80" cy="50" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="62" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="62" r="4.5" fill="#38bdf8" filter="url(#dot-glow)" />
<circle cx="80" cy="62" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="84" cy="62" r="3" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="74" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="74" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="74" cy="74" r="3" fill="#00f0ff" filter="url(#dot-glow)" />
</svg>
);
}

View File

@ -0,0 +1,515 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { LabTemplate, Device, TopologyLink } from '../types';
import TopologyPanel from './TopologyPanel';
import {
Server, Plus, Edit3, Trash, User, MapPin,
Layers, ChevronRight, Save, X, Check
} from 'lucide-react';
interface LabTemplatesProps {
labs: LabTemplate[];
devices: Device[];
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void;
onUpdateLab: (lab: LabTemplate) => void;
onDeleteLab: (id: string) => void;
onOpenDeviceDetails: (device: Device) => void;
}
export default function LabTemplates({
labs,
devices,
onAddLab,
onUpdateLab,
onDeleteLab,
onOpenDeviceDetails
}: LabTemplatesProps) {
const [selectedLab, setSelectedLab] = useState<LabTemplate | null>(labs[0] || null);
const [isEditing, setIsEditing] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
// Topology custom links helper state
const [tempLinks, setTempLinks] = useState<TopologyLink[]>([]);
const [linkFrom, setLinkFrom] = useState('');
const [linkTo, setLinkTo] = useState('');
const [linkType, setLinkType] = useState('Trunk Uplink');
const [formData, setFormData] = useState<{
id?: string;
name: string;
description: string;
contactPerson: string;
location: string;
deviceIds: string[];
}>({
name: '',
description: '',
contactPerson: '',
location: '',
deviceIds: []
});
// Calculate filtered devices associated with selected lab
const labDevices = selectedLab
? devices.filter(d => selectedLab.deviceIds.includes(d.id))
: [];
const handleOpenAdd = () => {
setFormMode('add');
setTempLinks([]);
setFormData({
name: '',
description: '',
contactPerson: '',
location: '',
deviceIds: []
});
setIsEditing(true);
};
const handleOpenEdit = (lab: LabTemplate) => {
setFormMode('edit');
setTempLinks([...lab.topology]);
setFormData({
id: lab.id,
name: lab.name,
description: lab.description,
contactPerson: lab.contactPerson,
location: lab.location,
deviceIds: [...lab.deviceIds]
});
setIsEditing(true);
};
// Toggle device association in form
const handleToggleDevice = (devId: string) => {
const isChosen = formData.deviceIds.includes(devId);
let newDevices = [];
if (isChosen) {
newDevices = formData.deviceIds.filter(id => id !== devId);
// Clean up invalid topology links referencing deleted devices
setTempLinks(tempLinks.filter(l => l.fromDevice !== devId && l.toDevice !== devId));
} else {
newDevices = [...formData.deviceIds, devId];
}
setFormData({ ...formData, deviceIds: newDevices });
};
// Add path link to list
const handleAddLink = () => {
if (!linkFrom || !linkTo || linkFrom === linkTo) return;
setTempLinks([...tempLinks, { fromDevice: linkFrom, toDevice: linkTo, type: linkType }]);
setLinkFrom('');
setLinkTo('');
};
const handleRemoveLink = (idx: number) => {
setTempLinks(tempLinks.filter((_, i) => i !== idx));
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || formData.deviceIds.length === 0) {
alert('Please provide a descriptive template name and associate at least one hardware device node.');
return;
}
const savedLabData = {
name: formData.name,
description: formData.description,
contactPerson: formData.contactPerson,
location: formData.location,
deviceIds: formData.deviceIds,
topology: tempLinks
};
if (formMode === 'add') {
onAddLab(savedLabData);
} else if (formMode === 'edit' && formData.id) {
onUpdateLab({
...savedLabData,
id: formData.id
});
}
setIsEditing(false);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
{/* 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="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-emerald-400" />
Topology
</h2>
<p className="text-xs text-slate-400">Predefined architectural scenarios & wiring profiles.</p>
</div>
<button
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"
title="Create new lab template"
>
<Plus className="w-4 h-4 text-slate-950" />
New
</button>
</div>
{/* Labs templates list */}
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
{labs.map((lab) => {
const isSelected = selectedLab?.id === lab.id;
return (
<div
key={lab.id}
onClick={() => setSelectedLab(lab)}
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
isSelected
? 'bg-slate-900 border-emerald-500'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex justify-between items-start">
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
<button
onClick={() => handleOpenEdit(lab)}
className="text-slate-400 hover:text-indigo-400 p-0.5"
title="Edit template configuration"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
onDeleteLab(lab.id);
}
}}
className="text-slate-400 hover:text-rose-400 p-0.5"
title="Delete template"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{lab.description}
</p>
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
<div className="flex items-center gap-1">
<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>
{/* RIGHT COLUMN: Active Lab Details, Devices details, and TOPOLOGY MAP */}
<div className="lg:col-span-8 space-y-6" id="labs-view-section">
{selectedLab ? (
<>
{/* Template Card Meta */}
<div className="bg-[#1E293B] border border-slate-800 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>
<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">
TEMPLATE ID: {selectedLab.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-white mt-1.5">{selectedLab.name}</h3>
</div>
<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">
<User className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-slate-400 leading-none">Primary Contact</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
</div>
</div>
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-slate-400 leading-none">Testing Location</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.location}</p>
</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">
{selectedLab.description}
</p>
</div>
{/* Interactive Visual Topology */}
<TopologyPanel
devices={labDevices}
links={selectedLab.topology}
onSelectDevice={onOpenDeviceDetails}
/>
{/* Sub-Devices components list */}
<div className="bg-[#1E293B] border border-slate-800 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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{labDevices.map((device) => (
<div
key={device.id}
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"
>
<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`}>
<Server className="w-4 h-4" />
</div>
<div>
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
<p className="text-[9px] font-mono text-emerald-400 mt-1">{device.ip}</p>
</div>
</div>
<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="text-[10px] text-slate-400 capitalize">{device.status}</span>
</div>
</div>
))}
</div>
</div>
</>
) : (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-16 text-center text-slate-500 text-xs font-sans">
Select a lab scenario template from the left directory column to inspect active port topology connections.
</div>
)}
</div>
{/* FORM MODAL: Create or Edit Lab Template */}
{isEditing && (
<div className="fixed inset-0 bg-slate-950/80 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-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans overflow-x-auto">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-emerald-400" />
{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs max-h-[85vh] overflow-y-auto">
{/* Name & Location */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Topology Name</label>
<input
type="text"
required
value={formData.name}
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"
placeholder="e.g. Campus Core OSPF Backup Route"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
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"
placeholder="e.g. Server Room R01, Cabinet B"
/>
</div>
</div>
{/* Description & Contact person */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Caretaker / Owner</label>
<input
type="text"
required
value={formData.contactPerson}
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"
placeholder="e.g. Jane Doe"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Description</label>
<input
type="text"
required
value={formData.description}
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"
placeholder="Purpose, VLAN mappings, target device models..."
/>
</div>
</div>
{/* Hardware checklist */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
<p className="text-[10px] text-slate-400 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">
{devices.map((dev) => {
const isChecked = formData.deviceIds.includes(dev.id);
return (
<button
type="button"
key={dev.id}
onClick={() => handleToggleDevice(dev.id)}
className={`p-2 rounded text-left border flex items-center justify-between transition-colors ${
isChecked
? 'bg-emerald-900/20 border-emerald-500 text-slate-100'
: 'bg-slate-900 border-slate-800 hover:border-slate-700 text-slate-300'
}`}
>
<div className="truncate pr-1">
<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>
</div>
{isChecked && <Check className="w-3.5 h-3.5 text-emerald-400 shrink-0" />}
</button>
);
})}
</div>
</div>
{/* Physical/Logical topology builder link creator */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 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>
{/* 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>
<label className="block text-[10px] text-slate-400 mb-1">Source Node</label>
<select
value={linkFrom}
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]"
>
<option value="">-- Choose --</option>
{formData.deviceIds.map((id) => {
const d = devices.find(x => x.id === id);
return d ? <option key={id} value={id}>{d.hostname}</option> : null;
})}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-400 mb-1">Target Node</label>
<select
value={linkTo}
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]"
>
<option value="">-- Choose --</option>
{formData.deviceIds.map((id) => {
const d = devices.find(x => x.id === id);
return d ? <option key={id} value={id}>{d.hostname}</option> : null;
})}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-400 mb-1">Link Identifier Description (Label)</label>
<input
type="text"
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
placeholder="e.g. LACP Port-Channel 1"
value={linkType}
onChange={(e) => setLinkType(e.target.value)}
/>
</div>
<button
id="add-link-btn"
type="button"
onClick={handleAddLink}
className="w-full py-1.5 bg-indigo-600 hover:bg-indigo-500 rounded text-xs font-bold text-white transition-colors"
>
Add Link
</button>
</div>
{/* Listing added links list */}
{tempLinks.length > 0 ? (
<div className="space-y-1 max-h-[140px] overflow-y-auto pr-1">
{tempLinks.map((link, idx) => {
const fromDev = devices.find(d => d.id === link.fromDevice)?.hostname || link.fromDevice;
const toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice;
return (
<div key={idx} className="flex justify-between items-center bg-slate-950 px-3 py-1.5 rounded border border-slate-855 font-mono text-[10px] hover:border-slate-700">
<span className="text-slate-300">
<strong>{fromDev}</strong> {link.type} <strong>{toDev}</strong>
</span>
<button
type="button"
onClick={() => handleRemoveLink(idx)}
className="text-rose-500 hover:text-rose-450 font-sans font-bold"
>
Remove
</button>
</div>
);
})}
</div>
) : (
<p className="text-[10px] text-slate-500 italic">No interface connections formulated yet.</p>
)}
</div>
{/* Form submit handlers */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
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"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
>
Save Lab Template
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,367 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { QuickLink, User } from '../types';
import {
LinkIcon, Plus, ExternalLink, Pencil, Trash2, Save, X,
Search, Globe, FolderOpen, Star
} from 'lucide-react';
interface LinkDashboardProps {
links: QuickLink[];
currentUser: User;
onAddLink: (link: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => void;
onUpdateLink: (link: QuickLink) => void;
onDeleteLink: (id: string) => void;
}
// 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 }> = {
emerald: { ring: 'hover:border-emerald-500/60', text: 'text-emerald-400', bg: 'bg-emerald-950/40', dot: 'bg-emerald-500', bar: 'bg-emerald-500' },
cyan: { ring: 'hover:border-cyan-500/60', text: 'text-cyan-400', bg: 'bg-cyan-950/40', dot: 'bg-cyan-500', bar: 'bg-cyan-500' },
indigo: { ring: 'hover:border-indigo-500/60', text: 'text-indigo-400', bg: 'bg-indigo-950/40', dot: 'bg-indigo-500', bar: 'bg-indigo-500' },
amber: { ring: 'hover:border-amber-500/60', text: 'text-amber-400', bg: 'bg-amber-950/40', dot: 'bg-amber-500', bar: 'bg-amber-500' },
rose: { ring: 'hover:border-rose-500/60', text: 'text-rose-400', bg: 'bg-rose-950/40', dot: 'bg-rose-500', bar: 'bg-rose-500' },
violet: { ring: 'hover:border-violet-500/60', text: 'text-violet-400', bg: 'bg-violet-950/40', dot: 'bg-violet-500', bar: 'bg-violet-500' },
};
const ACCENT_KEYS = Object.keys(ACCENTS);
const accent = (key: string) => ACCENTS[key] ?? ACCENTS.emerald;
function hostOf(url: string): string {
try { return new URL(url).host; } catch { return url.replace(/^https?:\/\//, '').split('/')[0]; }
}
type Draft = { title: string; url: string; description: string; category: string; color: string };
const EMPTY_DRAFT: Draft = { title: '', url: '', description: '', category: '', color: 'emerald' };
export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateLink, onDeleteLink }: LinkDashboardProps) {
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [activeCategory, setActiveCategory] = useState<string>('all');
const categories = useMemo(() => {
const set = new Set<string>();
links.forEach(l => { if (l.category?.trim()) set.add(l.category.trim()); });
return Array.from(set).sort();
}, [links]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return links.filter(l => {
const matchesSearch = !q ||
l.title.toLowerCase().includes(q) ||
l.description.toLowerCase().includes(q) ||
l.url.toLowerCase().includes(q) ||
l.category.toLowerCase().includes(q);
const matchesCat = activeCategory === 'all'
|| (activeCategory === '__uncat' ? !l.category?.trim() : l.category === activeCategory);
return matchesSearch && matchesCat;
});
}, [links, search, activeCategory]);
// Group filtered links by category for a tidy board layout
const grouped = useMemo(() => {
const map = new Map<string, QuickLink[]>();
filtered.forEach(l => {
const key = l.category?.trim() || 'Uncategorized';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(l);
});
return Array.from(map.entries()).sort((a, b) => {
if (a[0] === 'Uncategorized') return 1;
if (b[0] === 'Uncategorized') return -1;
return a[0].localeCompare(b[0]);
});
}, [filtered]);
const openAdd = () => {
setEditingId(null);
setDraft({ ...EMPTY_DRAFT, category: activeCategory !== 'all' && activeCategory !== '__uncat' ? activeCategory : '' });
setShowForm(true);
};
const openEdit = (link: QuickLink) => {
setEditingId(link.id);
setDraft({ title: link.title, url: link.url, description: link.description, category: link.category, color: link.color });
setShowForm(true);
};
const closeForm = () => {
setShowForm(false);
setEditingId(null);
setDraft(EMPTY_DRAFT);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const title = draft.title.trim();
let url = draft.url.trim();
if (!title || !url) return;
// Be forgiving - assume https:// if no scheme was typed.
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
const payload = {
title,
url,
description: draft.description.trim(),
category: draft.category.trim(),
color: draft.color,
};
if (editingId) {
const original = links.find(l => l.id === editingId);
if (original) onUpdateLink({ ...original, ...payload });
} else {
onAddLink(payload);
}
closeForm();
};
return (
<div className="space-y-6 font-sans" id="link-dashboard-root">
{/* 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="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
LINKS
</div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
<div className="space-y-1.5">
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
<LinkIcon className="w-6 h-6 text-emerald-400" />
Tooling & Quick Links
</h2>
<p className="text-xs text-slate-300 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.
</p>
</div>
<button
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"
id="btn-add-link">
<Plus className="w-4 h-4" />
Add Link
</button>
</div>
</div>
{/* 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="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input
type="text"
placeholder="Search links by name, host, category…"
value={search}
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"
/>
</div>
<div className="flex gap-1 flex-wrap shrink-0">
<button
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'}`}
>
All
</button>
{categories.map(cat => (
<button
key={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'}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Empty state */}
{links.length === 0 ? (
<div className="text-center py-16 bg-[#1E293B] border border-dashed border-slate-700 rounded-2xl">
<Globe className="w-10 h-10 text-slate-600 mx-auto mb-3 opacity-60" />
<h3 className="text-sm font-bold text-slate-200">404: links not found</h3>
<p className="text-xs text-slate-400 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.
</p>
<button
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"
>
<Plus className="w-4 h-4" /> Add your first link
</button>
</div>
) : filtered.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">No links match your search.</p>
) : (
<div className="space-y-8">
{grouped.map(([category, items]) => (
<section key={category}>
<div className="flex items-center gap-2 mb-3">
<FolderOpen className="w-4 h-4 text-slate-500" />
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">{category}</h3>
<span className="text-[10px] text-slate-600 font-mono">({items.length})</span>
<div className="flex-1 h-px bg-slate-850 ml-2" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{items.map(link => {
const a = accent(link.color);
return (
<div
key={link.id}
className={`group relative bg-[#1E293B] border border-slate-800 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`} />
<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`}>
<Globe className={`w-5 h-5 ${a.text}`} />
</div>
<div className="flex-1 min-w-0">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-bold text-white hover:underline flex items-center gap-1.5 truncate"
title={link.title}
>
<span className="truncate">{link.title}</span>
<ExternalLink className={`w-3.5 h-3.5 shrink-0 ${a.text}`} />
</a>
<p className={`text-[10px] font-mono ${a.text} truncate mt-0.5`}>{hostOf(link.url)}</p>
</div>
</div>
{link.description && (
<p className="text-[11px] text-slate-400 leading-relaxed mt-3 line-clamp-2">{link.description}</p>
)}
{/* Hover actions */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(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"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Remove "${link.title}" from the shared link dashboard?`)) onDeleteLink(link.id);
}}
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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</section>
))}
</div>
)}
{/* Add / Edit modal */}
{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="w-full max-w-md bg-[#1E293B] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-slate-900 px-5 py-3.5 border-b border-slate-800 flex items-center justify-between">
<h3 className="font-bold text-sm text-white flex items-center gap-2">
<Star className="w-4 h-4 text-emerald-400" />
{editingId ? 'Edit Link' : 'New Quick Link'}
</h3>
<button onClick={closeForm} className="text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
<div>
<label className="block text-slate-300 font-semibold mb-1">Title *</label>
<input
required autoFocus
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
placeholder="e.g. CheckMK Monitoring"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">URL *</label>
<input
required
value={draft.url}
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
placeholder="https://checkmk.internal"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Category</label>
<input
list="link-categories"
value={draft.category}
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
placeholder="e.g. Monitoring, Automation, Docs"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
<datalist id="link-categories">
{categories.map(c => <option key={c} value={c} />)}
</datalist>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Description</label>
<textarea
rows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
placeholder="What is this tool for?"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1.5">Accent</label>
<div className="flex gap-2">
{ACCENT_KEYS.map(key => (
<button
type="button"
key={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'}`}
title={key}
/>
))}
</div>
</div>
<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">
Cancel
</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">
<Save className="w-3.5 h-3.5" />
{editingId ? 'Save Changes' : 'Add Link'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

270
src/components/Logbook.tsx Normal file
View File

@ -0,0 +1,270 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { LogEntry, Device, User } from '../types';
import {
History, Search, Plus, Hammer, UserIcon, Server,
Info, Save, ChevronRight
} from 'lucide-react';
interface LogbookProps {
logs: LogEntry[];
devices: Device[];
users: User[];
currentUser: User;
onAddLog: (log: Omit<LogEntry, 'id' | 'timestamp'>) => void;
}
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
// Custom Maintenance Log state
const [showAddLog, setShowAddLog] = useState(false);
const [targetDeviceId, setTargetDeviceId] = useState('');
const [logMessage, setLogMessage] = useState('');
// Sorted list: latest logs first
const sortedLogs = [...logs].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Filter logs
const filteredLogs = sortedLogs.filter(log => {
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || log.type === typeFilter;
return matchesSearch && matchesType;
});
const handleSubmitLog = (e: React.FormEvent) => {
e.preventDefault();
if (!logMessage.trim()) return;
let finalMsg = logMessage;
if (targetDeviceId) {
const dev = devices.find(x => x.id === targetDeviceId);
if (dev) {
finalMsg = `[Maintenance on ${dev.hostname}] ${logMessage}`;
}
}
onAddLog({
type: 'maintenance',
message: `${currentUser.name} registered the following maintenance update: ${finalMsg}`,
deviceId: targetDeviceId || undefined,
userId: currentUser.id
});
setLogMessage('');
setTargetDeviceId('');
setShowAddLog(false);
};
const getLogTypeBadge = (type: string) => {
switch (type) {
case 'maintenance':
return 'bg-amber-950 border border-amber-900/60 text-amber-400';
case 'booking':
return 'bg-emerald-950 border border-emerald-900/60 text-emerald-400';
case 'status':
return 'bg-cyan-950 border border-cyan-900/60 text-cyan-400';
case 'system':
default:
return 'bg-slate-900 border border-slate-800 text-slate-350';
}
};
const getLogTypeLabel = (type: string) => {
switch (type) {
case 'maintenance': return 'Maintenance';
case 'booking': return 'Booking';
case 'status': return 'Status';
case 'system': default: return 'System';
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-sans" id="logbook-dashboard">
{/* 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="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<History className="w-5 h-5 text-emerald-400" />
Audit Log & Maintenance Journal
</h2>
<p className="text-xs text-slate-400">Append-only history of who touched what. git blame, but for the lab.</p>
</div>
<button
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"
id="btn-toggle-add-log"
>
<Plus className="w-4 h-4 text-emerald-400" />
File Maintenance Report
</button>
</div>
{/* 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="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-550">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Filter audit log entries..."
value={searchTerm}
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"
/>
</div>
<div className="flex gap-1 shrink-0 text-xs font-medium">
{['all', 'booking', 'maintenance'].map((type) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium capitalize transition-all ${
typeFilter === type
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : getLogTypeLabel(type)}
</button>
))}
</div>
</div>
{/* Audit Log Sheet */}
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
{filteredLogs.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">
No audit records match the selected filtering rules.
</p>
) : (
filteredLogs.map((log) => {
const dev = devices.find(d => d.id === log.deviceId);
const user = users.find(u => u.id === log.userId);
const timestampFormatted = new Date(log.timestamp).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'medium'
});
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 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)}`}>
{getLogTypeLabel(log.type)}
</span>
<span className="text-[9px] font-mono text-slate-500 leading-none">
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex-1">
<p className="text-xs text-slate-200 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">
<span>Calendar Time: {timestampFormatted}</span>
{user && (
<span className="flex items-center gap-1 text-slate-400">
<UserIcon className="w-3 h-3 text-slate-500" />
Operator: {user.name}
</span>
)}
{dev && (
<span className="flex items-center gap-1 text-emerald-450 font-semibold">
<Server className="w-3 h-3 text-slate-500" />
Node: {dev.hostname}
</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
<div className="lg:col-span-4" id="logbook-forms-side">
{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="flex items-center justify-between pb-2 border-b border-slate-800">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5">
<Hammer className="w-4 h-4 text-amber-500" />
Journal Maintenance Work
</h3>
<button
onClick={() => setShowAddLog(false)}
className="text-slate-400 hover:text-white"
>
Cancel
</button>
</div>
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
<div>
<label className="block text-slate-300 font-semibold mb-1">Target Network Host (Optional)</label>
<select
value={targetDeviceId}
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"
>
<option value="">-- Complete Lab Cluster / General Event --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.hostname} ({d.ip})
</option>
))}
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Documented Actions / Findings</label>
<textarea
required
rows={4}
value={logMessage}
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"
placeholder="Describe SFP fiber replacements, port trunking alterations, routing table diagnostic evaluations..."
/>
</div>
<div className="bg-slate-900 border border-slate-800 p-2.5 rounded text-[11px] text-slate-450 leading-normal flex gap-2">
<Info className="w-4 h-4 text-emerald-400 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>
</div>
<button
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"
>
<Save className="w-3.5 h-3.5" />
Publish to Shared Log Book
</button>
</form>
</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">
<h3 className="font-bold text-white mb-2 text-sm flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-emerald-400 shrink-0" />
Shared Audit & Fault Logging
</h3>
<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.
</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]">
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { GhostGridLogo } from './Header';
import { authFetch, saveSession } from '../lib/auth';
import { User } from '../types';
import { LogIn, Eye, EyeOff, AlertCircle } from 'lucide-react';
interface LoginPageProps {
onLogin: (user: User) => void;
onNavigateToRegister: () => void;
}
export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPageProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await authFetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Login failed.');
return;
}
saveSession(data.token, data.user);
onLogin(data.user);
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Brand */}
<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)]">
<GhostGridLogo className="w-14 h-14" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
</p>
<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="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" />
AirITSystems
</span>
</div>
</div>
</div>
{/* Login Card */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Sign in</h2>
<p className="text-xs text-slate-400 mt-1">Enter your credentials to access the platform.</p>
</div>
{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">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="email">
Email address
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
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"
placeholder="user@airit.rocks"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="password">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
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"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
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"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<LogIn className="w-4 h-4" />
)}
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="text-center text-xs text-slate-400">
No account yet?{' '}
<button
onClick={onNavigateToRegister}
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
>
Create one
</button>
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { GhostGridLogo } from './Header';
import { authFetch, saveSession } from '../lib/auth';
import { User } from '../types';
import { UserPlus, Eye, EyeOff, AlertCircle, CheckCircle2 } from 'lucide-react';
interface RegisterPageProps {
onLogin: (user: User) => void;
onNavigateToLogin: () => void;
}
export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPageProps) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const passwordStrong = password.length >= 8;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!passwordStrong) {
setError('Password must be at least 8 characters.');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setLoading(true);
try {
const res = await authFetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Registration failed.');
return;
}
saveSession(data.token, data.user);
onLogin(data.user);
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Brand */}
<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)]">
<GhostGridLogo className="w-14 h-14" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
</p>
<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="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" />
AirIT Systems
</span>
</div>
</div>
</div>
{/* Register Card */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Create account</h2>
<p className="text-xs text-slate-400 mt-1">Register to gain access to the platform.</p>
</div>
{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">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-name">
Full name
</label>
<input
id="reg-name"
type="text"
autoComplete="name"
required
value={name}
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"
placeholder="Max Mustermann"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-email">
Email address
</label>
<input
id="reg-email"
type="email"
autoComplete="email"
required
value={email}
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"
placeholder="you@example.com"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-password">
Password
</label>
<div className="relative">
<input
id="reg-password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={password}
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"
placeholder="Min. 8 characters"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{password.length > 0 && (
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-emerald-400' : 'text-amber-400'}`}>
<CheckCircle2 className="w-3 h-3" />
{passwordStrong ? 'Password meets requirements' : 'At least 8 characters required'}
</div>
)}
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-confirm">
Confirm password
</label>
<input
id="reg-confirm"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={confirmPassword}
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 ${
confirmPassword.length > 0 && confirmPassword !== password
? 'border-red-700 focus:ring-red-500/50'
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
}`}
placeholder="Repeat password"
/>
</div>
<button
type="submit"
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"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<UserPlus className="w-4 h-4" />
)}
{loading ? 'Creating account…' : 'Create account'}
</button>
</form>
<p className="text-center text-xs text-slate-400">
Already have an account?{' '}
<button
onClick={onNavigateToLogin}
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
>
Sign in
</button>
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,300 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Device, TopologyLink } from '../types';
import { Activity, Shield, Wifi, Server, Cpu } from 'lucide-react';
interface TopologyPanelProps {
devices: Device[];
links: TopologyLink[];
onSelectDevice?: (device: Device) => void;
}
export default function TopologyPanel({ devices, links, onSelectDevice }: TopologyPanelProps) {
const [hoveredLink, setHoveredLink] = useState<TopologyLink | null>(null);
// Layout calculations for nodes in a circle/nice grid layout inside an SVG viewport of 800x400
const width = 800;
const height = 400;
const cx = width / 2;
const cy = height / 2;
const radius = 130;
// Let's map each device ID to a constant (x, y) coordinate so we get beautiful layouts
const getCoordinates = (index: number, total: number) => {
if (total === 1) {
return { x: cx, y: cy };
}
if (total === 2) {
return index === 0 ? { x: cx - 180, y: cy } : { x: cx + 180, y: cy };
}
if (total === 3) {
if (index === 0) return { x: cx, y: cy - 90 };
if (index === 1) return { x: cx - 180, y: cy + 90 };
return { x: cx + 180, y: cy + 90 };
}
const angle = (index * 2 * Math.PI) / total - Math.PI / 2;
return {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
};
};
const nodePositions = devices.reduce((acc, dev, idx) => {
acc[dev.id] = getCoordinates(idx, devices.length);
return acc;
}, {} as Record<string, { x: number; y: number }>);
// Multiple links between the same device pair would otherwise stack on one
// straight line. We fan them out into separate quadratic-Bézier curves with a
// symmetric perpendicular offset, and place each badge on its curve's apex.
const pairKey = (a: string, b: string) => [a, b].sort().join('::');
const parallelSpread = 26; // px between adjacent parallel links
const linkPairCounts: Record<string, number> = {};
links.forEach((l) => {
const k = pairKey(l.fromDevice, l.toDevice);
linkPairCounts[k] = (linkPairCounts[k] || 0) + 1;
});
const pairSeen: Record<string, number> = {};
const linkLayout = links.map((link) => {
const start = nodePositions[link.fromDevice];
const end = nodePositions[link.toDevice];
if (!start || !end) return null;
const k = pairKey(link.fromDevice, link.toDevice);
const total = linkPairCounts[k];
const indexInPair = pairSeen[k] ?? 0;
pairSeen[k] = indexInPair + 1;
// Signed offset symmetric around the direct line: …, -1, 0, +1, …
const offset = total > 1 ? parallelSpread * (indexInPair - (total - 1) / 2) : 0;
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
// Perpendicular from a canonical endpoint order, so every link in the same
// pair bows consistently regardless of its from/to direction.
const [firstId, secondId] = [link.fromDevice, link.toDevice].sort();
const p1 = nodePositions[firstId];
const p2 = nodePositions[secondId];
const len = Math.hypot(p2.x - p1.x, p2.y - p1.y) || 1;
const perpX = -(p2.y - p1.y) / len;
const perpY = (p2.x - p1.x) / len;
// Control point at 2×offset puts the curve apex (t=0.5) exactly at offset.
const cpX = midX + perpX * offset * 2;
const cpY = midY + perpY * offset * 2;
return {
path: `M ${start.x} ${start.y} Q ${cpX} ${cpY} ${end.x} ${end.y}`,
apexX: midX + perpX * offset,
apexY: midY + perpY * offset,
};
});
const getDeviceIcon = (type: string) => {
switch (type) {
case 'Firewall':
return <Shield className="w-5 h-5 text-rose-400" />;
case 'Access-Point':
return <Wifi className="w-5 h-5 text-amber-400" />;
case 'Controller':
return <Cpu className="w-5 h-5 text-cyan-400" />;
case 'Switch':
default:
return <Server className="w-5 h-5 text-teal-400" />;
}
};
const getDeviceColorClass = (type: string) => {
switch (type) {
case 'Firewall':
return 'border-rose-500/60 bg-rose-950/20 text-rose-200';
case 'Access-Point':
return 'border-amber-500/60 bg-amber-950/20 text-amber-200';
case 'Controller':
return 'border-cyan-500/60 bg-cyan-950/20 text-cyan-200';
case 'Switch':
default:
return 'border-teal-500/60 bg-teal-950/20 text-teal-200';
}
};
return (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-inner" id="topology-panel">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-slate-100 flex items-center gap-2 font-sans">
<Activity className="w-4 h-4 text-emerald-400 animate-pulse" />
Interactive Topology Diagram (Physical & Logical Links)
</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>
</div>
<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="w-1.5 h-1.5 rounded-full bg-teal-500"></span> Switch
</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="w-1.5 h-1.5 rounded-full bg-rose-500"></span> Firewall
</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="w-1.5 h-1.5 rounded-full bg-amber-500"></span> AP
</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="w-1.5 h-1.5 rounded-full bg-cyan-500"></span> WLC
</span>
</div>
</div>
<div className="relative overflow-auto border border-slate-800 rounded-lg bg-slate-950/60 flex justify-center items-center">
{devices.length === 0 ? (
<div className="py-20 text-center text-slate-500 text-xs font-sans">
No active hardware nodes assigned. Build or update a lab template to associate physical equipment.
</div>
) : (
<div className="min-w-[800px] h-[340px] relative">
{/* SVG Link lines */}
<svg className="absolute inset-0 w-full h-full pointer-events-none z-10">
<defs>
<marker
id="arrow"
viewBox="0 0 10 10"
refX="6"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#475569" />
</marker>
{/* Glow effects for active physical links */}
<filter id="glow-teal" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="glow" />
<feComposite in="SourceGraphic" in2="glow" operator="over" />
</filter>
</defs>
{links.map((link, idx) => {
const layout = linkLayout[idx];
if (!layout) return null;
const isHovered = hoveredLink === link;
const isTrunk = link.type.toLowerCase().includes('trunk');
return (
<g key={`link-${idx}`} className="pointer-events-auto cursor-pointer">
{/* Hover hotspot path */}
<path
d={layout.path}
fill="none"
stroke="transparent"
strokeWidth="15"
onMouseEnter={() => setHoveredLink(link)}
onMouseLeave={() => setHoveredLink(null)}
/>
{/* Background stroke path */}
<path
d={layout.path}
fill="none"
stroke={isHovered ? '#10b981' : '#334155'}
strokeWidth={isHovered ? '3' : '2'}
strokeDasharray={isTrunk ? '0' : '4 4'}
className="transition-all duration-200"
/>
{/* Tiny animated flow dots for trunk links */}
{isTrunk && (
<path
d={layout.path}
fill="none"
stroke="#10B981"
strokeWidth="1.5"
strokeDasharray="8 30"
style={{ animation: 'dash 4s linear infinite' }}
/>
)}
</g>
);
})}
</svg>
{/* Custom SVG styling to support animated dots along lines */}
<style>{`
@keyframes dash {
to {
stroke-dashoffset: -100;
}
}
`}</style>
{/* Link Description Badges overlay (placed on each curve's apex so
parallel links between the same pair don't overlap) */}
{links.map((link, idx) => {
const layout = linkLayout[idx];
if (!layout) return null;
const isHovered = hoveredLink === link;
return (
<div
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 ${
isHovered
? 'bg-emerald-500 text-slate-950 scale-110 font-bold border border-emerald-400 z-30'
: 'bg-slate-800 text-slate-400 border border-slate-700'
}`}
style={{ left: layout.apexX, top: layout.apexY }}
>
{link.type}
</div>
);
})}
{/* Virtual Device Nodes */}
{devices.map((device) => {
const pos = nodePositions[device.id];
if (!pos) return null;
return (
<button
key={device.id}
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)]`}
style={{ left: pos.x, top: pos.y }}
title={`${device.hostname} (${device.ip}) - Click for troubleshooting details`}
>
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-slate-950 ${
!device.checkMkUrl ? 'bg-slate-500' :
device.status === 'online' ? 'bg-emerald-500' :
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
}`} />
<div className="p-1.5 bg-slate-950/60 rounded-lg border border-slate-800/40">
{getDeviceIcon(device.type)}
</div>
<div className="leading-none">
<p className="text-[11px] font-mono font-bold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
{device.hostname}
</p>
<p className="text-[9px] font-mono text-slate-400 group-hover:text-slate-300 mt-0.5">
{device.ip}
</p>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,169 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { User, Booking } from '../types';
import { Users, Search, Mail, Calendar, Activity } from 'lucide-react';
interface UserDirectoryProps {
users: User[];
currentUser: User;
bookings: Booking[];
}
// Deterministic accent so a given user always renders the same colour.
const AVATAR_COLORS = [
'from-emerald-500 to-teal-600',
'from-cyan-500 to-blue-600',
'from-indigo-500 to-violet-600',
'from-amber-500 to-orange-600',
'from-rose-500 to-pink-600',
'from-fuchsia-500 to-purple-600',
];
function colorFor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
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 UserDirectory({ users, currentUser, bookings }: UserDirectoryProps) {
const [search, setSearch] = useState('');
const bookingCount = useMemo(() => {
const map = new Map<string, number>();
bookings.forEach(b => map.set(b.userId, (map.get(b.userId) ?? 0) + 1));
return map;
}, [bookings]);
const activeCount = useMemo(() => {
const map = new Map<string, number>();
bookings
.filter(b => b.status === 'active' || b.status === 'upcoming')
.forEach(b => map.set(b.userId, (map.get(b.userId) ?? 0) + 1));
return map;
}, [bookings]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name));
if (!q) return sorted;
return sorted.filter(u =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.role.toLowerCase().includes(q)
);
}, [users, search]);
return (
<div className="space-y-6 font-sans" id="user-directory-root">
{/* 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="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
TEAM
</div>
<div className="relative space-y-1.5">
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
<Users className="w-6 h-6 text-emerald-400" />
Registered Operators
</h2>
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
Everyone with an account on this box. booking counts come straight from the shared reservation pool - no shadow IT here.
</p>
<div className="flex flex-wrap gap-2 pt-3">
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Users className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{users.length}</strong> registered
</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">
<Calendar className="w-3.5 h-3.5 text-indigo-400" />
<strong className="text-white font-mono">{bookings.length}</strong> total bookings
</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">
<Activity className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
</span>
</div>
</div>
</div>
{/* Search */}
<div className="relative">
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input
type="text"
placeholder="Search operators by name, email or role…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
/>
</div>
{/* User grid */}
{filtered.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">No operators match your search.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map(user => {
const isMe = user.id === currentUser.id;
const total = bookingCount.get(user.id) ?? 0;
const active = activeCount.get(user.id) ?? 0;
return (
<div
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'}`}
>
{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">
You
</span>
)}
<div className="flex items-center gap-3.5">
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${colorFor(user.id)} flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-inner`}>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-bold text-white truncate">{user.name}</h3>
<a
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"
>
<Mail className="w-3 h-3 shrink-0" />
<span className="truncate">{user.email}</span>
</a>
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-850 flex items-center justify-between">
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">Operator</span>
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
<span className="flex items-center gap-1" title="Total bookings">
<Calendar className="w-3 h-3 text-indigo-400" />
{total}
</span>
<span className="flex items-center gap-1" title="Active / upcoming bookings">
<Activity className="w-3 h-3 text-emerald-400" />
{active}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

559
src/index.css Normal file
View File

@ -0,0 +1,559 @@
/* Fonts are self-hosted via @fontsource (imported in main.tsx) - no external CDN. */
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
/* ── AirIT brand tokens ────────────────────────────────────────── */
:root {
--airit-navy: #003A70;
--airit-navy-dark: #002B55;
--airit-blue: #005AA0;
--airit-gray: #6F7478;
--airit-text: #1F2933;
--airit-bg: #FFFFFF;
--airit-bg-soft: #F3F5F7;
--airit-border: #D6DADF;
}
/* ── CSS custom properties ─────────────────────────────────────── */
:root {
--bg: #0b0f19;
--bg-header: #0f172a;
--bg-card: #1e293b;
--bg-inner: #090d16;
--bg-input: #020408;
--border: #1e293b;
--border-muted:#334155;
--text: #f1f5f9;
--text-muted: #94a3b8;
--text-label: #cbd5e1;
}
:root.light {
--bg: #f1f5f9;
--bg-header: #ffffff;
--bg-card: #ffffff;
--bg-inner: #f8fafc;
--bg-input: #ffffff;
--border: #e2e8f0;
--border-muted:#cbd5e1;
--text: #0f172a;
--text-muted: #475569;
--text-label: #334155;
}
/* ── LIGHT MODE OVERRIDES ─────────────────────────────────────── */
/* Root / body */
:root.light body,
:root.light #main-root {
background-color: var(--bg) !important;
color: var(--text) !important;
}
/* ── Backgrounds: all dark hex variants → card/inner */
:root.light .bg-\[\#0B0F19\],
:root.light .bg-\[\#0b0f19\] {
background-color: var(--bg) !important;
}
:root.light .bg-\[\#0F172A\],
:root.light .bg-\[\#0f172a\] {
background-color: var(--bg-header) !important;
border-color: var(--border) !important;
}
: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 {
color: #ffffff !important;
background-color: var(--airit-navy) !important;
}
/* ── Text selection ───────────────────────────────────────────── */
:root.light ::selection {
background-color: rgba(5, 150, 105, 0.2) !important;
color: #047857 !important;
}
/* ── Reservation Details Modal (BookingDetailsModal) ──────────── */
/* 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 {
filter: invert(0.6);
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;
}

35
src/lib/auth.ts Normal file
View File

@ -0,0 +1,35 @@
import { User } from '../types';
const TOKEN_KEY = 'ghostgrid_token';
const USER_KEY = 'ghostgrid_user';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getStoredUser(): User | null {
const raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
}
export function saveSession(token: string, user: User): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearSession(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export async function authFetch(input: RequestInfo, init: RequestInit = {}): Promise<Response> {
const token = getToken();
return fetch(input, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers as Record<string, string>),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}

18
src/main.tsx Normal file
View File

@ -0,0 +1,18 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
// Self-hosted fonts - bundled locally so the app works fully offline (no Google Fonts CDN).
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import '@fontsource/jetbrains-mono/700.css';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

76
src/types.ts Normal file
View File

@ -0,0 +1,76 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
// Known presets plus any custom class the user defines.
// The `(string & {})` keeps literal autocomplete while allowing arbitrary values.
export type DeviceType = 'Switch' | 'Access-Point' | 'Firewall' | 'Controller' | (string & {});
export interface Device {
id: string;
hostname: string;
ip: string;
location: string;
notes: string;
type: DeviceType;
status: 'online' | 'offline' | 'unknown'; // 'unknown' until CheckMK reports a state
emergencySheet: string; // Markdown text
checkMkUrl: string; // Link to this host in CheckMK; live status comes from the CheckMK API
lastCheckedAt?: string;
}
export interface TopologyLink {
fromDevice: string;
toDevice: string;
type: string; // e.g. "LACP-Trunk", "Uplink", "OOB-Management", "VLAN-Core"
}
export interface LabTemplate {
id: string;
name: string;
description: string;
contactPerson: string;
location: string;
deviceIds: string[];
topology: TopologyLink[];
}
export interface Booking {
id: string;
labId: string;
userId: string;
startDateTime: string;
endDateTime: string;
notes: string;
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
notified: boolean;
emailSent?: boolean;
}
export interface LogEntry {
id: string;
timestamp: string;
type: 'maintenance' | 'booking' | 'status' | 'system';
message: string;
deviceId?: string;
userId?: string;
}
export interface User {
id: string;
name: string;
role: string;
email: string;
}
export interface QuickLink {
id: string;
title: string;
url: string;
description: string;
category: string;
color: string; // accent key, e.g. 'emerald' | 'cyan' | 'amber' | ...
createdBy?: string;
createdAt: string;
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

22
vite.config.ts Normal file
View File

@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});