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();
|
||||
});
|
||||
Reference in New Issue
Block a user