20 Commits

Author SHA1 Message Date
josh 9295354e72 Merge pull request 'v1.5.0' (#53) from dev into main
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
josh 372cda6a58 Merge pull request 'chore: bump version to 1.5.0' (#52) from feat/jobs-system into dev
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
josh 3301e942ef Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:48:48 -04:00
josh c4ebb76deb chore: bump version to 1.5.0
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
josh bb765453ab Merge pull request 'feat: include job config and run history in export/import backup' (#51) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 40s
Reviewed-on: #51
2026-03-28 19:44:37 -04:00
josh 88474d1048 Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:44:05 -04:00
josh 954d85ca81 feat: include job config and run history in export/import backup
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
josh 117dfc5f17 Merge pull request 'feat: add Semaphore Sync job' (#50) from feat/jobs-system into dev
CI / test (push) Successful in 15s
CI / build-dev (push) Successful in 27s
Reviewed-on: #50
2026-03-28 19:35:47 -04:00
josh c39c7a8aef Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 19s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:35:10 -04:00
josh a934db1a14 feat: add Semaphore Sync job
CI / test (pull_request) Successful in 15s
CI / build-dev (pull_request) Has been skipped
Fetches Semaphore project inventory via Bearer auth, parses the
Ansible INI format to extract hostnames, and sets semaphore=1/0
on matching instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 19:34:45 -04:00
josh ea4c5f7c95 Merge pull request 'feat: add Patchmon Sync job' (#49) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 25s
Reviewed-on: #49
2026-03-28 19:24:12 -04:00
josh 5c12acb6c7 Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:23:37 -04:00
josh 0b350f3b28 feat: add Patchmon Sync job
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Syncs patchmon field on instances by querying the Patchmon hosts API
and matching hostnames. API token masked as REDACTED in responses.
seedJobs now uses INSERT OR IGNORE so new jobs are seeded on existing
installs without re-running the full seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 19:22:41 -04:00
josh db4071a2cf Merge pull request 'fix: move page-jobs inside main so it renders at the top' (#48) from feat/jobs-system into dev
CI / test (push) Successful in 15s
CI / build-dev (push) Successful in 30s
Reviewed-on: #48
2026-03-28 19:15:38 -04:00
josh 07cef73fae Merge pull request 'v1.4.0' (#44) from dev into main
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
josh e3d089a71f Merge pull request 'v1.3.1' (#39) from dev into main
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
josh 120b61a423 Merge pull request 'v1.3.0' (#35) from dev into main
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
josh cd16b7ea28 Merge pull request 'v1.2.2' (#16) from dev into main
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
josh afbdefa549 Merge pull request 'v1.2.1' (#13) from dev into main
CI / test (push) Successful in 14s
CI / build-dev (push) Has been skipped
Reviewed-on: #13
2026-03-28 13:55:34 -04:00
josh f1e192c5d4 Merge pull request 'v1.2.0' (#10) from dev into main
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
7 changed files with 247 additions and 28 deletions
+27 -7
View File
@@ -382,15 +382,17 @@ async function importDB() {
document.getElementById('confirm-ok').onclick = async () => { document.getElementById('confirm-ok').onclick = async () => {
closeConfirm(); closeConfirm();
try { 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', { const res = await fetch('/api/import', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instances, history }), body: JSON.stringify({ instances, history, jobs, job_runs }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } 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(); closeSettingsModal();
renderDashboard(); renderDashboard();
} catch { } catch {
@@ -483,6 +485,20 @@ function _renderJobConfigFields(key, cfg) {
<input class="form-input" id="job-cfg-api-key" type="password" <input class="form-input" id="job-cfg-api-key" type="password"
placeholder="tskey-api-…" value="${esc(cfg.api_key ?? '')}"> placeholder="tskey-api-…" value="${esc(cfg.api_key ?? '')}">
</div>`; </div>`;
if (key === 'patchmon_sync' || key === 'semaphore_sync') {
const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)';
return `
<div class="form-group">
<label class="form-label" for="job-cfg-api-url">API URL</label>
<input class="form-input" id="job-cfg-api-url" type="text"
value="${esc(cfg.api_url ?? '')}">
</div>
<div class="form-group">
<label class="form-label" for="job-cfg-api-token">${label}</label>
<input class="form-input" id="job-cfg-api-token" type="password"
value="${esc(cfg.api_token ?? '')}">
</div>`;
}
return ''; return '';
} }
@@ -501,10 +517,14 @@ async function saveJobDetail(jobId) {
const enabled = document.getElementById('job-enabled').checked; const enabled = document.getElementById('job-enabled').checked;
const schedule = document.getElementById('job-schedule').value; const schedule = document.getElementById('job-schedule').value;
const cfg = {}; const cfg = {};
const tailnet = document.getElementById('job-cfg-tailnet'); const tailnet = document.getElementById('job-cfg-tailnet');
const apiKey = document.getElementById('job-cfg-api-key'); const apiKey = document.getElementById('job-cfg-api-key');
if (tailnet) cfg.tailnet = tailnet.value.trim(); const apiUrl = document.getElementById('job-cfg-api-url');
if (apiKey) cfg.api_key = apiKey.value; const apiToken = document.getElementById('job-cfg-api-token');
if (tailnet) cfg.tailnet = tailnet.value.trim();
if (apiKey) cfg.api_key = apiKey.value;
if (apiUrl) cfg.api_url = apiUrl.value.trim();
if (apiToken) cfg.api_token = apiToken.value;
const res = await fetch(`/api/jobs/${jobId}`, { const res = await fetch(`/api/jobs/${jobId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+1 -1
View File
@@ -1 +1 @@
const VERSION = "1.4.0"; const VERSION = "1.5.0";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.4.0", "version": "1.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",
+45 -10
View File
@@ -109,18 +109,26 @@ function seed() {
} }
function seedJobs() { function seedJobs() {
const count = db.prepare('SELECT COUNT(*) as n FROM jobs').get().n; const upsert = db.prepare(`
if (count > 0) return; INSERT OR IGNORE INTO jobs (key, name, description, enabled, schedule, config)
VALUES (?, ?, ?, ?, ?, ?)
`);
const apiKey = getConfig('tailscale_api_key'); const apiKey = getConfig('tailscale_api_key');
const tailnet = getConfig('tailscale_tailnet'); const tailnet = getConfig('tailscale_tailnet');
const schedule = parseInt(getConfig('tailscale_poll_minutes', '15'), 10) || 15; const tsSchedule = parseInt(getConfig('tailscale_poll_minutes', '15'), 10) || 15;
const enabled = getConfig('tailscale_enabled') === '1' ? 1 : 0; const tsEnabled = getConfig('tailscale_enabled') === '1' ? 1 : 0;
db.prepare(` upsert.run('tailscale_sync', 'Tailscale Sync',
INSERT INTO jobs (key, name, description, enabled, schedule, config) 'Syncs Tailscale device status and IPs to instances by matching hostnames.',
VALUES ('tailscale_sync', 'Tailscale Sync', tsEnabled, tsSchedule, JSON.stringify({ api_key: apiKey, tailnet }));
'Syncs Tailscale device status and IPs to instances by matching hostnames.',
?, ?, ?) upsert.run('patchmon_sync', 'Patchmon Sync',
`).run(enabled, schedule, JSON.stringify({ api_key: apiKey, tailnet })); 'Syncs Patchmon host registration status to instances by matching hostnames.',
0, 60, JSON.stringify({ api_url: 'http://patchmon:3000/api/v1/api/hosts', api_token: '' }));
upsert.run('semaphore_sync', 'Semaphore Sync',
'Syncs Semaphore inventory membership to instances by matching hostnames.',
0, 60, JSON.stringify({ api_url: 'http://semaphore:3000/api/project/1/inventory/1', api_token: '' }));
} }
// ── Queries ─────────────────────────────────────────────────────────────────── // ── Queries ───────────────────────────────────────────────────────────────────
@@ -227,6 +235,33 @@ export function getAllHistory() {
return db.prepare('SELECT * FROM instance_history ORDER BY vmid, changed_at').all(); 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 = '') { export function getConfig(key, defaultVal = '') {
const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key); const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key);
return row ? row.value : defaultVal; return row ? row.value : defaultVal;
+68 -1
View File
@@ -35,10 +35,77 @@ async function tailscaleSyncHandler(cfg) {
return { summary: `${updated} updated of ${instances.length}` }; return { summary: `${updated} updated of ${instances.length}` };
} }
// ── Patchmon Sync ─────────────────────────────────────────────────────────────
async function patchmonSyncHandler(cfg) {
const { api_url, api_token } = cfg;
if (!api_url || !api_token) throw new Error('Patchmon not configured — set API URL and token');
const res = await fetch(api_url, {
headers: { Authorization: `Basic ${api_token}` },
});
if (!res.ok) throw new Error(`Patchmon API ${res.status}`);
const data = await res.json();
const items = Array.isArray(data) ? data : (data.hosts ?? data.data ?? []);
const hostSet = new Set(
items.map(h => (typeof h === 'string' ? h : (h.name ?? h.hostname ?? h.host ?? '')))
.filter(Boolean)
);
const instances = getInstances();
let updated = 0;
for (const inst of instances) {
const newPatchmon = hostSet.has(inst.name) ? 1 : 0;
if (newPatchmon !== inst.patchmon) {
const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst;
updateInstance(inst.vmid, { ...instData, patchmon: newPatchmon });
updated++;
}
}
return { summary: `${updated} updated of ${instances.length}` };
}
// ── Semaphore Sync ────────────────────────────────────────────────────────────
async function semaphoreSyncHandler(cfg) {
const { api_url, api_token } = cfg;
if (!api_url || !api_token) throw new Error('Semaphore not configured — set API URL and token');
const res = await fetch(api_url, {
headers: { Authorization: `Bearer ${api_token}` },
});
if (!res.ok) throw new Error(`Semaphore API ${res.status}`);
const data = await res.json();
// Inventory is an Ansible INI string; extract bare hostnames
const hostSet = new Set(
(data.inventory ?? '').split('\n')
.map(l => l.trim())
.filter(l => l && !l.startsWith('[') && !l.startsWith('#') && !l.startsWith(';'))
.map(l => l.split(/[\s=]/)[0])
.filter(Boolean)
);
const instances = getInstances();
let updated = 0;
for (const inst of instances) {
const newSemaphore = hostSet.has(inst.name) ? 1 : 0;
if (newSemaphore !== inst.semaphore) {
const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst;
updateInstance(inst.vmid, { ...instData, semaphore: newSemaphore });
updated++;
}
}
return { summary: `${updated} updated of ${instances.length}` };
}
// ── Registry ────────────────────────────────────────────────────────────────── // ── Registry ──────────────────────────────────────────────────────────────────
const HANDLERS = { const HANDLERS = {
tailscale_sync: tailscaleSyncHandler, tailscale_sync: tailscaleSyncHandler,
patchmon_sync: patchmonSyncHandler,
semaphore_sync: semaphoreSyncHandler,
}; };
// ── Public API ──────────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────────
+18 -6
View File
@@ -3,6 +3,7 @@ import {
getInstances, getInstance, getDistinctStacks, getInstances, getInstance, getDistinctStacks,
createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory,
getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns, getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns,
getAllJobs, getAllJobRuns, importJobs,
} from './db.js'; } from './db.js';
import { runJob, restartJobs } from './jobs.js'; import { runJob, restartJobs } from './jobs.js';
@@ -18,7 +19,8 @@ const REDACTED = '**REDACTED**';
function maskJob(job) { function maskJob(job) {
const cfg = JSON.parse(job.config || '{}'); const cfg = JSON.parse(job.config || '{}');
if (cfg.api_key) cfg.api_key = REDACTED; if (cfg.api_key) cfg.api_key = REDACTED;
if (cfg.api_token) cfg.api_token = REDACTED;
return { ...job, config: cfg }; return { ...job, config: cfg };
} }
@@ -126,15 +128,17 @@ router.put('/instances/:vmid', (req, res) => {
// GET /api/export // GET /api/export
router.get('/export', (_req, res) => { router.get('/export', (_req, res) => {
const instances = getInstances(); const instances = getInstances();
const history = getAllHistory(); const history = getAllHistory();
const jobs = getAllJobs();
const job_runs = getAllJobRuns();
const date = new Date().toISOString().slice(0, 10); const date = new Date().toISOString().slice(0, 10);
res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); 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 // POST /api/import
router.post('/import', (req, res) => { router.post('/import', (req, res) => {
const { instances, history = [] } = req.body ?? {}; const { instances, history = [], jobs, job_runs } = req.body ?? {};
if (!Array.isArray(instances)) { if (!Array.isArray(instances)) {
return res.status(400).json({ error: 'body must contain an instances array' }); return res.status(400).json({ error: 'body must contain an instances array' });
} }
@@ -146,7 +150,14 @@ router.post('/import', (req, res) => {
if (errors.length) return res.status(400).json({ errors }); if (errors.length) return res.status(400).json({ errors });
try { try {
importInstances(instances.map(normalise), Array.isArray(history) ? history : []); 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) { } catch (e) {
console.error('POST /api/import', e); console.error('POST /api/import', e);
res.status(500).json({ error: 'internal server error' }); res.status(500).json({ error: 'internal server error' });
@@ -194,7 +205,8 @@ router.put('/jobs/:id', (req, res) => {
const { enabled, schedule, config: newCfg } = req.body ?? {}; const { enabled, schedule, config: newCfg } = req.body ?? {};
const existingCfg = JSON.parse(job.config || '{}'); const existingCfg = JSON.parse(job.config || '{}');
const mergedCfg = { ...existingCfg, ...(newCfg ?? {}) }; const mergedCfg = { ...existingCfg, ...(newCfg ?? {}) };
if (newCfg?.api_key === REDACTED) mergedCfg.api_key = existingCfg.api_key; if (newCfg?.api_key === REDACTED) mergedCfg.api_key = existingCfg.api_key;
if (newCfg?.api_token === REDACTED) mergedCfg.api_token = existingCfg.api_token;
updateJob(id, { updateJob(id, {
enabled: enabled != null ? (enabled ? 1 : 0) : job.enabled, enabled: enabled != null ? (enabled ? 1 : 0) : job.enabled,
schedule: schedule != null ? (parseInt(schedule, 10) || 15) : job.schedule, schedule: schedule != null ? (parseInt(schedule, 10) || 15) : job.schedule,
+87 -2
View File
@@ -276,9 +276,9 @@ describe('GET /api/export', () => {
expect(res.body.instances).toEqual([]) expect(res.body.instances).toEqual([])
}) })
it('returns version 2', async () => { it('returns version 3', async () => {
const res = await request(app).get('/api/export') 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 () => { it('includes a history array', async () => {
@@ -287,6 +287,21 @@ describe('GET /api/export', () => {
expect(res.body.history).toBeInstanceOf(Array) expect(res.body.history).toBeInstanceOf(Array)
expect(res.body.history.some(e => e.field === 'created')).toBe(true) 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 ────────────────────────────────────────────────────────── // ── POST /api/import ──────────────────────────────────────────────────────────
@@ -341,6 +356,28 @@ describe('POST /api/import', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.imported).toBe(1) 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 ─────────────────────────────────────────────── // ── Static assets & SPA routing ───────────────────────────────────────────────
@@ -460,6 +497,12 @@ const testJob = {
config: JSON.stringify({ api_key: 'tskey-test', tailnet: 'example.com' }), config: JSON.stringify({ api_key: 'tskey-test', tailnet: 'example.com' }),
} }
const patchmonJob = {
key: 'patchmon_sync', name: 'Patchmon Sync', description: 'Test patchmon job',
enabled: 0, schedule: 60,
config: JSON.stringify({ api_url: 'http://patchmon:3000/api/v1/api/hosts', api_token: 'secret-token' }),
}
// ── GET /api/jobs ───────────────────────────────────────────────────────────── // ── GET /api/jobs ─────────────────────────────────────────────────────────────
describe('GET /api/jobs', () => { describe('GET /api/jobs', () => {
@@ -475,6 +518,12 @@ describe('GET /api/jobs', () => {
expect(res.body).toHaveLength(1) expect(res.body).toHaveLength(1)
expect(res.body[0].config.api_key).toBe('**REDACTED**') expect(res.body[0].config.api_key).toBe('**REDACTED**')
}) })
it('returns jobs with masked api_token', async () => {
createJob(patchmonJob)
const res = await request(app).get('/api/jobs')
expect(res.body[0].config.api_token).toBe('**REDACTED**')
})
}) })
// ── GET /api/jobs/:id ───────────────────────────────────────────────────────── // ── GET /api/jobs/:id ─────────────────────────────────────────────────────────
@@ -554,4 +603,40 @@ describe('POST /api/jobs/:id/run', () => {
const detail = await request(app).get(`/api/jobs/${id}`) const detail = await request(app).get(`/api/jobs/${id}`)
expect(detail.body.runs[0].status).toBe('error') expect(detail.body.runs[0].status).toBe('error')
}) })
it('patchmon_sync: marks instances present in host list as patchmon=1', async () => {
createJob(patchmonJob)
const id = (await request(app).get('/api/jobs')).body[0].id
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => [{ name: 'plex' }, { name: 'traefik' }],
}))
const res = await request(app).post(`/api/jobs/${id}/run`)
expect(res.status).toBe(200)
expect(res.body.summary).toMatch(/updated of/)
})
it('patchmon_sync: returns 500 when API token is missing', async () => {
createJob({ ...patchmonJob, config: JSON.stringify({ api_url: 'http://patchmon:3000/api/v1/api/hosts', api_token: '' }) })
const id = (await request(app).get('/api/jobs')).body[0].id
const res = await request(app).post(`/api/jobs/${id}/run`)
expect(res.status).toBe(500)
})
it('semaphore_sync: parses ansible inventory and updates instances', async () => {
const semaphoreJob = {
key: 'semaphore_sync', name: 'Semaphore Sync', description: 'test',
enabled: 0, schedule: 60,
config: JSON.stringify({ api_url: 'http://semaphore:3000/api/project/1/inventory/1', api_token: 'bearer-token' }),
}
createJob(semaphoreJob)
const id = (await request(app).get('/api/jobs')).body[0].id
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ inventory: '[production]\nplex\nhomeassistant\n' }),
}))
const res = await request(app).post(`/api/jobs/${id}/run`)
expect(res.status).toBe(200)
expect(res.body.summary).toMatch(/updated of/)
})
}) })