20 Commits

Author SHA1 Message Date
9295354e72 Merge pull request 'v1.5.0' (#53) from dev into main
All checks were successful
CI / test (push) Successful in 14s
Release / release (push) Successful in 47s
CI / build-dev (push) Has been skipped
Reviewed-on: #53
2026-03-28 19:51:29 -04:00
372cda6a58 Merge pull request 'chore: bump version to 1.5.0' (#52) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 38s
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #52
2026-03-28 19:49:19 -04:00
3301e942ef Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:48:48 -04:00
c4ebb76deb chore: bump version to 1.5.0
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 19:48:16 -04:00
bb765453ab Merge pull request 'feat: include job config and run history in export/import backup' (#51) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 40s
Reviewed-on: #51
2026-03-28 19:44:37 -04:00
88474d1048 Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:44:05 -04:00
954d85ca81 feat: include job config and run history in export/import backup
All checks were successful
CI / test (pull_request) Successful in 16s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 19:43:34 -04:00
117dfc5f17 Merge pull request 'feat: add Semaphore Sync job' (#50) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 15s
CI / build-dev (push) Successful in 27s
Reviewed-on: #50
2026-03-28 19:35:47 -04:00
c39c7a8aef Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 19s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:35:10 -04:00
ea4c5f7c95 Merge pull request 'feat: add Patchmon Sync job' (#49) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 25s
Reviewed-on: #49
2026-03-28 19:24:12 -04:00
5c12acb6c7 Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:23:37 -04:00
db4071a2cf Merge pull request 'fix: move page-jobs inside main so it renders at the top' (#48) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 15s
CI / build-dev (push) Successful in 30s
Reviewed-on: #48
2026-03-28 19:15:38 -04:00
37cd77850e Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:15:07 -04:00
550135ca37 Merge pull request 'feat: jobs system with dedicated nav page and run history' (#47) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 26s
Reviewed-on: #47
2026-03-28 19:10:50 -04:00
07cef73fae Merge pull request 'v1.4.0' (#44) from dev into main
All checks were successful
CI / test (push) Successful in 14s
Release / release (push) Successful in 39s
CI / build-dev (push) Has been skipped
Reviewed-on: #44
2026-03-28 16:16:46 -04:00
e3d089a71f Merge pull request 'v1.3.1' (#39) from dev into main
All checks were successful
CI / test (push) Successful in 14s
Release / release (push) Successful in 42s
CI / build-dev (push) Has been skipped
Reviewed-on: #39
2026-03-28 15:42:00 -04:00
120b61a423 Merge pull request 'v1.3.0' (#35) from dev into main
All checks were successful
CI / test (push) Successful in 14s
Release / release (push) Successful in 40s
CI / build-dev (push) Has been skipped
Reviewed-on: #35
2026-03-28 15:31:57 -04:00
cd16b7ea28 Merge pull request 'v1.2.2' (#16) from dev into main
All checks were successful
CI / test (push) Successful in 13s
Release / release (push) Successful in 34s
CI / build-dev (push) Has been skipped
Reviewed-on: #16
2026-03-28 14:01:33 -04:00
afbdefa549 Merge pull request 'v1.2.1' (#13) from dev into main
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Has been skipped
Reviewed-on: #13
2026-03-28 13:55:34 -04:00
f1e192c5d4 Merge pull request 'v1.2.0' (#10) from dev into main
Some checks failed
CI / test (push) Successful in 13s
Release / release (push) Failing after 5m14s
CI / build-dev (push) Has been skipped
Reviewed-on: #10
2026-03-28 13:24:34 -04:00
6 changed files with 87 additions and 11 deletions

View File

@@ -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 {

View File

@@ -1 +1 @@
const VERSION = "1.4.0";
const VERSION = "1.5.0";

View File

@@ -1,6 +1,6 @@
{
"name": "catalyst",
"version": "1.4.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"start": "node server/server.js",

View File

@@ -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;

View File

@@ -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' });

View File

@@ -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 ───────────────────────────────────────────────