70c71161f3
- 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
628 lines
21 KiB
JavaScript
628 lines
21 KiB
JavaScript
/* ============================================================
|
||
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,
|
||
};
|
||
})();
|