diff --git a/css/app.css b/css/app.css index d21a23a..5595d55 100644 --- a/css/app.css +++ b/css/app.css @@ -70,6 +70,19 @@ nav { .nav-sep { flex: 1; } +.nav-btn { + background: none; + border: 1px solid var(--border2); + color: var(--text2); + border-radius: 6px; + padding: 4px 8px; + font-size: 14px; + cursor: pointer; + margin-left: 10px; + line-height: 1; +} +.nav-btn:hover { border-color: var(--accent); color: var(--accent); } + .nav-divider { color: var(--border2); } .nav-status { @@ -615,6 +628,31 @@ select:focus { border-color: var(--accent); } .confirm-actions { display: flex; justify-content: flex-end; gap: 10px; } +/* ── SETTINGS MODAL ── */ +.settings-section { padding: 16px 0; border-bottom: 1px solid var(--border); } +.settings-section:last-child { border-bottom: none; padding-bottom: 0; } +.settings-section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text3); + margin-bottom: 8px; +} +.settings-desc { font-size: 12px; color: var(--text2); margin: 0 0 14px; line-height: 1.6; } +.import-row { display: flex; gap: 10px; align-items: center; } +.import-file-input { flex: 1; } + +.btn-secondary { + background: var(--bg3); + border-color: var(--border2); + color: var(--text); +} +.btn-secondary:hover { border-color: var(--accent); color: var(--accent); } + +.btn-danger { background: var(--red2); border-color: var(--red); color: var(--text); } +.btn-danger:hover { background: var(--red); } + /* ── SCROLLBAR ── */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--bg); } diff --git a/index.html b/index.html index ab3c809..2fdbb6a 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@ · +
@@ -171,6 +172,31 @@ + + +
diff --git a/js/ui.js b/js/ui.js index 70f70de..ab5b332 100644 --- a/js/ui.js +++ b/js/ui.js @@ -258,12 +258,62 @@ function showToast(msg, type = 'success') { toastTimer = setTimeout(() => t.classList.remove('show'), 3000); } +// ── Settings Modal ──────────────────────────────────────────────────────────── + +function openSettingsModal() { + document.getElementById('settings-modal').classList.add('open'); +} + +function closeSettingsModal() { + document.getElementById('settings-modal').classList.remove('open'); + document.getElementById('import-file').value = ''; +} + +async function exportDB() { + const res = await fetch('/api/export'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `catalyst-backup-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +async function importDB() { + const file = document.getElementById('import-file').files[0]; + if (!file) { showToast('Select a backup file first', 'error'); return; } + document.getElementById('confirm-title').textContent = 'Replace all instances?'; + document.getElementById('confirm-msg').textContent = + `This will delete all current instances and replace them with the contents of "${file.name}". This cannot be undone.`; + document.getElementById('confirm-overlay').classList.add('open'); + document.getElementById('confirm-ok').onclick = async () => { + closeConfirm(); + try { + const { instances } = JSON.parse(await file.text()); + const res = await fetch('/api/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instances }), + }); + const data = await res.json(); + if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } + showToast(`Imported ${data.imported} instance${data.imported !== 1 ? 's' : ''}`, 'success'); + closeSettingsModal(); + renderDashboard(); + } catch { + showToast('Invalid backup file', 'error'); + } + }; +} + // ── Keyboard / backdrop ─────────────────────────────────────────────────────── 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; } + if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; } + if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; } + if (document.getElementById('settings-modal').classList.contains('open')) { closeSettingsModal(); return; } }); document.getElementById('instance-modal').addEventListener('click', e => { @@ -272,3 +322,6 @@ document.getElementById('instance-modal').addEventListener('click', e => { document.getElementById('confirm-overlay').addEventListener('click', e => { if (e.target === document.getElementById('confirm-overlay')) closeConfirm(); }); +document.getElementById('settings-modal').addEventListener('click', e => { + if (e.target === document.getElementById('settings-modal')) closeSettingsModal(); +}); diff --git a/server/db.js b/server/db.js index b88e974..1a2e373 100644 --- a/server/db.js +++ b/server/db.js @@ -125,6 +125,21 @@ export function deleteInstance(vmid) { return db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid); } +export function importInstances(rows) { + db.exec('BEGIN'); + db.exec('DELETE FROM instances'); + const insert = db.prepare(` + INSERT INTO instances + (name, state, stack, vmid, atlas, argus, semaphore, patchmon, + tailscale, andromeda, tailscale_ip, hardware_acceleration) + VALUES + (@name, @state, @stack, @vmid, @atlas, @argus, @semaphore, @patchmon, + @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) + `); + for (const row of rows) insert.run(row); + db.exec('COMMIT'); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/routes.js b/server/routes.js index 79eaad6..4ad9048 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, + createInstance, updateInstance, deleteInstance, importInstances, } from './db.js'; export const router = Router(); @@ -104,6 +104,35 @@ router.put('/instances/:vmid', (req, res) => { } }); +// GET /api/export +router.get('/export', (_req, res) => { + const instances = getInstances(); + const date = new Date().toISOString().slice(0, 10); + res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); + res.json({ version: 1, exported_at: new Date().toISOString(), instances }); +}); + +// POST /api/import +router.post('/import', (req, res) => { + const { instances } = req.body ?? {}; + if (!Array.isArray(instances)) { + return res.status(400).json({ error: 'body must contain an instances array' }); + } + const errors = []; + for (const [i, row] of instances.entries()) { + const errs = validate(normalise(row)); + if (errs.length) errors.push({ index: i, errors: errs }); + } + if (errors.length) return res.status(400).json({ errors }); + try { + importInstances(instances.map(normalise)); + res.json({ imported: instances.length }); + } catch (e) { + console.error('POST /api/import', e); + res.status(500).json({ error: 'internal server error' }); + } +}); + // DELETE /api/instances/:vmid router.delete('/instances/:vmid', (req, res) => { const vmid = parseInt(req.params.vmid, 10); diff --git a/tests/api.test.js b/tests/api.test.js index c807c30..8742ae1 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -239,6 +239,52 @@ describe('DELETE /api/instances/:vmid', () => { }) }) +// ── GET /api/export ─────────────────────────────────────────────────────────── + +describe('GET /api/export', () => { + it('returns 200 with instances array and attachment header', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).get('/api/export') + expect(res.status).toBe(200) + expect(res.headers['content-disposition']).toMatch(/attachment/) + expect(res.body.instances).toHaveLength(1) + expect(res.body.instances[0].name).toBe('traefik') + }) + + it('returns empty instances array when no data', async () => { + const res = await request(app).get('/api/export') + expect(res.body.instances).toEqual([]) + }) +}) + +// ── POST /api/import ────────────────────────────────────────────────────────── + +describe('POST /api/import', () => { + it('replaces all instances and returns imported count', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, vmid: 999, name: 'imported' }] }) + expect(res.status).toBe(200) + expect(res.body.imported).toBe(1) + expect((await request(app).get('/api/instances')).body[0].name).toBe('imported') + }) + + it('returns 400 if instances is not an array', async () => { + expect((await request(app).post('/api/import').send({ instances: 'bad' })).status).toBe(400) + }) + + it('returns 400 with per-row errors for invalid rows', async () => { + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, name: '', vmid: 1 }] }) + expect(res.status).toBe(400) + expect(res.body.errors[0].index).toBe(0) + }) + + it('returns 400 if body has no instances key', async () => { + expect((await request(app).post('/api/import').send({})).status).toBe(400) + }) +}) + // ── Static assets & SPA routing ─────────────────────────────────────────────── describe('static assets and SPA routing', () => { diff --git a/tests/db.test.js b/tests/db.test.js index 746f2b5..7237615 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { _resetForTest, getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, + createInstance, updateInstance, deleteInstance, importInstances, } from '../server/db.js' beforeEach(() => _resetForTest()); @@ -166,6 +166,25 @@ describe('deleteInstance', () => { }); }); +// ── importInstances ─────────────────────────────────────────────────────────── + +describe('importInstances', () => { + const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 }; + + it('replaces all existing instances with the imported set', () => { + createInstance({ ...base, name: 'old', vmid: 1 }); + importInstances([{ ...base, name: 'new', vmid: 2 }]); + expect(getInstance(1)).toBeNull(); + expect(getInstance(2)).not.toBeNull(); + }); + + it('clears all instances when passed an empty array', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + importInstances([]); + expect(getInstances()).toEqual([]); + }); +}); + // ── Test environment boot isolation ─────────────────────────────────────────── describe('test environment boot isolation', () => {