feat: jobs system with dedicated nav page and run history
Replaces ad-hoc Tailscale config tracking with a proper jobs system. Jobs get their own nav page (master/detail layout), a dedicated DB table, and full run history persisted forever. Tailscale connection settings move from the Settings modal into the Jobs page. Registry pattern makes adding future jobs straightforward. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import { app } from '../server/server.js'
|
||||
import { _resetForTest } from '../server/db.js'
|
||||
import { _resetForTest, createJob } from '../server/db.js'
|
||||
import * as dbModule from '../server/db.js'
|
||||
|
||||
beforeEach(() => _resetForTest())
|
||||
@@ -454,72 +454,104 @@ describe('error handling — unexpected DB failures', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config ───────────────────────────────────────────────────────────
|
||||
const testJob = {
|
||||
key: 'tailscale_sync', name: 'Tailscale Sync', description: 'Test job',
|
||||
enabled: 0, schedule: 15,
|
||||
config: JSON.stringify({ api_key: 'tskey-test', tailnet: 'example.com' }),
|
||||
}
|
||||
|
||||
describe('GET /api/config', () => {
|
||||
it('returns 200 with all config keys', async () => {
|
||||
const res = await request(app).get('/api/config')
|
||||
// ── GET /api/jobs ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/jobs', () => {
|
||||
it('returns empty array when no jobs', async () => {
|
||||
const res = await request(app).get('/api/jobs')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toHaveProperty('tailscale_enabled')
|
||||
expect(res.body).toHaveProperty('tailscale_api_key')
|
||||
expect(res.body).toHaveProperty('tailscale_poll_minutes')
|
||||
expect(res.body).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty string for api key when not set', async () => {
|
||||
expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('')
|
||||
})
|
||||
|
||||
it('masks api key as **REDACTED** when set', async () => {
|
||||
await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-secret' })
|
||||
expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('**REDACTED**')
|
||||
it('returns jobs with masked api key', async () => {
|
||||
createJob(testJob)
|
||||
const res = await request(app).get('/api/jobs')
|
||||
expect(res.body).toHaveLength(1)
|
||||
expect(res.body[0].config.api_key).toBe('**REDACTED**')
|
||||
})
|
||||
})
|
||||
|
||||
// ── PUT /api/config ───────────────────────────────────────────────────────────
|
||||
// ── GET /api/jobs/:id ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('PUT /api/config', () => {
|
||||
it('saves config and returns ok', async () => {
|
||||
const res = await request(app).put('/api/config').send({ tailscale_tailnet: 'example.com' })
|
||||
describe('GET /api/jobs/:id', () => {
|
||||
it('returns job with runs array', async () => {
|
||||
createJob(testJob)
|
||||
const id = (await request(app).get('/api/jobs')).body[0].id
|
||||
const res = await request(app).get(`/api/jobs/${id}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.ok).toBe(true)
|
||||
expect(res.body.runs).toBeInstanceOf(Array)
|
||||
})
|
||||
|
||||
it('does not overwrite api key when **REDACTED** is sent', async () => {
|
||||
await request(app).put('/api/config').send({ tailscale_api_key: 'real-key' })
|
||||
await request(app).put('/api/config').send({ tailscale_api_key: '**REDACTED**' })
|
||||
expect(dbModule.getConfig('tailscale_api_key')).toBe('real-key')
|
||||
it('returns 404 for unknown id', async () => {
|
||||
expect((await request(app).get('/api/jobs/999')).status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 for non-numeric id', async () => {
|
||||
expect((await request(app).get('/api/jobs/abc')).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /api/jobs/tailscale/run ──────────────────────────────────────────────
|
||||
// ── PUT /api/jobs/:id ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/jobs/tailscale/run', () => {
|
||||
describe('PUT /api/jobs/:id', () => {
|
||||
it('updates enabled and schedule', async () => {
|
||||
createJob(testJob)
|
||||
const id = (await request(app).get('/api/jobs')).body[0].id
|
||||
const res = await request(app).put(`/api/jobs/${id}`).send({ enabled: true, schedule: 30 })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.enabled).toBe(1)
|
||||
expect(res.body.schedule).toBe(30)
|
||||
})
|
||||
|
||||
it('does not overwrite api_key when **REDACTED** is sent', async () => {
|
||||
createJob(testJob)
|
||||
const id = (await request(app).get('/api/jobs')).body[0].id
|
||||
await request(app).put(`/api/jobs/${id}`).send({ config: { api_key: '**REDACTED**' } })
|
||||
expect(dbModule.getJob(id).config).toContain('tskey-test')
|
||||
})
|
||||
|
||||
it('returns 404 for unknown id', async () => {
|
||||
expect((await request(app).put('/api/jobs/999').send({})).status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /api/jobs/:id/run ────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/jobs/:id/run', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('returns 400 when not configured', async () => {
|
||||
const res = await request(app).post('/api/jobs/tailscale/run')
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toMatch(/not configured/i)
|
||||
it('returns 404 for unknown id', async () => {
|
||||
expect((await request(app).post('/api/jobs/999/run')).status).toBe(404)
|
||||
})
|
||||
|
||||
it('updates matching instance and returns count', async () => {
|
||||
await request(app).put('/api/config').send({
|
||||
tailscale_api_key: 'tskey-test',
|
||||
tailscale_tailnet: 'example.com',
|
||||
})
|
||||
await request(app).post('/api/instances').send({ ...base, name: 'traefik', vmid: 100 })
|
||||
|
||||
it('runs job, returns summary, and logs the run', async () => {
|
||||
createJob(testJob)
|
||||
const id = (await request(app).get('/api/jobs')).body[0].id
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ devices: [{ hostname: 'traefik', addresses: ['100.64.0.2'] }] }),
|
||||
json: async () => ({ devices: [] }),
|
||||
}))
|
||||
|
||||
const res = await request(app).post('/api/jobs/tailscale/run')
|
||||
const res = await request(app).post(`/api/jobs/${id}/run`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.updated).toBe(1)
|
||||
expect(res.body.summary).toBeDefined()
|
||||
const detail = await request(app).get(`/api/jobs/${id}`)
|
||||
expect(detail.body.runs).toHaveLength(1)
|
||||
expect(detail.body.runs[0].status).toBe('success')
|
||||
})
|
||||
|
||||
const inst = await request(app).get('/api/instances/100')
|
||||
expect(inst.body.tailscale).toBe(1)
|
||||
expect(inst.body.tailscale_ip).toBe('100.64.0.2')
|
||||
it('logs error run on failure', async () => {
|
||||
createJob(testJob)
|
||||
const id = (await request(app).get('/api/jobs')).body[0].id
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new Error('network error')))
|
||||
const res = await request(app).post(`/api/jobs/${id}/run`)
|
||||
expect(res.status).toBe(500)
|
||||
const detail = await request(app).get(`/api/jobs/${id}`)
|
||||
expect(detail.body.runs[0].status).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getInstances, getInstance, getDistinctStacks,
|
||||
createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory,
|
||||
getConfig, setConfig,
|
||||
getJobs, getJob, createJob, updateJob, createJobRun, completeJobRun, getJobRuns,
|
||||
} from '../server/db.js'
|
||||
|
||||
beforeEach(() => _resetForTest());
|
||||
@@ -299,3 +300,89 @@ describe('getConfig / setConfig', () => {
|
||||
expect(getConfig('tailscale_api_key')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── jobs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const baseJob = {
|
||||
key: 'test_job', name: 'Test Job', description: 'desc',
|
||||
enabled: 0, schedule: 15, config: '{}',
|
||||
};
|
||||
|
||||
describe('jobs', () => {
|
||||
it('returns empty array when no jobs', () => {
|
||||
expect(getJobs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('createJob + getJobs returns the job', () => {
|
||||
createJob(baseJob);
|
||||
expect(getJobs()).toHaveLength(1);
|
||||
expect(getJobs()[0].name).toBe('Test Job');
|
||||
});
|
||||
|
||||
it('getJob returns null for unknown id', () => {
|
||||
expect(getJob(999)).toBeNull();
|
||||
});
|
||||
|
||||
it('updateJob changes enabled and schedule', () => {
|
||||
createJob(baseJob);
|
||||
const id = getJobs()[0].id;
|
||||
updateJob(id, { enabled: 1, schedule: 30, config: '{}' });
|
||||
expect(getJob(id).enabled).toBe(1);
|
||||
expect(getJob(id).schedule).toBe(30);
|
||||
});
|
||||
|
||||
it('getJobs includes last_status null when no runs', () => {
|
||||
createJob(baseJob);
|
||||
expect(getJobs()[0].last_status).toBeNull();
|
||||
});
|
||||
|
||||
it('getJobs reflects last_status after a run', () => {
|
||||
createJob(baseJob);
|
||||
const id = getJobs()[0].id;
|
||||
const runId = createJobRun(id);
|
||||
completeJobRun(runId, 'success', 'ok');
|
||||
expect(getJobs()[0].last_status).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
// ── job_runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('job_runs', () => {
|
||||
it('createJobRun returns a positive id', () => {
|
||||
createJob(baseJob);
|
||||
const id = getJobs()[0].id;
|
||||
expect(createJobRun(id)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('new run has status running and no ended_at', () => {
|
||||
createJob(baseJob);
|
||||
const id = getJobs()[0].id;
|
||||
const runId = createJobRun(id);
|
||||
const runs = getJobRuns(id);
|
||||
expect(runs[0].status).toBe('running');
|
||||
expect(runs[0].ended_at).toBeNull();
|
||||
});
|
||||
|
||||
it('completeJobRun sets status, result, and ended_at', () => {
|
||||
createJob(baseJob);
|
||||
const id = getJobs()[0].id;
|
||||
const runId = createJobRun(id);
|
||||
completeJobRun(runId, 'success', '2 updated of 8');
|
||||
const run = getJobRuns(id)[0];
|
||||
expect(run.status).toBe('success');
|
||||
expect(run.result).toBe('2 updated of 8');
|
||||
expect(run.ended_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('getJobRuns returns newest first', () => {
|
||||
createJob(baseJob);
|
||||
const id = getJobs()[0].id;
|
||||
const r1 = createJobRun(id);
|
||||
const r2 = createJobRun(id);
|
||||
completeJobRun(r1, 'success', 'first');
|
||||
completeJobRun(r2, 'error', 'second');
|
||||
const runs = getJobRuns(id);
|
||||
expect(runs[0].id).toBe(r2);
|
||||
expect(runs[1].id).toBe(r1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user