Files
sovereign-orchestrator/static/app.js
T
wmantly 70c71161f3 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
2026-06-21 22:57:32 -04:00

628 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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,
};
})();