Files
Catalyst/catalyst.html
2026-03-27 22:57:08 -04:00

818 lines
35 KiB
HTML

<!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>