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 <noreply@anthropic.com>
This commit is contained in:
23
js/ui.js
23
js/ui.js
@@ -483,6 +483,17 @@ 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') 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"
|
||||||
|
placeholder="http://patchmon:3000/api/v1/api/hosts" value="${esc(cfg.api_url ?? '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="job-cfg-api-token">API Token (Basic)</label>
|
||||||
|
<input class="form-input" id="job-cfg-api-token" type="password"
|
||||||
|
placeholder="Basic token…" value="${esc(cfg.api_token ?? '')}">
|
||||||
|
</div>`;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,10 +512,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' },
|
||||||
|
|||||||
24
server/db.js
24
server/db.js
@@ -109,18 +109,22 @@ 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: '' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Queries ───────────────────────────────────────────────────────────────────
|
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -35,10 +35,42 @@ 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}` };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const HANDLERS = {
|
const HANDLERS = {
|
||||||
tailscale_sync: tailscaleSyncHandler,
|
tailscale_sync: tailscaleSyncHandler,
|
||||||
|
patchmon_sync: patchmonSyncHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,7 +18,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +195,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,
|
||||||
|
|||||||
@@ -460,6 +460,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 +481,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 +566,23 @@ 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user