- 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
246 lines
8.8 KiB
PHP
246 lines
8.8 KiB
PHP
<?php
|
|
require __DIR__ . '/config.php';
|
|
require_login();
|
|
|
|
$config = require __DIR__ . '/sizes.php';
|
|
$sizes = $config['sizes'];
|
|
$domains = $config['domains'];
|
|
|
|
// Pre-selected size from URL (e.g. /create.php?size=medium)
|
|
$selectedSize = trim((string) ($_GET['size'] ?? ''));
|
|
if (!isset($sizes[$selectedSize])) {
|
|
$selectedSize = '';
|
|
}
|
|
|
|
// If no size selected, send them back to the shop
|
|
if ($selectedSize === '') {
|
|
header('Location: /index.php');
|
|
exit;
|
|
}
|
|
|
|
$size = $sizes[$selectedSize];
|
|
|
|
// Error message from send.php redirect
|
|
$error = $_GET['error'] ?? '';
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Order <?= h($size['label']) ?> VM</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="/style.css">
|
|
</head>
|
|
<body>
|
|
<main class="shop-page">
|
|
<nav class="topbar">
|
|
<div>
|
|
<p class="eyebrow">Infrastructure Portal</p>
|
|
<a href="/index.php" style="text-decoration:none;color:inherit;">
|
|
<h1>VM Shop</h1>
|
|
</a>
|
|
</div>
|
|
<div class="nav-links">
|
|
<a href="/index.php" class="nav-link">Shop</a>
|
|
<a href="/requests.php" class="nav-link">My Requests</a>
|
|
<a class="link-button" href="/logout.php">← Logout</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<a href="/index.php" class="breadcrumb">← Back to all VMs</a>
|
|
|
|
<div class="order-layout">
|
|
<section class="order-preview panel">
|
|
<img src="/vm_images/<?= h($selectedSize) ?>_vm.png" alt="<?= h($size['label']) ?>" class="order-preview-img">
|
|
<h2><?= h($size['label']) ?> VM</h2>
|
|
<ul class="order-specs">
|
|
<li><?= h((string)$size['vcpus']) ?> vCPU<?= $size['vcpus'] > 1 ? 's' : '' ?></li>
|
|
<li><?= h((string)$size['ram_gb']) ?> GB RAM</li>
|
|
<li><?= h((string)$size['disk_gb']) ?> GB Disk</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<form class="order-form panel" id="vmForm" action="/send.php" method="post" novalidate>
|
|
<input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
|
|
<input type="hidden" name="size" value="<?= h($selectedSize) ?>">
|
|
|
|
<?php if ($error): ?>
|
|
<div class="error-message">✘ <?= h(urldecode($error)) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<h2>Fill in the details</h2>
|
|
|
|
<div class="form-group">
|
|
<label for="VM_Name">VM Name</label>
|
|
<span class="field-label-sub">Starts with a letter or number, then letters, numbers, dots, dashes, or underscores (2—64 chars).</span>
|
|
<input
|
|
type="text"
|
|
id="VM_Name"
|
|
name="VM_Name"
|
|
required
|
|
maxlength="64"
|
|
placeholder="app-server-01"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
>
|
|
<div class="field-error" id="nameError">Please enter a valid VM name (e.g. <em>web-01</em>).</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<span class="field-label">Domain</span>
|
|
<span class="field-label-sub">Choose the domain this VM will belong to.</span>
|
|
<div class="choice-grid">
|
|
<?php foreach ($domains as $domain): ?>
|
|
<label class="choice">
|
|
<input type="radio" name="domain" value="<?= h($domain) ?>" required>
|
|
<span><?= h($domain) ?></span>
|
|
</label>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<div class="field-error" id="domainError">Please select a domain.</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="description">Description <span style="font-weight:400;color:var(--text-tertiary)">(optional)</span></label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
rows="3"
|
|
maxlength="500"
|
|
placeholder="What is this VM for? (e.g. CI runner, staging environment…)"
|
|
></textarea>
|
|
</div>
|
|
|
|
<button type="submit" id="submitBtn">
|
|
<span class="btn-label">Submit Request</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Confirmation dialog -->
|
|
<div class="confirm-overlay" id="confirmOverlay">
|
|
<div class="confirm-box">
|
|
<h2>Confirm VM Request</h2>
|
|
<p id="confirmText">Are you sure you want to create this VM request?</p>
|
|
<div class="confirm-actions">
|
|
<button type="button" class="link-button" id="confirmCancel">Cancel</button>
|
|
<button type="button" id="confirmOk">Submit Request</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
const form = document.getElementById('vmForm');
|
|
const nameInput = document.getElementById('VM_Name');
|
|
const nameError = document.getElementById('nameError');
|
|
const domainError = document.getElementById('domainError');
|
|
const overlay = document.getElementById('confirmOverlay');
|
|
const confirmText = document.getElementById('confirmText');
|
|
const confirmCancel = document.getElementById('confirmCancel');
|
|
const confirmOk = document.getElementById('confirmOk');
|
|
|
|
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$/;
|
|
|
|
nameInput.addEventListener('blur', function() {
|
|
const val = this.value.trim();
|
|
if (val.length > 0 && !namePattern.test(val)) {
|
|
nameError.classList.add('visible');
|
|
this.classList.add('invalid');
|
|
} else {
|
|
nameError.classList.remove('visible');
|
|
this.classList.remove('invalid');
|
|
}
|
|
});
|
|
|
|
nameInput.addEventListener('input', function() {
|
|
if (this.classList.contains('invalid') && namePattern.test(this.value.trim())) {
|
|
nameError.classList.remove('visible');
|
|
this.classList.remove('invalid');
|
|
}
|
|
});
|
|
|
|
function htmlEscape(s) {
|
|
var d = document.createElement('div');
|
|
d.appendChild(document.createTextNode(s));
|
|
return d.innerHTML;
|
|
}
|
|
|
|
form.addEventListener('submit', function(e) {
|
|
let valid = true;
|
|
|
|
const name = nameInput.value.trim();
|
|
if (!namePattern.test(name)) {
|
|
nameError.classList.add('visible');
|
|
nameInput.classList.add('invalid');
|
|
valid = false;
|
|
} else {
|
|
nameError.classList.remove('visible');
|
|
nameInput.classList.remove('invalid');
|
|
}
|
|
|
|
if (!form.querySelector('input[name="domain"]:checked')) {
|
|
domainError.classList.add('visible');
|
|
valid = false;
|
|
} else {
|
|
domainError.classList.remove('visible');
|
|
}
|
|
|
|
if (!valid) {
|
|
e.preventDefault();
|
|
const firstError = form.querySelector('.field-error.visible');
|
|
if (firstError) {
|
|
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
const domainSelected = form.querySelector('input[name="domain"]:checked');
|
|
const desc = document.getElementById('description').value.trim();
|
|
const sizeRadio = form.querySelector('input[name="size"]');
|
|
const sizeLabel = '<?= h($size["label"]) ?>';
|
|
|
|
var parts = [];
|
|
parts.push(htmlEscape(name) + ' — ' + sizeLabel);
|
|
parts.push('<br><span style="color:var(--text-secondary)">Domain: ' + htmlEscape(domainSelected.value) + '</span>');
|
|
if (desc) {
|
|
parts.push('<br><span style="color:var(--text-secondary)">Note: ' + htmlEscape(desc) + '</span>');
|
|
}
|
|
|
|
confirmText.innerHTML = parts.join('');
|
|
overlay.classList.add('visible');
|
|
});
|
|
|
|
confirmCancel.addEventListener('click', function() {
|
|
overlay.classList.remove('visible');
|
|
});
|
|
|
|
confirmOk.addEventListener('click', function() {
|
|
overlay.classList.remove('visible');
|
|
const btn = document.getElementById('submitBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner"></span>';
|
|
form.submit();
|
|
});
|
|
|
|
overlay.addEventListener('click', function(e) {
|
|
if (e.target === overlay) {
|
|
overlay.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && overlay.classList.contains('visible')) {
|
|
overlay.classList.remove('visible');
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|