39
.gitea/workflows/deploy.yml
Normal file
39
.gitea/workflows/deploy.yml
Normal 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
|
||||||
817
catalyst.html
817
catalyst.html
@@ -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
633
css/app.css
Normal 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
40
deploy/setup.sh
Normal 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
183
index.html
Normal 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
43
js/app.js
Normal 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
21
js/config.js
Normal 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
159
js/db.js
Normal 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
278
js/ui.js
Normal 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
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "catalyst",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "serve . --listen 3000 --single"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"serve": "^14.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user