feat: initial orchestrator service with FastAPI backend and premium GUI

- FastAPI backend with full Proxmox VE API integration
- ISO builder using proxmox-auto-install-assistant
- Premium dark-mode SPA frontend with glassmorphism design
- VM lifecycle management (create, start, stop, destroy)
- Build pipeline tracking with real-time logs
- Deployment automation for custom auto-installer ISOs
- Production deployment script (setup.sh + systemd)
- Comprehensive README with API documentation
This commit is contained in:
2026-06-21 22:57:32 -04:00
parent f2935fa1e1
commit 70c71161f3
4464 changed files with 825937 additions and 2 deletions
+627
View File
@@ -0,0 +1,627 @@
/* ============================================================
Sovereign Orchestrator — Frontend Application
Pure vanilla JS — no frameworks, no libraries
============================================================ */
const App = (() => {
'use strict';
// ── State ─────────────────────────────────────────────────
const state = {
currentSection: 'dashboard',
systemStatus: null,
builds: [],
isos: [],
vms: {}, // vmid -> status data
pollTimer: null,
activeBuildId: null,
buildPollTimer: null,
};
// ── DOM Refs ──────────────────────────────────────────────
const $ = (sel, ctx = document) => ctx.querySelector(sel);
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
// ── API Helper ────────────────────────────────────────────
async function api(method, path, body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try {
const j = await res.json();
detail = j.detail || j.message || detail;
} catch (_) { /* ignore */ }
throw new Error(detail);
}
return res.json();
}
// ── Toast System ──────────────────────────────────────────
const TOAST_ICONS = {
success: '✅', error: '❌', info: '️', warning: '⚠️',
};
function toast(type, message, duration = 4500) {
const container = $('#toast-container');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerHTML = `
<span class="toast-icon">${TOAST_ICONS[type] || ''}</span>
<span class="toast-message">${escHtml(message)}</span>
`;
container.appendChild(el);
setTimeout(() => {
el.classList.add('leaving');
el.addEventListener('animationend', () => el.remove());
}, duration);
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Navigation ────────────────────────────────────────────
function initNav() {
$$('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
navigateTo(link.dataset.section);
});
});
// Quick action cards
$$('.action-card[data-goto]').forEach(card => {
card.addEventListener('click', () => navigateTo(card.dataset.goto));
});
// Mobile toggle
const toggle = $('#mobile-toggle');
const sidebar = $('#sidebar');
toggle.addEventListener('click', () => sidebar.classList.toggle('open'));
// Close sidebar on mobile when nav clicked
$$('.nav-link').forEach(link => {
link.addEventListener('click', () => sidebar.classList.remove('open'));
});
// Handle hash on load
const hash = location.hash.replace('#', '');
if (hash && $(`[data-section="${hash}"]`)) {
navigateTo(hash);
}
}
function navigateTo(section) {
state.currentSection = section;
// Update nav
$$('.nav-link').forEach(l => l.classList.remove('active'));
const activeLink = $(`.nav-link[data-section="${section}"]`);
if (activeLink) activeLink.classList.add('active');
// Update sections
$$('.section').forEach(s => s.classList.remove('active'));
const activeSection = $(`#section-${section}`);
if (activeSection) {
activeSection.classList.remove('active');
// Force reflow for animation
void activeSection.offsetWidth;
activeSection.classList.add('active');
}
location.hash = section;
// Section-specific loads
if (section === 'deployments') {
loadBuilds();
loadISOs();
} else if (section === 'dashboard') {
refreshAll();
}
}
// ── System Status ─────────────────────────────────────────
async function fetchStatus() {
try {
const data = await api('GET', '/api/status');
state.systemStatus = data;
updateStatusUI(data);
} catch (err) {
updateStatusUI(null, err.message);
}
}
function updateStatusUI(data, error) {
const badge = $('#system-status-badge');
const dashStatus = $('#dash-system-status');
const indicator = $('#dash-status-indicator');
if (error) {
badge.className = 'status-badge error';
badge.querySelector('.status-text').textContent = 'Offline';
dashStatus.textContent = 'Offline';
if (indicator) indicator.innerHTML = '<span class="pulse-dot" style="background:var(--red)"></span>';
return;
}
badge.className = 'status-badge';
badge.querySelector('.status-text').textContent = 'System Online';
dashStatus.textContent = data.status || 'Online';
// Update dashboard counts if available
if (data.iso_count !== undefined) $('#dash-iso-count').textContent = data.iso_count;
if (data.build_count !== undefined) $('#dash-build-count').textContent = data.build_count;
if (data.vm_count !== undefined) $('#dash-vm-count').textContent = data.vm_count;
}
// ── Builds ────────────────────────────────────────────────
async function loadBuilds() {
try {
const data = await api('GET', '/api/builds');
state.builds = Array.isArray(data) ? data : (data.builds || []);
renderBuilds();
renderTimeline();
$('#dash-build-count').textContent = state.builds.length;
} catch (err) {
// Silently handle — builds endpoint may not exist yet
console.warn('Failed to load builds:', err.message);
}
}
function renderBuilds() {
const container = $('#builds-list');
if (!state.builds.length) {
container.innerHTML = `
<div class="empty-state">
<span class="empty-icon">📦</span>
<p>No builds found.</p>
</div>`;
return;
}
container.innerHTML = state.builds.map(b => {
const statusClass = b.status === 'success' || b.status === 'completed' ? 'success'
: b.status === 'error' || b.status === 'failed' ? 'error'
: 'pending';
return `
<div class="build-item" data-build-id="${escHtml(b.id || b.build_id || '')}">
<span class="build-status-dot ${statusClass}"></span>
<div class="build-info">
<div class="build-name">${escHtml(b.fqdn || b.name || b.id || 'Build')}</div>
<div class="build-detail">${escHtml(b.id || b.build_id || '')}</div>
</div>
<span class="build-badge ${statusClass}">${escHtml(b.status || 'unknown')}</span>
</div>`;
}).join('');
// Click to view details
$$('.build-item', container).forEach(item => {
item.addEventListener('click', () => viewBuild(item.dataset.buildId));
});
}
function renderTimeline() {
const container = $('#recent-builds-timeline');
const recent = state.builds.slice(0, 8);
if (!recent.length) {
container.innerHTML = `
<div class="empty-state">
<span class="empty-icon">📦</span>
<p>No builds yet. Generate your first ISO to get started.</p>
</div>`;
return;
}
container.innerHTML = recent.map(b => {
const statusClass = b.status === 'success' || b.status === 'completed' ? 'success'
: b.status === 'error' || b.status === 'failed' ? 'error'
: 'pending';
const timeStr = b.created_at || b.timestamp || '';
return `
<div class="timeline-item">
<div class="timeline-dot ${statusClass}"></div>
<div class="timeline-content">
<div class="timeline-title">${escHtml(b.fqdn || b.name || 'Build')}</div>
<div class="timeline-meta">
<span class="timeline-id">${escHtml(b.id || b.build_id || '')}</span>
${timeStr ? ` · ${escHtml(formatTime(timeStr))}` : ''}
</div>
</div>
</div>`;
}).join('');
}
async function viewBuild(buildId) {
if (!buildId) return;
try {
const data = await api('GET', `/api/builds/${buildId}`);
const logCard = $('#build-log-card');
const logOutput = $('#build-log-output');
logCard.hidden = false;
logOutput.textContent = data.log || data.output || JSON.stringify(data, null, 2);
logOutput.scrollTop = logOutput.scrollHeight;
navigateTo('iso-builder');
} catch (err) {
toast('error', `Failed to load build: ${err.message}`);
}
}
// ── ISOs ──────────────────────────────────────────────────
async function loadISOs() {
try {
const data = await api('GET', '/api/isos');
state.isos = Array.isArray(data) ? data : (data.isos || []);
renderISOSelect();
$('#dash-iso-count').textContent = state.isos.length;
} catch (err) {
console.warn('Failed to load ISOs:', err.message);
}
}
function renderISOSelect() {
const select = $('#deploy-iso');
// Keep the first placeholder option
const placeholder = select.querySelector('option');
select.innerHTML = '';
select.appendChild(placeholder);
state.isos.forEach(iso => {
const opt = document.createElement('option');
const name = typeof iso === 'string' ? iso : (iso.name || iso.filename || iso.id || '');
opt.value = name;
opt.textContent = name;
select.appendChild(opt);
});
}
// ── ISO Builder Form ─────────────────────────────────────
function initISOForm() {
const form = $('#iso-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = $('#iso-submit-btn');
setButtonLoading(btn, true);
const formData = new FormData(form);
const payload = {};
for (const [key, value] of formData.entries()) {
if (key === 'ssh_keys') {
payload[key] = value.split('\n').map(k => k.trim()).filter(Boolean);
} else if (key === 'disk_list') {
payload[key] = value.split(',').map(d => d.trim()).filter(Boolean);
} else {
payload[key] = value;
}
}
try {
const data = await api('POST', '/api/generate-iso', payload);
toast('success', 'ISO generation started!');
// Show build log
const logCard = $('#build-log-card');
const logOutput = $('#build-log-output');
logCard.hidden = false;
logOutput.textContent = '▶ Build initiated…\n';
// If we got a build ID, start polling
const buildId = data.build_id || data.id;
if (buildId) {
state.activeBuildId = buildId;
pollBuildLog(buildId);
} else {
logOutput.textContent += JSON.stringify(data, null, 2);
}
} catch (err) {
toast('error', `ISO generation failed: ${err.message}`);
} finally {
setButtonLoading(btn, false);
}
});
}
async function pollBuildLog(buildId) {
// Clear previous timer
if (state.buildPollTimer) clearInterval(state.buildPollTimer);
const logOutput = $('#build-log-output');
const poll = async () => {
try {
const data = await api('GET', `/api/builds/${buildId}`);
const log = data.log || data.output || '';
if (log) {
logOutput.textContent = log;
logOutput.scrollTop = logOutput.scrollHeight;
}
const status = data.status || '';
if (status === 'success' || status === 'completed') {
toast('success', 'ISO build completed successfully!');
clearInterval(state.buildPollTimer);
state.buildPollTimer = null;
loadBuilds();
} else if (status === 'error' || status === 'failed') {
toast('error', 'ISO build failed.');
clearInterval(state.buildPollTimer);
state.buildPollTimer = null;
loadBuilds();
}
} catch (_) { /* ignore poll errors */ }
};
await poll();
state.buildPollTimer = setInterval(poll, 3000);
}
// ── Deploy Form ───────────────────────────────────────────
function initDeployForm() {
const form = $('#deploy-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = $('#deploy-submit-btn');
setButtonLoading(btn, true);
const formData = new FormData(form);
const payload = {};
for (const [key, value] of formData.entries()) {
if (['vmid', 'cores', 'memory', 'disk_size'].includes(key)) {
payload[key] = parseInt(value, 10);
} else {
payload[key] = value;
}
}
try {
const data = await api('POST', '/api/deploy', payload);
toast('success', `VM ${payload.vmid} deployment started!`);
// Add the VM to watched list
if (payload.vmid) {
addVMCard(payload.vmid);
}
} catch (err) {
toast('error', `Deployment failed: ${err.message}`);
} finally {
setButtonLoading(btn, false);
}
});
}
// ── VM Control ────────────────────────────────────────────
function initVMControl() {
const lookupBtn = $('#vm-lookup-btn');
const lookupInput = $('#vm-lookup-id');
lookupBtn.addEventListener('click', () => {
const vmid = parseInt(lookupInput.value, 10);
if (vmid && vmid >= 100) {
addVMCard(vmid);
lookupInput.value = '';
} else {
toast('warning', 'Enter a valid VM ID (≥ 100)');
}
});
lookupInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
lookupBtn.click();
}
});
}
async function addVMCard(vmid) {
const grid = $('#vm-cards-grid');
// Remove empty state
const empty = grid.querySelector('.empty-state');
if (empty) empty.remove();
// Don't duplicate
if ($(`#vm-card-${vmid}`, grid)) {
fetchVMStatus(vmid);
return;
}
const card = document.createElement('div');
card.className = 'glass-card vm-card';
card.id = `vm-card-${vmid}`;
card.innerHTML = `
<div class="vm-card-header">
<div class="vm-card-title">🖥️ VM <span>${vmid}</span></div>
<div class="vm-status-tag unknown">
<span class="tag-dot"></span>
<span class="tag-label">Loading…</span>
</div>
</div>
<div class="vm-card-body">
<div class="vm-info-grid">
<div class="vm-info-item">
<div class="vm-info-label">Status</div>
<div class="vm-info-value" data-field="status">—</div>
</div>
<div class="vm-info-item">
<div class="vm-info-label">Node</div>
<div class="vm-info-value" data-field="node">—</div>
</div>
<div class="vm-info-item">
<div class="vm-info-label">CPU</div>
<div class="vm-info-value" data-field="cpu">—</div>
</div>
<div class="vm-info-item">
<div class="vm-info-label">Memory</div>
<div class="vm-info-value" data-field="mem">—</div>
</div>
</div>
<div class="vm-card-actions">
<button class="btn btn-success btn-sm" onclick="App.vmAction(${vmid}, 'start')">
<span class="btn-icon">⚡</span> Start
</button>
<button class="btn btn-danger btn-sm" onclick="App.vmAction(${vmid}, 'stop')">
<span class="btn-icon">⏹️</span> Stop
</button>
<button class="btn btn-ghost btn-sm" onclick="App.fetchVMStatus(${vmid})">
<span class="btn-icon">🔄</span> Refresh
</button>
</div>
</div>
`;
grid.appendChild(card);
card.style.animation = 'fadeSlideIn 0.4s ease both';
await fetchVMStatus(vmid);
}
async function fetchVMStatus(vmid) {
const card = $(`#vm-card-${vmid}`);
if (!card) return;
try {
const data = await api('GET', `/api/vm/${vmid}/status`);
state.vms[vmid] = data;
updateVMCard(vmid, data);
} catch (err) {
updateVMCard(vmid, { status: 'unknown', error: err.message });
}
}
function updateVMCard(vmid, data) {
const card = $(`#vm-card-${vmid}`);
if (!card) return;
const status = (data.status || 'unknown').toLowerCase();
const isRunning = status === 'running';
const isStopped = status === 'stopped';
// Status tag
const tag = card.querySelector('.vm-status-tag');
tag.className = `vm-status-tag ${isRunning ? 'running' : isStopped ? 'stopped' : 'unknown'}`;
tag.querySelector('.tag-label').textContent = capitalize(status);
// Info fields
const setField = (name, val) => {
const el = card.querySelector(`[data-field="${name}"]`);
if (el) el.textContent = val;
};
setField('status', capitalize(status));
setField('node', data.node || '—');
setField('cpu', data.cpus || data.cores || '—');
setField('mem', data.maxmem ? formatBytes(data.maxmem) : (data.memory ? `${data.memory} MB` : '—'));
}
async function vmAction(vmid, action) {
const actionPath = action === 'start' ? 'start' : 'stop';
try {
toast('info', `${capitalize(action)}ing VM ${vmid}`);
await api('POST', `/api/vm/${vmid}/${actionPath}`);
toast('success', `VM ${vmid} ${action} command sent!`);
// Refresh after a short delay
setTimeout(() => fetchVMStatus(vmid), 2000);
} catch (err) {
toast('error', `Failed to ${action} VM ${vmid}: ${err.message}`);
}
}
// ── Polling ───────────────────────────────────────────────
function startPolling() {
// Initial fetch
fetchStatus();
loadBuilds();
loadISOs();
// Poll every 5 seconds
state.pollTimer = setInterval(() => {
fetchStatus();
// Refresh VM statuses
for (const vmid of Object.keys(state.vms)) {
fetchVMStatus(vmid);
}
}, 5000);
}
// ── Utility ───────────────────────────────────────────────
function setButtonLoading(btn, loading) {
const text = btn.querySelector('.btn-text');
const spinner = btn.querySelector('.btn-spinner');
if (loading) {
btn.disabled = true;
if (text) text.style.opacity = '0.6';
if (spinner) spinner.hidden = false;
} else {
btn.disabled = false;
if (text) text.style.opacity = '1';
if (spinner) spinner.hidden = true;
}
}
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatTime(ts) {
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
const now = new Date();
const diff = now - d;
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch (_) {
return ts;
}
}
// ── Refresh All ───────────────────────────────────────────
async function refreshAll() {
await Promise.all([
fetchStatus(),
loadBuilds(),
loadISOs(),
]);
toast('info', 'Data refreshed');
}
// ── Init ──────────────────────────────────────────────────
function init() {
initNav();
initISOForm();
initDeployForm();
initVMControl();
startPolling();
}
// Boot
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// ── Public API ────────────────────────────────────────────
return {
refreshAll,
fetchVMStatus,
vmAction,
};
})();
+352
View File
@@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Sovereign Orchestrator — Automated VM provisioning and ISO generation platform by Theta42.">
<meta name="keywords" content="sovereign, orchestrator, VM, ISO, provisioning, automation, Theta42, Proxmox">
<meta name="author" content="Theta42">
<meta name="theme-color" content="#0a0e1a">
<meta property="og:title" content="Sovereign Orchestrator">
<meta property="og:description" content="Automated VM provisioning and ISO generation platform.">
<meta property="og:type" content="website">
<title>Sovereign ✦ Orchestrator</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@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Toast Container -->
<div id="toast-container" class="toast-container"></div>
<!-- Sidebar Navigation -->
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="logo">
<span class="logo-icon"></span>
<div class="logo-text">
<span class="logo-title">Sovereign</span>
<span class="logo-subtitle">Orchestrator</span>
</div>
</div>
</div>
<ul class="nav-list">
<li>
<a href="#dashboard" class="nav-link active" data-section="dashboard">
<span class="nav-icon">📊</span>
<span class="nav-label">Dashboard</span>
</a>
</li>
<li>
<a href="#iso-builder" class="nav-link" data-section="iso-builder">
<span class="nav-icon">💿</span>
<span class="nav-label">ISO Builder</span>
</a>
</li>
<li>
<a href="#deployments" class="nav-link" data-section="deployments">
<span class="nav-icon">🚀</span>
<span class="nav-label">Deployments</span>
</a>
</li>
<li>
<a href="#vm-control" class="nav-link" data-section="vm-control">
<span class="nav-icon">🖥️</span>
<span class="nav-label">VM Control</span>
</a>
</li>
</ul>
<div class="sidebar-footer">
<div class="status-badge" id="system-status-badge">
<span class="pulse-dot"></span>
<span class="status-text">Connecting…</span>
</div>
</div>
</nav>
<!-- Mobile Hamburger -->
<button class="mobile-toggle" id="mobile-toggle" aria-label="Toggle navigation">
<span></span><span></span><span></span>
</button>
<!-- Main Content -->
<main class="main-content" id="main-content">
<!-- ==================== DASHBOARD ==================== -->
<section class="section active" id="section-dashboard">
<div class="section-header">
<h1 class="section-title">Dashboard</h1>
<p class="section-subtitle">System overview and quick actions</p>
</div>
<!-- Status Cards Row -->
<div class="status-cards-grid">
<div class="glass-card status-card">
<div class="status-card-icon blue">📊</div>
<div class="status-card-body">
<span class="status-card-label">System Status</span>
<span class="status-card-value" id="dash-system-status"></span>
</div>
<div class="status-card-indicator" id="dash-status-indicator">
<span class="pulse-dot"></span>
</div>
</div>
<div class="glass-card status-card">
<div class="status-card-icon cyan">💿</div>
<div class="status-card-body">
<span class="status-card-label">ISOs Available</span>
<span class="status-card-value" id="dash-iso-count"></span>
</div>
</div>
<div class="glass-card status-card">
<div class="status-card-icon emerald">🚀</div>
<div class="status-card-body">
<span class="status-card-label">Total Builds</span>
<span class="status-card-value" id="dash-build-count"></span>
</div>
</div>
<div class="glass-card status-card">
<div class="status-card-icon amber"></div>
<div class="status-card-body">
<span class="status-card-label">Active VMs</span>
<span class="status-card-value" id="dash-vm-count"></span>
</div>
</div>
</div>
<!-- Quick Actions -->
<h2 class="sub-heading">Quick Actions</h2>
<div class="quick-actions-grid">
<button class="glass-card action-card" data-goto="iso-builder">
<span class="action-icon">💿</span>
<span class="action-title">Build ISO</span>
<span class="action-desc">Generate a custom auto-installer</span>
</button>
<button class="glass-card action-card" data-goto="deployments">
<span class="action-icon">🚀</span>
<span class="action-title">Deploy VM</span>
<span class="action-desc">Launch a new virtual machine</span>
</button>
<button class="glass-card action-card" data-goto="vm-control">
<span class="action-icon">🖥️</span>
<span class="action-title">Manage VMs</span>
<span class="action-desc">Start, stop, or inspect VMs</span>
</button>
<button class="glass-card action-card" onclick="App.refreshAll()">
<span class="action-icon">🔄</span>
<span class="action-title">Refresh All</span>
<span class="action-desc">Reload status and build data</span>
</button>
</div>
<!-- Recent Builds Timeline -->
<h2 class="sub-heading">Recent Builds</h2>
<div class="glass-card timeline-card">
<div id="recent-builds-timeline" class="timeline">
<div class="empty-state">
<span class="empty-icon">📦</span>
<p>No builds yet. Generate your first ISO to get started.</p>
</div>
</div>
</div>
</section>
<!-- ==================== ISO BUILDER ==================== -->
<section class="section" id="section-iso-builder">
<div class="section-header">
<h1 class="section-title">💿 ISO Builder</h1>
<p class="section-subtitle">Generate a custom auto-installer ISO image</p>
</div>
<form id="iso-form" class="iso-form" autocomplete="off">
<div class="glass-card form-card">
<h3 class="form-group-title">🌐 System Configuration</h3>
<div class="form-grid">
<div class="form-field">
<label for="iso-fqdn">Fully Qualified Domain Name</label>
<input type="text" id="iso-fqdn" name="fqdn" placeholder="host.example.com" required>
<span class="field-hint">The FQDN for the target system</span>
</div>
<div class="form-field">
<label for="iso-keyboard">Keyboard Layout</label>
<input type="text" id="iso-keyboard" name="keyboard_layout" placeholder="us" value="us">
</div>
<div class="form-field">
<label for="iso-country">Country</label>
<input type="text" id="iso-country" name="country" placeholder="US" value="US">
</div>
<div class="form-field">
<label for="iso-timezone">Timezone</label>
<input type="text" id="iso-timezone" name="timezone" placeholder="America/New_York" value="America/New_York">
</div>
</div>
</div>
<div class="glass-card form-card">
<h3 class="form-group-title">🔐 Security</h3>
<div class="form-grid">
<div class="form-field">
<label for="iso-email">Admin Email</label>
<input type="email" id="iso-email" name="email" placeholder="admin@example.com" required>
</div>
<div class="form-field">
<label for="iso-root-password">Root Password</label>
<input type="password" id="iso-root-password" name="root_password" placeholder="••••••••" required>
</div>
<div class="form-field full-width">
<label for="iso-ssh-keys">SSH Authorized Keys</label>
<textarea id="iso-ssh-keys" name="ssh_keys" rows="3" placeholder="ssh-ed25519 AAAA... user@host"></textarea>
<span class="field-hint">One key per line</span>
</div>
</div>
</div>
<div class="glass-card form-card">
<h3 class="form-group-title">💾 Storage &amp; Network</h3>
<div class="form-grid">
<div class="form-field">
<label for="iso-network">Network Source</label>
<select id="iso-network" name="network_source">
<option value="dhcp">DHCP (Automatic)</option>
<option value="static">Static IP</option>
</select>
</div>
<div class="form-field">
<label for="iso-filesystem">Filesystem</label>
<select id="iso-filesystem" name="filesystem">
<option value="ext4">ext4</option>
<option value="zfs">ZFS</option>
<option value="xfs">XFS</option>
<option value="btrfs">Btrfs</option>
</select>
</div>
<div class="form-field full-width">
<label for="iso-disks">Disk List</label>
<input type="text" id="iso-disks" name="disk_list" placeholder="/dev/sda, /dev/sdb">
<span class="field-hint">Comma-separated disk device paths</span>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-lg" id="iso-submit-btn">
<span class="btn-icon">💿</span>
<span class="btn-text">Generate ISO</span>
<span class="btn-spinner" hidden></span>
</button>
<button type="reset" class="btn btn-ghost">Clear Form</button>
</div>
</form>
<!-- Build Log Viewer -->
<div class="glass-card terminal-card" id="build-log-card" hidden>
<div class="terminal-header">
<div class="terminal-dots">
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
</div>
<span class="terminal-title">Build Log</span>
<button class="terminal-close" onclick="document.getElementById('build-log-card').hidden=true"></button>
</div>
<pre class="terminal-body" id="build-log-output"></pre>
</div>
</section>
<!-- ==================== DEPLOYMENTS ==================== -->
<section class="section" id="section-deployments">
<div class="section-header">
<h1 class="section-title">🚀 Deployments</h1>
<p class="section-subtitle">Deploy virtual machines with generated ISOs</p>
</div>
<div class="deploy-layout">
<!-- Deploy Form -->
<div class="glass-card form-card">
<h3 class="form-group-title">⚙️ VM Configuration</h3>
<form id="deploy-form" autocomplete="off">
<div class="form-grid">
<div class="form-field">
<label for="deploy-vmid">VM ID</label>
<input type="number" id="deploy-vmid" name="vmid" placeholder="100" required min="100">
</div>
<div class="form-field">
<label for="deploy-node">Proxmox Node</label>
<input type="text" id="deploy-node" name="node" placeholder="pve" required>
</div>
<div class="form-field">
<label for="deploy-cores">CPU Cores</label>
<input type="number" id="deploy-cores" name="cores" placeholder="2" value="2" min="1" max="128">
</div>
<div class="form-field">
<label for="deploy-memory">Memory (MB)</label>
<input type="number" id="deploy-memory" name="memory" placeholder="2048" value="2048" min="256" step="256">
</div>
<div class="form-field">
<label for="deploy-disk">Disk Size (GB)</label>
<input type="number" id="deploy-disk" name="disk_size" placeholder="32" value="32" min="8">
</div>
<div class="form-field">
<label for="deploy-iso">ISO Image</label>
<select id="deploy-iso" name="iso">
<option value="">Select an ISO…</option>
</select>
<span class="field-hint">Choose a previously built ISO</span>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-lg" id="deploy-submit-btn">
<span class="btn-icon">🚀</span>
<span class="btn-text">Deploy VM</span>
<span class="btn-spinner" hidden></span>
</button>
</div>
</form>
</div>
<!-- Builds List -->
<div class="glass-card">
<h3 class="form-group-title">📋 Build History</h3>
<div id="builds-list" class="builds-list">
<div class="empty-state">
<span class="empty-icon">📦</span>
<p>No builds found.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ==================== VM CONTROL ==================== -->
<section class="section" id="section-vm-control">
<div class="section-header">
<h1 class="section-title">🖥️ VM Control</h1>
<p class="section-subtitle">Manage and monitor your virtual machines</p>
</div>
<!-- VM Lookup -->
<div class="glass-card form-card vm-lookup-card">
<h3 class="form-group-title">🔍 VM Lookup</h3>
<div class="vm-lookup-row">
<div class="form-field">
<input type="number" id="vm-lookup-id" placeholder="Enter VM ID…" min="100">
</div>
<button class="btn btn-primary" id="vm-lookup-btn">
<span class="btn-icon">🔍</span>
<span class="btn-text">Lookup</span>
</button>
</div>
</div>
<!-- VM Cards Grid -->
<div id="vm-cards-grid" class="vm-cards-grid">
<div class="empty-state">
<span class="empty-icon">🖥️</span>
<p>Enter a VM ID above to view its status and controls.</p>
</div>
</div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+1042
View File
File diff suppressed because it is too large Load Diff