From 0b350f3b2857299a42f7cac8a601c75a5a2c2819 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 19:22:41 -0400 Subject: [PATCH] feat: add Patchmon Sync job 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 --- js/ui.js | 23 +++++++++++++++++++---- server/db.js | 24 ++++++++++++++---------- server/jobs.js | 32 ++++++++++++++++++++++++++++++++ server/routes.js | 6 ++++-- tests/api.test.js | 31 +++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 16 deletions(-) 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) + }) }) -- 2.39.5