oops
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-03-27 22:57:13 -04:00
parent de42fe9d2d
commit 12125e8942
10 changed files with 1406 additions and 817 deletions

View File

@@ -0,0 +1,39 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Write SSH key
run: |
echo "${{ secrets.DEPLOY_SSH_KEY }}" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
- name: Sync files
run: |
rsync -avz --delete \
-e "ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no -p ${{ secrets.DEPLOY_SSH_PORT || 22 }}" \
--exclude='.git' \
--exclude='.gitea' \
--exclude='deploy' \
./ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/srv/catalyst/
- name: Install dependencies and restart
run: |
ssh -i /tmp/deploy_key \
-o StrictHostKeyChecking=no \
-p ${{ secrets.DEPLOY_SSH_PORT || 22 }} \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
"cd /srv/catalyst && npm ci && pm2 restart catalyst || pm2 start npm --name catalyst -- start"
- name: Cleanup
if: always()
run: rm -f /tmp/deploy_key

View File

@@ -1,817 +0,0 @@
<!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>

633
css/app.css Normal file
View File

@@ -0,0 +1,633 @@
*, *::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;
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; }
.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;
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); }
.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); }
/* ── 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);
}
.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; }
/* ── 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); }
.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; }
/* ── SCROLLBAR ── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
/* ── CURSOR BLINK ── */
.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; }
}

40
deploy/setup.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Run once on a fresh server to prepare it for Catalyst deployments.
# Usage: bash setup.sh <deploy-public-key>
# Example: bash setup.sh "ssh-ed25519 AAAA... gitea-actions"
set -euo pipefail
DEPLOY_KEY="${1:?usage: setup.sh <deploy-public-key>}"
echo "==> Installing Node.js"
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt-get install -y nodejs
echo "==> Installing PM2"
npm install -g pm2
pm2 startup systemd -u deploy --hp /home/deploy | tail -1 | bash
echo "==> Creating app directory"
mkdir -p /srv/catalyst
chown -R deploy:deploy /srv/catalyst
echo "==> Creating deploy user"
if ! id -u deploy &>/dev/null; then
useradd -m -s /bin/bash deploy
fi
DEPLOY_SSH_DIR="/home/deploy/.ssh"
mkdir -p "$DEPLOY_SSH_DIR"
echo "$DEPLOY_KEY" >> "$DEPLOY_SSH_DIR/authorized_keys"
chmod 700 "$DEPLOY_SSH_DIR"
chmod 600 "$DEPLOY_SSH_DIR/authorized_keys"
chown -R deploy:deploy "$DEPLOY_SSH_DIR"
echo ""
echo "Done. Add these secrets to your Gitea repo:"
echo " DEPLOY_HOST → (your Tailscale IP or hostname)"
echo " DEPLOY_USER → deploy"
echo " DEPLOY_SSH_KEY → (the private key paired with the public key you provided)"
echo " DEPLOY_SSH_PORT → 22"
echo ""
echo "Catalyst will be served on port 3000."

183
index.html Normal file
View File

@@ -0,0 +1,183 @@
<!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">
<link rel="stylesheet" href="css/app.css">
</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>
</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" min="1">
</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">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 src="js/config.js"></script>
<script src="js/db.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
</body>
</html>

43
js/app.js Normal file
View File

@@ -0,0 +1,43 @@
// ── Router ────────────────────────────────────────────────────────────────────
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 m = window.location.pathname.match(/^\/instance\/(\d+)/);
if (m) {
document.getElementById('page-detail').classList.add('active');
renderDetailPage(parseInt(m[1], 10));
} else {
document.getElementById('page-dashboard').classList.add('active');
}
}
window.addEventListener('popstate', e => {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
if (e.state?.page === 'instance') {
document.getElementById('page-detail').classList.add('active');
renderDetailPage(e.state.vmid);
} else {
document.getElementById('page-dashboard').classList.add('active');
renderDashboard();
}
});
// ── Bootstrap ─────────────────────────────────────────────────────────────────
initDB().then(() => {
renderDashboard();
handleRoute();
});

21
js/config.js Normal file
View File

@@ -0,0 +1,21 @@
// Services shown as dots on instance cards (all tracked services)
const CARD_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda'];
// Services shown in the detail page service grid
// (tailscale is shown separately under "network" alongside its IP)
const DETAIL_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'andromeda'];
const SQL_JS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/';
const STORAGE_KEY = 'catalyst_db';
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 },
];

159
js/db.js Normal file
View File

@@ -0,0 +1,159 @@
let db = null;
// ── Persistence ──────────────────────────────────────────────────────────────
function saveToStorage() {
try {
const data = db.export(); // Uint8Array
let binary = '';
const chunk = 8192;
for (let i = 0; i < data.length; i += chunk) {
binary += String.fromCharCode(...data.subarray(i, i + chunk));
}
localStorage.setItem(STORAGE_KEY, btoa(binary));
} catch (e) {
console.warn('catalyst: failed to persist database', e);
}
}
function loadFromStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const binary = atob(stored);
const buf = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
return buf;
} catch (e) {
console.warn('catalyst: failed to load database from storage', e);
return null;
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function initDB() {
const SQL = await initSqlJs({ locateFile: f => SQL_JS_CDN + f });
const saved = loadFromStorage();
if (saved) {
db = new SQL.Database(saved);
return;
}
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();
saveToStorage();
}
// ── Queries ──────────────────────────────────────────────────────────────────
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 getDistinctStacks() {
const res = db.exec(`SELECT DISTINCT stack FROM instances WHERE stack != '' ORDER BY stack`);
if (!res.length) return [];
return res[0].values.map(row => row[0]);
}
// ── Mutations ─────────────────────────────────────────────────────────────────
function createInstance(data) {
try {
db.run(
`INSERT INTO instances
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[data.name, data.state, data.stack, data.vmid,
data.atlas, data.argus, data.semaphore, data.patchmon,
data.tailscale, data.andromeda, data.tailscale_ip, data.hardware_acceleration]
);
saveToStorage();
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
function updateInstance(id, data) {
try {
db.run(
`UPDATE instances SET
name=?, state=?, stack=?, vmid=?,
atlas=?, argus=?, semaphore=?, patchmon=?,
tailscale=?, andromeda=?, tailscale_ip=?, hardware_acceleration=?,
updatedAt=datetime('now')
WHERE id=?`,
[data.name, data.state, data.stack, data.vmid,
data.atlas, data.argus, data.semaphore, data.patchmon,
data.tailscale, data.andromeda, data.tailscale_ip, data.hardware_acceleration,
id]
);
saveToStorage();
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
function deleteInstance(id) {
db.run('DELETE FROM instances WHERE id = ?', [id]);
saveToStorage();
}

278
js/ui.js Normal file
View File

@@ -0,0 +1,278 @@
// Module-level UI state
let editingId = null;
let currentVmid = null;
let toastTimer = null;
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(str) {
const d = document.createElement('div');
d.textContent = (str == null) ? '' : String(str);
return d.innerHTML;
}
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; }
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
function renderDashboard() {
const all = getInstances();
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
const states = {};
all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; });
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">${states['deployed'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">stacks</div><div class="stat-value">${getDistinctStacks().length}</div></div>
`;
populateStackFilter();
filterInstances();
}
function populateStackFilter() {
const select = document.getElementById('filter-stack');
const current = select.value;
select.innerHTML = '<option value="">all stacks</option>';
getDistinctStacks().forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
if (s === current) opt.selected = true;
select.appendChild(opt);
});
}
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 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 dots = CARD_SERVICES.map(s =>
`<div class="svc-dot ${inst[s] ? 'on' : ''}" title="${s}"></div>`
).join('');
const activeCount = CARD_SERVICES.filter(s => inst[s]).length;
return `
<div class="instance-card state-${esc(inst.state)}" onclick="navigate('instance', ${inst.vmid})">
<div class="card-top">
<div>
<div class="card-name">${esc(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 ${esc(inst.state)}">${esc(inst.state)}</div>
<div class="badge ${esc(inst.stack)}">${esc(inst.stack)}</div>
</div>
</div>
<div class="card-services">
${dots}
<span class="svc-label">${activeCount} service${activeCount !== 1 ? 's' : ''} active</span>
</div>
${inst.tailscale_ip ? `<div class="card-ip">ts: <span>${esc(inst.tailscale_ip)}</span></div>` : ''}
</div>`;
}).join('');
}
// ── Detail Page ───────────────────────────────────────────────────────────────
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">${esc(inst.name)}</span></div>
<div class="kv-row"><span class="kv-key">state</span><span class="kv-val"><span class="badge ${esc(inst.state)}">${esc(inst.state)}</span></span></div>
<div class="kv-row"><span class="kv-key">stack</span><span class="kv-val highlight">${esc(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">${esc(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>
`;
document.getElementById('detail-services').innerHTML = [
...DETAIL_SERVICES.map(s => ({ key: s, label: s })),
{ key: 'hardware_acceleration', label: 'hw acceleration' },
].map(({ key, label }) => `
<div class="svc-item">
<span class="svc-name">${esc(label)}</span>
<span class="svc-status ${inst[key] ? 'on' : 'off'}">${inst[key] ? '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 = () => confirmDeleteDialog(inst);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
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() {
document.getElementById('f-name').value = '';
document.getElementById('f-vmid').value = '';
document.getElementById('f-tailscale-ip').value = '';
document.getElementById('f-state').value = 'deployed';
document.getElementById('f-stack').value = 'production';
['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, 10);
const state = document.getElementById('f-state').value;
const stack = document.getElementById('f-stack').value;
const tip = document.getElementById('f-tailscale-ip').value.trim();
if (!name) { showToast('name is required', 'error'); return; }
if (!vmid || vmid < 1) { showToast('a valid vmid is required', 'error'); return; }
const data = {
name, state, stack, vmid,
tailscale_ip: tip,
atlas: +document.getElementById('f-atlas').checked,
argus: +document.getElementById('f-argus').checked,
semaphore: +document.getElementById('f-semaphore').checked,
patchmon: +document.getElementById('f-patchmon').checked,
tailscale: +document.getElementById('f-tailscale').checked,
andromeda: +document.getElementById('f-andromeda').checked,
hardware_acceleration: +document.getElementById('f-hardware-accel').checked,
};
const result = editingId ? updateInstance(editingId, data) : createInstance(data);
if (!result.ok) {
showToast(result.error.includes('UNIQUE') ? 'vmid already exists' : 'error saving instance', 'error');
return;
}
showToast(`${name} ${editingId ? 'updated' : 'created'}`, 'success');
closeModal();
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
renderDetailPage(vmid);
} else {
renderDashboard();
}
}
// ── Confirm Dialog ────────────────────────────────────────────────────────────
function confirmDeleteDialog(inst) {
if (inst.stack !== 'development') {
showToast(`demote ${inst.name} to development before deleting`, 'error');
return;
}
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 = () => doDelete(inst.id, inst.name);
document.getElementById('confirm-overlay').classList.add('open');
}
function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open');
}
function doDelete(id, name) {
deleteInstance(id);
closeConfirm();
showToast(`${name} deleted`, 'success');
navigate('dashboard');
}
// ── Toast ─────────────────────────────────────────────────────────────────────
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);
}
// ── Global keyboard handler ───────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; }
if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; }
});
// Close modals on backdrop click
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();
});

10
package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "catalyst",
"version": "1.0.0",
"scripts": {
"start": "serve . --listen 3000 --single"
},
"dependencies": {
"serve": "^14.0.0"
}
}