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)
+ })
})