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 0000000..3a73839 Binary files /dev/null and b/vm_images/big_vm.png differ diff --git a/vm_images/medium_vm.png b/vm_images/medium_vm.png new file mode 100644 index 0000000..ff55667 Binary files /dev/null and b/vm_images/medium_vm.png differ diff --git a/vm_images/small_vm.png b/vm_images/small_vm.png new file mode 100644 index 0000000..d137e82 Binary files /dev/null and b/vm_images/small_vm.png differ