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>
598 lines
26 KiB
JavaScript
598 lines
26 KiB
JavaScript
// Module-level UI state
|
|
let editingVmid = null;
|
|
let currentVmid = null;
|
|
let toastTimer = null;
|
|
|
|
// ── Timezone ──────────────────────────────────────────────────────────────────
|
|
|
|
const TIMEZONES = [
|
|
{ label: 'UTC', tz: 'UTC' },
|
|
{ label: 'Hawaii (HST)', tz: 'Pacific/Honolulu' },
|
|
{ label: 'Alaska (AKT)', tz: 'America/Anchorage' },
|
|
{ label: 'Pacific (PT)', tz: 'America/Los_Angeles' },
|
|
{ label: 'Mountain (MT)', tz: 'America/Denver' },
|
|
{ label: 'Central (CT)', tz: 'America/Chicago' },
|
|
{ label: 'Eastern (ET)', tz: 'America/New_York' },
|
|
{ label: 'Atlantic (AT)', tz: 'America/Halifax' },
|
|
{ label: 'London (GMT/BST)', tz: 'Europe/London' },
|
|
{ label: 'Paris / Berlin (CET)', tz: 'Europe/Paris' },
|
|
{ label: 'Helsinki (EET)', tz: 'Europe/Helsinki' },
|
|
{ label: 'Istanbul (TRT)', tz: 'Europe/Istanbul' },
|
|
{ label: 'Dubai (GST)', tz: 'Asia/Dubai' },
|
|
{ label: 'India (IST)', tz: 'Asia/Kolkata' },
|
|
{ label: 'Singapore (SGT)', tz: 'Asia/Singapore' },
|
|
{ label: 'China (CST)', tz: 'Asia/Shanghai' },
|
|
{ label: 'Japan / Korea (JST/KST)', tz: 'Asia/Tokyo' },
|
|
{ label: 'Sydney (AEST)', tz: 'Australia/Sydney' },
|
|
{ label: 'Auckland (NZST)', tz: 'Pacific/Auckland' },
|
|
];
|
|
|
|
function getTimezone() {
|
|
return localStorage.getItem('catalyst_tz') || 'UTC';
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function esc(str) {
|
|
const d = document.createElement('div');
|
|
d.textContent = (str == null) ? '' : String(str);
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// SQLite datetime('now') → 'YYYY-MM-DD HH:MM:SS' (UTC, no timezone marker).
|
|
// Appending 'Z' tells JS to parse it as UTC rather than local time.
|
|
function parseUtc(d) {
|
|
if (typeof d !== 'string') return new Date(d);
|
|
const hasZone = d.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(d);
|
|
return new Date(hasZone ? d : d.replace(' ', 'T') + 'Z');
|
|
}
|
|
|
|
function fmtDate(d) {
|
|
if (!d) return '—';
|
|
try {
|
|
return parseUtc(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: getTimezone() });
|
|
} catch (e) { return d; }
|
|
}
|
|
|
|
function fmtDateFull(d) {
|
|
if (!d) return '—';
|
|
try {
|
|
return parseUtc(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: getTimezone(), timeZoneName: 'short' });
|
|
} catch (e) { return d; }
|
|
}
|
|
|
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
|
|
async function renderDashboard() {
|
|
const all = await getInstances();
|
|
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
|
|
|
|
const states = {};
|
|
all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; });
|
|
|
|
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"><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"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div>
|
|
`;
|
|
|
|
await populateStackFilter();
|
|
await filterInstances();
|
|
}
|
|
|
|
async function populateStackFilter() {
|
|
const select = document.getElementById('filter-stack');
|
|
const current = select.value;
|
|
select.innerHTML = '<option value="">all stacks</option>';
|
|
const stacks = await getDistinctStacks();
|
|
stacks.forEach(s => {
|
|
const opt = document.createElement('option');
|
|
opt.value = s;
|
|
opt.textContent = s;
|
|
if (s === current) opt.selected = true;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function filterInstances() {
|
|
const search = document.getElementById('search-input').value;
|
|
const state = document.getElementById('filter-state').value;
|
|
const stack = document.getElementById('filter-stack').value;
|
|
const instances = await getInstances({ search, state, stack });
|
|
const grid = document.getElementById('instance-grid');
|
|
|
|
if (!instances.length) {
|
|
grid.innerHTML = `<div class="empty-state"><div class="empty-icon">⊘</div><p>no instances match the current filters</p></div>`;
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = instances.map(inst => {
|
|
const dots = CARD_SERVICES.map(s =>
|
|
`<div class="svc-dot ${inst[s] ? 'on' : ''}" title="${s}"></div>`
|
|
).join('');
|
|
const activeCount = CARD_SERVICES.filter(s => inst[s]).length;
|
|
return `
|
|
<div class="instance-card state-${esc(inst.state)}" onclick="navigate('instance', ${inst.vmid})">
|
|
<div class="card-top">
|
|
<div>
|
|
<div class="card-name">${esc(inst.name)}</div>
|
|
<div class="card-vmid">vmid: ${inst.vmid}</div>
|
|
</div>
|
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
|
<div class="badge ${esc(inst.state)}">${esc(inst.state)}</div>
|
|
<div class="badge ${esc(inst.stack)}">${esc(inst.stack)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-services">
|
|
${dots}
|
|
<span class="svc-label">${activeCount} service${activeCount !== 1 ? 's' : ''} active</span>
|
|
</div>
|
|
${inst.tailscale_ip ? `<div class="card-ip">ts: <span>${esc(inst.tailscale_ip)}</span></div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── Detail Page ───────────────────────────────────────────────────────────────
|
|
|
|
const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration'];
|
|
|
|
const FIELD_LABELS = {
|
|
name: 'name',
|
|
state: 'state',
|
|
stack: 'stack',
|
|
vmid: 'vmid',
|
|
tailscale_ip: 'tailscale ip',
|
|
atlas: 'atlas',
|
|
argus: 'argus',
|
|
semaphore: 'semaphore',
|
|
patchmon: 'patchmon',
|
|
tailscale: 'tailscale',
|
|
andromeda: 'andromeda',
|
|
hardware_acceleration: 'hw acceleration',
|
|
};
|
|
|
|
function stateClass(field, val) {
|
|
if (field !== 'state') return '';
|
|
return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? '';
|
|
}
|
|
|
|
function fmtHistVal(field, val) {
|
|
if (val == null || val === '') return '—';
|
|
if (BOOL_FIELDS.includes(field)) return val === '1' ? 'on' : 'off';
|
|
return esc(val);
|
|
}
|
|
|
|
async function renderDetailPage(vmid) {
|
|
const [inst, history, all] = await Promise.all([getInstance(vmid), getInstanceHistory(vmid), getInstances()]);
|
|
if (!inst) { navigate('dashboard'); return; }
|
|
currentVmid = vmid;
|
|
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
|
|
|
|
document.getElementById('detail-vmid-crumb').textContent = vmid;
|
|
document.getElementById('detail-name').textContent = inst.name;
|
|
document.getElementById('detail-vmid-sub').textContent = inst.vmid;
|
|
document.getElementById('detail-created-sub').textContent = fmtDate(inst.created_at);
|
|
|
|
document.getElementById('detail-identity').innerHTML = `
|
|
<div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${esc(inst.name)}</span></div>
|
|
<div class="kv-row"><span class="kv-key">state</span><span class="kv-val"><span class="badge ${esc(inst.state)}">${esc(inst.state)}</span></span></div>
|
|
<div class="kv-row"><span class="kv-key">stack</span><span class="kv-val"><span class="badge ${esc(inst.stack)}">${esc(inst.stack) || '—'}</span></span></div>
|
|
<div class="kv-row"><span class="kv-key">vmid</span><span class="kv-val highlight">${inst.vmid}</span></div>
|
|
`;
|
|
|
|
document.getElementById('detail-network').innerHTML = `
|
|
<div class="kv-row"><span class="kv-key">tailscale ip</span><span class="kv-val highlight">${esc(inst.tailscale_ip) || '—'}</span></div>
|
|
<div class="kv-row"><span class="kv-key">tailscale</span><span class="kv-val ${inst.tailscale ? 'on' : 'off'}">${inst.tailscale ? 'enabled' : 'disabled'}</span></div>
|
|
`;
|
|
|
|
document.getElementById('detail-services').innerHTML = [
|
|
...DETAIL_SERVICES.map(s => ({ key: s, label: s })),
|
|
{ key: 'hardware_acceleration', label: 'hw acceleration' },
|
|
].map(({ key, label }) => `
|
|
<div class="svc-item">
|
|
<span class="svc-name">${esc(label)}</span>
|
|
<span class="svc-status ${inst[key] ? 'on' : 'off'}">${inst[key] ? 'enabled' : 'disabled'}</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
document.getElementById('detail-timestamps').innerHTML = history.length
|
|
? history.map(e => {
|
|
if (e.field === 'created') return `
|
|
<div class="tl-item tl-created">
|
|
<span class="tl-event">instance created</span>
|
|
<span class="tl-time">${fmtDateFull(e.changed_at)}</span>
|
|
</div>`;
|
|
const label = FIELD_LABELS[e.field] ?? esc(e.field);
|
|
const newCls = (e.field === 'state' || e.field === 'stack')
|
|
? `badge ${esc(e.new_value)}`
|
|
: `tl-new ${stateClass(e.field, e.new_value)}`;
|
|
return `
|
|
<div class="tl-item">
|
|
<div class="tl-event">
|
|
<span class="tl-label">${label}</span>
|
|
<span class="tl-sep">·</span>
|
|
<span class="tl-old">${fmtHistVal(e.field, e.old_value)}</span>
|
|
<span class="tl-arrow">→</span>
|
|
<span class="${newCls}">${fmtHistVal(e.field, e.new_value)}</span>
|
|
</div>
|
|
<span class="tl-time">${fmtDateFull(e.changed_at)}</span>
|
|
</div>`;
|
|
}).join('')
|
|
: '<div class="tl-empty">no history yet</div>';
|
|
|
|
document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid);
|
|
document.getElementById('detail-delete-btn').onclick = () => confirmDeleteDialog(inst);
|
|
}
|
|
|
|
// ── Modal ─────────────────────────────────────────────────────────────────────
|
|
|
|
function openNewModal() {
|
|
editingVmid = null;
|
|
document.getElementById('modal-title').textContent = 'new instance';
|
|
clearForm();
|
|
document.getElementById('instance-modal').classList.add('open');
|
|
}
|
|
|
|
async function openEditModal(vmid) {
|
|
const inst = await getInstance(vmid);
|
|
if (!inst) return;
|
|
editingVmid = inst.vmid;
|
|
document.getElementById('modal-title').textContent = `edit / ${inst.name}`;
|
|
document.getElementById('f-name').value = inst.name;
|
|
document.getElementById('f-vmid').value = inst.vmid;
|
|
document.getElementById('f-state').value = inst.state;
|
|
document.getElementById('f-stack').value = inst.stack;
|
|
document.getElementById('f-tailscale-ip').value = inst.tailscale_ip;
|
|
document.getElementById('f-atlas').checked = !!inst.atlas;
|
|
document.getElementById('f-argus').checked = !!inst.argus;
|
|
document.getElementById('f-semaphore').checked = !!inst.semaphore;
|
|
document.getElementById('f-patchmon').checked = !!inst.patchmon;
|
|
document.getElementById('f-tailscale').checked = !!inst.tailscale;
|
|
document.getElementById('f-andromeda').checked = !!inst.andromeda;
|
|
document.getElementById('f-hardware-accel').checked = !!inst.hardware_acceleration;
|
|
document.getElementById('instance-modal').classList.add('open');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('instance-modal').classList.remove('open');
|
|
}
|
|
|
|
function clearForm() {
|
|
document.getElementById('f-name').value = '';
|
|
document.getElementById('f-vmid').value = '';
|
|
document.getElementById('f-tailscale-ip').value = '';
|
|
document.getElementById('f-state').value = 'deployed';
|
|
document.getElementById('f-stack').value = 'production';
|
|
['f-atlas', 'f-argus', 'f-semaphore', 'f-patchmon', 'f-tailscale', 'f-andromeda', 'f-hardware-accel']
|
|
.forEach(id => { document.getElementById(id).checked = false; });
|
|
}
|
|
|
|
async function saveInstance() {
|
|
const name = document.getElementById('f-name').value.trim();
|
|
const vmid = parseInt(document.getElementById('f-vmid').value, 10);
|
|
const state = document.getElementById('f-state').value;
|
|
const stack = document.getElementById('f-stack').value;
|
|
|
|
if (!name) { showToast('name is required', 'error'); return; }
|
|
if (!vmid || vmid < 1) { showToast('a valid vmid is required', 'error'); return; }
|
|
|
|
const data = {
|
|
name, state, stack, vmid,
|
|
tailscale_ip: document.getElementById('f-tailscale-ip').value.trim(),
|
|
atlas: +document.getElementById('f-atlas').checked,
|
|
argus: +document.getElementById('f-argus').checked,
|
|
semaphore: +document.getElementById('f-semaphore').checked,
|
|
patchmon: +document.getElementById('f-patchmon').checked,
|
|
tailscale: +document.getElementById('f-tailscale').checked,
|
|
andromeda: +document.getElementById('f-andromeda').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
|
|
? await updateInstance(editingVmid, data)
|
|
: await createInstance(data);
|
|
|
|
if (!result.ok) { showToast(result.error, 'error'); return; }
|
|
|
|
showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success');
|
|
closeModal();
|
|
|
|
if (jobBaseline) await _waitForOnCreateJobs(jobBaseline);
|
|
|
|
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
|
|
await renderDetailPage(vmid);
|
|
} else {
|
|
await renderDashboard();
|
|
}
|
|
}
|
|
|
|
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 => {
|
|
try { return JSON.parse(j.config || '{}').run_on_create; } catch { return false; }
|
|
});
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
function confirmDeleteDialog(inst) {
|
|
if (inst.stack !== 'development') {
|
|
showToast(`demote ${inst.name} to development before deleting`, 'error');
|
|
return;
|
|
}
|
|
document.getElementById('confirm-title').textContent = `delete ${inst.name}?`;
|
|
document.getElementById('confirm-msg').textContent =
|
|
`This will permanently remove instance "${inst.name}" (vmid: ${inst.vmid}) from Catalyst. This action cannot be undone.`;
|
|
document.getElementById('confirm-ok').onclick = () => doDelete(inst.vmid, inst.name);
|
|
document.getElementById('confirm-overlay').classList.add('open');
|
|
}
|
|
|
|
function closeConfirm() {
|
|
document.getElementById('confirm-overlay').classList.remove('open');
|
|
}
|
|
|
|
async function doDelete(vmid, name) {
|
|
closeConfirm();
|
|
await deleteInstance(vmid);
|
|
showToast(`${name} deleted`, 'success');
|
|
navigate('dashboard');
|
|
}
|
|
|
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
|
|
function showToast(msg, type = 'success') {
|
|
const t = document.getElementById('toast');
|
|
document.getElementById('toast-msg').textContent = msg;
|
|
t.className = `toast ${type} show`;
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
|
|
}
|
|
|
|
// ── Settings Modal ────────────────────────────────────────────────────────────
|
|
|
|
function openSettingsModal() {
|
|
const sel = document.getElementById('tz-select');
|
|
if (!sel.options.length) {
|
|
for (const { label, tz } of TIMEZONES) {
|
|
const opt = document.createElement('option');
|
|
opt.value = tz;
|
|
opt.textContent = label;
|
|
sel.appendChild(opt);
|
|
}
|
|
}
|
|
sel.value = getTimezone();
|
|
document.getElementById('settings-modal').classList.add('open');
|
|
}
|
|
|
|
function closeSettingsModal() {
|
|
document.getElementById('settings-modal').classList.remove('open');
|
|
document.getElementById('import-file').value = '';
|
|
}
|
|
|
|
async function exportDB() {
|
|
const res = await fetch('/api/export');
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `catalyst-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function importDB() {
|
|
const file = document.getElementById('import-file').files[0];
|
|
if (!file) { showToast('Select a backup file first', 'error'); return; }
|
|
document.getElementById('confirm-title').textContent = 'Replace all instances?';
|
|
document.getElementById('confirm-msg').textContent =
|
|
`This will delete all current instances and replace them with the contents of "${file.name}". This cannot be undone.`;
|
|
document.getElementById('confirm-overlay').classList.add('open');
|
|
document.getElementById('confirm-ok').onclick = async () => {
|
|
closeConfirm();
|
|
try {
|
|
const { instances, history = [], jobs, job_runs } = JSON.parse(await file.text());
|
|
const res = await fetch('/api/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ instances, history, jobs, job_runs }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; }
|
|
const parts = [`${data.imported} instance${data.imported !== 1 ? 's' : ''}`];
|
|
if (data.imported_jobs != null) parts.push(`${data.imported_jobs} job${data.imported_jobs !== 1 ? 's' : ''}`);
|
|
showToast(`Imported ${parts.join(', ')}`, 'success');
|
|
closeSettingsModal();
|
|
renderDashboard();
|
|
} catch {
|
|
showToast('Invalid backup file', 'error');
|
|
}
|
|
};
|
|
}
|
|
|
|
// ── Keyboard / backdrop ───────────────────────────────────────────────────────
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key !== 'Escape') return;
|
|
if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; }
|
|
if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; }
|
|
if (document.getElementById('settings-modal').classList.contains('open')) { closeSettingsModal(); return; }
|
|
});
|
|
|
|
document.getElementById('instance-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('instance-modal')) closeModal();
|
|
});
|
|
document.getElementById('confirm-overlay').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('confirm-overlay')) closeConfirm();
|
|
});
|
|
document.getElementById('settings-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('settings-modal')) closeSettingsModal();
|
|
});
|
|
|
|
document.getElementById('tz-select').addEventListener('change', e => {
|
|
localStorage.setItem('catalyst_tz', e.target.value);
|
|
const m = window.location.pathname.match(/^\/instance\/(\d+)/);
|
|
if (m) renderDetailPage(parseInt(m[1], 10));
|
|
else renderDashboard();
|
|
});
|
|
|
|
// ── Jobs Page ─────────────────────────────────────────────────────────────────
|
|
|
|
async function renderJobsPage() {
|
|
const jobs = await fetch('/api/jobs').then(r => r.json());
|
|
_updateJobsNavDot(jobs);
|
|
document.getElementById('jobs-list').innerHTML = jobs.length
|
|
? jobs.map(j => `
|
|
<div class="job-item" id="job-item-${j.id}" onclick="loadJobDetail(${j.id})">
|
|
<span class="job-dot job-dot--${j.last_status ?? 'none'}"></span>
|
|
<span class="job-item-name">${esc(j.name)}</span>
|
|
</div>`).join('')
|
|
: '<div class="jobs-placeholder">No jobs</div>';
|
|
if (jobs.length) loadJobDetail(jobs[0].id);
|
|
}
|
|
|
|
async function loadJobDetail(jobId) {
|
|
document.querySelectorAll('.job-item').forEach(el => el.classList.remove('active'));
|
|
document.getElementById(`job-item-${jobId}`)?.classList.add('active');
|
|
const job = await fetch(`/api/jobs/${jobId}`).then(r => r.json());
|
|
const cfg = job.config ?? {};
|
|
document.getElementById('jobs-detail').innerHTML = `
|
|
<div class="jobs-detail-hd">
|
|
<div class="jobs-detail-title">${esc(job.name)}</div>
|
|
<div class="jobs-detail-desc">${esc(job.description)}</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
<input type="checkbox" id="job-enabled" ${job.enabled ? 'checked' : ''}
|
|
style="accent-color:var(--accent);width:13px;height:13px">
|
|
Enable scheduled runs
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="job-schedule">Poll interval (minutes)</label>
|
|
<input class="form-input" id="job-schedule" type="number" min="1" value="${job.schedule}" style="max-width:100px">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
<input type="checkbox" id="job-run-on-create" ${cfg.run_on_create ? 'checked' : ''}
|
|
style="accent-color:var(--accent);width:13px;height:13px">
|
|
Run on instance creation
|
|
</label>
|
|
</div>
|
|
${_renderJobConfigFields(job.key, cfg)}
|
|
<div class="job-actions">
|
|
<button class="btn btn-secondary" onclick="saveJobDetail(${job.id})">Save</button>
|
|
<button class="btn btn-secondary" id="job-run-btn" onclick="runJobNow(${job.id})">Run Now</button>
|
|
</div>
|
|
<div class="detail-section-title" style="margin:28px 0 10px">Run History</div>
|
|
${_renderRunList(job.runs)}
|
|
`;
|
|
}
|
|
|
|
function _renderJobConfigFields(key, cfg) {
|
|
if (key === 'tailscale_sync') return `
|
|
<div class="form-group">
|
|
<label class="form-label" for="job-cfg-tailnet">Tailnet</label>
|
|
<input class="form-input" id="job-cfg-tailnet" type="text"
|
|
placeholder="e.g. Tt3Btpm6D921CNTRL" value="${esc(cfg.tailnet ?? '')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="job-cfg-api-key">API Key</label>
|
|
<input class="form-input" id="job-cfg-api-key" type="password"
|
|
placeholder="tskey-api-…" value="${esc(cfg.api_key ?? '')}">
|
|
</div>`;
|
|
if (key === 'patchmon_sync' || key === 'semaphore_sync') {
|
|
const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)';
|
|
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"
|
|
value="${esc(cfg.api_url ?? '')}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="job-cfg-api-token">${label}</label>
|
|
<input class="form-input" id="job-cfg-api-token" type="password"
|
|
value="${esc(cfg.api_token ?? '')}">
|
|
</div>`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function _renderRunList(runs) {
|
|
if (!runs?.length) return '<div class="run-empty">No runs yet</div>';
|
|
return `<div class="run-list">${runs.map(r => `
|
|
<div class="run-item">
|
|
<span class="job-dot job-dot--${r.status}"></span>
|
|
<span class="run-time">${fmtDateFull(r.started_at)}</span>
|
|
<span class="run-status">${esc(r.status)}</span>
|
|
<span class="run-result">${esc(r.result)}</span>
|
|
</div>`).join('')}</div>`;
|
|
}
|
|
|
|
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');
|
|
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 runOnCreate = document.getElementById('job-run-on-create');
|
|
if (runOnCreate) cfg.run_on_create = runOnCreate.checked;
|
|
const res = await fetch(`/api/jobs/${jobId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled, schedule: parseInt(schedule, 10), config: cfg }),
|
|
});
|
|
if (res.ok) { showToast('Job saved', 'success'); loadJobDetail(jobId); }
|
|
else { showToast('Failed to save', 'error'); }
|
|
}
|
|
|
|
async function runJobNow(jobId) {
|
|
const btn = document.getElementById('job-run-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Running…';
|
|
try {
|
|
const res = await fetch(`/api/jobs/${jobId}/run`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (res.ok) { showToast(`Done — ${data.summary}`, 'success'); loadJobDetail(jobId); }
|
|
else { showToast(data.error ?? 'Run failed', 'error'); }
|
|
} catch { showToast('Run failed', 'error'); }
|
|
finally { btn.disabled = false; btn.textContent = 'Run Now'; }
|
|
}
|
|
|
|
function _updateJobsNavDot(jobs) {
|
|
const dot = document.getElementById('nav-jobs-dot');
|
|
const cls = jobs.some(j => j.last_status === 'error') ? 'error'
|
|
: jobs.some(j => j.last_status === 'success') ? 'success'
|
|
: 'none';
|
|
dot.className = `nav-job-dot nav-job-dot--${cls}`;
|
|
}
|