56 Commits

Author SHA1 Message Date
josh 194cd3c175 Merge pull request 'fix: base64-encode Patchmon Basic auth credentials server-side' (#77) from dev into main
CI / test (push) Successful in 10s
Release / release (push) Successful in 23s
CI / build-dev (push) Has been skipped
Reviewed-on: #77
2026-05-30 19:04:27 -04:00
josh 72b8d60985 Merge pull request 'Merge pull request 'test: add failing tests for sort/order on GET /api/instances' (#71) from dev into main' (#76) from merge/main-into-dev-v1.7.1 into dev
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 15s
CI / build-dev (push) Successful in 15s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #76
2026-05-30 19:03:24 -04:00
josh 917fb26e05 Merge branch 'main' into dev — resolve 1.7.0/1.7.1 version overlap, keep 1.7.1
CI / test (pull_request) Successful in 11s
CI / build-dev (pull_request) Has been skipped
# Conflicts:
#	package.json
2026-05-30 19:00:49 -04:00
josh a28867b398 Merge pull request 'fix: base64-encode Patchmon Basic auth credentials server-side' (#74) from fix/patchmon-sync-basic-auth into dev
CI / test (push) Successful in 9s
CI / build-dev (push) Successful in 23s
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #74
2026-05-30 18:54:53 -04:00
josh ac71ea2c49 chore: bump to version 1.7.1
CI / test (pull_request) Successful in 19s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:51:27 -04:00
josh efa1750cac fix: base64-encode Patchmon Basic auth credentials server-side
Patchmon's API uses standard RFC 7617 Basic auth — `Basic base64(token_key:token_secret)`. The handler was sending the api_token field verbatim, so it only worked if the user had manually base64-encoded the credential. After a Patchmon upgrade, the sync started returning HTML (the SPA, served when auth is rejected) and failing with "Unexpected token '<'" on JSON.parse.

Now: if the token contains ':' (raw key:secret), encode it server-side; otherwise pass through unchanged for backward compatibility. UI gets a placeholder hint showing the expected format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:33:41 -04:00
josh aa6e28d818 Merge pull request 'chore: bump to version 1.7.0' (#72) from chore/bump-v1.7.0 into main
CI / test (push) Successful in 13s
Release / release (push) Successful in 30s
CI / build-dev (push) Has been skipped
Reviewed-on: #72
2026-05-29 16:10:56 -04:00
josh 2cf797545c chore: bump to version 1.7.0
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-05-29 16:08:18 -04:00
josh a0381b12cc Merge pull request 'test: add failing tests for sort/order on GET /api/instances' (#71) from dev into main
CI / test (push) Successful in 16s
Release / release (push) Failing after 11s
CI / build-dev (push) Has been skipped
Reviewed-on: #71
2026-05-29 16:05:54 -04:00
josh 64eacb28d2 Merge pull request 'feat: accept partial bodies on PUT /api/instances/:vmid' (#70) from feat/partial-instance-updates into dev
CI / test (push) Successful in 11s
CI / build-dev (push) Successful in 38s
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #70
2026-05-29 16:03:44 -04:00
josh ea671535fc feat: accept partial bodies on PUT /api/instances/:vmid
CI / test (pull_request) Successful in 10s
CI / build-dev (pull_request) Has been skipped
Merge the request body onto the existing instance row before validating,
so external callers (n8n, scripts) can send only the fields they want to
change instead of GET-then-splat-then-PUT the full record.

Mirrors the partial-update pattern already used by PUT /api/jobs/:id.
Full-body PUTs (today's frontend) are unaffected.
2026-05-29 15:57:58 -04:00
josh fb3c6405c9 Merge pull request 'feat/sort-instances' (#69) from feat/sort-instances into dev
CI / test (push) Successful in 9s
CI / build-dev (push) Successful in 25s
Reviewed-on: #69
2026-03-29 08:46:15 -04:00
Josh Wright b6ca460ac6 feat: add sort by vmid, name, last created, last updated on dashboard
CI / test (pull_request) Successful in 9s
CI / build-dev (pull_request) Has been skipped
- GET /api/instances now accepts ?sort= (name|vmid|created_at|updated_at)
  and ?order= (asc|desc); invalid sort fields fall back to name asc
- Timestamp sorts use id as a tiebreaker (datetime() precision is 1 s)
- Toolbar gains a sort-field <select> and a ↑/↓ direction toggle button
- toggleSortDir() flips direction and re-fetches; state held in data-dir attr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:26:46 -04:00
Josh Wright 8312701147 test: add failing tests for sort/order on GET /api/instances
Tests cover:
- sort by vmid asc/desc
- sort by name desc
- sort by created_at asc/desc (id tiebreaker for same-second inserts)
- sort by updated_at asc/desc (id tiebreaker for same-second inserts)
- invalid sort field falls back to name asc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:25:53 -04:00
josh edf6f674b3 Merge pull request 'v1.6.0' (#65) from dev into main
CI / test (push) Successful in 15s
Release / release (push) Successful in 52s
CI / build-dev (push) Has been skipped
Reviewed-on: #65
2026-03-28 21:01:27 -04:00
josh a8d367b4be Merge pull request 'chore: bump to version 1.6.0' (#64) from chore/bump-v1.6.0 into dev
CI / test (push) Successful in 16s
CI / build-dev (push) Successful in 44s
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #64
2026-03-28 20:59:21 -04:00
josh 5ca0b648ca chore: bump to version 1.6.0
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:58:41 -04:00
josh 518ed42f60 Merge pull request 'feat: make stats bar cells clickable to filter by state' (#62) from feat/jobs-system into dev
CI / test (push) Successful in 17s
CI / build-dev (push) Successful in 27s
Reviewed-on: #62
2026-03-28 20:53:14 -04:00
josh a9147b0198 Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:52:44 -04:00
josh 2e3484b1d9 feat: make stats bar cells clickable to filter by state
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Clicking deployed/testing/degraded sets the state filter to that
value. Clicking total clears all filters. Hover style added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:51:31 -04:00
josh cb83d11261 Merge pull request 'fix: config is already a parsed object from the jobs API response' (#61) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 26s
Reviewed-on: #61
2026-03-28 20:47:46 -04:00
josh 047fd0653e Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:47:18 -04:00
josh 027ed52768 fix: config is already a parsed object from the jobs API response
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
maskJob parses job.config before returning it, so calling JSON.parse
on it again threw an exception. The catch returned false for every
job, so relevant was always empty and _waitForOnCreateJobs returned
immediately without polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:46:49 -04:00
josh e2935c58c8 Merge pull request 'fix: capture job baseline before POST to avoid race condition' (#60) from feat/jobs-system into dev
CI / test (push) Successful in 16s
CI / build-dev (push) Successful in 29s
Reviewed-on: #60
2026-03-28 20:43:26 -04:00
josh 1bbe743dba fix: capture job baseline before POST to avoid race condition
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
The previous version snapshotted last_run_id after the 201 response,
but jobs fire immediately server-side — by the time the client fetched
/api/jobs the runs were already complete, so the baseline matched the
new state and the poll loop never detected completion.

Baseline is now captured before the creation POST so it always
reflects pre-run state regardless of job speed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:42:46 -04:00
josh d88b79e9f0 Merge pull request 'feat: auto-refresh UI after on-create jobs complete' (#59) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 29s
Reviewed-on: #59
2026-03-28 20:26:26 -04:00
josh 8a9de6d72a Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 15s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:25:55 -04:00
josh ddd528a682 feat: auto-refresh UI after on-create jobs complete
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
After creating an instance, if any jobs have run_on_create enabled,
the client polls /api/jobs every 500ms until each relevant job has a
new completed run (tracked via last_run_id baseline). The dashboard
or detail page then refreshes automatically. 30s timeout as a safety
net if a job hangs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:25:26 -04:00
josh 03cf2aa9c6 Merge pull request 'fix: millisecond precision timestamps and correct history ordering' (#58) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 29s
Reviewed-on: #58
2026-03-28 20:20:42 -04:00
josh d84674b0c6 Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:20:03 -04:00
josh 7999f46ca2 fix: millisecond precision timestamps and correct history ordering
CI / test (pull_request) Successful in 21s
CI / build-dev (pull_request) Has been skipped
datetime('now') only stores to the second, making same-second events
indistinguishable. Switched all instance_history and job_runs writes
to strftime('%Y-%m-%dT%H:%M:%f', 'now') for millisecond precision.

Reverted getInstanceHistory to ORDER BY changed_at DESC, id DESC so
newest events appear at the top and instance creation (lowest id,
earliest timestamp) is always at the bottom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:19:42 -04:00
josh 307c5cf9e8 Merge pull request 'fix: initialize jobs nav dot on every page load' (#57) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 28s
Reviewed-on: #57
2026-03-28 20:16:02 -04:00
josh 34af8e5a8f Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:15:37 -04:00
josh 76d2bffb4f fix: initialize jobs nav dot on every page load
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Previously the dot only updated when visiting the Jobs page.
Now a jobs fetch runs at bootstrap so the dot reflects status
immediately on any page, including after a hard refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:14:53 -04:00
josh 64de0e432c Merge pull request 'fix: queue on-create jobs sequentially and fix history ordering' (#56) from feat/jobs-system into dev
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 28s
Reviewed-on: #56
2026-03-28 20:12:31 -04:00
josh a5b409a348 Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:09:59 -04:00
josh cec82a3347 Merge pull request 'feat: run jobs on instance creation when run_on_create is enabled' (#54) from feat/jobs-system into dev
CI / test (push) Successful in 17s
CI / build-dev (push) Successful in 34s
Reviewed-on: #54
2026-03-28 20:01:53 -04:00
josh 883e59789b Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:01:20 -04:00
josh 9295354e72 Merge pull request 'v1.5.0' (#53) from dev into main
CI / test (push) Successful in 14s
Release / release (push) Successful in 47s
CI / build-dev (push) Has been skipped
Reviewed-on: #53
2026-03-28 19:51:29 -04:00
josh 372cda6a58 Merge pull request 'chore: bump version to 1.5.0' (#52) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 38s
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #52
2026-03-28 19:49:19 -04:00
josh 3301e942ef Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:48:48 -04:00
josh bb765453ab Merge pull request 'feat: include job config and run history in export/import backup' (#51) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 40s
Reviewed-on: #51
2026-03-28 19:44:37 -04:00
josh 88474d1048 Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:44:05 -04:00
josh 117dfc5f17 Merge pull request 'feat: add Semaphore Sync job' (#50) from feat/jobs-system into dev
CI / test (push) Successful in 15s
CI / build-dev (push) Successful in 27s
Reviewed-on: #50
2026-03-28 19:35:47 -04:00
josh c39c7a8aef Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 19s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:35:10 -04:00
josh ea4c5f7c95 Merge pull request 'feat: add Patchmon Sync job' (#49) from feat/jobs-system into dev
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 25s
Reviewed-on: #49
2026-03-28 19:24:12 -04:00
josh 5c12acb6c7 Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:23:37 -04:00
josh db4071a2cf Merge pull request 'fix: move page-jobs inside main so it renders at the top' (#48) from feat/jobs-system into dev
CI / test (push) Successful in 15s
CI / build-dev (push) Successful in 30s
Reviewed-on: #48
2026-03-28 19:15:38 -04:00
josh 37cd77850e Merge branch 'dev' into feat/jobs-system
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 19:15:07 -04:00
josh 550135ca37 Merge pull request 'feat: jobs system with dedicated nav page and run history' (#47) from feat/jobs-system into dev
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 26s
Reviewed-on: #47
2026-03-28 19:10:50 -04:00
josh 07cef73fae Merge pull request 'v1.4.0' (#44) from dev into main
CI / test (push) Successful in 14s
Release / release (push) Successful in 39s
CI / build-dev (push) Has been skipped
Reviewed-on: #44
2026-03-28 16:16:46 -04:00
josh e3d089a71f Merge pull request 'v1.3.1' (#39) from dev into main
CI / test (push) Successful in 14s
Release / release (push) Successful in 42s
CI / build-dev (push) Has been skipped
Reviewed-on: #39
2026-03-28 15:42:00 -04:00
josh 120b61a423 Merge pull request 'v1.3.0' (#35) from dev into main
CI / test (push) Successful in 14s
Release / release (push) Successful in 40s
CI / build-dev (push) Has been skipped
Reviewed-on: #35
2026-03-28 15:31:57 -04:00
josh cd16b7ea28 Merge pull request 'v1.2.2' (#16) from dev into main
CI / test (push) Successful in 13s
Release / release (push) Successful in 34s
CI / build-dev (push) Has been skipped
Reviewed-on: #16
2026-03-28 14:01:33 -04:00
josh afbdefa549 Merge pull request 'v1.2.1' (#13) from dev into main
CI / test (push) Successful in 14s
CI / build-dev (push) Has been skipped
Reviewed-on: #13
2026-03-28 13:55:34 -04:00
josh f1e192c5d4 Merge pull request 'v1.2.0' (#10) from dev into main
CI / test (push) Successful in 13s
Release / release (push) Failing after 5m14s
CI / build-dev (push) Has been skipped
Reviewed-on: #10
2026-03-28 13:24:34 -04:00
11 changed files with 223 additions and 20 deletions
+2
View File
@@ -153,6 +153,8 @@ main { flex: 1; }
} }
.stat-cell:last-child { border-right: none; } .stat-cell:last-child { border-right: none; }
.stat-clickable { cursor: pointer; user-select: none; }
.stat-clickable:hover { background: var(--bg2); }
.stat-label { .stat-label {
font-size: 10px; font-size: 10px;
+7
View File
@@ -48,6 +48,13 @@
<select id="filter-stack" onchange="filterInstances()"> <select id="filter-stack" onchange="filterInstances()">
<option value="">all stacks</option> <option value="">all stacks</option>
</select> </select>
<select id="sort-field" onchange="filterInstances()">
<option value="name">name</option>
<option value="vmid">vmid</option>
<option value="updated_at">last updated</option>
<option value="created_at">last created</option>
</select>
<button id="sort-dir" class="btn" data-dir="asc" onclick="toggleSortDir()" title="reverse sort"></button>
<div class="toolbar-right"> <div class="toolbar-right">
<button class="btn primary" onclick="openNewModal()">+ new instance</button> <button class="btn primary" onclick="openNewModal()">+ new instance</button>
</div> </div>
+2
View File
@@ -53,4 +53,6 @@ if (VERSION) {
document.getElementById('nav-version').textContent = label; document.getElementById('nav-version').textContent = label;
} }
fetch('/api/jobs').then(r => r.json()).then(_updateJobsNavDot).catch(() => {});
handleRoute(); handleRoute();
+52 -5
View File
@@ -71,10 +71,10 @@ async function renderDashboard() {
all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; }); all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; });
document.getElementById('stats-bar').innerHTML = ` document.getElementById('stats-bar').innerHTML = `
<div class="stat-cell"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('')"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</div></div>
<div class="stat-cell"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('deployed')"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('testing')"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('degraded')"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div>
`; `;
await populateStackFilter(); await populateStackFilter();
@@ -95,11 +95,26 @@ async function populateStackFilter() {
}); });
} }
function setStateFilter(state) {
document.getElementById('filter-state').value = state;
filterInstances();
}
function toggleSortDir() {
const btn = document.getElementById('sort-dir');
const next = btn.dataset.dir === 'asc' ? 'desc' : 'asc';
btn.dataset.dir = next;
btn.textContent = next === 'asc' ? '↑' : '↓';
filterInstances();
}
async function filterInstances() { async function filterInstances() {
const search = document.getElementById('search-input').value; const search = document.getElementById('search-input').value;
const state = document.getElementById('filter-state').value; const state = document.getElementById('filter-state').value;
const stack = document.getElementById('filter-stack').value; const stack = document.getElementById('filter-stack').value;
const instances = await getInstances({ search, state, stack }); const sort = document.getElementById('sort-field').value;
const order = document.getElementById('sort-dir').dataset.dir || 'asc';
const instances = await getInstances({ search, state, stack, sort, order });
const grid = document.getElementById('instance-grid'); const grid = document.getElementById('instance-grid');
if (!instances.length) { if (!instances.length) {
@@ -289,6 +304,10 @@ async function saveInstance() {
hardware_acceleration: +document.getElementById('f-hardware-accel').checked, hardware_acceleration: +document.getElementById('f-hardware-accel').checked,
}; };
// Snapshot job state before creation — jobs fire immediately after the 201
// so the baseline must be captured before the POST, not after.
const jobBaseline = !editingVmid ? await _snapshotJobBaseline() : null;
const result = editingVmid const result = editingVmid
? await updateInstance(editingVmid, data) ? await updateInstance(editingVmid, data)
: await createInstance(data); : await createInstance(data);
@@ -298,6 +317,8 @@ async function saveInstance() {
showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success'); showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success');
closeModal(); closeModal();
if (jobBaseline) await _waitForOnCreateJobs(jobBaseline);
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) { if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
await renderDetailPage(vmid); await renderDetailPage(vmid);
} else { } else {
@@ -305,6 +326,30 @@ async function saveInstance() {
} }
} }
async function _snapshotJobBaseline() {
const jobs = await fetch('/api/jobs').then(r => r.json());
return new Map(jobs.map(j => [j.id, j.last_run_id ?? null]));
}
async function _waitForOnCreateJobs(baseline) {
const jobs = await fetch('/api/jobs').then(r => r.json());
const relevant = jobs.filter(j => (j.config ?? {}).run_on_create);
if (!relevant.length) return;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 500));
const current = await fetch('/api/jobs').then(r => r.json());
const allDone = relevant.every(j => {
const cur = current.find(c => c.id === j.id);
if (!cur) return true;
if (cur.last_run_id === baseline.get(j.id)) return false; // new run not started yet
return cur.last_status !== 'running'; // new run complete
});
if (allDone) return;
}
}
// ── Confirm Dialog ──────────────────────────────────────────────────────────── // ── Confirm Dialog ────────────────────────────────────────────────────────────
function confirmDeleteDialog(inst) { function confirmDeleteDialog(inst) {
@@ -494,6 +539,7 @@ function _renderJobConfigFields(key, cfg) {
</div>`; </div>`;
if (key === 'patchmon_sync' || key === 'semaphore_sync') { if (key === 'patchmon_sync' || key === 'semaphore_sync') {
const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)'; const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)';
const tokenPlaceholder = key === 'patchmon_sync' ? 'token_key:token_secret' : '';
return ` return `
<div class="form-group"> <div class="form-group">
<label class="form-label" for="job-cfg-api-url">API URL</label> <label class="form-label" for="job-cfg-api-url">API URL</label>
@@ -503,6 +549,7 @@ function _renderJobConfigFields(key, cfg) {
<div class="form-group"> <div class="form-group">
<label class="form-label" for="job-cfg-api-token">${label}</label> <label class="form-label" for="job-cfg-api-token">${label}</label>
<input class="form-input" id="job-cfg-api-token" type="password" <input class="form-input" id="job-cfg-api-token" type="password"
placeholder="${tokenPlaceholder}"
value="${esc(cfg.api_token ?? '')}"> value="${esc(cfg.api_token ?? '')}">
</div>`; </div>`;
} }
+1 -1
View File
@@ -1 +1 @@
const VERSION = "1.5.0"; const VERSION = "1.7.1";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.5.0", "version": "1.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",
+17 -7
View File
@@ -133,6 +133,8 @@ function seedJobs() {
// ── Queries ─────────────────────────────────────────────────────────────────── // ── Queries ───────────────────────────────────────────────────────────────────
const VALID_SORTS = ['name', 'vmid', 'updated_at', 'created_at'];
export function getInstances(filters = {}) { export function getInstances(filters = {}) {
const parts = ['SELECT * FROM instances WHERE 1=1']; const parts = ['SELECT * FROM instances WHERE 1=1'];
const params = {}; const params = {};
@@ -142,7 +144,11 @@ export function getInstances(filters = {}) {
} }
if (filters.state) { parts.push('AND state = @state'); params.state = filters.state; } if (filters.state) { parts.push('AND state = @state'); params.state = filters.state; }
if (filters.stack) { parts.push('AND stack = @stack'); params.stack = filters.stack; } if (filters.stack) { parts.push('AND stack = @stack'); params.stack = filters.stack; }
parts.push('ORDER BY name ASC'); const sortField = VALID_SORTS.includes(filters.sort) ? filters.sort : 'name';
const sortOrder = filters.order === 'desc' ? 'DESC' : 'ASC';
// id is a stable tiebreaker for timestamp fields (datetime precision is 1 s)
const tiebreaker = (sortField === 'created_at' || sortField === 'updated_at') ? `, id ${sortOrder}` : '';
parts.push(`ORDER BY ${sortField} ${sortOrder}${tiebreaker}`);
return db.prepare(parts.join(' ')).all(params); return db.prepare(parts.join(' ')).all(params);
} }
@@ -173,7 +179,8 @@ export function createInstance(data) {
@tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration)
`).run(data); `).run(data);
db.prepare( db.prepare(
`INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, 'created', NULL, NULL)` `INSERT INTO instance_history (vmid, field, old_value, new_value, changed_at)
VALUES (?, 'created', NULL, NULL, strftime('%Y-%m-%dT%H:%M:%f', 'now'))`
).run(data.vmid); ).run(data.vmid);
} }
@@ -184,12 +191,13 @@ export function updateInstance(vmid, data) {
name=@name, state=@state, stack=@stack, vmid=@newVmid, name=@name, state=@state, stack=@stack, vmid=@newVmid,
atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon, atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon,
tailscale=@tailscale, andromeda=@andromeda, tailscale_ip=@tailscale_ip, tailscale=@tailscale, andromeda=@andromeda, tailscale_ip=@tailscale_ip,
hardware_acceleration=@hardware_acceleration, updated_at=datetime('now') hardware_acceleration=@hardware_acceleration, updated_at=strftime('%Y-%m-%dT%H:%M:%f', 'now')
WHERE vmid=@vmid WHERE vmid=@vmid
`).run({ ...data, newVmid: data.vmid, vmid }); `).run({ ...data, newVmid: data.vmid, vmid });
const newVmid = data.vmid; const newVmid = data.vmid;
const insertEvt = db.prepare( const insertEvt = db.prepare(
`INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, ?, ?, ?)` `INSERT INTO instance_history (vmid, field, old_value, new_value, changed_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))`
); );
for (const field of HISTORY_FIELDS) { for (const field of HISTORY_FIELDS) {
const oldVal = String(old[field] ?? ''); const oldVal = String(old[field] ?? '');
@@ -227,7 +235,7 @@ export function importInstances(rows, historyRows = []) {
export function getInstanceHistory(vmid) { export function getInstanceHistory(vmid) {
return db.prepare( return db.prepare(
'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at ASC, id ASC' 'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at DESC, id DESC'
).all(vmid); ).all(vmid);
} }
@@ -309,12 +317,14 @@ export function updateJob(id, { enabled, schedule, config }) {
} }
export function createJobRun(jobId) { export function createJobRun(jobId) {
return Number(db.prepare('INSERT INTO job_runs (job_id) VALUES (?)').run(jobId).lastInsertRowid); return Number(db.prepare(
`INSERT INTO job_runs (job_id, started_at) VALUES (?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))`
).run(jobId).lastInsertRowid);
} }
export function completeJobRun(runId, status, result) { export function completeJobRun(runId, status, result) {
db.prepare(` db.prepare(`
UPDATE job_runs SET ended_at=datetime('now'), status=@status, result=@result WHERE id=@id UPDATE job_runs SET ended_at=strftime('%Y-%m-%dT%H:%M:%f', 'now'), status=@status, result=@result WHERE id=@id
`).run({ id: runId, status, result }); `).run({ id: runId, status, result });
} }
+7 -1
View File
@@ -41,8 +41,14 @@ async function patchmonSyncHandler(cfg) {
const { api_url, api_token } = cfg; const { api_url, api_token } = cfg;
if (!api_url || !api_token) throw new Error('Patchmon not configured — set API URL and token'); if (!api_url || !api_token) throw new Error('Patchmon not configured — set API URL and token');
// Accept raw "key:secret" (recommended) or a pre-encoded base64 string.
// ":" cannot appear in a valid base64 string, so it's a reliable discriminator.
const credential = api_token.includes(':')
? Buffer.from(api_token).toString('base64')
: api_token;
const res = await fetch(api_url, { const res = await fetch(api_url, {
headers: { Authorization: `Basic ${api_token}` }, headers: { Authorization: `Basic ${credential}` },
}); });
if (!res.ok) throw new Error(`Patchmon API ${res.status}`); if (!res.ok) throw new Error(`Patchmon API ${res.status}`);
+9 -5
View File
@@ -69,8 +69,8 @@ router.get('/instances/stacks', (_req, res) => {
// GET /api/instances // GET /api/instances
router.get('/instances', (req, res) => { router.get('/instances', (req, res) => {
const { search, state, stack } = req.query; const { search, state, stack, sort, order } = req.query;
res.json(getInstances({ search, state, stack })); res.json(getInstances({ search, state, stack, sort, order }));
}); });
// GET /api/instances/:vmid/history // GET /api/instances/:vmid/history
@@ -112,13 +112,17 @@ router.post('/instances', (req, res) => {
router.put('/instances/:vmid', (req, res) => { router.put('/instances/:vmid', (req, res) => {
const vmid = parseInt(req.params.vmid, 10); const vmid = parseInt(req.params.vmid, 10);
if (!vmid) return res.status(400).json({ error: 'invalid vmid' }); if (!vmid) return res.status(400).json({ error: 'invalid vmid' });
if (!getInstance(vmid)) return res.status(404).json({ error: 'instance not found' });
const errors = validate(req.body); const existing = getInstance(vmid);
if (!existing) return res.status(404).json({ error: 'instance not found' });
const merged = { ...existing, ...(req.body ?? {}) };
const errors = validate(merged);
if (errors.length) return res.status(400).json({ errors }); if (errors.length) return res.status(400).json({ errors });
try { try {
const data = normalise(req.body); const data = normalise(merged);
updateInstance(vmid, data); updateInstance(vmid, data);
res.json(getInstance(data.vmid)); res.json(getInstance(data.vmid));
} catch (e) { } catch (e) {
+60
View File
@@ -74,6 +74,54 @@ describe('GET /api/instances', () => {
expect(res.body).toHaveLength(1) expect(res.body).toHaveLength(1)
expect(res.body[0].name).toBe('plex') expect(res.body[0].name).toBe('plex')
}) })
it('sorts by vmid ascending', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' })
await request(app).post('/api/instances').send({ ...base, vmid: 100, name: 'a' })
const res = await request(app).get('/api/instances?sort=vmid&order=asc')
expect(res.body[0].vmid).toBe(100)
expect(res.body[1].vmid).toBe(200)
})
it('sorts by vmid descending', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 100, name: 'a' })
await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' })
const res = await request(app).get('/api/instances?sort=vmid&order=desc')
expect(res.body[0].vmid).toBe(200)
expect(res.body[1].vmid).toBe(100)
})
it('sorts by name descending', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'alpha' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'zebra' })
const res = await request(app).get('/api/instances?sort=name&order=desc')
expect(res.body[0].name).toBe('zebra')
expect(res.body[1].name).toBe('alpha')
})
it('sorts by created_at desc — id tiebreaker preserves insertion order', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'first' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'second' })
const res = await request(app).get('/api/instances?sort=created_at&order=desc')
expect(res.body[0].name).toBe('second') // id=2 before id=1
expect(res.body[1].name).toBe('first')
})
it('sorts by updated_at desc — id tiebreaker preserves insertion order', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b' })
const res = await request(app).get('/api/instances?sort=updated_at&order=desc')
expect(res.body[0].name).toBe('b') // id=2 before id=1
expect(res.body[1].name).toBe('a')
})
it('ignores invalid sort field and falls back to name asc', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'zebra' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'alpha' })
const res = await request(app).get('/api/instances?sort=bad_field')
expect(res.status).toBe(200)
expect(res.body[0].name).toBe('alpha')
})
}) })
// ── GET /api/instances/stacks ───────────────────────────────────────────────── // ── GET /api/instances/stacks ─────────────────────────────────────────────────
@@ -209,6 +257,18 @@ describe('PUT /api/instances/:vmid', () => {
const res = await request(app).put('/api/instances/100').send({ ...base, vmid: 200 }) const res = await request(app).put('/api/instances/100').send({ ...base, vmid: 200 })
expect(res.status).toBe(409) expect(res.status).toBe(409)
}) })
it('accepts a partial body and preserves unspecified fields', async () => {
await request(app).post('/api/instances').send({ ...base, atlas: 1, tailscale_ip: '100.64.0.1' })
const res = await request(app).put('/api/instances/100').send({ state: 'degraded' })
expect(res.status).toBe(200)
expect(res.body.state).toBe('degraded')
expect(res.body.name).toBe(base.name)
expect(res.body.vmid).toBe(100)
expect(res.body.stack).toBe(base.stack)
expect(res.body.atlas).toBe(1)
expect(res.body.tailscale_ip).toBe('100.64.0.1')
})
}) })
// ── DELETE /api/instances/:vmid ─────────────────────────────────────────────── // ── DELETE /api/instances/:vmid ───────────────────────────────────────────────
+65
View File
@@ -58,6 +58,71 @@ describe('getInstances', () => {
createInstance({ name: 'plex2', state: 'degraded', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 }); createInstance({ name: 'plex2', state: 'degraded', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
expect(getInstances({ search: 'plex', state: 'deployed' })).toHaveLength(1); expect(getInstances({ search: 'plex', state: 'deployed' })).toHaveLength(1);
}); });
it('sorts by vmid ascending', () => {
createInstance({ name: 'b', state: 'deployed', stack: 'production', vmid: 200, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 100, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'vmid' });
expect(result[0].vmid).toBe(100);
expect(result[1].vmid).toBe(200);
});
it('sorts by vmid descending', () => {
createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 100, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'b', state: 'deployed', stack: 'production', vmid: 200, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'vmid', order: 'desc' });
expect(result[0].vmid).toBe(200);
expect(result[1].vmid).toBe(100);
});
it('sorts by name descending', () => {
createInstance({ name: 'alpha', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'zebra', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'name', order: 'desc' });
expect(result[0].name).toBe('zebra');
expect(result[1].name).toBe('alpha');
});
it('sorts by created_at asc — id is tiebreaker when timestamps are equal (same second)', () => {
// datetime('now') has second precision; rapid inserts share the same timestamp.
// The implementation uses id ASC as secondary sort so insertion order is preserved.
createInstance({ name: 'first', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'second', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'created_at', order: 'asc' });
expect(result[0].name).toBe('first'); // id=1 before id=2
expect(result[1].name).toBe('second');
});
it('sorts by created_at desc — id is tiebreaker when timestamps are equal (same second)', () => {
createInstance({ name: 'first', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'second', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'created_at', order: 'desc' });
expect(result[0].name).toBe('second'); // id=2 before id=1
expect(result[1].name).toBe('first');
});
it('sorts by updated_at asc — id is tiebreaker when timestamps are equal (same second)', () => {
createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'b', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'updated_at', order: 'asc' });
expect(result[0].name).toBe('a'); // id=1 before id=2
expect(result[1].name).toBe('b');
});
it('sorts by updated_at desc — id is tiebreaker when timestamps are equal (same second)', () => {
createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'b', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'updated_at', order: 'desc' });
expect(result[0].name).toBe('b'); // id=2 before id=1
expect(result[1].name).toBe('a');
});
it('falls back to name asc for an invalid sort field', () => {
createInstance({ name: 'zebra', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
createInstance({ name: 'alpha', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'injected; DROP TABLE instances--' });
expect(result[0].name).toBe('alpha');
});
}); });
// ── getInstance ─────────────────────────────────────────────────────────────── // ── getInstance ───────────────────────────────────────────────────────────────