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:
+627
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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 & 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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user