claude went crazy
All checks were successful
Build / test (push) Successful in 9m28s
Build / release (push) Successful in 1s
Build / build (push) Successful in 25s

This commit is contained in:
2026-03-28 02:35:00 -04:00
parent d7d4bbc099
commit 6e40413385
22 changed files with 2167 additions and 787 deletions

View File

@@ -21,6 +21,7 @@ function handleRoute() {
renderDetailPage(parseInt(m[1], 10));
} else {
document.getElementById('page-dashboard').classList.add('active');
renderDashboard();
}
}
@@ -39,7 +40,4 @@ window.addEventListener('popstate', e => {
if (VERSION) document.getElementById('nav-version').textContent = `v${VERSION}`;
initDB().then(() => {
renderDashboard();
handleRoute();
});
handleRoute();

View File

@@ -1,21 +1,6 @@
// Services shown as dots on instance cards (all tracked services)
// Services shown as dots on instance cards
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)
// (tailscale lives in the network section 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 },
];

182
js/db.js
View File

@@ -1,159 +1,57 @@
let db = null;
// API client — replaces the sql.js database layer.
// Swap these fetch() calls for any other transport when needed.
// ── Persistence ──────────────────────────────────────────────────────────────
const BASE = '/api';
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);
}
async function api(path, options = {}) {
const res = await fetch(BASE + path, options);
if (res.status === 204) return null;
return res.json().then(data => ({ ok: res.ok, status: res.status, data }));
}
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;
}
// ── Queries ───────────────────────────────────────────────────────────────────
async function getInstances(filters = {}) {
const params = new URLSearchParams(
Object.entries(filters).filter(([, v]) => v)
);
const res = await fetch(`${BASE}/instances?${params}`);
return res.json();
}
// ── 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();
async function getInstance(vmid) {
const res = await fetch(`${BASE}/instances/${vmid}`);
if (res.status === 404) return null;
return res.json();
}
// ── 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]);
async function getDistinctStacks() {
const res = await fetch(`${BASE}/instances/stacks`);
return res.json();
}
// ── 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 };
}
async function createInstance(data) {
const { ok, data: body } = await api('/instances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!ok) return { ok: false, error: body.error ?? body.errors?.[0] ?? 'error creating instance' };
return { ok: true };
}
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 };
}
async function updateInstance(vmid, data) {
const { ok, data: body } = await api(`/instances/${vmid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!ok) return { ok: false, error: body.error ?? body.errors?.[0] ?? 'error updating instance' };
return { ok: true };
}
function deleteInstance(id) {
db.run('DELETE FROM instances WHERE id = ?', [id]);
saveToStorage();
async function deleteInstance(vmid) {
await api(`/instances/${vmid}`, { method: 'DELETE' });
}

View File

@@ -1,5 +1,5 @@
// Module-level UI state
let editingId = null;
let editingVmid = null;
let currentVmid = null;
let toastTimer = null;
@@ -27,8 +27,8 @@ function fmtDateFull(d) {
// ── Dashboard ─────────────────────────────────────────────────────────────────
function renderDashboard() {
const all = getInstances();
async function renderDashboard() {
const all = await getInstances();
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
const states = {};
@@ -39,18 +39,19 @@ function renderDashboard() {
<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>
<div class="stat-cell"><div class="stat-label">stacks</div><div class="stat-value">${(await getDistinctStacks()).length}</div></div>
`;
populateStackFilter();
filterInstances();
await populateStackFilter();
await filterInstances();
}
function populateStackFilter() {
async function populateStackFilter() {
const select = document.getElementById('filter-stack');
const current = select.value;
select.innerHTML = '<option value="">all stacks</option>';
getDistinctStacks().forEach(s => {
const stacks = await getDistinctStacks();
stacks.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
@@ -59,11 +60,11 @@ function populateStackFilter() {
});
}
function filterInstances() {
async 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 instances = await getInstances({ search, state, stack });
const grid = document.getElementById('instance-grid');
if (!instances.length) {
@@ -76,7 +77,6 @@ function filterInstances() {
`<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">
@@ -100,8 +100,8 @@ function filterInstances() {
// ── Detail Page ───────────────────────────────────────────────────────────────
function renderDetailPage(vmid) {
const inst = getInstance(vmid);
async function renderDetailPage(vmid) {
const inst = await getInstance(vmid);
if (!inst) { navigate('dashboard'); return; }
currentVmid = vmid;
@@ -109,7 +109,7 @@ function renderDetailPage(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-created-sub').textContent = fmtDate(inst.created_at);
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>
@@ -135,8 +135,8 @@ function renderDetailPage(vmid) {
`).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>
<div class="kv-row"><span class="kv-key">created</span><span class="kv-val">${fmtDateFull(inst.created_at)}</span></div>
<div class="kv-row"><span class="kv-key">updated</span><span class="kv-val">${fmtDateFull(inst.updated_at)}</span></div>
`;
document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid);
@@ -146,16 +146,16 @@ function renderDetailPage(vmid) {
// ── Modal ─────────────────────────────────────────────────────────────────────
function openNewModal() {
editingId = null;
editingVmid = null;
document.getElementById('modal-title').textContent = 'new instance';
clearForm();
document.getElementById('instance-modal').classList.add('open');
}
function openEditModal(vmid) {
const inst = getInstance(vmid);
async function openEditModal(vmid) {
const inst = await getInstance(vmid);
if (!inst) return;
editingId = inst.id;
editingVmid = inst.vmid;
document.getElementById('modal-title').textContent = `edit / ${inst.name}`;
document.getElementById('f-name').value = inst.name;
document.getElementById('f-vmid').value = inst.vmid;
@@ -186,19 +186,18 @@ function clearForm() {
.forEach(id => { document.getElementById(id).checked = false; });
}
function saveInstance() {
async 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,
tailscale_ip: document.getElementById('f-tailscale-ip').value.trim(),
atlas: +document.getElementById('f-atlas').checked,
argus: +document.getElementById('f-argus').checked,
semaphore: +document.getElementById('f-semaphore').checked,
@@ -208,20 +207,19 @@ function saveInstance() {
hardware_acceleration: +document.getElementById('f-hardware-accel').checked,
};
const result = editingId ? updateInstance(editingId, data) : createInstance(data);
const result = editingVmid
? await updateInstance(editingVmid, data)
: await createInstance(data);
if (!result.ok) {
showToast(result.error.includes('UNIQUE') ? 'vmid already exists' : 'error saving instance', 'error');
return;
}
if (!result.ok) { showToast(result.error, 'error'); return; }
showToast(`${name} ${editingId ? 'updated' : 'created'}`, 'success');
showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success');
closeModal();
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
renderDetailPage(vmid);
await renderDetailPage(vmid);
} else {
renderDashboard();
await renderDashboard();
}
}
@@ -232,11 +230,10 @@ function confirmDeleteDialog(inst) {
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-ok').onclick = () => doDelete(inst.vmid, inst.name);
document.getElementById('confirm-overlay').classList.add('open');
}
@@ -244,9 +241,9 @@ function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open');
}
function doDelete(id, name) {
deleteInstance(id);
async function doDelete(vmid, name) {
closeConfirm();
await deleteInstance(vmid);
showToast(`${name} deleted`, 'success');
navigate('dashboard');
}
@@ -261,7 +258,7 @@ function showToast(msg, type = 'success') {
toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
}
// ── Global keyboard handler ───────────────────────────────────────────────────
// ── Keyboard / backdrop ───────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
@@ -269,7 +266,6 @@ document.addEventListener('keydown', e => {
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();
});