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:
@@ -0,0 +1 @@
|
||||
# UVM Dashboard static assets package.
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ─ Boot ─
|
||||
window.addEventListener('hashchange', route);
|
||||
initAuth();
|
||||
route();
|
||||
})();
|
||||
Reference in New Issue
Block a user