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', () => {