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

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

43
js/app.js Normal file
View File

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

21
js/config.js Normal file
View File

@@ -0,0 +1,21 @@
// Services shown as dots on instance cards (all tracked services)
const CARD_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda'];
// Services shown in the detail page service grid
// (tailscale is shown separately under "network" alongside its IP)
const DETAIL_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'andromeda'];
const SQL_JS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/';
const STORAGE_KEY = 'catalyst_db';
const SEED = [
{ name: 'plex', state: 'deployed', stack: 'production', vmid: 117, atlas: true, argus: true, semaphore: false, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.1', hardware_acceleration: true },
{ name: 'foldergram', state: 'testing', stack: 'development', vmid: 137, atlas: false, argus: false, semaphore: false, patchmon: false, tailscale: false, andromeda: false, tailscale_ip: '', hardware_acceleration: false },
{ name: 'homeassistant', state: 'deployed', stack: 'production', vmid: 102, atlas: true, argus: true, semaphore: true, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.5', hardware_acceleration: false },
{ name: 'gitea', state: 'deployed', stack: 'production', vmid: 110, atlas: true, argus: false, semaphore: true, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.8', hardware_acceleration: false },
{ name: 'postgres-primary', state: 'degraded', stack: 'production', vmid: 201, atlas: true, argus: true, semaphore: false, patchmon: true, tailscale: false, andromeda: true, tailscale_ip: '', hardware_acceleration: false },
{ name: 'nextcloud', state: 'testing', stack: 'development', vmid: 144, atlas: false, argus: false, semaphore: false, patchmon: false, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.12', hardware_acceleration: false },
{ name: 'traefik', state: 'deployed', stack: 'production', vmid: 100, atlas: true, argus: true, semaphore: false, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.2', hardware_acceleration: false },
{ name: 'monitoring-stack', state: 'testing', stack: 'development', vmid: 155, atlas: false, argus: false, semaphore: true, patchmon: false, tailscale: false, andromeda: false, tailscale_ip: '', hardware_acceleration: false },
];

159
js/db.js Normal file
View File

@@ -0,0 +1,159 @@
let db = null;
// ── Persistence ──────────────────────────────────────────────────────────────
function saveToStorage() {
try {
const data = db.export(); // Uint8Array
let binary = '';
const chunk = 8192;
for (let i = 0; i < data.length; i += chunk) {
binary += String.fromCharCode(...data.subarray(i, i + chunk));
}
localStorage.setItem(STORAGE_KEY, btoa(binary));
} catch (e) {
console.warn('catalyst: failed to persist database', e);
}
}
function loadFromStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const binary = atob(stored);
const buf = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
return buf;
} catch (e) {
console.warn('catalyst: failed to load database from storage', e);
return null;
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function initDB() {
const SQL = await initSqlJs({ locateFile: f => SQL_JS_CDN + f });
const saved = loadFromStorage();
if (saved) {
db = new SQL.Database(saved);
return;
}
db = new SQL.Database();
db.run(`
CREATE TABLE instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
state TEXT DEFAULT 'deployed',
stack TEXT DEFAULT '',
vmid INTEGER UNIQUE NOT NULL,
atlas INTEGER DEFAULT 0,
argus INTEGER DEFAULT 0,
semaphore INTEGER DEFAULT 0,
patchmon INTEGER DEFAULT 0,
tailscale INTEGER DEFAULT 0,
andromeda INTEGER DEFAULT 0,
tailscale_ip TEXT DEFAULT '',
hardware_acceleration INTEGER DEFAULT 0,
createdAt TEXT DEFAULT (datetime('now')),
updatedAt TEXT DEFAULT (datetime('now'))
)
`);
const stmt = db.prepare(`
INSERT INTO instances
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
SEED.forEach(s => stmt.run([
s.name, s.state, s.stack, s.vmid,
+s.atlas, +s.argus, +s.semaphore, +s.patchmon,
+s.tailscale, +s.andromeda, s.tailscale_ip, +s.hardware_acceleration,
]));
stmt.free();
saveToStorage();
}
// ── Queries ──────────────────────────────────────────────────────────────────
function getInstances(filters = {}) {
let sql = 'SELECT * FROM instances WHERE 1=1';
const params = [];
if (filters.search) {
sql += ' AND (name LIKE ? OR CAST(vmid AS TEXT) LIKE ? OR stack LIKE ?)';
const s = `%${filters.search}%`;
params.push(s, s, s);
}
if (filters.state) { sql += ' AND state = ?'; params.push(filters.state); }
if (filters.stack) { sql += ' AND stack = ?'; params.push(filters.stack); }
sql += ' ORDER BY name ASC';
const res = db.exec(sql, params);
if (!res.length) return [];
const cols = res[0].columns;
return res[0].values.map(row => Object.fromEntries(cols.map((c, i) => [c, row[i]])));
}
function getInstance(vmid) {
const res = db.exec('SELECT * FROM instances WHERE vmid = ?', [vmid]);
if (!res.length) return null;
const cols = res[0].columns;
return Object.fromEntries(cols.map((c, i) => [c, res[0].values[0][i]]));
}
function getDistinctStacks() {
const res = db.exec(`SELECT DISTINCT stack FROM instances WHERE stack != '' ORDER BY stack`);
if (!res.length) return [];
return res[0].values.map(row => row[0]);
}
// ── Mutations ─────────────────────────────────────────────────────────────────
function createInstance(data) {
try {
db.run(
`INSERT INTO instances
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[data.name, data.state, data.stack, data.vmid,
data.atlas, data.argus, data.semaphore, data.patchmon,
data.tailscale, data.andromeda, data.tailscale_ip, data.hardware_acceleration]
);
saveToStorage();
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
function updateInstance(id, data) {
try {
db.run(
`UPDATE instances SET
name=?, state=?, stack=?, vmid=?,
atlas=?, argus=?, semaphore=?, patchmon=?,
tailscale=?, andromeda=?, tailscale_ip=?, hardware_acceleration=?,
updatedAt=datetime('now')
WHERE id=?`,
[data.name, data.state, data.stack, data.vmid,
data.atlas, data.argus, data.semaphore, data.patchmon,
data.tailscale, data.andromeda, data.tailscale_ip, data.hardware_acceleration,
id]
);
saveToStorage();
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
function deleteInstance(id) {
db.run('DELETE FROM instances WHERE id = ?', [id]);
saveToStorage();
}

278
js/ui.js Normal file
View File

@@ -0,0 +1,278 @@
// Module-level UI state
let editingId = null;
let currentVmid = null;
let toastTimer = null;
// ── Helpers ───────────────────────────────────────────────────────────────────
function esc(str) {
const d = document.createElement('div');
d.textContent = (str == null) ? '' : String(str);
return d.innerHTML;
}
function fmtDate(d) {
if (!d) return '—';
try {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch (e) { return d; }
}
function fmtDateFull(d) {
if (!d) return '—';
try {
return new Date(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch (e) { return d; }
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
function renderDashboard() {
const all = getInstances();
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
const states = {};
all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; });
document.getElementById('stats-bar').innerHTML = `
<div class="stat-cell"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</div></div>
<div class="stat-cell"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">stacks</div><div class="stat-value">${getDistinctStacks().length}</div></div>
`;
populateStackFilter();
filterInstances();
}
function populateStackFilter() {
const select = document.getElementById('filter-stack');
const current = select.value;
select.innerHTML = '<option value="">all stacks</option>';
getDistinctStacks().forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
if (s === current) opt.selected = true;
select.appendChild(opt);
});
}
function filterInstances() {
const search = document.getElementById('search-input').value;
const state = document.getElementById('filter-state').value;
const stack = document.getElementById('filter-stack').value;
const instances = getInstances({ search, state, stack });
const grid = document.getElementById('instance-grid');
if (!instances.length) {
grid.innerHTML = `<div class="empty-state"><div class="empty-icon">⊘</div><p>no instances match the current filters</p></div>`;
return;
}
grid.innerHTML = instances.map(inst => {
const dots = CARD_SERVICES.map(s =>
`<div class="svc-dot ${inst[s] ? 'on' : ''}" title="${s}"></div>`
).join('');
const activeCount = CARD_SERVICES.filter(s => inst[s]).length;
return `
<div class="instance-card state-${esc(inst.state)}" onclick="navigate('instance', ${inst.vmid})">
<div class="card-top">
<div>
<div class="card-name">${esc(inst.name)}</div>
<div class="card-vmid">vmid: ${inst.vmid}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<div class="badge ${esc(inst.state)}">${esc(inst.state)}</div>
<div class="badge ${esc(inst.stack)}">${esc(inst.stack)}</div>
</div>
</div>
<div class="card-services">
${dots}
<span class="svc-label">${activeCount} service${activeCount !== 1 ? 's' : ''} active</span>
</div>
${inst.tailscale_ip ? `<div class="card-ip">ts: <span>${esc(inst.tailscale_ip)}</span></div>` : ''}
</div>`;
}).join('');
}
// ── Detail Page ───────────────────────────────────────────────────────────────
function renderDetailPage(vmid) {
const inst = getInstance(vmid);
if (!inst) { navigate('dashboard'); return; }
currentVmid = vmid;
document.getElementById('detail-vmid-crumb').textContent = vmid;
document.getElementById('detail-name').textContent = inst.name;
document.getElementById('detail-vmid-sub').textContent = inst.vmid;
document.getElementById('detail-id-sub').textContent = inst.id;
document.getElementById('detail-created-sub').textContent = fmtDate(inst.createdAt);
document.getElementById('detail-identity').innerHTML = `
<div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${esc(inst.name)}</span></div>
<div class="kv-row"><span class="kv-key">state</span><span class="kv-val"><span class="badge ${esc(inst.state)}">${esc(inst.state)}</span></span></div>
<div class="kv-row"><span class="kv-key">stack</span><span class="kv-val highlight">${esc(inst.stack) || '—'}</span></div>
<div class="kv-row"><span class="kv-key">vmid</span><span class="kv-val highlight">${inst.vmid}</span></div>
<div class="kv-row"><span class="kv-key">internal id</span><span class="kv-val">${inst.id}</span></div>
`;
document.getElementById('detail-network').innerHTML = `
<div class="kv-row"><span class="kv-key">tailscale ip</span><span class="kv-val highlight">${esc(inst.tailscale_ip) || '—'}</span></div>
<div class="kv-row"><span class="kv-key">tailscale</span><span class="kv-val ${inst.tailscale ? 'on' : 'off'}">${inst.tailscale ? 'enabled' : 'disabled'}</span></div>
`;
document.getElementById('detail-services').innerHTML = [
...DETAIL_SERVICES.map(s => ({ key: s, label: s })),
{ key: 'hardware_acceleration', label: 'hw acceleration' },
].map(({ key, label }) => `
<div class="svc-item">
<span class="svc-name">${esc(label)}</span>
<span class="svc-status ${inst[key] ? 'on' : 'off'}">${inst[key] ? 'enabled' : 'disabled'}</span>
</div>
`).join('');
document.getElementById('detail-timestamps').innerHTML = `
<div class="kv-row"><span class="kv-key">created</span><span class="kv-val">${fmtDateFull(inst.createdAt)}</span></div>
<div class="kv-row"><span class="kv-key">updated</span><span class="kv-val">${fmtDateFull(inst.updatedAt)}</span></div>
`;
document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid);
document.getElementById('detail-delete-btn').onclick = () => confirmDeleteDialog(inst);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
function openNewModal() {
editingId = null;
document.getElementById('modal-title').textContent = 'new instance';
clearForm();
document.getElementById('instance-modal').classList.add('open');
}
function openEditModal(vmid) {
const inst = getInstance(vmid);
if (!inst) return;
editingId = inst.id;
document.getElementById('modal-title').textContent = `edit / ${inst.name}`;
document.getElementById('f-name').value = inst.name;
document.getElementById('f-vmid').value = inst.vmid;
document.getElementById('f-state').value = inst.state;
document.getElementById('f-stack').value = inst.stack;
document.getElementById('f-tailscale-ip').value = inst.tailscale_ip;
document.getElementById('f-atlas').checked = !!inst.atlas;
document.getElementById('f-argus').checked = !!inst.argus;
document.getElementById('f-semaphore').checked = !!inst.semaphore;
document.getElementById('f-patchmon').checked = !!inst.patchmon;
document.getElementById('f-tailscale').checked = !!inst.tailscale;
document.getElementById('f-andromeda').checked = !!inst.andromeda;
document.getElementById('f-hardware-accel').checked = !!inst.hardware_acceleration;
document.getElementById('instance-modal').classList.add('open');
}
function closeModal() {
document.getElementById('instance-modal').classList.remove('open');
}
function clearForm() {
document.getElementById('f-name').value = '';
document.getElementById('f-vmid').value = '';
document.getElementById('f-tailscale-ip').value = '';
document.getElementById('f-state').value = 'deployed';
document.getElementById('f-stack').value = 'production';
['f-atlas', 'f-argus', 'f-semaphore', 'f-patchmon', 'f-tailscale', 'f-andromeda', 'f-hardware-accel']
.forEach(id => { document.getElementById(id).checked = false; });
}
function saveInstance() {
const name = document.getElementById('f-name').value.trim();
const vmid = parseInt(document.getElementById('f-vmid').value, 10);
const state = document.getElementById('f-state').value;
const stack = document.getElementById('f-stack').value;
const tip = document.getElementById('f-tailscale-ip').value.trim();
if (!name) { showToast('name is required', 'error'); return; }
if (!vmid || vmid < 1) { showToast('a valid vmid is required', 'error'); return; }
const data = {
name, state, stack, vmid,
tailscale_ip: tip,
atlas: +document.getElementById('f-atlas').checked,
argus: +document.getElementById('f-argus').checked,
semaphore: +document.getElementById('f-semaphore').checked,
patchmon: +document.getElementById('f-patchmon').checked,
tailscale: +document.getElementById('f-tailscale').checked,
andromeda: +document.getElementById('f-andromeda').checked,
hardware_acceleration: +document.getElementById('f-hardware-accel').checked,
};
const result = editingId ? updateInstance(editingId, data) : createInstance(data);
if (!result.ok) {
showToast(result.error.includes('UNIQUE') ? 'vmid already exists' : 'error saving instance', 'error');
return;
}
showToast(`${name} ${editingId ? 'updated' : 'created'}`, 'success');
closeModal();
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
renderDetailPage(vmid);
} else {
renderDashboard();
}
}
// ── Confirm Dialog ────────────────────────────────────────────────────────────
function confirmDeleteDialog(inst) {
if (inst.stack !== 'development') {
showToast(`demote ${inst.name} to development before deleting`, 'error');
return;
}
document.getElementById('confirm-title').textContent = `delete ${inst.name}?`;
document.getElementById('confirm-msg').textContent =
`This will permanently remove instance "${inst.name}" (vmid: ${inst.vmid}) from Catalyst. This action cannot be undone.`;
document.getElementById('confirm-ok').onclick = () => doDelete(inst.id, inst.name);
document.getElementById('confirm-overlay').classList.add('open');
}
function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open');
}
function doDelete(id, name) {
deleteInstance(id);
closeConfirm();
showToast(`${name} deleted`, 'success');
navigate('dashboard');
}
// ── Toast ─────────────────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
document.getElementById('toast-msg').textContent = msg;
t.className = `toast ${type} show`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
}
// ── Global keyboard handler ───────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; }
if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; }
});
// Close modals on backdrop click
document.getElementById('instance-modal').addEventListener('click', e => {
if (e.target === document.getElementById('instance-modal')) closeModal();
});
document.getElementById('confirm-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('confirm-overlay')) closeConfirm();
});