diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..09128de --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/catalyst.html b/catalyst.html deleted file mode 100644 index af37169..0000000 --- a/catalyst.html +++ /dev/null @@ -1,817 +0,0 @@ - - - - - -Catalyst - - - - - - -
- -
- -
-
-
infrastructure / overview
-
Instance Registry
-
-
-
-
- - -
- - -
- -
-
-
-
- - -
-
- -
-
-
-
- vmid - id - created -
-
-
- - -
-
-
-
-
identity
-
-
-
-
network
-
-
-
-
services
-
-
-
-
timestamps
-
-
-
-
-
-
-
- - - - - -
-
-
confirm action
-
-
- - -
-
-
- - -
-
- -
- - - - - diff --git a/css/app.css b/css/app.css new file mode 100644 index 0000000..1fc1f9f --- /dev/null +++ b/css/app.css @@ -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; } +} diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100644 index 0000000..1517364 --- /dev/null +++ b/deploy/setup.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Run once on a fresh server to prepare it for Catalyst deployments. +# Usage: bash setup.sh +# Example: bash setup.sh "ssh-ed25519 AAAA... gitea-actions" +set -euo pipefail + +DEPLOY_KEY="${1:?usage: setup.sh }" + +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." diff --git a/index.html b/index.html new file mode 100644 index 0000000..65b7d7c --- /dev/null +++ b/index.html @@ -0,0 +1,183 @@ + + + + + + Catalyst + + + + + + + +
+ + +
+ +
+
+
infrastructure / overview
+
Instance Registry
+
+
+
+
+ + +
+ + +
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ vmid + id + created +
+
+
+ + +
+
+
+
+
identity
+
+
+
+
network
+
+
+
+
services
+
+
+
+
timestamps
+
+
+
+
+
+
+
+ + + + + +
+
+
confirm action
+
+
+ + +
+
+
+ + +
+
+ +
+ + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..9423da7 --- /dev/null +++ b/js/app.js @@ -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(); +}); diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..50c26da --- /dev/null +++ b/js/config.js @@ -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 }, +]; diff --git a/js/db.js b/js/db.js new file mode 100644 index 0000000..4864f99 --- /dev/null +++ b/js/db.js @@ -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(); +} diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..466ad4b --- /dev/null +++ b/js/ui.js @@ -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 = ` +
total
${all.length}
+
deployed
${states['deployed'] || 0}
+
testing
${states['testing'] || 0}
+
degraded
${states['degraded'] || 0}
+
stacks
${getDistinctStacks().length}
+ `; + + populateStackFilter(); + filterInstances(); +} + +function populateStackFilter() { + const select = document.getElementById('filter-stack'); + const current = select.value; + select.innerHTML = ''; + 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 = `

no instances match the current filters

`; + return; + } + + grid.innerHTML = instances.map(inst => { + const dots = CARD_SERVICES.map(s => + `
` + ).join(''); + const activeCount = CARD_SERVICES.filter(s => inst[s]).length; + + return ` +
+
+
+
${esc(inst.name)}
+
vmid: ${inst.vmid}
+
+
+
${esc(inst.state)}
+
${esc(inst.stack)}
+
+
+
+ ${dots} + ${activeCount} service${activeCount !== 1 ? 's' : ''} active +
+ ${inst.tailscale_ip ? `
ts: ${esc(inst.tailscale_ip)}
` : ''} +
`; + }).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 = ` +
name${esc(inst.name)}
+
state${esc(inst.state)}
+
stack${esc(inst.stack) || '—'}
+
vmid${inst.vmid}
+
internal id${inst.id}
+ `; + + document.getElementById('detail-network').innerHTML = ` +
tailscale ip${esc(inst.tailscale_ip) || '—'}
+
tailscale${inst.tailscale ? 'enabled' : 'disabled'}
+ `; + + document.getElementById('detail-services').innerHTML = [ + ...DETAIL_SERVICES.map(s => ({ key: s, label: s })), + { key: 'hardware_acceleration', label: 'hw acceleration' }, + ].map(({ key, label }) => ` +
+ ${esc(label)} + ${inst[key] ? 'enabled' : 'disabled'} +
+ `).join(''); + + document.getElementById('detail-timestamps').innerHTML = ` +
created${fmtDateFull(inst.createdAt)}
+
updated${fmtDateFull(inst.updatedAt)}
+ `; + + 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(); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..52c542e --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "catalyst", + "version": "1.0.0", + "scripts": { + "start": "serve . --listen 3000 --single" + }, + "dependencies": { + "serve": "^14.0.0" + } +}