Files
phpsite/create.php
alex d629689473 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
2026-05-09 23:49:02 +02:00

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">&larr; Logout</a>
</div>
</nav>
<a href="/index.php" class="breadcrumb">&larr; 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">&#10008; <?= 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&#8212;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&#8230;)"
></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>