init
This commit is contained in:
817
catalyst.html
Normal file
817
catalyst.html
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Catalyst</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0b0d;
|
||||||
|
--bg1: #0f1114;
|
||||||
|
--bg2: #151820;
|
||||||
|
--bg3: #1c2030;
|
||||||
|
--border: #1e2535;
|
||||||
|
--border2: #2a3347;
|
||||||
|
--text: #c8d0e0;
|
||||||
|
--text2: #7a8599;
|
||||||
|
--text3: #4a5568;
|
||||||
|
--accent: #00d4aa;
|
||||||
|
--accent2: #0a8c72;
|
||||||
|
--blue: #4a9eff;
|
||||||
|
--blue2: #1a4a80;
|
||||||
|
--amber: #f0a030;
|
||||||
|
--amber2: #7a5010;
|
||||||
|
--red: #e05060;
|
||||||
|
--red2: #6a2030;
|
||||||
|
--green: #50d090;
|
||||||
|
--green2: #1a5535;
|
||||||
|
--purple: #9060f0;
|
||||||
|
--purple2: #3a2070;
|
||||||
|
--mono: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 13px; line-height: 1.6; }
|
||||||
|
|
||||||
|
#app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ── NAV ── */
|
||||||
|
nav {
|
||||||
|
display: flex; align-items: center; gap: 0; height: 48px;
|
||||||
|
background: var(--bg1); border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px; position: sticky; top: 0; z-index: 100;
|
||||||
|
}
|
||||||
|
.nav-logo {
|
||||||
|
font-size: 15px; font-weight: 600; letter-spacing: 0.08em;
|
||||||
|
color: var(--accent); text-transform: uppercase; cursor: pointer;
|
||||||
|
display: flex; align-items: center; gap: 8px; user-select: none;
|
||||||
|
}
|
||||||
|
.nav-logo::before {
|
||||||
|
content: '⬡'; font-size: 18px; color: var(--accent);
|
||||||
|
}
|
||||||
|
.nav-sep { flex: 1; }
|
||||||
|
.nav-status {
|
||||||
|
font-size: 11px; color: var(--text3); display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.pulse {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%; background: var(--accent);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
|
||||||
|
|
||||||
|
/* ── LAYOUT ── */
|
||||||
|
main { flex: 1; padding: 0; }
|
||||||
|
|
||||||
|
/* ── PAGE SHELL ── */
|
||||||
|
.page { display: none; }
|
||||||
|
.page.active { display: block; }
|
||||||
|
|
||||||
|
/* ── DASHBOARD ── */
|
||||||
|
.dash-header {
|
||||||
|
padding: 28px 32px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.dash-title { font-size: 11px; color: var(--text3); letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 6px; }
|
||||||
|
.dash-headline { font-size: 22px; font-weight: 500; color: var(--text); }
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex; gap: 0; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.stat-cell {
|
||||||
|
flex: 1; padding: 14px 24px; border-right: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.stat-cell:last-child { border-right: none; }
|
||||||
|
.stat-label { font-size: 10px; color: var(--text3); letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
.stat-value { font-size: 20px; font-weight: 500; color: var(--text); }
|
||||||
|
.stat-value.accent { color: var(--accent); }
|
||||||
|
.stat-value.amber { color: var(--amber); }
|
||||||
|
.stat-value.red { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── TOOLBAR ── */
|
||||||
|
.toolbar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 14px 32px; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.search-wrap {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--bg2); border: 1px solid var(--border2);
|
||||||
|
border-radius: 4px; padding: 6px 12px; flex: 1; max-width: 380px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.search-wrap:focus-within { border-color: var(--accent); }
|
||||||
|
.search-icon { color: var(--text3); font-size: 12px; }
|
||||||
|
.search-wrap input {
|
||||||
|
background: none; border: none; outline: none;
|
||||||
|
color: var(--text); font-family: var(--mono); font-size: 12px; width: 100%;
|
||||||
|
}
|
||||||
|
.search-wrap input::placeholder { color: var(--text3); }
|
||||||
|
select {
|
||||||
|
background: var(--bg2); border: 1px solid var(--border2);
|
||||||
|
color: var(--text); font-family: var(--mono); font-size: 12px;
|
||||||
|
padding: 6px 10px; border-radius: 4px; outline: none; cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
select:focus { border-color: var(--accent); }
|
||||||
|
.toolbar-right { margin-left: auto; display: flex; gap: 8px; }
|
||||||
|
.btn {
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 500;
|
||||||
|
padding: 6px 14px; border-radius: 4px; cursor: pointer;
|
||||||
|
border: 1px solid var(--border2); background: var(--bg2); color: var(--text2);
|
||||||
|
letter-spacing: 0.05em; text-transform: uppercase; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn.primary {
|
||||||
|
border-color: var(--accent2); background: var(--accent2); color: var(--bg);
|
||||||
|
}
|
||||||
|
.btn.primary:hover { background: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── INSTANCE GRID ── */
|
||||||
|
.instance-grid {
|
||||||
|
padding: 24px 32px;
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.instance-card {
|
||||||
|
background: var(--bg1); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 16px 18px;
|
||||||
|
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.instance-card::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
|
||||||
|
background: var(--border2); transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.instance-card.state-deployed::before { background: var(--accent); }
|
||||||
|
.instance-card.state-testing::before { background: var(--amber); }
|
||||||
|
.instance-card.state-degraded::before { background: var(--red); }
|
||||||
|
.instance-card:hover { border-color: var(--border2); background: var(--bg2); }
|
||||||
|
.card-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 10px; }
|
||||||
|
.card-name { font-size: 14px; font-weight: 500; color: var(--text); }
|
||||||
|
.card-vmid { font-size: 11px; color: var(--text3); margin-top: 2px; }
|
||||||
|
.badge {
|
||||||
|
font-size: 10px; font-weight: 500; padding: 2px 8px; border-radius: 3px;
|
||||||
|
letter-spacing: 0.08em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge.deployed { background: var(--accent2); color: var(--accent); }
|
||||||
|
.badge.testing { background: var(--amber2); color: var(--amber); }
|
||||||
|
.badge.degraded { background: var(--red2); color: var(--red); }
|
||||||
|
.badge.production { background: rgba(74,158,255,0.15); color: var(--blue); }
|
||||||
|
.badge.development { background: var(--bg3); color: var(--text2); }
|
||||||
|
|
||||||
|
.card-services { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.svc-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--border2); title: '';
|
||||||
|
}
|
||||||
|
.svc-dot.on { background: var(--accent); }
|
||||||
|
.svc-label { font-size: 10px; color: var(--text3); margin-left: 2px; }
|
||||||
|
.card-ip { font-size: 10px; color: var(--text3); font-family: var(--mono); margin-top: 6px; }
|
||||||
|
.card-ip span { color: var(--text2); }
|
||||||
|
.empty-state {
|
||||||
|
text-align: center; padding: 80px 32px; color: var(--text3);
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.empty-state .empty-icon { font-size: 32px; margin-bottom: 12px; }
|
||||||
|
.empty-state p { font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── INSTANCE DETAIL PAGE ── */
|
||||||
|
.detail-page { max-width: 900px; margin: 0 auto; padding: 32px; }
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 11px; color: var(--text3); margin-bottom: 20px;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.breadcrumb a { color: var(--text3); cursor: pointer; text-decoration: none; }
|
||||||
|
.breadcrumb a:hover { color: var(--accent); }
|
||||||
|
.breadcrumb .sep { color: var(--text3); }
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
margin-bottom: 28px; padding-bottom: 20px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.detail-name { font-size: 26px; font-weight: 500; color: var(--text); letter-spacing: -0.02em; }
|
||||||
|
.detail-sub {
|
||||||
|
font-size: 12px; color: var(--text3); margin-top: 6px;
|
||||||
|
display: flex; gap: 16px;
|
||||||
|
}
|
||||||
|
.detail-sub span { display: flex; gap: 4px; }
|
||||||
|
.detail-sub .lbl { color: var(--text3); }
|
||||||
|
.detail-sub .val { color: var(--text2); }
|
||||||
|
.detail-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
background: var(--bg1); border: 1px solid var(--border); border-radius: 6px; padding: 18px;
|
||||||
|
}
|
||||||
|
.detail-section.full { grid-column: 1 / -1; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--text3); margin-bottom: 14px; padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.kv-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 6px 0; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.kv-row:last-child { border-bottom: none; }
|
||||||
|
.kv-key { color: var(--text3); }
|
||||||
|
.kv-val { color: var(--text2); font-weight: 400; }
|
||||||
|
.kv-val.highlight { color: var(--text); }
|
||||||
|
.kv-val.on { color: var(--accent); }
|
||||||
|
.kv-val.off { color: var(--text3); }
|
||||||
|
|
||||||
|
.services-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.svc-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 8px 12px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.svc-name { color: var(--text2); }
|
||||||
|
.svc-status {
|
||||||
|
font-size: 10px; font-weight: 500; padding: 2px 7px;
|
||||||
|
border-radius: 3px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.svc-status.on { background: rgba(0,212,170,0.12); color: var(--accent); }
|
||||||
|
.svc-status.off { background: var(--bg3); color: var(--text3); }
|
||||||
|
|
||||||
|
/* ── MODAL ── */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(0,0,0,0.7); align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--bg1); border: 1px solid var(--border2); border-radius: 8px;
|
||||||
|
width: 540px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 18px 22px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 13px; font-weight: 500; color: var(--text); }
|
||||||
|
.modal-close {
|
||||||
|
background: none; border: none; color: var(--text3); cursor: pointer;
|
||||||
|
font-size: 18px; line-height: 1; padding: 2px 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: var(--text); }
|
||||||
|
.modal-body { padding: 22px; }
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-label { display: block; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text3); margin-bottom: 6px; }
|
||||||
|
.form-input {
|
||||||
|
width: 100%; background: var(--bg2); border: 1px solid var(--border2);
|
||||||
|
color: var(--text); font-family: var(--mono); font-size: 12px;
|
||||||
|
padding: 8px 12px; border-radius: 4px; outline: none; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.form-input:focus { border-color: var(--accent); }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.form-check { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.form-check input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; }
|
||||||
|
.form-check label { font-size: 12px; color: var(--text2); cursor: pointer; }
|
||||||
|
.toggle-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||||
|
.modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 10px;
|
||||||
|
padding: 16px 22px; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TOAST ── */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; right: 24px; z-index: 300;
|
||||||
|
background: var(--bg2); border: 1px solid var(--border2);
|
||||||
|
border-radius: 6px; padding: 12px 18px; font-size: 12px;
|
||||||
|
color: var(--text); display: flex; align-items: center; gap: 10px;
|
||||||
|
transform: translateY(100px); opacity: 0; transition: all 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast.show { transform: translateY(0); opacity: 1; }
|
||||||
|
.toast.success { border-color: var(--accent2); }
|
||||||
|
.toast.error { border-color: var(--red2); }
|
||||||
|
.toast-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
|
||||||
|
.toast.error .toast-dot { background: var(--red); }
|
||||||
|
|
||||||
|
/* ── CONFIRM DIALOG ── */
|
||||||
|
.confirm-overlay {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 250;
|
||||||
|
background: rgba(0,0,0,0.6); align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.confirm-overlay.open { display: flex; }
|
||||||
|
.confirm-box {
|
||||||
|
background: var(--bg1); border: 1px solid var(--border2); border-radius: 8px;
|
||||||
|
width: 380px; padding: 24px;
|
||||||
|
}
|
||||||
|
.confirm-title { font-size: 14px; font-weight: 500; color: var(--text); margin-bottom: 8px; }
|
||||||
|
.confirm-msg { font-size: 12px; color: var(--text2); margin-bottom: 20px; line-height: 1.7; }
|
||||||
|
.confirm-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||||
|
.btn.danger { border-color: var(--red2); color: var(--red); background: var(--red2); }
|
||||||
|
.btn.danger:hover { background: var(--red); color: var(--bg); border-color: var(--red); }
|
||||||
|
|
||||||
|
/* ── SCROLLBAR ── */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
|
||||||
|
|
||||||
|
.cursor-blink {
|
||||||
|
display: inline-block; width: 8px; height: 14px;
|
||||||
|
background: var(--accent); animation: blink 1s step-end infinite; vertical-align: middle;
|
||||||
|
}
|
||||||
|
@keyframes blink { 0%,100%{opacity:1;} 50%{opacity:0;} }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<nav>
|
||||||
|
<div class="nav-logo" onclick="navigate('dashboard')">Catalyst</div>
|
||||||
|
<div class="nav-sep"></div>
|
||||||
|
<div class="nav-status">
|
||||||
|
<div class="pulse"></div>
|
||||||
|
<span id="nav-count">— instances</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<div id="page-dashboard" class="page">
|
||||||
|
<div class="dash-header">
|
||||||
|
<div class="dash-title">infrastructure / overview</div>
|
||||||
|
<div class="dash-headline">Instance Registry <span class="cursor-blink"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-bar" id="stats-bar"></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span class="search-icon">⌕</span>
|
||||||
|
<input type="text" id="search-input" placeholder="search instances..." oninput="filterInstances()">
|
||||||
|
</div>
|
||||||
|
<select id="filter-state" onchange="filterInstances()">
|
||||||
|
<option value="">all states</option>
|
||||||
|
<option value="deployed">deployed</option>
|
||||||
|
<option value="testing">testing</option>
|
||||||
|
<option value="degraded">degraded</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-stack" onchange="filterInstances()">
|
||||||
|
<option value="">all stacks</option>
|
||||||
|
<option value="production">production</option>
|
||||||
|
<option value="development">development</option>
|
||||||
|
</select>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button class="btn primary" onclick="openNewModal()">+ new instance</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="instance-grid" id="instance-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DETAIL PAGE -->
|
||||||
|
<div id="page-detail" class="page">
|
||||||
|
<div class="detail-page">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a onclick="navigate('dashboard')">catalyst</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>instance</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span id="detail-vmid-crumb">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-header">
|
||||||
|
<div>
|
||||||
|
<div class="detail-name" id="detail-name">—</div>
|
||||||
|
<div class="detail-sub">
|
||||||
|
<span><span class="lbl">vmid</span> <span class="val" id="detail-vmid-sub">—</span></span>
|
||||||
|
<span><span class="lbl">id</span> <span class="val" id="detail-id-sub">—</span></span>
|
||||||
|
<span><span class="lbl">created</span> <span class="val" id="detail-created-sub">—</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="btn" id="detail-edit-btn">edit</button>
|
||||||
|
<button class="btn danger" id="detail-delete-btn">delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="section-title">identity</div>
|
||||||
|
<div id="detail-identity"></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="section-title">network</div>
|
||||||
|
<div id="detail-network"></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section full">
|
||||||
|
<div class="section-title">services</div>
|
||||||
|
<div class="services-grid" id="detail-services"></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section full">
|
||||||
|
<div class="section-title">timestamps</div>
|
||||||
|
<div id="detail-timestamps"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: New/Edit Instance -->
|
||||||
|
<div class="modal-overlay" id="instance-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title" id="modal-title">new instance</span>
|
||||||
|
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">name</label>
|
||||||
|
<input class="form-input" id="f-name" type="text" placeholder="plex">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">vmid</label>
|
||||||
|
<input class="form-input" id="f-vmid" type="number" placeholder="137">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">state</label>
|
||||||
|
<select class="form-input" id="f-state">
|
||||||
|
<option value="deployed">deployed</option>
|
||||||
|
<option value="testing">testing</option>
|
||||||
|
<option value="degraded">degraded</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">stack</label>
|
||||||
|
<select class="form-input" id="f-stack">
|
||||||
|
<option value="production">production</option>
|
||||||
|
<option value="development">development</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">tailscale ip</label>
|
||||||
|
<input class="form-input" id="f-tailscale-ip" type="text" placeholder="100.x.x.x">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">services</label>
|
||||||
|
<div class="toggle-grid">
|
||||||
|
<div class="form-check"><input type="checkbox" id="f-atlas"><label for="f-atlas">atlas</label></div>
|
||||||
|
<div class="form-check"><input type="checkbox" id="f-argus"><label for="f-argus">argus</label></div>
|
||||||
|
<div class="form-check"><input type="checkbox" id="f-semaphore"><label for="f-semaphore">semaphore</label></div>
|
||||||
|
<div class="form-check"><input type="checkbox" id="f-patchmon"><label for="f-patchmon">patchmon</label></div>
|
||||||
|
<div class="form-check"><input type="checkbox" id="f-tailscale"><label for="f-tailscale">tailscale</label></div>
|
||||||
|
<div class="form-check"><input type="checkbox" id="f-andromeda"><label for="f-andromeda">andromeda</label></div>
|
||||||
|
<div class="form-check"><input type="checkbox" id="f-hardware-accel"><label for="f-hardware-accel">hw acceleration</label></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="closeModal()">cancel</button>
|
||||||
|
<button class="btn primary" onclick="saveInstance()">save instance</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONFIRM DIALOG -->
|
||||||
|
<div class="confirm-overlay" id="confirm-overlay">
|
||||||
|
<div class="confirm-box">
|
||||||
|
<div class="confirm-title" id="confirm-title">confirm action</div>
|
||||||
|
<div class="confirm-msg" id="confirm-msg"></div>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="btn" onclick="closeConfirm()">cancel</button>
|
||||||
|
<button class="btn danger" id="confirm-ok" onclick="">confirm delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOAST -->
|
||||||
|
<div class="toast" id="toast">
|
||||||
|
<div class="toast-dot"></div>
|
||||||
|
<span id="toast-msg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js"></script>
|
||||||
|
<script>
|
||||||
|
let db = null;
|
||||||
|
let editingId = null;
|
||||||
|
let currentVmid = null;
|
||||||
|
|
||||||
|
const SEED = [
|
||||||
|
{name:'plex',state:'deployed',stack:'production',vmid:117,atlas:true,argus:true,semaphore:false,patchmon:true,tailscale:true,andromeda:false,tailscale_ip:'100.64.0.1',hardware_acceleration:true},
|
||||||
|
{name:'foldergram',state:'testing',stack:'development',vmid:137,atlas:false,argus:false,semaphore:false,patchmon:false,tailscale:false,andromeda:false,tailscale_ip:'',hardware_acceleration:false},
|
||||||
|
{name:'homeassistant',state:'deployed',stack:'production',vmid:102,atlas:true,argus:true,semaphore:true,patchmon:true,tailscale:true,andromeda:false,tailscale_ip:'100.64.0.5',hardware_acceleration:false},
|
||||||
|
{name:'gitea',state:'deployed',stack:'production',vmid:110,atlas:true,argus:false,semaphore:true,patchmon:true,tailscale:true,andromeda:false,tailscale_ip:'100.64.0.8',hardware_acceleration:false},
|
||||||
|
{name:'postgres-primary',state:'degraded',stack:'production',vmid:201,atlas:true,argus:true,semaphore:false,patchmon:true,tailscale:false,andromeda:true,tailscale_ip:'',hardware_acceleration:false},
|
||||||
|
{name:'nextcloud',state:'testing',stack:'development',vmid:144,atlas:false,argus:false,semaphore:false,patchmon:false,tailscale:true,andromeda:false,tailscale_ip:'100.64.0.12',hardware_acceleration:false},
|
||||||
|
{name:'traefik',state:'deployed',stack:'production',vmid:100,atlas:true,argus:true,semaphore:false,patchmon:true,tailscale:true,andromeda:false,tailscale_ip:'100.64.0.2',hardware_acceleration:false},
|
||||||
|
{name:'monitoring-stack',state:'testing',stack:'development',vmid:155,atlas:false,argus:false,semaphore:true,patchmon:false,tailscale:false,andromeda:false,tailscale_ip:'',hardware_acceleration:false},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function initDB() {
|
||||||
|
const SQL = await initSqlJs({ locateFile: f => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${f}` });
|
||||||
|
db = new SQL.Database();
|
||||||
|
db.run(`CREATE TABLE instances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
state TEXT DEFAULT 'deployed',
|
||||||
|
stack TEXT DEFAULT '',
|
||||||
|
vmid INTEGER UNIQUE NOT NULL,
|
||||||
|
atlas INTEGER DEFAULT 0,
|
||||||
|
argus INTEGER DEFAULT 0,
|
||||||
|
semaphore INTEGER DEFAULT 0,
|
||||||
|
patchmon INTEGER DEFAULT 0,
|
||||||
|
tailscale INTEGER DEFAULT 0,
|
||||||
|
andromeda INTEGER DEFAULT 0,
|
||||||
|
tailscale_ip TEXT DEFAULT '',
|
||||||
|
hardware_acceleration INTEGER DEFAULT 0,
|
||||||
|
createdAt TEXT DEFAULT (datetime('now')),
|
||||||
|
updatedAt TEXT DEFAULT (datetime('now'))
|
||||||
|
)`);
|
||||||
|
const stmt = db.prepare(`INSERT INTO instances (name,state,stack,vmid,atlas,argus,semaphore,patchmon,tailscale,andromeda,tailscale_ip,hardware_acceleration) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
|
||||||
|
SEED.forEach(s => stmt.run([s.name,s.state,s.stack,s.vmid,+s.atlas,+s.argus,+s.semaphore,+s.patchmon,+s.tailscale,+s.andromeda,s.tailscale_ip,+s.hardware_acceleration]));
|
||||||
|
stmt.free();
|
||||||
|
renderDashboard();
|
||||||
|
handleRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstances(filters={}) {
|
||||||
|
let sql = 'SELECT * FROM instances WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
if (filters.search) {
|
||||||
|
sql += ' AND (name LIKE ? OR CAST(vmid AS TEXT) LIKE ? OR stack LIKE ?)';
|
||||||
|
const s = `%${filters.search}%`;
|
||||||
|
params.push(s, s, s);
|
||||||
|
}
|
||||||
|
if (filters.state) { sql += ' AND state = ?'; params.push(filters.state); }
|
||||||
|
if (filters.stack) { sql += ' AND stack = ?'; params.push(filters.stack); }
|
||||||
|
sql += ' ORDER BY name ASC';
|
||||||
|
const res = db.exec(sql, params);
|
||||||
|
if (!res.length) return [];
|
||||||
|
const cols = res[0].columns;
|
||||||
|
return res[0].values.map(row => Object.fromEntries(cols.map((c,i) => [c, row[i]])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstance(vmid) {
|
||||||
|
const res = db.exec('SELECT * FROM instances WHERE vmid = ?', [vmid]);
|
||||||
|
if (!res.length) return null;
|
||||||
|
const cols = res[0].columns;
|
||||||
|
return Object.fromEntries(cols.map((c,i) => [c, res[0].values[0][i]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard() {
|
||||||
|
const all = getInstances();
|
||||||
|
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
const states = {};
|
||||||
|
const stacks = new Set();
|
||||||
|
all.forEach(i => {
|
||||||
|
states[i.state] = (states[i.state]||0)+1;
|
||||||
|
if (i.stack) stacks.add(i.stack);
|
||||||
|
});
|
||||||
|
const deployed = states['deployed']||0;
|
||||||
|
const testing = states['testing']||0;
|
||||||
|
const degraded = states['degraded']||0;
|
||||||
|
document.getElementById('stats-bar').innerHTML = `
|
||||||
|
<div class="stat-cell"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</div></div>
|
||||||
|
<div class="stat-cell"><div class="stat-label">deployed</div><div class="stat-value">${deployed}</div></div>
|
||||||
|
<div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${testing}</div></div>
|
||||||
|
<div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${degraded}</div></div>
|
||||||
|
<div class="stat-cell"><div class="stat-label">stacks</div><div class="stat-value">${stacks.size}</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
filterInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterInstances() {
|
||||||
|
const search = document.getElementById('search-input').value;
|
||||||
|
const state = document.getElementById('filter-state').value;
|
||||||
|
const stack = document.getElementById('filter-stack').value;
|
||||||
|
const instances = getInstances({search, state, stack});
|
||||||
|
const SVCS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda'];
|
||||||
|
const grid = document.getElementById('instance-grid');
|
||||||
|
if (!instances.length) {
|
||||||
|
grid.innerHTML = `<div class="empty-state"><div class="empty-icon">⊘</div><p>no instances match the current filters</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = instances.map(inst => {
|
||||||
|
const activeSvcs = SVCS.filter(s => inst[s]);
|
||||||
|
const svcDots = SVCS.map(s => `<div class="svc-dot ${inst[s]?'on':''}" title="${s}"></div>`).join('');
|
||||||
|
return `<div class="instance-card state-${inst.state}" onclick="navigate('instance',${inst.vmid})">
|
||||||
|
<div class="card-top">
|
||||||
|
<div>
|
||||||
|
<div class="card-name">${inst.name}</div>
|
||||||
|
<div class="card-vmid">vmid: ${inst.vmid}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||||
|
<div class="badge ${inst.state}">${inst.state}</div>
|
||||||
|
<div class="badge ${inst.stack}">${inst.stack}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-services">${svcDots}<span class="svc-label">${activeSvcs.length} service${activeSvcs.length!==1?'s':''} active</span></div>
|
||||||
|
${inst.tailscale_ip ? `<div class="card-ip">ts: <span>${inst.tailscale_ip}</span></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailPage(vmid) {
|
||||||
|
const inst = getInstance(vmid);
|
||||||
|
if (!inst) { navigate('dashboard'); return; }
|
||||||
|
currentVmid = vmid;
|
||||||
|
|
||||||
|
document.getElementById('detail-vmid-crumb').textContent = vmid;
|
||||||
|
document.getElementById('detail-name').textContent = inst.name;
|
||||||
|
document.getElementById('detail-vmid-sub').textContent = inst.vmid;
|
||||||
|
document.getElementById('detail-id-sub').textContent = inst.id;
|
||||||
|
document.getElementById('detail-created-sub').textContent = fmtDate(inst.createdAt);
|
||||||
|
|
||||||
|
document.getElementById('detail-identity').innerHTML = `
|
||||||
|
<div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${inst.name}</span></div>
|
||||||
|
<div class="kv-row"><span class="kv-key">state</span><span class="kv-val"><span class="badge ${inst.state}">${inst.state}</span></span></div>
|
||||||
|
<div class="kv-row"><span class="kv-key">stack</span><span class="kv-val highlight">${inst.stack||'—'}</span></div>
|
||||||
|
<div class="kv-row"><span class="kv-key">vmid</span><span class="kv-val highlight">${inst.vmid}</span></div>
|
||||||
|
<div class="kv-row"><span class="kv-key">internal id</span><span class="kv-val">${inst.id}</span></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('detail-network').innerHTML = `
|
||||||
|
<div class="kv-row"><span class="kv-key">tailscale ip</span><span class="kv-val highlight">${inst.tailscale_ip||'—'}</span></div>
|
||||||
|
<div class="kv-row"><span class="kv-key">tailscale</span><span class="kv-val ${inst.tailscale?'on':'off'}">${inst.tailscale?'enabled':'disabled'}</span></div>
|
||||||
|
<div class="kv-row"><span class="kv-key">hw acceleration</span><span class="kv-val ${inst.hardware_acceleration?'on':'off'}">${inst.hardware_acceleration?'enabled':'disabled'}</span></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SVCS = ['atlas','argus','semaphore','patchmon','andromeda'];
|
||||||
|
document.getElementById('detail-services').innerHTML = SVCS.map(s => `
|
||||||
|
<div class="svc-item">
|
||||||
|
<span class="svc-name">${s}</span>
|
||||||
|
<span class="svc-status ${inst[s]?'on':'off'}">${inst[s]?'enabled':'disabled'}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.getElementById('detail-timestamps').innerHTML = `
|
||||||
|
<div class="kv-row"><span class="kv-key">created</span><span class="kv-val">${fmtDateFull(inst.createdAt)}</span></div>
|
||||||
|
<div class="kv-row"><span class="kv-key">updated</span><span class="kv-val">${fmtDateFull(inst.updatedAt)}</span></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid);
|
||||||
|
document.getElementById('detail-delete-btn').onclick = () => confirmDelete(inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
if (!d) return '—';
|
||||||
|
try { return new Date(d).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); }
|
||||||
|
catch(e) { return d; }
|
||||||
|
}
|
||||||
|
function fmtDateFull(d) {
|
||||||
|
if (!d) return '—';
|
||||||
|
try { return new Date(d).toLocaleString('en-US',{year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
|
||||||
|
catch(e) { return d; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(page, vmid) {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
if (page === 'dashboard') {
|
||||||
|
document.getElementById('page-dashboard').classList.add('active');
|
||||||
|
history.pushState({page:'dashboard'}, '', '/');
|
||||||
|
renderDashboard();
|
||||||
|
} else if (page === 'instance') {
|
||||||
|
document.getElementById('page-detail').classList.add('active');
|
||||||
|
history.pushState({page:'instance',vmid}, '', `/instance/${vmid}`);
|
||||||
|
renderDetailPage(vmid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRoute() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const m = path.match(/^\/instance\/(\d+)/);
|
||||||
|
if (m) {
|
||||||
|
document.getElementById('page-detail').classList.add('active');
|
||||||
|
renderDetailPage(parseInt(m[1]));
|
||||||
|
} else {
|
||||||
|
document.getElementById('page-dashboard').classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', e => {
|
||||||
|
if (e.state?.page === 'instance') {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById('page-detail').classList.add('active');
|
||||||
|
renderDetailPage(e.state.vmid);
|
||||||
|
} else {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById('page-dashboard').classList.add('active');
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openNewModal() {
|
||||||
|
editingId = null;
|
||||||
|
document.getElementById('modal-title').textContent = 'new instance';
|
||||||
|
clearForm();
|
||||||
|
document.getElementById('instance-modal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(vmid) {
|
||||||
|
const inst = getInstance(vmid);
|
||||||
|
if (!inst) return;
|
||||||
|
editingId = inst.id;
|
||||||
|
document.getElementById('modal-title').textContent = `edit / ${inst.name}`;
|
||||||
|
document.getElementById('f-name').value = inst.name;
|
||||||
|
document.getElementById('f-vmid').value = inst.vmid;
|
||||||
|
document.getElementById('f-state').value = inst.state;
|
||||||
|
document.getElementById('f-stack').value = inst.stack;
|
||||||
|
document.getElementById('f-tailscale-ip').value = inst.tailscale_ip;
|
||||||
|
document.getElementById('f-atlas').checked = !!inst.atlas;
|
||||||
|
document.getElementById('f-argus').checked = !!inst.argus;
|
||||||
|
document.getElementById('f-semaphore').checked = !!inst.semaphore;
|
||||||
|
document.getElementById('f-patchmon').checked = !!inst.patchmon;
|
||||||
|
document.getElementById('f-tailscale').checked = !!inst.tailscale;
|
||||||
|
document.getElementById('f-andromeda').checked = !!inst.andromeda;
|
||||||
|
document.getElementById('f-hardware-accel').checked = !!inst.hardware_acceleration;
|
||||||
|
document.getElementById('instance-modal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('instance-modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForm() {
|
||||||
|
['f-name','f-vmid','f-tailscale-ip'].forEach(id => document.getElementById(id).value = '');
|
||||||
|
document.getElementById('f-state').value = 'deployed';
|
||||||
|
['f-atlas','f-argus','f-semaphore','f-patchmon','f-tailscale','f-andromeda','f-hardware-accel'].forEach(id => document.getElementById(id).checked = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveInstance() {
|
||||||
|
const name = document.getElementById('f-name').value.trim();
|
||||||
|
const vmid = parseInt(document.getElementById('f-vmid').value);
|
||||||
|
const state = document.getElementById('f-state').value;
|
||||||
|
const stack = document.getElementById('f-stack').value.trim();
|
||||||
|
const tip = document.getElementById('f-tailscale-ip').value.trim();
|
||||||
|
const atlas = +document.getElementById('f-atlas').checked;
|
||||||
|
const argus = +document.getElementById('f-argus').checked;
|
||||||
|
const semaphore = +document.getElementById('f-semaphore').checked;
|
||||||
|
const patchmon = +document.getElementById('f-patchmon').checked;
|
||||||
|
const tailscale = +document.getElementById('f-tailscale').checked;
|
||||||
|
const andromeda = +document.getElementById('f-andromeda').checked;
|
||||||
|
const hwaccel = +document.getElementById('f-hardware-accel').checked;
|
||||||
|
|
||||||
|
if (!name || !vmid) { showToast('name and vmid are required', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
db.run(`UPDATE instances SET name=?,state=?,stack=?,vmid=?,atlas=?,argus=?,semaphore=?,patchmon=?,tailscale=?,andromeda=?,tailscale_ip=?,hardware_acceleration=?,updatedAt=datetime('now') WHERE id=?`,
|
||||||
|
[name,state,stack,vmid,atlas,argus,semaphore,patchmon,tailscale,andromeda,tip,hwaccel,editingId]);
|
||||||
|
showToast(`${name} updated`,'success');
|
||||||
|
} else {
|
||||||
|
db.run(`INSERT INTO instances (name,state,stack,vmid,atlas,argus,semaphore,patchmon,tailscale,andromeda,tailscale_ip,hardware_acceleration) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
[name,state,stack,vmid,atlas,argus,semaphore,patchmon,tailscale,andromeda,tip,hwaccel]);
|
||||||
|
showToast(`${name} created`,'success');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
|
||||||
|
renderDetailPage(vmid);
|
||||||
|
} else {
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showToast(e.message.includes('UNIQUE') ? 'vmid already exists' : 'error saving instance', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(inst) {
|
||||||
|
document.getElementById('confirm-title').textContent = `delete ${inst.name}?`;
|
||||||
|
document.getElementById('confirm-msg').textContent = `This will permanently remove instance "${inst.name}" (vmid: ${inst.vmid}) from Catalyst. This action cannot be undone.`;
|
||||||
|
document.getElementById('confirm-ok').onclick = () => deleteInstance(inst.id, inst.name);
|
||||||
|
document.getElementById('confirm-overlay').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('confirm-overlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteInstance(id, name) {
|
||||||
|
db.run('DELETE FROM instances WHERE id = ?', [id]);
|
||||||
|
closeConfirm();
|
||||||
|
showToast(`${name} deleted`,'success');
|
||||||
|
navigate('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
function showToast(msg, type='success') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
document.getElementById('toast-msg').textContent = msg;
|
||||||
|
t.className = `toast ${type} show`;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('instance-modal').addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('instance-modal')) closeModal();
|
||||||
|
});
|
||||||
|
document.getElementById('confirm-overlay').addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('confirm-overlay')) closeConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
initDB();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user