diff --git a/js/ui.js b/js/ui.js index d31f385..96aae8a 100644 --- a/js/ui.js +++ b/js/ui.js @@ -483,6 +483,17 @@ function _renderJobConfigFields(key, cfg) { `; + if (key === 'patchmon_sync') return ` +
+ + +
+
+ + +
`; return ''; } @@ -501,10 +512,14 @@ async function saveJobDetail(jobId) { const enabled = document.getElementById('job-enabled').checked; const schedule = document.getElementById('job-schedule').value; const cfg = {}; - const tailnet = document.getElementById('job-cfg-tailnet'); - const apiKey = document.getElementById('job-cfg-api-key'); - if (tailnet) cfg.tailnet = tailnet.value.trim(); - if (apiKey) cfg.api_key = apiKey.value; + const tailnet = document.getElementById('job-cfg-tailnet'); + const apiKey = document.getElementById('job-cfg-api-key'); + const apiUrl = document.getElementById('job-cfg-api-url'); + 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}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/server/db.js b/server/db.js index 80c7e3c..660f654 100644 --- a/server/db.js +++ b/server/db.js @@ -109,18 +109,22 @@ function seed() { } function seedJobs() { - const count = db.prepare('SELECT COUNT(*) as n FROM jobs').get().n; - if (count > 0) return; + const upsert = db.prepare(` + INSERT OR IGNORE INTO jobs (key, name, description, enabled, schedule, config) + VALUES (?, ?, ?, ?, ?, ?) + `); + const apiKey = getConfig('tailscale_api_key'); const tailnet = getConfig('tailscale_tailnet'); - const schedule = parseInt(getConfig('tailscale_poll_minutes', '15'), 10) || 15; - const enabled = getConfig('tailscale_enabled') === '1' ? 1 : 0; - db.prepare(` - INSERT INTO jobs (key, name, description, enabled, schedule, config) - VALUES ('tailscale_sync', 'Tailscale Sync', - 'Syncs Tailscale device status and IPs to instances by matching hostnames.', - ?, ?, ?) - `).run(enabled, schedule, JSON.stringify({ api_key: apiKey, tailnet })); + const tsSchedule = parseInt(getConfig('tailscale_poll_minutes', '15'), 10) || 15; + const tsEnabled = getConfig('tailscale_enabled') === '1' ? 1 : 0; + upsert.run('tailscale_sync', 'Tailscale Sync', + 'Syncs Tailscale device status and IPs to instances by matching hostnames.', + tsEnabled, tsSchedule, JSON.stringify({ api_key: apiKey, tailnet })); + + upsert.run('patchmon_sync', 'Patchmon Sync', + '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: '' })); } // ── Queries ─────────────────────────────────────────────────────────────────── diff --git a/server/jobs.js b/server/jobs.js index 4cc1a20..cd2070b 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -35,10 +35,42 @@ async function tailscaleSyncHandler(cfg) { 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}` }; +} + // ── Registry ────────────────────────────────────────────────────────────────── const HANDLERS = { tailscale_sync: tailscaleSyncHandler, + patchmon_sync: patchmonSyncHandler, }; // ── Public API ──────────────────────────────────────────────────────────────── diff --git a/server/routes.js b/server/routes.js index 59f0fe1..7140b84 100644 --- a/server/routes.js +++ b/server/routes.js @@ -18,7 +18,8 @@ const REDACTED = '**REDACTED**'; function maskJob(job) { 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 }; } @@ -194,7 +195,8 @@ router.put('/jobs/:id', (req, res) => { const { enabled, schedule, config: newCfg } = req.body ?? {}; const existingCfg = JSON.parse(job.config || '{}'); 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, { enabled: enabled != null ? (enabled ? 1 : 0) : job.enabled, schedule: schedule != null ? (parseInt(schedule, 10) || 15) : job.schedule, diff --git a/tests/api.test.js b/tests/api.test.js index 8dd1085..7654980 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -460,6 +460,12 @@ const testJob = { 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 ───────────────────────────────────────────────────────────── describe('GET /api/jobs', () => { @@ -475,6 +481,12 @@ describe('GET /api/jobs', () => { expect(res.body).toHaveLength(1) 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 ───────────────────────────────────────────────────────── @@ -554,4 +566,23 @@ describe('POST /api/jobs/:id/run', () => { const detail = await request(app).get(`/api/jobs/${id}`) 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) + }) })