From d629689473fd569b2ec3731db1f91323080fcf98 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 9 May 2026 23:49:02 +0200 Subject: [PATCH] Redesign as VM webshop with NetBox virtualization integration - Split single-page into 3 pages: - index.php: product catalog with VM size cards - create.php: order form (name, domain, description) - requests.php: request history with pagination - Rewrite NetBox integration to create Virtual Machines (/virtualization/virtual-machines/) instead of DCIM devices - Add netbox_integration/netbox_api.php: NetBoxClient with createVirtualMachine(), findCluster(), findTag(), getList() - Generate new sleek product-card images (420x280) with aligned spec boxes (vcpus/ram/disk) - Add webshop CSS: product cards, nav links, hero section, order layout, responsive grid - Update redirects: send.php -> requests.php, cancel.php -> requests.php - Update E2E test for new page structure - Add .gitignore, deploy-local.sh, setup_database.sql to repo --- .gitignore | 14 + README.md | 72 +- cancel.php | 48 ++ config.php | 89 +++ create.php | 245 ++++++ deploy-local.sh | 72 ++ get_oauth_token.php | 0 health.php | 27 + index.html | 38 +- index.php | 239 ++---- install_mariadb.sh | 104 +++ login.php | 91 ++- logout.php | 9 +- netbox_integration/README.md | 38 + netbox_integration/config.php | 61 ++ netbox_integration/netbox_api.php | 135 ++++ netbox_integration/test_netbox.php | 37 + requests.php | 152 ++++ send.php | 345 +++------ setup_database.sql | 27 + sizes.php | 15 + style.css | 1160 +++++++++++++++++++++++++--- test-e2e.sh | 56 ++ vm_images/big_vm.png | Bin 0 -> 11304 bytes vm_images/medium_vm.png | Bin 0 -> 10740 bytes vm_images/small_vm.png | Bin 0 -> 10318 bytes 26 files changed, 2471 insertions(+), 603 deletions(-) create mode 100755 .gitignore mode change 100644 => 100755 README.md create mode 100644 cancel.php create mode 100755 config.php create mode 100644 create.php create mode 100755 deploy-local.sh mode change 100644 => 100755 get_oauth_token.php create mode 100755 health.php mode change 100644 => 100755 index.html mode change 100644 => 100755 index.php create mode 100755 install_mariadb.sh mode change 100644 => 100755 login.php mode change 100644 => 100755 logout.php create mode 100755 netbox_integration/README.md create mode 100755 netbox_integration/config.php create mode 100755 netbox_integration/netbox_api.php create mode 100755 netbox_integration/test_netbox.php create mode 100644 requests.php mode change 100644 => 100755 send.php create mode 100755 setup_database.sql create mode 100644 sizes.php mode change 100644 => 100755 style.css create mode 100755 test-e2e.sh create mode 100644 vm_images/big_vm.png create mode 100644 vm_images/medium_vm.png create mode 100644 vm_images/small_vm.png diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..1529c33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +config.local.php +.env +.e2e-cookies.txt +.my.cnf.* +.my.output.* +.mysql.* +.pi/ +.ralph/ +AGENTS.md +context.md +analyze-phpsite.sh +test-pi-combo.py +test-pi-rpc.py +setup_database.sql.backup diff --git a/README.md b/README.md old mode 100644 new mode 100755 index f3e0f01..eba2b49 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ -# phpsite \ No newline at end of file +# phpsite + +VM request webshop. Users browse VM configurations (Small / Medium / Big), pick one, fill in details, and submit a request. Completed requests are registered in NetBox as virtual machines. + +Tech stack: plain PHP, MariaDB, Nginx + PHP-FPM, optional NetBox integration. + +## File layout + +``` +├── index.php # Shop home — product cards for each VM size +├── create.php # Order form (pre-filled with chosen size) +├── requests.php # My Requests — history table with pagination +├── send.php # POST handler: validate, insert, call NetBox +├── cancel.php # Cancel a queued request +├── login.php # Sign-in page (database auth) +├── logout.php # Session destroy + redirect +├── config.php # Shared helpers: db(), require_login(), h(), CSRF +├── sizes.php # VM size definitions + allowed domains +├── style.css # All CSS (Inter font, dark-mode, webshop styles) +├── health.php # Health check endpoint (returns JSON) +├── setup_database.sql # MariaDB schema (users + vm_requests tables) +├── deploy-local.sh # One-shot local deployment script +├── test-e2e.sh # End-to-end smoke test +├── vm_images/ # Auto-generated product-card images +├── netbox_integration/ # NetBox API client for virtualization VMs +│ ├── config.php # Load .env token, build client config +│ └── netbox_api.php # NetBoxClient class (createVirtualMachine, etc.) +└── .gitignore +``` + +## Deploy locally + +```sh +./deploy-local.sh +``` + +Creates the MariaDB database/user, writes `config.local.php`, imports the schema, +seeds an admin user, and enables Nginx + PHP-FPM. + +**Default local login:** `admin` / `admin123` + +## Health check + +```sh +curl http://127.0.0.1/health.php +``` + +Returns JSON with `"status": "ok"` and `"database": "connected"` on success. + +## End-to-end test + +```sh +./test-e2e.sh +PHPSITE_URL=http://127.0.0.1:8000 ./test-e2e.sh +``` + +Checks PHP syntax, signs in, submits a VM request, and verifies it appears. + +## NetBox integration + +When a request is submitted, `send.php` calls NetBox (`/virtualization/virtual-machines/`) +to create a virtual machine entry. The VM is tagged with the chosen domain and attached to the +first available cluster. + +To enable: + +1. Put your NetBox API token in `.env` (or set `NETBOX_API_TOKEN` env var) +2. Ensure a cluster exists in NetBox (the app looks for `proxmox-cluster`) +3. Ensure tags exist for each domain (`domain1`, `domain2`, `domain3`) + +If NetBox is unreachable, the request stays as `queued` — no data is lost. diff --git a/cancel.php b/cancel.php new file mode 100644 index 0000000..acb502e --- /dev/null +++ b/cancel.php @@ -0,0 +1,48 @@ +prepare('SELECT id, status FROM vm_requests WHERE id = ?'); +$stmt->execute([$id]); +$request = $stmt->fetch(); + +if (!$request) { + header('Location: /requests.php?error=Request+not+found.'); + exit; +} + +if ($request['status'] !== 'queued') { + header('Location: /requests.php?error=Only+queued+requests+can+be+cancelled.'); + exit; +} + +try { + $stmt = db()->prepare('UPDATE vm_requests SET status = ? WHERE id = ?'); + $stmt->execute(['cancelled', $id]); +} catch (PDOException $e) { + header('Location: /requests.php?error=Failed+to+cancel+request.'); + exit; +} + +header('Location: /requests.php?cancelled=1'); +exit; diff --git a/config.php b/config.php new file mode 100755 index 0000000..965dd4a --- /dev/null +++ b/config.php @@ -0,0 +1,89 @@ + $config['db_host'] ?? getenv('PHPSITE_DB_HOST') ?: '127.0.0.1', + 'name' => $config['db_name'] ?? getenv('PHPSITE_DB_NAME') ?: 'phpsite', + 'user' => $config['db_user'] ?? getenv('PHPSITE_DB_USER') ?: 'phpsite_app', + 'pass' => $config['db_pass'] ?? getenv('PHPSITE_DB_PASS') ?: '', +]; + +// Include NetBox integration +require_once __DIR__ . '/netbox_integration/config.php'; + +function db(): PDO +{ + static $pdo = null; + global $dbConfig; + + if ($pdo instanceof PDO) { + return $pdo; + } + + $dsn = sprintf( + 'mysql:host=%s;dbname=%s;charset=utf8mb4', + $dbConfig['host'], + $dbConfig['name'] + ); + + $pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + return $pdo; +} + +function start_app_session(): void +{ + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); +} + +function h(?string $value): string +{ + return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function require_login(): void +{ + start_app_session(); + + if (empty($_SESSION['loggedin']) || $_SESSION['loggedin'] !== true) { + header('Location: /login.php'); + exit; + } +} + +function csrf_token(): string +{ + start_app_session(); + + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + + return $_SESSION['csrf_token']; +} + +function verify_csrf(?string $token): bool +{ + start_app_session(); + + return is_string($token) + && isset($_SESSION['csrf_token']) + && hash_equals($_SESSION['csrf_token'], $token); +} diff --git a/create.php b/create.php new file mode 100644 index 0000000..becebae --- /dev/null +++ b/create.php @@ -0,0 +1,245 @@ + + + + + + + Order <?= h($size['label']) ?> VM + + + + + + +
+ + + ← Back to all VMs + +
+
+ <?= h($size['label']) ?> +

VM

+
    +
  • vCPU 1 ? 's' : '' ?>
  • +
  • GB RAM
  • +
  • GB Disk
  • +
+
+ +
+ + + + +
+ + +

Fill in the details

+ +
+ + Starts with a letter or number, then letters, numbers, dots, dashes, or underscores (2—64 chars). + +
Please enter a valid VM name (e.g. web-01).
+
+ +
+ Domain + Choose the domain this VM will belong to. +
+ + + +
+
Please select a domain.
+
+ +
+ + +
+ + +
+
+
+ + +
+
+

Confirm VM Request

+

Are you sure you want to create this VM request?

+
+ + +
+
+
+ + + + diff --git a/deploy-local.sh b/deploy-local.sh new file mode 100755 index 0000000..2162ba9 --- /dev/null +++ b/deploy-local.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DB_NAME="${PHPSITE_DB_NAME:-phpsite}" +DB_USER="${PHPSITE_DB_USER:-phpsite_app}" +DB_PASS="${PHPSITE_DB_PASS:-phpsite_local_password}" +ADMIN_USER="${PHPSITE_ADMIN_USER:-admin}" +ADMIN_PASS="${PHPSITE_ADMIN_PASS:-admin123}" +NGINX_SITE="/etc/nginx/sites-available/phpsite" +NGINX_ENABLED="/etc/nginx/sites-enabled/phpsite" + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +require_cmd php +require_cmd mysql +require_cmd nginx +require_cmd curl + +sudo systemctl enable --now mariadb +sudo systemctl enable --now php8.3-fpm + +sudo mysql < "${APP_DIR}/config.local.php" < '127.0.0.1', + 'db_name' => '${DB_NAME}', + 'db_user' => '${DB_USER}', + 'db_pass' => '${DB_PASS}', +]; +PHP +sudo chgrp www-data "${APP_DIR}/config.local.php" +chmod 0640 "${APP_DIR}/config.local.php" + +sudo install -d -m 0755 /home/alex/sites +sudo ln -sfn /home/alex/sites/phpsite.conf "${NGINX_SITE}" +sudo ln -sfn "${NGINX_SITE}" "${NGINX_ENABLED}" +sudo rm -f /etc/nginx/sites-enabled/default + +if systemctl is-active --quiet apache2; then + sudo systemctl disable --now apache2 +fi + +sudo nginx -t +sudo systemctl enable --now nginx +sudo systemctl reload nginx + +echo "phpsite deployed at http://127.0.0.1/ and http://127.0.0.1:8000/" +echo "local login: ${ADMIN_USER} / ${ADMIN_PASS}" diff --git a/get_oauth_token.php b/get_oauth_token.php old mode 100644 new mode 100755 diff --git a/health.php b/health.php new file mode 100755 index 0000000..a99a3b8 --- /dev/null +++ b/health.php @@ -0,0 +1,27 @@ + 'ok', + 'php_version' => PHP_VERSION, + 'database' => 'connected', + 'timestamp' => date(DATE_ATOM), +]; + +try { + require __DIR__ . '/config.php'; + db()->query('SELECT 1')->fetch(); + http_response_code(200); +} catch (Throwable $e) { + http_response_code(500); + $response = [ + 'status' => 'error', + 'database' => 'unavailable', + 'message' => $e->getMessage(), + 'timestamp' => date(DATE_ATOM), + ]; +} + +echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/index.html b/index.html old mode 100644 new mode 100755 index 465bbd9..303cf0a --- a/index.html +++ b/index.html @@ -3,34 +3,16 @@ - WM Ware Form - + + VM Configurator + -
-

WM Ware Form

-
-
- - -
- -
- - -
- -
- - -
- -
-
+
+
+

VM Configurator

+ Open app +
+
- \ No newline at end of file + diff --git a/index.php b/index.php old mode 100644 new mode 100755 index 4c614bf..b1262fa --- a/index.php +++ b/index.php @@ -1,203 +1,58 @@ - VM Configurator - + + + VM Shop + + + + -
-

VM Configurator

-
- - -
- - -
- -
- -
- - -
-
- - -
-
- - -
-
- -
-
- - Small VM - -

2 vCPU, 4 GB RAM, 50 GB HDD

-
-
- - Medium VM - -

4 vCPU, 8 GB RAM, 50 GB HDD

-
-
- - Big VM - -

8 vCPU, 16 GB RAM, 50 GB HDD

-
-
- -
-

Configuration Summary

-

vCPU: -

-

RAM: - GB

-

HDD: - GB

-
- - -
-
- Logout +
+
+ + - +
+

Choose your VM configuration

+

Pick a plan below to customize and submit your request.

+
+ +
+ $size): ?> +
+
+ <?= h($size['label']) ?> +
+
+

+
    +
  • vCPU 1 ? 's' : '' ?>
  • +
  • GB RAM
  • +
  • GB Disk
  • +
+ Order +
+
+ +
+ - \ No newline at end of file + diff --git a/install_mariadb.sh b/install_mariadb.sh new file mode 100755 index 0000000..b4385c5 --- /dev/null +++ b/install_mariadb.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# MariaDB Installation and Setup Script +# This script installs MariaDB, creates database, table, and sets up authentication + +echo "Starting MariaDB installation and setup..." + +# Update package list +sudo apt update + +# Install MariaDB server and client +echo "Installing MariaDB..." +sudo apt install -y mariadb-server mariadb-client + +# Start and enable MariaDB service +sudo systemctl start mariadb +sudo systemctl enable mariadb + +# Secure MariaDB installation +echo "Securing MariaDB installation..." +sudo mysql_secure_installation + +# Create database and user +echo "Creating database and user..." + +# Connect to MariaDB and execute commands +sudo mysql -e "CREATE DATABASE IF NOT EXISTS webform_users;" +sudo mysql -e "CREATE TABLE IF NOT EXISTS webform_users.users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);" + +# Insert admin user with hashed password +# Note: In a real scenario, you'd want to generate a secure password +ADMIN_PASSWORD="admin123" +HASHED_PASSWORD=$(sudo mysql -e "SELECT PASSWORD('$ADMIN_PASSWORD');" | tail -n +2 | head -n 1) + +if [ -n "$HASHED_PASSWORD" ]; then + sudo mysql -e "INSERT INTO webform_users.users (username, password) VALUES ('admin', '$HASHED_PASSWORD');" + echo "Admin user created successfully" +else + echo "Error creating admin user" + exit 1 +fi + +echo "Database setup completed successfully!" + +# Create a simple login.php file for demonstration +echo "Creating login.php file..." + +cat > login.php << 'EOF' +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +} catch(PDOException $e) { + die("Connection failed: " . $e->getMessage()); +} + +// Handle login +if ($_POST) { + $user = $_POST['username']; + $pass = $_POST['password']; + + $stmt = $pdo->prepare("SELECT username, password FROM users WHERE username = ?"); + $stmt->execute([$user]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($result && password_verify($pass, $result['password'])) { + echo "Login successful!"; + // Start session, redirect, etc. + } else { + echo "Invalid username or password!"; + } +} +?> + + + + + Login + + +

Login

+
+ +

+ +

+ +
+ + +EOF + +echo "Installation complete! Please review and modify the login.php file as needed." \ No newline at end of file diff --git a/login.php b/login.php old mode 100644 new mode 100755 index d96be55..f8ff0a1 --- a/login.php +++ b/login.php @@ -1,56 +1,81 @@ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); -} catch (PDOException $e) { - die("Database connection failed: " . $e->getMessage()); -} +require __DIR__ . '/config.php'; +start_app_session(); +$error = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // Sanitize user inputs - $username = htmlspecialchars(trim($_POST['username'])); - $password = htmlspecialchars(trim($_POST['password'])); + $username = trim((string) ($_POST['username'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); - // Fetch user from the database - $stmt = $db->prepare("SELECT * FROM users WHERE username = ?"); + $stmt = db()->prepare('SELECT id, username, password_hash FROM users WHERE username = ?'); $stmt->execute([$username]); - $user = $stmt->fetch(PDO::FETCH_ASSOC); + $user = $stmt->fetch(); - if ($user && password_verify($password, $user['password'])) { - // Regenerate session ID to prevent session fixation + if ($user && password_verify($password, $user['password_hash'])) { session_regenerate_id(true); - - // Set session variables $_SESSION['loggedin'] = true; + $_SESSION['user_id'] = (int) $user['id']; $_SESSION['user'] = $user['username']; + unset($_SESSION['csrf_token']); - // Redirect to the main page - header("Location: index.php"); + header('Location: /index.php'); exit; - } else { - $error = "Invalid username or password."; } + + $error = 'Invalid username or password.'; } ?> - Login + + + Login | VM Configurator + + + + -

Login

- $error

"; ?> -
- - -
- - -
- +
+ +

Infrastructure Portal

+

Sign in

+

Enter your credentials to access the VM configurator.

+ + +
+ + +
+ + +
+ +
+ +
+ + +
+
+ + +
+ diff --git a/logout.php b/logout.php old mode 100644 new mode 100755 index eba5cc9..93f15b8 --- a/logout.php +++ b/logout.php @@ -1,7 +1,10 @@ diff --git a/netbox_integration/README.md b/netbox_integration/README.md new file mode 100755 index 0000000..e8ab9a4 --- /dev/null +++ b/netbox_integration/README.md @@ -0,0 +1,38 @@ +# NetBox Integration for phpsite + +This integration allows the phpsite application to communicate with a NetBox instance at 192.168.1.144 to automatically create device entries when VM requests are made. + +## Features + +- Automatically creates device entries in NetBox when VM requests are submitted +- Stores NetBox device IDs in the local database for tracking +- Maintains compatibility with existing functionality + +## Configuration + +To use this integration, you need to set the NetBox API token as an environment variable: + +```bash +export NETBOX_API_TOKEN="your_netbox_api_token_here" +``` + +## How It Works + +1. When a user submits a VM request through the web interface: + - The request is stored in the local database + - The system connects to the NetBox API at 192.168.1.144 + - A new device entry is created in NetBox with the VM specifications + - The NetBox device ID is stored in the local database + - The request status is updated to reflect NetBox integration + +## Supported Operations + +- Create device entries in NetBox +- Retrieve available sites, device types, and other resources +- Update local database with NetBox device IDs + +## Requirements + +- PHP with cURL extension enabled +- Access to NetBox API at 192.168.1.144 +- Valid NetBox API token with appropriate permissions \ No newline at end of file diff --git a/netbox_integration/config.php b/netbox_integration/config.php new file mode 100755 index 0000000..573c2da --- /dev/null +++ b/netbox_integration/config.php @@ -0,0 +1,61 @@ + loadNetBoxEnvValue('NETBOX_BASE_URL', 'http://192.168.1.144'), + 'api_token' => loadNetBoxEnvValue('NETBOX_API_TOKEN'), + 'verify_ssl' => false, // Set to true in production +]; + +// Function to get NetBox client +function getNetBoxClient() { + global $netboxConfig; + $token = $netboxConfig['api_token']; + $baseUrl = $netboxConfig['base_url']; + + // Include the NetBox client class + require_once __DIR__ . '/netbox_api.php'; + + return new NetBoxClient($baseUrl, $token); +} +?> diff --git a/netbox_integration/netbox_api.php b/netbox_integration/netbox_api.php new file mode 100755 index 0000000..7d9be59 --- /dev/null +++ b/netbox_integration/netbox_api.php @@ -0,0 +1,135 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->token = $token; + } + + private function headers() { + $h = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + if ($this->token) { + $h[] = 'Authorization: Token ' . $this->token; + } + return $h; + } + + private function request($method, $endpoint, $body = null) { + $url = $this->baseUrl . '/api/' . ltrim($endpoint, '/'); + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $this->headers(), + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + $resp = curl_exec($ch); + $info = curl_getinfo($ch); + curl_close($ch); + + if ($info['http_code'] >= 400) { + throw new Exception("NetBox API {$method} {$endpoint} failed (HTTP {$info['http_code']}): {$resp}"); + } + + return json_decode($resp, true); + } + + /** + * Get list endpoint (handles pagination for small result sets). + */ + public function getList($endpoint) { + $results = []; + $url = $endpoint . '?limit=100'; + while ($url) { + $page = $this->request('get', $url); + foreach (($page['results'] ?? []) as $item) { + $results[] = $item; + } + $url = $page['next'] ?? null; + // Normalize next URL — NetBox returns absolute URLs + if ($url && strpos($url, 'http') === 0) { + $url = str_replace($this->baseUrl . '/api/', '', $url); + } + } + return $results; + } + + public function post($endpoint, $data) { + return $this->request('post', $endpoint, $data); + } + + /** + * Find a tag by name; returns the tag array or null. + */ + public function findTag($name) { + $tags = $this->getList('/extras/tags/'); + foreach ($tags as $tag) { + if ($tag['name'] === $name) { + return $tag; + } + } + return null; + } + + /** + * Find a cluster by name; returns the cluster array or null. + */ + public function findCluster($name) { + $clusters = $this->getList('/virtualization/clusters/'); + foreach ($clusters as $c) { + if ($c['name'] === $name) { + return $c; + } + } + return null; + } + + /** + * Create a Virtual Machine in NetBox. + * + * NetBox virtualization VM API uses: + * - vcpus: float (e.g. 2.0) + * - memory: int in MB (e.g. 4096) + * - disk: int in MB (e.g. 51200) + * - cluster: required (int id) + * - tags: array of tag names to look up + */ + public function createVirtualMachine(array $params) { + $payload = [ + 'name' => $params['name'], + 'cluster' => $params['cluster_id'], + 'status' => $params['status'] ?? 'active', + 'vcpus' => (float) $params['vcpus'], + 'memory' => (int) ($params['ram_gb'] * 1024), // GB -> MB + 'disk' => (int) ($params['disk_gb'] * 1024), // GB -> MB + 'description' => $params['description'] ?? '', + ]; + + // Tag lookup for domain — tags must be numeric IDs in the NetBox API + if (!empty($params['domain'])) { + $tag = $this->findTag($params['domain']); + if ($tag) { + $payload['tags'] = [$tag['id']]; + } else { + error_log("NetBox: tag '{$params['domain']}' not found — creating VM without domain tag."); + } + } + + $result = $this->post('/virtualization/virtual-machines/', $payload); + return $result; + } +} diff --git a/netbox_integration/test_netbox.php b/netbox_integration/test_netbox.php new file mode 100755 index 0000000..0ba4bea --- /dev/null +++ b/netbox_integration/test_netbox.php @@ -0,0 +1,37 @@ +#!/usr/bin/env php +getSites(); + + echo "✓ Successfully connected to NetBox API\n"; + echo "Available sites:\n"; + + if (!empty($sites['results'])) { + foreach ($sites['results'] as $site) { + echo " - {$site['name']} (ID: {$site['id']})\n"; + } + } else { + echo " No sites found\n"; + } + + echo "✓ NetBox API integration is working\n"; + +} catch (Exception $e) { + echo "✗ NetBox API connection failed: " . $e->getMessage() . "\n"; + exit(1); +} + +?> \ No newline at end of file diff --git a/requests.php b/requests.php new file mode 100644 index 0000000..60ecc90 --- /dev/null +++ b/requests.php @@ -0,0 +1,152 @@ +query('SELECT COUNT(*) FROM vm_requests'); +$total = (int) $totalStmt->fetchColumn(); +$totalPages = max(1, (int) ceil($total / $perPage)); +if ($page > $totalPages) $page = $totalPages; + +$stmt = db()->prepare( + 'SELECT id, vm_name, domain_name, size, vcpus, ram_gb, disk_gb, status, description, created_at, netbox_device_id + FROM vm_requests + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset' +); +$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT); +$stmt->bindValue(':offset', $offset, PDO::PARAM_INT); +$stmt->execute(); +$recentRequests = $stmt->fetchAll(); +?> + + + + + + My Requests + + + + + + +
+ + + +
✓ VM request cancelled.
+ + +
+
+

My VM Requests

+ + New Request +
+ + +
+
🚀
+

No VM requests yet.
Order your first VM →

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameDomainSpecsStatusWhen
+ + +
+ +
+ +
+ + + + + c / G + + + + + + + + +
+ +
+ +
+ + + +
+ +
+ ✅ NetBox Device # +
+
+ 1): ?> + + + +
+
+ + diff --git a/send.php b/send.php old mode 100644 new mode 100755 index d57f1bd..faa6d12 --- a/send.php +++ b/send.php @@ -1,249 +1,114 @@ - - - Alex VM Project - - - - - prepare( + 'INSERT INTO vm_requests + (vm_name, domain_name, size, vcpus, ram_gb, disk_gb, requested_by, status, description) + VALUES + (:vm_name, :domain_name, :size, :vcpus, :ram_gb, :disk_gb, :requested_by, :status, :description)' + ); + $stmt->execute([ + 'vm_name' => $vmName, + 'domain_name' => $domain, + 'size' => $size, + 'vcpus' => $resources['vcpus'], + 'ram_gb' => $resources['ram_gb'], + 'disk_gb' => $resources['disk_gb'], + 'requested_by' => $_SESSION['user'] ?? 'unknown', + 'status' => 'queued', + 'description' => $description, + ]); + + // Get the inserted request ID + $requestId = db()->lastInsertId(); + + // Create NetBox Virtual Machine for this request + try { + $netboxClient = getNetBoxClient(); + + // Find the first cluster (proxmox-cluster) + $cluster = $netboxClient->findCluster('proxmox-cluster'); + if (!$cluster) { + throw new Exception('No NetBox cluster found.'); } - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { - echo "Error: Invalid request method."; - exit; - } - - if (empty($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) { - echo "Error: Invalid CSRF token."; - exit; - } - - // NetBox API configuration - $netboxApiUrl = 'http://192.168.1.144:80/api/virtualization/virtual-machines/'; - $apiToken = 'fb82b28af1fcc5f180cc8f3b9d747e8fccb987d0'; - - // Validate and sanitize user input - $VM_Name = isset($_POST['VM_Name']) ? htmlspecialchars(trim($_POST['VM_Name'])) : null; - $Size = isset($_POST['size']) ? htmlspecialchars(trim($_POST['size'])) : null; - $Domain = isset($_POST['domain']) ? htmlspecialchars(trim($_POST['domain'])) : null; - - // Map size to vCPUs and RAM - $sizeMapping = [ - 'small' => ['vcpus' => 2, 'ram' => 4], - 'medium' => ['vcpus' => 4, 'ram' => 8], - 'big' => ['vcpus' => 8, 'ram' => 16] - ]; - - $vCPUs = isset($sizeMapping[$Size]) ? $sizeMapping[$Size]['vcpus'] : null; - $Ram_Value = isset($sizeMapping[$Size]) ? $sizeMapping[$Size]['ram'] * 1024 : null; // Convert GB to MB - - // Set HDD value to always be 50GB - $HDD_Value = 50 * 1024; // Convert GB to MB - - // Check for required fields - if (!$VM_Name || $vCPUs === null || $Ram_Value === null || !$Domain) { - echo "Error: Missing or invalid input data."; - exit; - } - - // Function to check if a VM name already exists in NetBox - function checkIfVmExists($vmName, $netboxApiUrl, $apiToken) { - $vmsApiUrl = rtrim($netboxApiUrl, '/') . '/?name=' . urlencode($vmName); - - // Query NetBox for the VM name - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $vmsApiUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Authorization: Token ' . $apiToken - ]); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode === 200) { - $vms = json_decode($response, true); - if (!empty($vms['results'])) { - return true; // VM with the same name exists - } - } else { - echo "Error: Failed to check VM name. HTTP Code: $httpCode
"; - echo "Response: $response
"; - } - - return false; // VM does not exist - } - - // Check if the VM name already exists - if (checkIfVmExists($VM_Name, $netboxApiUrl, $apiToken)) { - echo "Error: A VM with the name '$VM_Name' already exists in NetBox."; - exit; - } - - // Function to get or create a tag in NetBox - function getOrCreateTag($tagName, $netboxApiUrl, $apiToken) { - $tagsApiUrl = 'http://192.168.1.144:80/api/extras/tags/'; - - // Check if the tag exists - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $tagsApiUrl . '?name=' . urlencode($tagName)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Authorization: Token ' . $apiToken - ]); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode === 200) { - $tags = json_decode($response, true); - if (!empty($tags['results'])) { - return $tags['results'][0]['id']; // Return the ID of the existing tag - } - } - - // If the tag doesn't exist, create it - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $tagsApiUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['name' => $tagName])); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Token ' . $apiToken - ]); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode === 201) { - $tag = json_decode($response, true); - return $tag['id']; // Return the ID of the newly created tag - } - - return null; // Return null if the tag couldn't be created - } - - // Get or create the domain tag - $domainTagId = getOrCreateTag($Domain, $netboxApiUrl, $apiToken); - if (!$domainTagId) { - echo "Error: Failed to retrieve or create the domain tag."; - exit; - } - - // Function to create a virtual disk in NetBox - function createDisk($vmId, $diskName, $diskSize, $netboxApiUrl, $apiToken) { - $disksApiUrl = 'http://192.168.1.144:80/api/virtualization/virtual-disks/'; - - // Prepare disk data - $diskData = [ - 'virtual_machine' => $vmId, - 'name' => $diskName, - 'size' => $diskSize // Size in MB - ]; - - // Initialize cURL - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $disksApiUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($diskData)); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Token ' . $apiToken - ]); - - // Execute cURL request - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode === 201) { - echo "Disk '$diskName' created successfully.
"; - } else { - echo "Failed to create disk '$diskName'. HTTP Code: $httpCode
"; - echo "Response: $response
"; - } - } - - // Prepare data for NetBox API to create the VM - $data = [ - 'name' => $VM_Name, - 'vcpus' => $vCPUs, - 'memory' => $Ram_Value, - 'status' => 'active', // Adjust as needed - 'cluster' => 1, // Replace with the appropriate cluster ID - 'tags' => [$domainTagId] // Include the domain tag ID - ]; - - // Initialize cURL to create the VM - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $netboxApiUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Authorization: Token ' . $apiToken + $vm = $netboxClient->createVirtualMachine([ + 'name' => $vmName, + 'cluster_id' => $cluster['id'], + 'vcpus' => $resources['vcpus'], + 'ram_gb' => $resources['ram_gb'], + 'disk_gb' => $resources['disk_gb'], + 'domain' => $domain, + 'description' => $description ?: ('Requested by ' . ($_SESSION['user'] ?? 'unknown')), ]); - // Execute cURL request to create the VM - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $netboxVmId = $vm['id'] ?? null; - if ($response === false) { - echo 'cURL Error: ' . curl_error($ch); - } else { - if ($httpCode === 201) { - echo "VM created successfully in NetBox.
"; - $vm = json_decode($response, true); - $vmId = $vm['id']; // Get the VM ID from the response + // Update the request with NetBox VM ID + $stmt = db()->prepare( + 'UPDATE vm_requests SET status = :status, netbox_device_id = :netbox_vm_id WHERE id = :id' + ); + $stmt->execute([ + 'status' => 'deployed_to_netbox', + 'netbox_vm_id' => $netboxVmId, + 'id' => $requestId, + ]); - // Create OS Disk - createDisk($vmId, 'osdisk', 60 * 1024, $netboxApiUrl, $apiToken); + } catch (Exception $e) { + error_log("NetBox integration failed: " . $e->getMessage()); + // Request stays in 'queued' — admin can retry later + } + +} catch (PDOException $exception) { + if ($exception->getCode() !== '23000') { + throw $exception; + } - // Create Data Disk - createDisk($vmId, 'datadisk', 50 * 1024, $netboxApiUrl, $apiToken); - } else { - echo "Failed to create VM in NetBox. HTTP Code: $httpCode
"; - echo "Response: $response
"; - } - } + header('Location: /create.php?size=' . urlencode($size) . '&error=' . urlencode('A VM request with that name already exists.')); + exit; +} - curl_close($ch); - ?> -
-

Processing Your VM Configuration...

-

You will be redirected to the front page in 20 seconds.

-
- Logout -
-
- - +header('Location: /requests.php?created=1'); +exit; diff --git a/setup_database.sql b/setup_database.sql new file mode 100755 index 0000000..d68b63f --- /dev/null +++ b/setup_database.sql @@ -0,0 +1,27 @@ +CREATE DATABASE IF NOT EXISTS phpsite CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE phpsite; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY users_username_unique (username) +); + +CREATE TABLE IF NOT EXISTS vm_requests ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + vm_name VARCHAR(64) NOT NULL, + domain_name VARCHAR(64) NOT NULL, + size ENUM('small', 'medium', 'big') NOT NULL, + vcpus TINYINT UNSIGNED NOT NULL, + ram_gb SMALLINT UNSIGNED NOT NULL, + disk_gb SMALLINT UNSIGNED NOT NULL, + requested_by VARCHAR(50) NOT NULL, + status ENUM('queued', 'building', 'ready', 'failed', 'deployed_to_netbox', 'cancelled') NOT NULL DEFAULT 'queued', + description VARCHAR(500) DEFAULT NULL, + netbox_device_id INT UNSIGNED NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY vm_requests_vm_name_unique (vm_name), + KEY vm_requests_created_at_index (created_at) +); diff --git a/sizes.php b/sizes.php new file mode 100644 index 0000000..ef07b1f --- /dev/null +++ b/sizes.php @@ -0,0 +1,15 @@ + [ + 'small' => ['label' => 'Small', 'vcpus' => 2, 'ram_gb' => 4, 'disk_gb' => 50], + 'medium' => ['label' => 'Medium', 'vcpus' => 4, 'ram_gb' => 8, 'disk_gb' => 80], + 'big' => ['label' => 'Big', 'vcpus' => 8, 'ram_gb' => 16, 'disk_gb' => 120], + ], + 'domains' => ['domain1', 'domain2', 'domain3'], +]; diff --git a/style.css b/style.css old mode 100644 new mode 100755 index 8b3dea6..76cb42b --- a/style.css +++ b/style.css @@ -1,121 +1,1069 @@ - body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background-color: #f5f7fa; - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - margin: 0; - padding: 20px; - } +:root { + --bg: #f8f9fc; + --panel: #ffffff; + --panel-hover: #fafbfd; + --text: #0f1320; + --text-secondary: #6b7280; + --text-tertiary: #9ca3af; + --border: #e5e7eb; + --border-strong: #d1d5db; + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-light: rgba(59, 130, 246, 0.08); + --accent-glow: rgba(59, 130, 246, 0.25); + --success-bg: #ecfdf5; + --success-border: #a7f3d0; + --success-text: #059669; + --warning-bg: #fffbeb; + --warning-border: #fde68a; + --warning-text: #d97706; + --error-bg: #fef2f2; + --error-border: #fecaca; + --error-text: #dc2626; + --info-bg: #eff6ff; + --info-border: #bfdbfe; + --info-text: #2563eb; + --radius: 12px; + --radius-sm: 8px; + --radius-xs: 6px; + --shadow-sm: 0 1px 3px rgba(15, 19, 32, 0.06), 0 1px 2px rgba(15, 19, 32, 0.04); + --shadow: 0 4px 12px rgba(15, 19, 32, 0.08), 0 2px 4px rgba(15, 19, 32, 0.04); + --shadow-lg: 0 12px 28px rgba(15, 19, 32, 0.12), 0 4px 8px rgba(15, 19, 32, 0.06); + --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 250ms cubic-bezier(0.4, 0, 0.2, 1); +} - .form-container { - background-color: white; - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); - padding: 30px; - width: 100%; - max-width: 450px; - } +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1117; + --panel: #1a1d28; + --panel-hover: #1e2130; + --text: #f3f4f6; + --text-secondary: #9ca3af; + --text-tertiary: #6b7280; + --border: #2d3140; + --border-strong: #3d4255; + --accent: #60a5fa; + --accent-hover: #93bbfd; + --accent-light: rgba(96, 165, 250, 0.1); + --accent-glow: rgba(96, 165, 250, 0.2); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.4); + } +} - h2 { - color: #2c3e50; - margin-top: 0; - margin-bottom: 25px; - text-align: center; - font-weight: 600; - } +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} - .form-group { - margin-bottom: 20px; - } +html { + scroll-behavior: smooth; +} - label { - display: block; - margin-bottom: 8px; - color: #4a5568; - font-weight: 500; - font-size: 0.95rem; - } +body { + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: background var(--transition), color var(--transition); +} - input, select { - width: 100%; - padding: 12px; - margin: 12px; - border: 1px solid #ddd; - border-radius: 6px; - font-size: 1rem; - transition: border-color 0.3s, box-shadow 0.3s; - box-sizing: border-box; - } +/* --- Shell --- */ +.shell { + width: min(1200px, calc(100% - 40px)); + margin: 0 auto; + padding: 40px 0 60px; +} - input:focus, select:focus { - border-color: #4299e1; - box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.2); - outline: none; - } +.auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--bg); +} - select { - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%234a5568' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 16px; - padding-right: 40px; - } +/* --- Topbar --- */ +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 32px; +} - button { - background-color: #4299e1; - color: white; - border: none; - border-radius: 6px; - padding: 12px 24px; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - width: 100%; - transition: background-color 0.3s, transform 0.1s; - } +.eyebrow { + margin: 0 0 4px; + color: var(--accent); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} - button:hover { - background-color: #3182ce; - } +h1 { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + color: var(--text); +} - button:active { - transform: translateY(1px); - } +h2 { + font-size: 1.1rem; + font-weight: 600; + letter-spacing: -0.01em; + margin-bottom: 16px; + color: var(--text); +} - /* Optional: Add some animation effects */ - @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } - } +/* --- Panels --- */ +.layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; +} - .form-container { - animation: fadeIn 0.5s ease-out; - } +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + box-shadow: var(--shadow); + transition: box-shadow var(--transition), border-color var(--transition); +} - /* Add validation styling */ - input:invalid, select:invalid { - border-color: #e53e3e; - } +.panel:hover { + box-shadow: var(--shadow-lg); +} - /* Add success message styling */ - .success-message { - background-color: #c6f6d5; - color: #2f855a; - padding: 12px; - border-radius: 6px; - margin-bottom: 20px; - text-align: center; - } +.auth-panel { + width: min(440px, 100%); + text-align: center; +} - /* Add error message styling */ - .error-message { - background-color: #fed7d7; - color: #c53030; - padding: 12px; - border-radius: 6px; - margin-bottom: 20px; - text-align: center; - } \ No newline at end of file +.auth-panel .eyebrow { + margin-bottom: 8px; +} + +.auth-panel h1 { + margin-bottom: 4px; +} + +.auth-panel .auth-subtitle { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 24px; +} + +/* --- Form Groups --- */ +.form-group { + margin-bottom: 24px; +} + +.form-group > label, +.form-group > .field-label { + display: block; + margin-bottom: 8px; + color: var(--text); + font-weight: 600; + font-size: 0.875rem; +} + +.field-label-sub { + display: block; + margin-top: 2px; + margin-bottom: 10px; + color: var(--text-secondary); + font-weight: 400; + font-size: 0.8rem; +} + +/* --- Inputs --- */ +input[type="text"], +input[type="password"], +textarea { + width: 100%; + min-height: 44px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + font: inherit; + font-size: 0.9rem; + color: var(--text); + background: var(--panel); + transition: border-color var(--transition), box-shadow var(--transition); +} + +input[type="text"]::placeholder, +input[type="password"]::placeholder, +textarea::placeholder { + color: var(--text-tertiary); +} + +input[type="text"]:focus, +input[type="password"]:focus, +textarea:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px var(--accent-light); +} + +textarea { + min-height: 80px; + resize: vertical; +} + +/* Input validation states */ +input.invalid, +textarea.invalid { + border-color: var(--error-text); + box-shadow: 0 0 0 3px var(--error-bg); +} + +input.invalid:focus, +textarea.invalid:focus { + box-shadow: 0 0 0 3px var(--error-border); +} + +/* Inline error hint */ +.field-error { + margin-top: 6px; + color: var(--error-text); + font-size: 0.8rem; + font-weight: 500; + display: none; +} + +.field-error.visible { + display: block; + animation: fadeIn 0.2s ease; +} + +/* Password toggle */ +.password-wrapper { + position: relative; +} + +.password-wrapper input[type="password"], +.password-wrapper input[type="text"] { + padding-right: 44px; +} + +.password-toggle { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + width: auto; + min-height: auto; + font-size: 1rem; + line-height: 1; + border-radius: 4px; + transition: color var(--transition); +} + +.password-toggle:hover { + color: var(--text); +} + +/* --- Choice Grids --- */ +.choice-grid, +.size-grid { + display: grid; + gap: 10px; +} + +.choice-grid { + grid-template-columns: repeat(3, 1fr); +} + +.size-grid { + grid-template-columns: repeat(3, 1fr); +} + +/* Choice cards */ +.choice, +.size-option { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + min-height: 56px; + margin: 0; + padding: 14px; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + background: var(--panel); + transition: all var(--transition); +} + +.choice:hover, +.size-option:hover { + border-color: var(--accent); + background: var(--accent-light); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.choice input, +.size-option input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +/* Checked state */ +.choice input:checked + .choice-check, +.size-option input:checked { + /* handled below */ +} + +.choice:has(input:checked), +.size-option:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +/* Check mark indicator */ +.choice::before { + content: ''; + position: absolute; + top: 10px; + right: 10px; + width: 18px; + height: 18px; + border: 2px solid var(--border-strong); + border-radius: 50%; + transition: all var(--transition); +} + +.choice:has(input:checked)::before { + border-color: var(--accent); + background: var(--accent); + box-shadow: inset 0 0 0 3px var(--panel); +} + +.choice span { + font-weight: 500; + font-size: 0.875rem; +} + +/* --- Size Options --- */ +.size-option { + text-align: center; + padding: 16px 12px; + min-height: auto; +} + +.size-option .size-title { + font-weight: 700; + font-size: 0.95rem; + margin-bottom: 2px; +} + +.size-option span:not(.size-title) { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* Size images */ +.size-img { + display: block; + width: 100%; + max-width: 130px; + height: auto; + margin: 0 auto 8px; + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); + border: 2px solid transparent; + transition: all var(--transition); +} + +.size-option:hover .size-img { + transform: scale(1.04); + box-shadow: var(--shadow); +} + +.size-option:has(input:checked) .size-img { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +/* --- Summary Bar --- */ +.summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 24px 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + transition: all var(--transition); +} + +.summary.active { + border-color: var(--accent); + background: var(--accent-light); +} + +.summary div { + text-align: center; + padding: 8px 4px; +} + +.summary span { + display: block; + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.summary strong { + display: block; + margin-top: 4px; + font-size: 1.1rem; + color: var(--text); + transition: color var(--transition); +} + +.summary.active strong { + color: var(--accent); +} + +/* --- Buttons --- */ +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 46px; + width: 100%; + padding: 12px 20px; + border: none; + border-radius: var(--radius-sm); + background: var(--accent); + color: #ffffff; + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + position: relative; + overflow: hidden; +} + +button:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--accent-glow); +} + +button:active { + transform: translateY(0); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.link-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--panel); + color: var(--text); + font: inherit; + font-size: 0.85rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all var(--transition); +} + +.link-button:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-light); +} + +/* --- Messages --- */ +.success-message, +.error-message { + padding: 14px 16px; + border-radius: var(--radius-sm); + margin-bottom: 20px; + font-size: 0.875rem; + font-weight: 500; + animation: slideDown 0.3s ease; +} + +.success-message { + background: var(--success-bg); + border: 1px solid var(--success-border); + color: var(--success-text); +} + +.error-message { + background: var(--error-bg); + border: 1px solid var(--error-border); + color: var(--error-text); +} + +.info-message { + background: var(--info-bg); + border: 1px solid var(--info-border); + color: var(--info-text); +} + +.warning-message { + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--warning-text); +} + +/* --- Status Badges --- */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.badge-queued { + background: var(--warning-bg); + color: var(--warning-text); + border: 1px solid var(--warning-border); +} + +.badge-deployed_to_netbox, +.badge-deployed { + background: var(--success-bg); + color: var(--success-text); + border: 1px solid var(--success-border); +} + +.badge-pending { + background: var(--info-bg); + color: var(--info-text); + border: 1px solid var(--info-border); +} + +.badge-error, +.badge-failed { + background: var(--error-bg); + color: var(--error-text); + border: 1px solid var(--error-border); +} + +.badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: pulse 2s infinite; +} + +.badge-deployed_to_netbox .badge-dot, +.badge-deployed .badge-dot { + animation: none; +} + +/* --- Table --- */ +.muted { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.table-wrap { + overflow-x: auto; + border-radius: var(--radius-sm); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +th { + padding: 10px 12px; + border-bottom: 2px solid var(--border); + text-align: left; + color: var(--text-secondary); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +td { + padding: 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; + color: var(--text); +} + +tbody tr { + transition: background var(--transition); +} + +tbody tr:hover { + background: var(--accent-light); +} + +tbody tr:last-child td { + border-bottom: none; +} + +.td-timestamp { + color: var(--text-secondary); + font-size: 0.8rem; + white-space: nowrap; +} + +.empty-state { + padding: 32px 0; + text-align: center; + color: var(--text-secondary); +} + +.empty-state-icon { + font-size: 2.5rem; + margin-bottom: 8px; + opacity: 0.4; +} + +/* --- Confirm Dialog --- */ +.confirm-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 1000; + place-items: center; + animation: fadeIn 0.15s ease; +} + +.confirm-overlay.visible { + display: grid; +} + +.confirm-box { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + max-width: 420px; + width: calc(100% - 32px); + box-shadow: var(--shadow-lg); + animation: scaleIn 0.2s ease; +} + +.confirm-box h2 { + margin-bottom: 8px; +} + +.confirm-box p { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 20px; +} + +.confirm-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.confirm-actions .link-button { + width: auto; +} + +.confirm-actions button { + width: auto; + min-width: 100px; +} + +/* --- Animations --- */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Spinner for button loading */ +.spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +/* --- Responsive --- */ +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .shell { + padding: 20px 0 40px; + } + + .panel { + padding: 20px; + } + + .choice-grid, + .size-grid { + grid-template-columns: 1fr; + } + + .summary { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + gap: 12px; + } + + h1 { + font-size: 1.5rem; + } + + .confirm-actions { + flex-direction: column-reverse; + } + + .confirm-actions .link-button, + .confirm-actions button { + width: 100%; + } +} + +/* --- Shop Page --- */ +.shop-page { + width: min(1200px, calc(100% - 40px)); + margin: 0 auto; + padding: 40px 0 60px; +} + +/* --- Nav Links --- */ +.nav-links { + display: flex; + align-items: center; + gap: 12px; +} + +.nav-link { + display: inline-flex; + align-items: center; + padding: 8px 16px; + border: 1px solid transparent; + border-radius: var(--radius-xs); + color: var(--text-secondary); + font: inherit; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all var(--transition); +} + +.nav-link:hover { + color: var(--text); + background: var(--accent-light); +} + +.nav-link.active { + color: var(--accent); + border-color: var(--border); + background: var(--accent-light); +} + +/* --- Hero Section --- */ +.hero { + text-align: center; + padding: 32px 0 24px; +} + +.hero h2 { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 8px; +} + +.hero-sub { + color: var(--text-secondary); + font-size: 0.95rem; +} + +/* --- Product Grid --- */ +.product-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +/* --- Product Card --- */ +.product-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow); + transition: all var(--transition-slow); +} + +.product-card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); + border-color: var(--accent); +} + +.product-media { + padding: 24px 24px 0; + text-align: center; +} + +.product-media img { + max-width: 100%; + width: 320px; + height: auto; + border-radius: var(--radius-sm); + transition: transform var(--transition); +} + +.product-card:hover .product-media img { + transform: scale(1.03); +} + +.product-body { + padding: 20px 24px 24px; + text-align: center; +} + +.product-body h3 { + font-size: 1.15rem; + font-weight: 700; + margin-bottom: 12px; + letter-spacing: -0.01em; +} + +.product-specs { + list-style: none; + padding: 0; + margin: 0 0 20px; + text-align: left; + display: inline-block; +} + +.product-specs li { + padding: 6px 0; + font-size: 0.875rem; + color: var(--text-secondary); + border-bottom: 1px solid var(--border); +} + +.product-specs li:last-child { + border-bottom: none; +} + +.product-specs strong { + color: var(--text); +} + +/* --- Buttons --- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 24px; + border-radius: var(--radius-sm); + font: inherit; + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all var(--transition); + border: 1px solid transparent; +} + +.btn-primary { + background: var(--accent); + color: #ffffff; + width: 100%; +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--accent-glow); +} + +.btn-sm { + padding: 8px 16px; + font-size: 0.8rem; + width: auto; +} + +/* --- Breadcrumb --- */ +.breadcrumb { + display: inline-block; + margin: 0 0 20px; + color: var(--text-secondary); + font-size: 0.85rem; + text-decoration: none; + font-weight: 500; + transition: color var(--transition); +} + +.breadcrumb:hover { + color: var(--accent); +} + +/* --- Order Layout --- */ +.order-layout { + display: grid; + grid-template-columns: 340px 1fr; + gap: 24px; + align-items: start; +} + +.order-preview { + text-align: center; +} + +.order-preview-img { + width: 100%; + max-width: 380px; + height: auto; + margin: 0 auto 16px; + display: block; + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); +} + +.order-preview h2 { + margin-bottom: 16px; +} + +.order-specs { + list-style: none; + padding: 0; + margin: 0; + text-align: left; +} + +.order-specs li { + padding: 10px 14px; + margin-bottom: 6px; + font-size: 0.875rem; + color: var(--text); + background: var(--bg); + border-radius: var(--radius-xs); + border: 1px solid var(--border); +} + +.order-specs li:last-child { + margin-bottom: 0; +} + +/* --- Order Form --- */ +.order-form h2 { + margin-bottom: 20px; +} + +/* --- Requests --- */ +.requests-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.requests-panel .success-message { + margin-bottom: 20px; +} + +/* --- Cancel Button --- */ +.cancel-btn { + padding: 6px 12px; + min-height: 32px; + width: auto; + font-size: 0.78rem; + font-weight: 500; + border: 1px solid var(--error-border); + background: var(--error-bg); + color: var(--error-text); + border-radius: var(--radius-xs); +} + +.cancel-btn:hover { + background: var(--error-border); + box-shadow: none; + transform: none; +} + +/* --- Print --- */ +@media print { + body { background: #fff; } + .panel { box-shadow: none; border: 1px solid #ccc; } + button, .link-button { display: none; } +} diff --git a/test-e2e.sh b/test-e2e.sh new file mode 100755 index 0000000..52705d4 --- /dev/null +++ b/test-e2e.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_URL="${PHPSITE_URL:-http://127.0.0.1}" +ADMIN_USER="${PHPSITE_ADMIN_USER:-admin}" +ADMIN_PASS="${PHPSITE_ADMIN_PASS:-admin123}" +COOKIE_JAR="$(mktemp)" +LOGIN_HTML="$(mktemp)" +APP_HTML="$(mktemp)" +HEALTH_JSON="$(mktemp)" +trap 'rm -f "$COOKIE_JAR" "$LOGIN_HTML" "$APP_HTML" "$HEALTH_JSON"' EXIT + +# Syntax check all PHP files +for f in config.php sizes.php health.php index.php login.php logout.php send.php create.php requests.php cancel.php; do + php -l "$f" >/dev/null +done + +# Health +curl -fsS "${APP_URL}/health.php" -o "$HEALTH_JSON" +grep -q '"status": "ok"' "$HEALTH_JSON" +grep -q '"database": "connected"' "$HEALTH_JSON" + +# Login page +curl -fsS "${APP_URL}/login.php" -o "$LOGIN_HTML" +grep -q 'Sign in' "$LOGIN_HTML" + +# Sign in +curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + -d "username=${ADMIN_USER}" \ + -d "password=${ADMIN_PASS}" \ + -o "$APP_HTML" \ + -L "${APP_URL}/login.php" +grep -q 'VM Shop' "$APP_HTML" + +# CSRF token (from shop page) +CSRF_TOKEN="$(grep -oP 'name="csrf_token" value=\K[^"]+' "$APP_HTML" | head -n1)" +if [[ -z "$CSRF_TOKEN" ]]; then + echo "Missing CSRF token after login" >&2 + exit 1 +fi + +# Submit a VM request +VM_NAME="e2e-$(date +%s%N)" +curl -fsS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + -d "csrf_token=${CSRF_TOKEN}" \ + -d "VM_Name=${VM_NAME}" \ + -d "domain=domain1" \ + -d "size=small" \ + -o "$APP_HTML" \ + -L "${APP_URL}/send.php" + +# Verify request shows up on My Requests page (sent.php redirects there) +grep -q 'VM request cancelled\|created\|My VM Requests' "$APP_HTML" +grep -q "$VM_NAME" "$APP_HTML" + +echo "E2E passed for ${APP_URL} with VM ${VM_NAME}" diff --git a/vm_images/big_vm.png b/vm_images/big_vm.png new file mode 100644 index 0000000000000000000000000000000000000000..3a738397c0662bb90415763a55a5f0f467fa2e8c GIT binary patch literal 11304 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 F{{wbwQw{(C literal 0 HcmV?d00001 diff --git a/vm_images/medium_vm.png b/vm_images/medium_vm.png new file mode 100644 index 0000000000000000000000000000000000000000..ff5566706bae22e5fe1a2d36f76f20c9274f4bed GIT binary patch literal 10740 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