feat: Implement UVM Dashboard with frontend and backend integration

- Added dashboard.js for frontend functionality, including VM management and system health monitoring.
- Created dashboard route to serve the dashboard HTML.
- Introduced diagnostics route for token-protected host diagnostics.
- Enhanced VMs router with bulk actions, VM log retrieval, and port management.
- Updated lifecycle service to read VM log tails with truncation handling.
- Integrated static file serving for dashboard assets in the FastAPI server.
- Redirect root URL to the dashboard for improved user experience.
This commit is contained in:
2026-09-19 10:50:29 +05:30
parent 3d7af4adb8
commit b3b1b527e1
17 changed files with 2285 additions and 69 deletions
+1
View File
@@ -0,0 +1 @@
# UVM Dashboard static assets package.
+470
View File
@@ -0,0 +1,470 @@
/* ─── Design Tokens ─── */
:root {
--bg-body: #0b0b0f;
--bg-sidebar: #0f0f14;
--bg-card: #141419;
--bg-card-hover: #1a1a22;
--bg-input: #0e0e12;
--bg-elevated: #1c1c24;
--border-color: #1f1f28;
--border-hover: #2a2a36;
--divider: #1a1a22;
--text-primary: #f0f0f5;
--text-secondary: #8a8a98;
--text-muted: #5a5a68;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-soft: rgba(99, 102, 241, 0.12);
--green: #22c55e;
--green-soft: rgba(34, 197, 94, 0.12);
--red: #ef4444;
--red-soft: rgba(239, 68, 68, 0.12);
--amber: #f59e0b;
--amber-soft: rgba(245, 158, 11, 0.12);
--blue: #3b82f6;
--blue-soft: rgba(59, 130, 246, 0.12);
--purple: #a855f7;
--purple-soft: rgba(168, 85, 247, 0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.6);
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ─── Reset ─── */
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--font-sans);
background: var(--bg-body);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─── Layout ─── */
#app { min-height: 100vh; }
.screen { display: none; min-height: 100vh; }
.screen.active { display: flex; }
#main-app { flex-direction: row; }
.sidebar {
width: 260px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 24px 16px;
position: fixed;
inset: 0 auto 0 0;
z-index: 50;
}
.sidebar-brand { margin-bottom: 32px; padding: 0 8px; }
.sidebar-brand .logo {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
font-size: 18px;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.sidebar-brand .logo svg { color: var(--text-primary); }
.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.nav-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
font-size: 14px; font-weight: 500;
transition: background var(--transition), color var(--transition);
}
.nav-item:hover { background: var(--bg-card-hover); color: var(--text-primary); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
.nav-item svg { opacity: 0.8; }
.sidebar-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--divider);
display: flex;
flex-direction: column;
gap: 12px;
}
.connection-status {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--text-muted);
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--green);
box-shadow: 0 0 6px var(--green);
}
.status-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); }
.main {
flex: 1;
margin-left: 260px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
position: sticky; top: 0; z-index: 40;
background: rgba(11,11,15,0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
padding: 16px 32px;
display: flex; align-items: center; justify-content: space-between;
}
.topbar-left h2 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.content { padding: 28px 32px; flex: 1; }
/* ─── Pages ─── */
.page { display: none; animation: fadeIn 220ms ease; }
.page.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* ─── Cards ─── */
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
transition: border-color var(--transition), box-shadow var(--transition);
}
.card:hover { border-color: var(--border-hover); }
.card-title { margin: 0 0 16px; font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.card-heading { margin: 0 0 20px; font-size: 18px; font-weight: 600; }
.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.card-title-row .card-title { margin: 0; }
/* ─── Stats ─── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 28px;
}
.stats-grid.two { grid-template-columns: repeat(2, 1fr); }
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
}
.stat-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.stat-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.stat-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
}
.icon-blue { background: var(--blue-soft); color: var(--blue); }
.icon-green { background: var(--green-soft); color: var(--green); }
.icon-amber { background: var(--amber-soft); color: var(--amber); }
.icon-purple { background: var(--purple-soft); color: var(--purple); }
.stat-value { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
.stat-meta { font-size: 12px; color: var(--text-muted); margin-top: 8px; }
.text-green { color: var(--green); }
.text-amber { color: var(--amber); }
.text-red { color: var(--red); }
/* ─── Buttons ─── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
border: 1px solid transparent; border-radius: var(--radius-md);
padding: 8px 14px; font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: background var(--transition), border-color var(--transition), transform 80ms ease;
background: transparent; color: var(--text-primary);
}
.btn:active { transform: scale(0.98); }
.btn-primary { background: var(--text-primary); color: var(--bg-body); border-color: var(--text-primary); }
.btn-primary:hover { background: #e2e2e8; border-color: #e2e2e8; }
.btn-ghost { border-color: var(--border-color); color: var(--text-secondary); }
.btn-ghost:hover { background: var(--bg-card-hover); color: var(--text-primary); }
.btn-danger { background: var(--red-soft); color: var(--red); border-color: rgba(239,68,68,0.25); }
.btn-danger:hover { background: rgba(239,68,68,0.2); }
.btn-warning { background: var(--amber-soft); color: var(--amber); border-color: rgba(245,158,11,0.25); }
.btn-warning:hover { background: rgba(245,158,11,0.2); }
.btn-sm { padding: 6px 10px; font-size: 12px; }
.btn-lg { padding: 10px 18px; font-size: 14px; }
.btn-icon { padding: 8px; }
/* ─── Tables ─── */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table thead th {
text-align: left; padding: 12px 16px;
color: var(--text-secondary); font-weight: 500; font-size: 12px;
border-bottom: 1px solid var(--divider);
white-space: nowrap;
}
.data-table tbody td { padding: 14px 16px; border-bottom: 1px solid var(--divider); vertical-align: middle; }
.data-table tbody tr { transition: background var(--transition); }
.data-table tbody tr:hover { background: var(--bg-card-hover); }
.data-table .actions { text-align: right; white-space: nowrap; }
.data-table .selection { width: 36px; padding-right: 0; text-align: center; }
.data-table input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; }
.table-empty { display: none; padding: 40px 0; }
.table-empty.visible { display: block; }
/* ─── Badges ─── */
.badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
}
.badge-running { background: var(--green-soft); color: var(--green); }
.badge-stopped { background: var(--red-soft); color: var(--red); }
.badge-unknown { background: var(--bg-elevated); color: var(--text-muted); }
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: var(--radius-md); font-size: 13px; font-weight: 600;
}
.badge-loading { background: var(--bg-elevated); color: var(--text-muted); }
.badge-ok { background: var(--green-soft); color: var(--green); }
.badge-error { background: var(--red-soft); color: var(--red); }
/* ─── VM Grid / List ─── */
.vm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.vm-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 18px;
cursor: pointer;
transition: border-color var(--transition), transform var(--transition);
}
.vm-card:hover { border-color: var(--border-hover); transform: translateY(-2px); }
.vm-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.vm-card-name { font-weight: 600; font-size: 14px; }
.vm-card-ip { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.vm-card-meta { display: flex; gap: 12px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); }
.vm-card-meta span { display: flex; align-items: center; gap: 4px; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.section-header h3 { margin: 0; font-size: 16px; font-weight: 600; }
.link { color: var(--accent); text-decoration: none; font-size: 13px; font-weight: 500; }
.link:hover { text-decoration: underline; }
/* ─── Toolbar ─── */
.card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.search-box {
display: flex; align-items: center; gap: 10px;
background: var(--bg-input); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 0 12px; flex: 1; max-width: 360px;
}
.search-box input {
background: transparent; border: none; outline: none; color: var(--text-primary);
font: inherit; font-size: 13px; padding: 9px 0; width: 100%;
}
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
.select {
background: var(--bg-input); border: 1px solid var(--border-color);
border-radius: var(--radius-md); color: var(--text-primary);
font: inherit; font-size: 13px; padding: 8px 28px 8px 12px;
appearance: none; cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8a98' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center;
}
.toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.bulk-controls { display: none; align-items: center; gap: 8px; }
.bulk-controls.visible { display: flex; }
.bulk-controls span { color: var(--text-secondary); font-size: 12px; white-space: nowrap; }
/* ─── Forms ─── */
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field.full { grid-column: 1 / -1; }
.field label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.03em; }
.field input, .field select {
background: var(--bg-input); border: 1px solid var(--border-color);
border-radius: var(--radius-md); color: var(--text-primary);
font: inherit; font-size: 13px; padding: 10px 12px;
transition: border-color var(--transition);
}
.field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
.hint-inline { color: var(--text-muted); font-weight: 400; text-transform: none; letter-spacing: 0; }
.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 24px; }
.checkbox-field { flex-direction: row; align-items: center; gap: 10px; }
.checkbox { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-secondary); }
.checkbox input { position: absolute; opacity: 0; width: 0; height: 0; }
.checkmark {
width: 18px; height: 18px; border-radius: 5px;
border: 1px solid var(--border-hover); background: var(--bg-input);
display: flex; align-items: center; justify-content: center;
transition: background var(--transition), border-color var(--transition);
}
.checkbox input:checked + .checkmark { background: var(--accent); border-color: var(--accent); }
.checkmark::after {
content: ''; width: 5px; height: 9px; border: solid white;
border-width: 0 2px 2px 0; transform: rotate(45deg);
opacity: 0; transition: opacity var(--transition);
}
.checkbox input:checked + .checkmark::after { opacity: 1; }
/* ─── Auth Screen ─── */
#auth-screen { align-items: center; justify-content: center; padding: 24px; }
.auth-card {
width: 100%; max-width: 420px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
padding: 36px;
box-shadow: var(--shadow-lg);
}
.auth-header { text-align: center; margin-bottom: 28px; }
.auth-header h1 { margin: 16px 0 6px; font-size: 22px; font-weight: 700; }
.auth-header p { margin: 0; color: var(--text-secondary); font-size: 14px; }
.auth-form { display: flex; flex-direction: column; gap: 16px; }
.error-text { color: var(--red); font-size: 12px; margin: 0; min-height: 16px; }
.error-block { color: var(--red); font-size: 12px; background: var(--red-soft); padding: 12px; border-radius: var(--radius-md); border: 1px solid rgba(239,68,68,0.2); margin-top: 12px; white-space: pre-wrap; }
.error-block.hidden { display: none; }
/* ─── VM Detail ─── */
.vm-detail-header { margin-bottom: 20px; }
.back-btn { margin-bottom: 12px; }
.vm-detail-title { display: flex; align-items: center; gap: 12px; }
.vm-detail-title h2 { margin: 0; font-size: 22px; font-weight: 700; }
.detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.detail-card.wide { grid-column: 1 / -1; }
.detail-rows { display: flex; flex-direction: column; gap: 12px; }
.detail-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--divider); }
.detail-row:last-child { border-bottom: none; }
.detail-key { color: var(--text-secondary); }
.detail-value { color: var(--text-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.port-list { display: flex; flex-wrap: wrap; gap: 8px; }
.port-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 10px; border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border-color);
font-size: 12px; font-weight: 500;
}
.port-editor { display: none; margin-top: 12px; }
.port-editor-input {
width: 100%; padding: 10px 12px; border: 1px solid var(--border-color);
border-radius: var(--radius-md); background: var(--bg-input); color: var(--text-primary);
font: inherit; font-size: 13px;
}
.port-editor-input:focus { outline: none; border-color: var(--accent); }
.port-editor-actions, .port-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.action-bar { display: flex; gap: 10px; flex-wrap: wrap; }
.hint { font-size: 12px; color: var(--text-muted); margin-top: 10px; }
.muted { color: var(--text-muted); }
.ssh-command {
display: block; margin-bottom: 12px; padding: 10px;
border: 1px solid var(--border-color); border-radius: var(--radius-md);
background: var(--bg-input); color: #c4c4d2; overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
/* ─── System ─── */
.system-card .card-title { margin-bottom: 12px; }
.system-status { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.system-actions { display: flex; align-items: center; gap: 14px; margin-top: 12px; }
.code-block {
background: var(--bg-input); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px; color: var(--text-secondary); margin-top: 12px; overflow: auto;
}
.log-output {
max-height: 320px; overflow: auto; margin: 0;
padding: 14px; border: 1px solid var(--border-color); border-radius: var(--radius-md);
background: var(--bg-input); color: #b8b8c5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word;
}
.diagnostics-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.diagnostic-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md);
background: var(--bg-input);
}
.diagnostic-mark { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; margin-top: 5px; }
.diagnostic-mark.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
.diagnostic-mark.error { background: var(--red); box-shadow: 0 0 6px var(--red); }
.diagnostic-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.diagnostic-message { margin-top: 2px; font-size: 11px; color: var(--text-muted); overflow-wrap: anywhere; }
/* ─── Empty State ─── */
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted); }
.empty-state p { margin: 0 0 12px; }
/* ─── Modal ─── */
.modal { display: none; position: fixed; inset: 0; z-index: 100; align-items: center; justify-content: center; }
.modal.open { display: flex; }
.modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); }
.modal-card {
position: relative; z-index: 1;
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-xl); padding: 28px; width: 100%; max-width: 400px;
box-shadow: var(--shadow-lg);
}
.modal-card h3 { margin: 0 0 8px; font-size: 18px; }
.modal-card p { margin: 0 0 20px; color: var(--text-secondary); font-size: 13px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
/* ─── Toasts ─── */
#toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 10px; }
.toast {
background: var(--bg-card); border: 1px solid var(--border-color);
border-radius: var(--radius-md); padding: 12px 16px;
box-shadow: var(--shadow-md); font-size: 13px;
animation: slideIn 200ms ease; min-width: 260px;
display: flex; align-items: center; gap: 10px;
}
.toast.success { border-color: rgba(34,197,94,0.35); color: var(--green); }
.toast.error { border-color: rgba(239,68,68,0.35); color: var(--red); }
@keyframes slideIn { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: translateX(0); } }
/* ─── Responsive ─── */
@media (max-width: 1024px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.detail-grid { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
.main { margin-left: 0; }
#main-app { flex-direction: column; }
.stats-grid { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
.content { padding: 20px; }
.topbar { padding: 12px 20px; }
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2a36; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #3a3a48; }
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UVM Dashboard</title>
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css" />
</head>
<body>
<div id="app">
<!-- Auth Screen -->
<div id="auth-screen" class="screen active">
<div class="auth-card">
<div class="auth-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="currentColor" />
<path d="M9 11h14M9 16h10M9 21h6" stroke="#0b0b0f" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>UVM</span>
</div>
<h1>Welcome back</h1>
<p>Enter your management API token to access the dashboard.</p>
</div>
<div class="auth-form">
<div class="field">
<label for="token-input">API Token</label>
<input id="token-input" type="password" placeholder="Paste your UVM_API_TOKEN here" autocomplete="off" />
</div>
<button id="auth-btn" class="btn btn-primary btn-lg">Connect</button>
<p id="auth-error" class="error-text"></p>
</div>
</div>
</div>
<!-- Main App -->
<div id="main-app" class="screen">
<aside class="sidebar">
<div class="sidebar-brand">
<div class="logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="currentColor" />
<path d="M9 11h14M9 16h10M9 21h6" stroke="#0b0b0f" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>UVM</span>
</div>
</div>
<nav class="sidebar-nav">
<a href="#overview" class="nav-item active" data-page="overview">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>Overview</span>
</a>
<a href="#vms" class="nav-item" data-page="vms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<span>Virtual Machines</span>
</a>
<a href="#system" class="nav-item" data-page="system">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span>System</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="connection-status">
<span class="status-dot" id="conn-dot"></span>
<span class="status-label" id="conn-label">Connected</span>
</div>
<button id="logout-btn" class="btn btn-ghost btn-sm">Disconnect</button>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="topbar-left">
<h2 id="page-title">Overview</h2>
</div>
<div class="topbar-right">
<button id="refresh-btn" class="btn btn-ghost btn-icon" title="Refresh">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0118.8-4.3M22 12.5a10 10 0 01-18.8 4.3"/></svg>
</button>
<button id="create-vm-btn" class="btn btn-primary">+ New VM</button>
</div>
</header>
<div class="content">
<!-- Overview Page -->
<section id="page-overview" class="page active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Total VMs</span>
<div class="stat-icon icon-blue">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
</div>
<div class="stat-value" id="stat-total">—</div>
<div class="stat-meta">registered VMs</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Running</span>
<div class="stat-icon icon-green">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
</div>
<div class="stat-value text-green" id="stat-running">—</div>
<div class="stat-meta">active microVMs</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Stopped</span>
<div class="stat-icon icon-amber">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</div>
</div>
<div class="stat-value text-amber" id="stat-stopped">—</div>
<div class="stat-meta">inactive microVMs</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">API Health</span>
<div class="stat-icon icon-purple">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
</div>
<div class="stat-value" id="stat-health">—</div>
<div class="stat-meta">last check <span id="health-ago">—</span></div>
</div>
</div>
<div class="section-header">
<h3>Recent Virtual Machines</h3>
<a href="#vms" class="link">View all</a>
</div>
<div class="vm-grid" id="overview-vm-list">
<div class="empty-state">
<p>No virtual machines yet.</p>
<button class="btn btn-primary btn-sm" onclick="location.hash='#create'">Create your first VM</button>
</div>
</div>
</section>
<!-- VMs Page -->
<section id="page-vms" class="page">
<div class="card">
<div class="card-toolbar">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="vm-search" type="text" placeholder="Search by name, ID, or IP..." />
</div>
<div class="toolbar-actions">
<div id="bulk-controls" class="bulk-controls">
<span id="bulk-selection-count">0 selected</span>
<select id="bulk-action" class="select">
<option value="start">Start</option>
<option value="restart">Restart</option>
<option value="stop">Stop</option>
<option value="ports-sync">Sync ports</option>
<option value="delete">Delete</option>
</select>
<button id="bulk-run-btn" class="btn btn-ghost btn-sm">Run</button>
</div>
<select id="vm-filter-status" class="select">
<option value="all">All statuses</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
</div>
</div>
<div class="table-wrap">
<table class="data-table" id="vms-table">
<thead>
<tr>
<th class="selection"><input id="vm-select-all" type="checkbox" aria-label="Select all visible VMs" /></th>
<th>VM</th>
<th>Status</th>
<th>Guest IP</th>
<th>Resources</th>
<th>Ports</th>
<th>Policy</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table-empty" id="vms-empty">
<div class="empty-state">
<p>No virtual machines found.</p>
<button class="btn btn-primary btn-sm" onclick="location.hash='#create'">Create VM</button>
</div>
</div>
</div>
</section>
<!-- VM Detail Page -->
<section id="page-vm-detail" class="page">
<div class="vm-detail-header">
<button class="btn btn-ghost btn-sm back-btn" onclick="history.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back
</button>
<div class="vm-detail-title">
<h2 id="detail-name">—</h2>
<span class="badge" id="detail-status">—</span>
</div>
</div>
<div class="detail-grid">
<div class="card detail-card">
<h4 class="card-title">Configuration</h4>
<div class="detail-rows" id="detail-config">
</div>
</div>
<div class="card detail-card">
<h4 class="card-title">Networking</h4>
<div class="detail-rows" id="detail-network">
</div>
</div>
<div class="card detail-card">
<h4 class="card-title">SSH Access</h4>
<code id="detail-ssh-command" class="ssh-command">ssh root@10.42.0.2</code>
<button id="detail-ssh-copy-btn" class="btn btn-ghost btn-sm">Copy Command</button>
<p id="detail-ssh-hint" class="hint">Run this from a trusted local terminal.</p>
</div>
<div class="card detail-card wide">
<h4 class="card-title">Port Rules</h4>
<div class="port-list" id="detail-ports">
<p class="muted">No port rules configured.</p>
</div>
<div id="detail-ports-edit" class="port-editor">
<input id="detail-ports-input" class="port-editor-input" type="text" placeholder="e.g. 22:TCP,80:HTTP,443:HTTPS" />
<div class="port-editor-actions">
<button id="detail-ports-set" class="btn btn-primary btn-sm">Set</button>
<button id="detail-ports-open" class="btn btn-ghost btn-sm">Open</button>
<button id="detail-ports-close" class="btn btn-ghost btn-sm">Close</button>
<button id="detail-ports-cancel" class="btn btn-ghost btn-sm">Cancel</button>
</div>
</div>
<div class="port-actions">
<button id="detail-ports-edit-btn" class="btn btn-ghost btn-sm">Edit Ports</button>
<button id="detail-ports-sync-btn" class="btn btn-ghost btn-sm">Sync Ports</button>
</div>
</div>
<div class="card detail-card wide">
<div class="card-title-row">
<h4 class="card-title">Firecracker Log</h4>
<button id="detail-log-refresh-btn" class="btn btn-ghost btn-sm">Refresh Log</button>
</div>
<pre id="detail-log-output" class="log-output">Loading recent log entries…</pre>
<p id="detail-log-meta" class="hint">Showing up to 100 recent lines.</p>
</div>
<div class="card detail-card wide">
<h4 class="card-title">Actions</h4>
<div class="action-bar">
<button id="detail-start-btn" class="btn btn-primary">Start VM</button>
<button id="detail-restart-btn" class="btn btn-warning">Restart VM</button>
<button id="detail-stop-btn" class="btn btn-warning">Stop VM</button>
<button id="detail-delete-btn" class="btn btn-danger">Delete VM</button>
</div>
<p class="hint">Use the copied SSH command from a trusted local terminal.</p>
</div>
</div>
</section>
<!-- Create VM Page -->
<section id="page-create" class="page">
<div class="card create-card">
<h3 class="card-heading">Create Virtual Machine</h3>
<div class="form-grid">
<div class="field">
<label for="create-name">Name</label>
<input id="create-name" type="text" placeholder="e.g. web-server" />
</div>
<div class="field">
<label for="create-cpu">vCPUs</label>
<input id="create-cpu" type="number" min="1" value="1" />
</div>
<div class="field">
<label for="create-ram">RAM</label>
<input id="create-ram" type="text" placeholder="e.g. 512M or 1G" value="512M" />
</div>
<div class="field">
<label for="create-guest-ip">Guest IP <span class="hint-inline">(optional)</span></label>
<input id="create-guest-ip" type="text" placeholder="10.42.0.10" />
</div>
<div class="field">
<label for="create-username">Username</label>
<input id="create-username" type="text" placeholder="root" value="root" />
</div>
<div class="field">
<label for="create-password">Password</label>
<input id="create-password" type="password" placeholder="root" value="root" />
</div>
<div class="field full">
<label for="create-ports">Ports <span class="hint-inline">comma-separated, e.g. 22,80:HTTP,443:HTTPS</span></label>
<input id="create-ports" type="text" placeholder="22:TCP,80:HTTP,443:HTTPS" value="22:TCP" />
</div>
<div class="field full checkbox-field">
<label class="checkbox">
<input id="create-no-default-ports" type="checkbox" />
<span class="checkmark"></span>
<span class="checkbox-label">Do not add default SSH port (22)</span>
</label>
</div>
</div>
<div class="form-actions">
<button id="create-submit" class="btn btn-primary btn-lg">Create VM</button>
<button id="create-cancel" class="btn btn-ghost">Cancel</button>
</div>
<pre id="create-error" class="error-block"></pre>
</div>
</section>
<!-- System Page -->
<section id="page-system" class="page">
<div class="stats-grid two">
<div class="card system-card">
<h4 class="card-title">API Health</h4>
<div class="system-status" id="system-health">
<span class="status-badge badge-loading">Checking…</span>
<p class="muted">Send a test request to the management API.</p>
</div>
</div>
<div class="card system-card">
<h4 class="card-title">Installation</h4>
<p class="muted">Install or refresh host assets (Firecracker, kernel, rootfs). This is a privileged operation.</p>
<div class="system-actions">
<button id="install-btn" class="btn btn-primary">Install / Refresh</button>
<label class="checkbox inline">
<input id="install-force" type="checkbox" />
<span class="checkmark"></span>
<span class="checkbox-label">Force redownload</span>
</label>
</div>
<pre id="install-result" class="code-block hidden"></pre>
</div>
</div>
<div class="card wide">
<h4 class="card-title">Server Information</h4>
<div class="detail-rows" id="server-info">
<div class="detail-row"><span class="detail-key">Dashboard Version</span><span class="detail-value">1.0.0</span></div>
<div class="detail-row"><span class="detail-key">API URL</span><span class="detail-value" id="info-api-url">—</span></div>
<div class="detail-row"><span class="detail-key">Observed At</span><span class="detail-value" id="info-observed">—</span></div>
</div>
</div>
<div class="card wide">
<div class="card-title-row">
<h4 class="card-title">Host Diagnostics</h4>
<button id="diagnostics-refresh-btn" class="btn btn-ghost btn-sm">Run Checks</button>
</div>
<div id="diagnostics-list" class="diagnostics-list">
<p class="muted">Open System to run host readiness checks.</p>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Toast Container -->
<div id="toast-container"></div>
<!-- Confirm Modal -->
<div id="confirm-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-card">
<h3 id="confirm-title">Are you sure?</h3>
<p id="confirm-body">This action cannot be undone.</p>
<div class="modal-actions">
<button id="confirm-cancel" class="btn btn-ghost">Cancel</button>
<button id="confirm-ok" class="btn btn-danger">Confirm</button>
</div>
</div>
</div>
</div>
<script src="/dashboard/static/js/dashboard.js"></script>
</body>
</html>
+739
View File
@@ -0,0 +1,739 @@
/* ─── UVM Dashboard ─── */
(function () {
'use strict';
// ─ State ─
const state = {
token: sessionStorage.getItem('uvm_token') || '',
baseUrl: location.origin,
vms: [],
health: null,
diagnostics: null,
selectedVmIds: new Set(),
currentPage: 'overview',
currentVmId: null,
refreshTimer: null,
};
// ─ Helpers ─
const $ = (sel, el=document) => el.querySelector(sel);
const $$ = (sel, el=document) => [...el.querySelectorAll(sel)];
function formatRam(mib) {
const value = Number(mib);
if (!Number.isFinite(value)) return '—';
return value % 1024 === 0 ? `${value / 1024} GiB` : `${value} MiB`;
}
function statusClass(status) {
if (status === 'running') return 'running';
if (status === 'stopped') return 'stopped';
return 'unknown';
}
function showScreen(id) {
$$('.screen').forEach(s => s.classList.remove('active'));
$(id).classList.add('active');
}
function showPage(name) {
state.currentPage = name;
$$('.page').forEach(p => p.classList.remove('active'));
const page = $('#page-' + name);
if (page) page.classList.add('active');
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === name));
const titles = {
overview: 'Overview',
vms: 'Virtual Machines',
'vm-detail': 'VM Details',
create: 'Create VM',
system: 'System',
};
$('#page-title').textContent = titles[name] || name;
}
function toast(message, type='success') {
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = message;
$('#toast-container').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
async function api(path, opts={}) {
const url = (state.baseUrl || '').replace(/\/$/, '') + path;
const res = await fetch(url, {
...opts,
headers: {
'Content-Type': 'application/json',
'X-UVM-Token': state.token,
...(opts.headers || {}),
},
});
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch {}
if (!res.ok) {
const msg = data?.error?.message || `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
// ─ Auth ─
function initAuth() {
if (state.token) {
showScreen('#main-app');
showPage('overview');
startRefresh();
return;
}
showScreen('#auth-screen');
}
$('#auth-btn').addEventListener('click', async () => {
const token = $('#token-input').value.trim();
if (!token) { $('#auth-error').textContent = 'Token is required.'; return; }
state.token = token;
try {
// Health is intentionally public, so verify the supplied token on a protected route.
state.vms = await api('/vms') || [];
sessionStorage.setItem('uvm_token', token);
$('#token-input').value = '';
$('#auth-error').textContent = '';
showScreen('#main-app');
showPage('overview');
startRefresh();
toast('Connected successfully');
} catch (e) {
$('#auth-error').textContent = e.message || 'Connection failed.';
state.token = '';
}
});
$('#logout-btn').addEventListener('click', () => {
state.token = '';
$('#token-input').value = '';
sessionStorage.removeItem('uvm_token');
localStorage.removeItem('uvm_token');
localStorage.removeItem('uvm_baseUrl');
stopRefresh();
showScreen('#auth-screen');
});
// ─ Data Loading ─
async function loadVms() {
try {
state.vms = await api('/vms') || [];
const availableIds = new Set(state.vms.map(vm => vm.id));
state.selectedVmIds = new Set(
[...state.selectedVmIds].filter(id => availableIds.has(id)),
);
renderOverviewStats();
renderOverviewVMs();
renderVMsTable();
if (state.currentPage === 'vm-detail' && state.currentVmId) {
renderVMDetail(state.currentVmId);
}
updateConnStatus(true);
} catch (e) {
updateConnStatus(false);
console.error('loadVms failed', e);
}
}
async function loadHealth() {
try {
state.health = await api('/health');
const el = $('#stat-health');
el.textContent = state.health?.status || 'ok';
el.className = 'stat-value text-green';
$('#health-ago').textContent = 'just now';
renderSystemHealth();
} catch (e) {
$('#stat-health').textContent = 'unreachable';
$('#stat-health').className = 'stat-value text-red';
$('#health-ago').textContent = '—';
renderSystemHealth();
}
}
async function loadDiagnostics() {
const list = $('#diagnostics-list');
list.innerHTML = '<p class="muted">Running host readiness checks…</p>';
try {
state.diagnostics = await api('/diagnostics');
renderDiagnostics();
} catch (e) {
state.diagnostics = null;
list.innerHTML = `<p class="muted">Diagnostics unavailable: ${esc(e.message)}</p>`;
}
}
function renderDiagnostics() {
const list = $('#diagnostics-list');
if (!state.diagnostics?.length) {
list.innerHTML = '<p class="muted">No diagnostic checks returned.</p>';
return;
}
list.innerHTML = state.diagnostics.map((check) => `
<div class="diagnostic-item">
<span class="diagnostic-mark ${check.ok ? 'ok' : 'error'}"></span>
<div>
<div class="diagnostic-name">${esc(check.name)}</div>
<div class="diagnostic-message">${esc(check.message)}</div>
</div>
</div>
`).join('');
}
function updateConnStatus(ok) {
const dot = $('#conn-dot');
const label = $('#conn-label');
dot.classList.toggle('offline', !ok);
label.textContent = ok ? 'Connected' : 'Disconnected';
}
function renderOverviewStats() {
const total = state.vms.length;
const running = state.vms.filter(v => v.status === 'running').length;
const stopped = state.vms.filter(v => v.status !== 'running').length;
$('#stat-total').textContent = total;
$('#stat-running').textContent = running;
$('#stat-stopped').textContent = stopped;
}
function renderOverviewVMs() {
const container = $('#overview-vm-list');
if (!state.vms.length) {
container.innerHTML = `
<div class="empty-state">
<p>No virtual machines yet.</p>
<button class="btn btn-primary btn-sm" onclick="location.hash='#create'">Create your first VM</button>
</div>`;
return;
}
const recent = state.vms.slice(0, 6);
container.innerHTML = recent.map(vm => `
<div class="vm-card" onclick="location.hash='#vm/${vm.id}'">
<div class="vm-card-header">
<div>
<div class="vm-card-name">${esc(vm.name || vm.id)}</div>
<div class="vm-card-ip">${esc(vm.guest_ip || '—')}</div>
</div>
<span class="badge badge-${statusClass(vm.status)}">${esc(vm.status || 'unknown')}</span>
</div>
<div class="vm-card-meta">
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/></svg> ${vm.cpu || 1} vCPU</span>
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/></svg> ${formatRam(vm.ram_mib)}</span>
</div>
</div>
`).join('');
}
function renderVMsTable() {
const tbody = $('#vms-table tbody');
const empty = $('#vms-empty');
const selectAll = $('#vm-select-all');
const filterStatus = $('#vm-filter-status').value;
const search = $('#vm-search').value.trim().toLowerCase();
let list = state.vms.slice();
if (filterStatus !== 'all') {
list = list.filter(v => v.status === filterStatus);
}
if (search) {
list = list.filter(v =>
(v.name || '').toLowerCase().includes(search) ||
(v.id || '').toLowerCase().includes(search) ||
(v.guest_ip || '').includes(search)
);
}
if (!list.length) {
tbody.innerHTML = '';
empty.classList.add('visible');
selectAll.checked = false;
selectAll.indeterminate = false;
renderBulkControls();
return;
}
empty.classList.remove('visible');
tbody.innerHTML = list.map(vm => {
const ports = (vm.ports || []).map(p => `<span class="port-chip">${p.port}:${p.protocol}</span>`).join(' ') || '—';
const actionBtns = [];
if (vm.status === 'running') {
actionBtns.push(`<button class="btn btn-ghost btn-sm" data-vm-action="restart" data-vm-id="${esc(vm.id)}">Restart</button>`);
actionBtns.push(`<button class="btn btn-warning btn-sm" data-vm-action="stop" data-vm-id="${esc(vm.id)}">Stop</button>`);
} else {
actionBtns.push(`<button class="btn btn-primary btn-sm" data-vm-action="start" data-vm-id="${esc(vm.id)}">Start</button>`);
}
return `
<tr>
<td class="selection"><input type="checkbox" data-vm-select="${esc(vm.id)}" aria-label="Select ${esc(vm.name || vm.id)}" ${state.selectedVmIds.has(vm.id) ? 'checked' : ''} /></td>
<td>
<div style="font-weight:600;font-size:13px;">${esc(vm.name || '—')}</div>
<div style="font-size:11px;color:var(--text-muted);font-family:monospace;">${esc(vm.id)}</div>
</td>
<td><span class="badge badge-${statusClass(vm.status)}">${esc(vm.status || 'unknown')}</span></td>
<td style="font-family:monospace;font-size:12px;">${esc(vm.guest_ip || '—')}</td>
<td>${vm.cpu || 1} vCPU · ${formatRam(vm.ram_mib)}</td>
<td>${ports}</td>
<td><span class="badge badge-${vm.policy_status === 'enforced' ? 'running' : vm.policy_status === 'inactive' ? 'stopped' : 'unknown'}">${esc(vm.policy_status || '—')}</span></td>
<td class="actions">
<button class="btn btn-ghost btn-sm" data-vm-action="view" data-vm-id="${esc(vm.id)}">View</button>
${actionBtns.join('')}
<button class="btn btn-danger btn-sm" data-vm-action="delete" data-vm-id="${esc(vm.id)}">Delete</button>
</td>
</tr>
`;
}).join('');
const selectedVisible = list.filter(vm => state.selectedVmIds.has(vm.id)).length;
selectAll.checked = selectedVisible === list.length;
selectAll.indeterminate = selectedVisible > 0 && selectedVisible < list.length;
renderBulkControls();
}
function renderBulkControls() {
const count = state.selectedVmIds.size;
$('#bulk-controls').classList.toggle('visible', count > 0);
$('#bulk-selection-count').textContent = `${count} selected`;
}
// ─ VM Details ─
async function renderVMDetail(id) {
let vm = state.vms.find(v => v.id === id || v.name === id || v.guest_ip === id);
if (!vm) {
try { vm = await api(`/vms/${id}`); } catch (e) {
toast('Failed to load VM details: ' + e.message, 'error');
location.hash = '#vms';
return;
}
}
state.currentVmId = vm.id;
$('#detail-name').textContent = vm.name || vm.id;
$('#detail-status').textContent = vm.status || 'unknown';
$('#detail-status').className = 'badge badge-' + statusClass(vm.status);
$('#detail-config').innerHTML = `
<div class="detail-row"><span class="detail-key">ID</span><span class="detail-value">${esc(vm.id)}</span></div>
<div class="detail-row"><span class="detail-key">Name</span><span class="detail-value">${esc(vm.name || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Status</span><span class="detail-value">${esc(vm.status)}</span></div>
<div class="detail-row"><span class="detail-key">Operation</span><span class="detail-value">${esc(vm.operation || '—')}</span></div>
<div class="detail-row"><span class="detail-key">vCPUs</span><span class="detail-value">${vm.cpu || 1}</span></div>
<div class="detail-row"><span class="detail-key">RAM</span><span class="detail-value">${formatRam(vm.ram_mib)}</span></div>
<div class="detail-row"><span class="detail-key">Guest User</span><span class="detail-value">${esc(vm.username || '—')}</span></div>
`;
$('#detail-network').innerHTML = `
<div class="detail-row"><span class="detail-key">Guest IP</span><span class="detail-value">${esc(vm.guest_ip || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Observed Status</span><span class="detail-value">${esc(vm.observed_status || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Policy Status</span><span class="detail-value">${esc(vm.policy_status || '—')}</span></div>
`;
const sshCommand = `ssh ${vm.username || 'root'}@${vm.guest_ip}`;
const sshAllowed = (vm.ports || []).some(port => port.port === 22);
$('#detail-ssh-command').textContent = sshCommand;
$('#detail-ssh-copy-btn').onclick = () => copyText(sshCommand);
$('#detail-ssh-hint').textContent = sshAllowed
? 'Run this from a trusted local terminal; UVM does not expose guest passwords.'
: 'TCP/22 is not currently allowed by this VM policy.';
const ports = vm.ports || [];
$('#detail-ports').innerHTML = vm.ports === null
? `<p class="muted">This legacy VM has no managed port policy.${vm.status === 'stopped' ? ' Define one before starting it.' : ' Stop it before defining one.'}</p>`
: ports.length
? ports.map(p => `<span class="port-chip">${p.port} / ${p.protocol}</span>`).join(' ')
: '<p class="muted">No port rules configured.</p>';
// Actions visibility
$('#detail-start-btn').style.display = vm.status !== 'running' ? 'inline-flex' : 'none';
$('#detail-start-btn').onclick = () => startVM(vm.id);
$('#detail-restart-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none';
$('#detail-restart-btn').onclick = () => restartVM(vm.id);
$('#detail-stop-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none';
$('#detail-stop-btn').onclick = () => stopVM(vm.id);
$('#detail-delete-btn').onclick = () => deleteVM(vm.id);
// Port editing
const portsInput = $('#detail-ports-input');
portsInput.value = ports.map(p => `${p.port}:${p.protocol}`).join(', ');
$('#detail-ports-edit').style.display = 'none';
$('#detail-ports').style.display = 'block';
const canSetLegacyPorts = vm.ports === null && vm.status === 'stopped';
$('#detail-ports-edit-btn').style.display = vm.ports !== null || canSetLegacyPorts ? 'inline-flex' : 'none';
$('#detail-ports-edit-btn').onclick = () => {
$('#detail-ports-edit').style.display = 'block';
$('#detail-ports').style.display = 'none';
};
$('#detail-ports-cancel').onclick = () => {
$('#detail-ports-edit').style.display = 'none';
$('#detail-ports').style.display = 'block';
};
$('#detail-ports-set').onclick = () => updatePorts(vm.id, 'set');
$('#detail-ports-set').style.display = 'inline-flex';
$('#detail-ports-open').onclick = () => updatePorts(vm.id, 'open');
$('#detail-ports-open').style.display = vm.ports !== null ? 'inline-flex' : 'none';
$('#detail-ports-close').onclick = () => updatePorts(vm.id, 'close');
$('#detail-ports-close').style.display = vm.ports !== null ? 'inline-flex' : 'none';
$('#detail-ports-sync-btn').style.display = vm.ports !== null ? 'inline-flex' : 'none';
$('#detail-ports-sync-btn').onclick = () => syncPorts(vm.id);
$('#detail-log-refresh-btn').onclick = () => loadVmLogs(vm.id);
loadVmLogs(vm.id);
}
async function loadVmLogs(id) {
const output = $('#detail-log-output');
const meta = $('#detail-log-meta');
output.textContent = 'Loading recent log entries…';
meta.textContent = 'Showing up to 100 recent lines.';
try {
const log = await api(`/vms/${encodeURIComponent(id)}/logs?tail=100`);
if (state.currentVmId !== id) return;
output.textContent = log.content || 'No log entries yet.';
meta.textContent = log.truncated
? 'Showing the available tail; an oversized log line was truncated.'
: 'Showing up to 100 recent lines.';
} catch (e) {
if (state.currentVmId !== id) return;
output.textContent = 'Log unavailable.';
meta.textContent = e.message;
}
}
// ─ Actions ─
async function startVM(id) {
try {
await api(`/vms/${id}/start`, { method: 'POST' });
toast('VM started');
await loadVms();
if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id);
} catch (e) { toast('Start failed: ' + e.message, 'error'); }
}
async function restartVM(id) {
showConfirm(`Restart <b>${esc(id)}</b>?`, 'The VM will be stopped and then started again.', async () => {
try {
await api(`/vms/${id}/restart`, { method: 'POST' });
toast('VM restarted');
await loadVms();
if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id);
} catch (e) { toast('Restart failed: ' + e.message, 'error'); }
});
}
async function stopVM(id) {
showConfirm(`Stop <b>${esc(id)}</b>?`, 'The VM will be sent SIGTERM and SIGKILL if necessary. Guest data is retained.', async () => {
try {
await api(`/vms/${id}/stop`, { method: 'POST' });
toast('VM stopped');
await loadVms();
if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id);
} catch (e) { toast('Stop failed: ' + e.message, 'error'); }
});
}
async function deleteVM(id) {
showConfirm(`Delete <b>${esc(id)}</b>?`, 'This permanently removes the VM disk and releases its name and IP.', async () => {
try {
await api(`/vms/${id}`, { method: 'DELETE' });
toast('VM deleted');
if (state.currentPage === 'vm-detail') location.hash = '#vms';
await loadVms();
} catch (e) { toast('Delete failed: ' + e.message, 'error'); }
});
}
async function updatePorts(id, mode) {
let ports;
try {
ports = parsePorts($('#detail-ports-input').value);
} catch (e) {
toast(e.message, 'error');
return;
}
const payload = { mode, ports };
try {
await api(`/vms/${id}/ports`, { method: 'POST', body: JSON.stringify(payload) });
toast({ set: 'Ports updated', open: 'Ports opened', close: 'Ports closed' }[mode]);
await loadVms();
if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id);
} catch (e) { toast('Port update failed: ' + e.message, 'error'); }
}
async function syncPorts(id) {
try {
await api(`/vms/${id}/ports/sync`, { method: 'POST' });
toast('Ports synced');
await loadVms();
if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id);
} catch (e) { toast('Port sync failed: ' + e.message, 'error'); }
}
// ─ Create VM ─
$('#create-submit').addEventListener('click', async () => {
let ports;
try {
ports = parsePorts($('#create-ports').value);
} catch (e) {
showCreateError(e.message);
return;
}
const body = {
name: $('#create-name').value.trim() || undefined,
cpu: parseInt($('#create-cpu').value, 10) || 1,
ram: $('#create-ram').value.trim() || '512M',
guest_ip: $('#create-guest-ip').value.trim() || undefined,
username: $('#create-username').value.trim() || undefined,
password: $('#create-password').value,
ports,
no_default_ports: $('#create-no-default-ports').checked,
};
if (!body.password) { showCreateError('Password is required.'); return; }
const errEl = $('#create-error');
errEl.classList.add('hidden');
try {
await api('/vms', { method: 'POST', body: JSON.stringify(body) });
toast('VM created successfully');
resetCreateForm();
location.hash = '#vms';
await loadVms();
} catch (e) {
showCreateError(e.message);
}
});
$('#create-cancel').addEventListener('click', () => { history.back(); });
function parsePorts(val) {
if (!val.trim()) return [];
return val.split(',').map((entry) => {
const parts = entry.trim().split(':');
const port = Number(parts[0]);
const protocol = (parts[1] || 'TCP').trim().toUpperCase();
if (
parts.length > 2
|| !Number.isInteger(port)
|| port < 1
|| port > 65535
|| !['TCP', 'HTTP', 'HTTPS'].includes(protocol)
) {
throw new Error(`Invalid port rule: ${entry.trim()}`);
}
return { port, protocol };
});
}
async function copyText(value) {
try {
await navigator.clipboard.writeText(value);
toast('Command copied');
} catch {
const field = document.createElement('textarea');
field.value = value;
field.style.position = 'fixed';
field.style.opacity = '0';
document.body.appendChild(field);
field.select();
document.execCommand('copy');
field.remove();
toast('Command copied');
}
}
function showCreateError(msg) {
const el = $('#create-error');
el.textContent = msg;
el.classList.remove('hidden');
}
function resetCreateForm() {
$('#create-name').value = '';
$('#create-cpu').value = '1';
$('#create-ram').value = '512M';
$('#create-guest-ip').value = '';
$('#create-username').value = 'root';
$('#create-password').value = 'root';
$('#create-ports').value = '22:TCP';
$('#create-no-default-ports').checked = false;
$('#create-error').classList.add('hidden');
}
// ─ System ─
function renderSystemHealth() {
const wrap = $('#system-health');
if (!state.health) {
wrap.innerHTML = '<span class="status-badge badge-loading">Checking…</span><p class="muted">Send a test request to the management API.</p>';
return;
}
wrap.innerHTML = `<span class="status-badge badge-ok">Healthy</span><p class="muted">API responding normally.</p>`;
}
$('#install-btn').addEventListener('click', async () => {
const force = $('#install-force').checked;
const result = $('#install-result');
result.classList.remove('hidden');
result.textContent = 'Installing…';
try {
const data = await api('/install', { method: 'POST', body: JSON.stringify({ force }) });
result.textContent = JSON.stringify(data, null, 2);
toast('Installation completed');
} catch (e) {
result.textContent = 'Error: ' + e.message;
toast('Installation failed: ' + e.message, 'error');
}
});
$('#diagnostics-refresh-btn').addEventListener('click', loadDiagnostics);
// ─ Router ─
function route() {
const hash = location.hash.slice(1) || 'overview';
if (hash.startsWith('vm/')) {
showPage('vm-detail');
renderVMDetail(hash.slice(3));
} else if (hash === 'vms') {
showPage('vms');
renderVMsTable();
} else if (hash === 'create') {
showPage('create');
} else if (hash === 'system') {
showPage('system');
$('#info-api-url').textContent = state.baseUrl || '—';
$('#info-observed').textContent = new Date().toLocaleString();
loadDiagnostics();
} else {
showPage('overview');
}
}
// ─ Modal ─
function showConfirm(title, body, onOk) {
$('#confirm-title').innerHTML = title;
$('#confirm-body').textContent = body;
$('#confirm-modal').classList.add('open');
const okHandler = async () => {
$('#confirm-modal').classList.remove('open');
cleanup();
await onOk();
};
const cancelHandler = () => {
$('#confirm-modal').classList.remove('open');
cleanup();
};
function cleanup() {
$('#confirm-ok').removeEventListener('click', okHandler);
$('#confirm-cancel').removeEventListener('click', cancelHandler);
$('#confirm-modal .modal-overlay').removeEventListener('click', cancelHandler);
}
$('#confirm-ok').addEventListener('click', okHandler);
$('#confirm-cancel').addEventListener('click', cancelHandler);
$('#confirm-modal .modal-overlay').addEventListener('click', cancelHandler);
}
// ─ Refresh ─
function startRefresh() {
loadVms();
loadHealth();
state.refreshTimer = setInterval(() => { loadVms(); loadHealth(); }, 5000);
}
function stopRefresh() {
if (state.refreshTimer) clearInterval(state.refreshTimer);
state.refreshTimer = null;
}
$('#refresh-btn').addEventListener('click', () => {
loadVms();
loadHealth();
if (state.currentPage === 'system') loadDiagnostics();
toast('Refreshed');
});
$('#create-vm-btn').addEventListener('click', () => { location.hash = '#create'; });
// Search / Filter
$('#vm-search').addEventListener('input', renderVMsTable);
$('#vm-filter-status').addEventListener('change', renderVMsTable);
$('#vm-select-all').addEventListener('change', (event) => {
$$('input[data-vm-select]', $('#vms-table tbody')).forEach((checkbox) => {
if (event.target.checked) state.selectedVmIds.add(checkbox.dataset.vmSelect);
else state.selectedVmIds.delete(checkbox.dataset.vmSelect);
});
renderVMsTable();
});
$('#vms-table tbody').addEventListener('click', (event) => {
const selection = event.target.closest('input[data-vm-select]');
if (selection) {
if (selection.checked) state.selectedVmIds.add(selection.dataset.vmSelect);
else state.selectedVmIds.delete(selection.dataset.vmSelect);
renderVMsTable();
return;
}
const button = event.target.closest('button[data-vm-action]');
if (!button) return;
const { vmAction, vmId } = button.dataset;
if (vmAction === 'view') location.hash = `#vm/${vmId}`;
if (vmAction === 'start') startVM(vmId);
if (vmAction === 'restart') restartVM(vmId);
if (vmAction === 'stop') stopVM(vmId);
if (vmAction === 'delete') deleteVM(vmId);
});
$('#bulk-run-btn').addEventListener('click', () => {
const action = $('#bulk-action').value;
const ids = [...state.selectedVmIds];
const labels = {
start: 'Start',
restart: 'Restart',
stop: 'Stop',
'ports-sync': 'Sync ports for',
delete: 'Delete',
};
const detail = action === 'delete'
? 'This permanently removes every selected VM disk and releases its resources.'
: `${labels[action]} ${ids.length} selected VM${ids.length === 1 ? '' : 's'}.`;
showConfirm(
`${labels[action]} ${ids.length} VM${ids.length === 1 ? '' : 's'}?`,
detail,
async () => {
try {
const results = await api('/vms/actions', {
method: 'POST',
body: JSON.stringify({ action, identifiers: ids }),
});
const failed = results.filter(result => result.outcome !== 'succeeded');
state.selectedVmIds.clear();
await loadVms();
toast(
failed.length
? `${results.length - failed.length} succeeded, ${failed.length} failed`
: `${results.length} VM${results.length === 1 ? '' : 's'} updated`,
failed.length ? 'error' : 'success',
);
} catch (e) {
toast(`Bulk ${action} failed: ${e.message}`, 'error');
}
},
);
});
// ─ Utils ─
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ─ Boot ─
window.addEventListener('hashchange', route);
initAuth();
route();
})();