From 954d85ca81ec9ac9c3f2c8638e38fe1a82abe8d3 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 19:43:34 -0400 Subject: [PATCH] feat: include job config and run history in export/import backup Export bumped to version 3, now includes jobs (with raw unmasked config) and job_runs arrays. Import restores them when present and restarts the scheduler. Payloads without a jobs key leave jobs untouched, keeping v1/v2 backups fully compatible. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 8 +++++--- server/db.js | 27 +++++++++++++++++++++++++++ server/routes.js | 18 ++++++++++++++---- tests/api.test.js | 41 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/js/ui.js b/js/ui.js index 4b7d51e..7156fc5 100644 --- a/js/ui.js +++ b/js/ui.js @@ -382,15 +382,17 @@ async function importDB() { document.getElementById('confirm-ok').onclick = async () => { closeConfirm(); try { - const { instances, history = [] } = JSON.parse(await file.text()); + const { instances, history = [], jobs, job_runs } = JSON.parse(await file.text()); const res = await fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ instances, history }), + body: JSON.stringify({ instances, history, jobs, job_runs }), }); 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'); + const parts = [`${data.imported} instance${data.imported !== 1 ? 's' : ''}`]; + if (data.imported_jobs != null) parts.push(`${data.imported_jobs} job${data.imported_jobs !== 1 ? 's' : ''}`); + showToast(`Imported ${parts.join(', ')}`, 'success'); closeSettingsModal(); renderDashboard(); } catch { diff --git a/server/db.js b/server/db.js index c0d0fd5..52ef555 100644 --- a/server/db.js +++ b/server/db.js @@ -235,6 +235,33 @@ export function getAllHistory() { return db.prepare('SELECT * FROM instance_history ORDER BY vmid, changed_at').all(); } +export function getAllJobs() { + return db.prepare('SELECT id, key, name, description, enabled, schedule, config FROM jobs ORDER BY id').all(); +} + +export function getAllJobRuns() { + return db.prepare('SELECT * FROM job_runs ORDER BY job_id, id').all(); +} + +export function importJobs(jobRows, jobRunRows = []) { + db.exec('BEGIN'); + db.exec('DELETE FROM job_runs'); + db.exec('DELETE FROM jobs'); + const insertJob = db.prepare(` + INSERT INTO jobs (id, key, name, description, enabled, schedule, config) + VALUES (@id, @key, @name, @description, @enabled, @schedule, @config) + `); + for (const j of jobRows) insertJob.run(j); + if (jobRunRows.length) { + const insertRun = db.prepare(` + INSERT INTO job_runs (id, job_id, started_at, ended_at, status, result) + VALUES (@id, @job_id, @started_at, @ended_at, @status, @result) + `); + for (const r of jobRunRows) insertRun.run(r); + } + db.exec('COMMIT'); +} + export function getConfig(key, defaultVal = '') { const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key); return row ? row.value : defaultVal; diff --git a/server/routes.js b/server/routes.js index 7140b84..eed881c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -3,6 +3,7 @@ import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns, + getAllJobs, getAllJobRuns, importJobs, } from './db.js'; import { runJob, restartJobs } from './jobs.js'; @@ -127,15 +128,17 @@ router.put('/instances/:vmid', (req, res) => { // GET /api/export router.get('/export', (_req, res) => { const instances = getInstances(); - const history = getAllHistory(); + const history = getAllHistory(); + const jobs = getAllJobs(); + const job_runs = getAllJobRuns(); const date = new Date().toISOString().slice(0, 10); res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); - res.json({ version: 2, exported_at: new Date().toISOString(), instances, history }); + res.json({ version: 3, exported_at: new Date().toISOString(), instances, history, jobs, job_runs }); }); // POST /api/import router.post('/import', (req, res) => { - const { instances, history = [] } = req.body ?? {}; + const { instances, history = [], jobs, job_runs } = req.body ?? {}; if (!Array.isArray(instances)) { return res.status(400).json({ error: 'body must contain an instances array' }); } @@ -147,7 +150,14 @@ router.post('/import', (req, res) => { if (errors.length) return res.status(400).json({ errors }); try { importInstances(instances.map(normalise), Array.isArray(history) ? history : []); - res.json({ imported: instances.length }); + if (Array.isArray(jobs)) { + importJobs(jobs, Array.isArray(job_runs) ? job_runs : []); + try { restartJobs(); } catch (e) { console.error('POST /api/import restartJobs', e); } + } + res.json({ + imported: instances.length, + imported_jobs: Array.isArray(jobs) ? jobs.length : undefined, + }); } catch (e) { console.error('POST /api/import', e); res.status(500).json({ error: 'internal server error' }); diff --git a/tests/api.test.js b/tests/api.test.js index 3cb7f1c..6b7e652 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -276,9 +276,9 @@ describe('GET /api/export', () => { expect(res.body.instances).toEqual([]) }) - it('returns version 2', async () => { + it('returns version 3', async () => { const res = await request(app).get('/api/export') - expect(res.body.version).toBe(2) + expect(res.body.version).toBe(3) }) it('includes a history array', async () => { @@ -287,6 +287,21 @@ describe('GET /api/export', () => { expect(res.body.history).toBeInstanceOf(Array) expect(res.body.history.some(e => e.field === 'created')).toBe(true) }) + + it('includes jobs and job_runs arrays', async () => { + createJob(testJob) + const res = await request(app).get('/api/export') + expect(res.body.jobs).toBeInstanceOf(Array) + expect(res.body.jobs).toHaveLength(1) + expect(res.body.jobs[0].key).toBe('tailscale_sync') + expect(res.body.job_runs).toBeInstanceOf(Array) + }) + + it('exports raw job config without masking', async () => { + createJob(testJob) + const res = await request(app).get('/api/export') + expect(res.body.jobs[0].config).toContain('tskey-test') + }) }) // ── POST /api/import ────────────────────────────────────────────────────────── @@ -341,6 +356,28 @@ describe('POST /api/import', () => { expect(res.status).toBe(200) expect(res.body.imported).toBe(1) }) + + it('imports jobs and job_runs and returns imported_jobs count', async () => { + const exp = await request(app).get('/api/export') + createJob(testJob) + const fullExport = await request(app).get('/api/export') + const res = await request(app).post('/api/import').send({ + instances: fullExport.body.instances, + history: fullExport.body.history, + jobs: fullExport.body.jobs, + job_runs: fullExport.body.job_runs, + }) + expect(res.status).toBe(200) + expect(res.body.imported_jobs).toBe(1) + expect((await request(app).get('/api/jobs')).body).toHaveLength(1) + }) + + it('leaves jobs untouched when no jobs key in payload', async () => { + createJob(testJob) + await request(app).post('/api/import') + .send({ instances: [{ ...base, vmid: 1, name: 'x' }] }) + expect((await request(app).get('/api/jobs')).body).toHaveLength(1) + }) }) // ── Static assets & SPA routing ───────────────────────────────────────────────