- 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>
611 lines
27 KiB
JavaScript
611 lines
27 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 stat-clickable" onclick="setStateFilter('')"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</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 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 stat-clickable" onclick="setStateFilter('degraded')"><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);
|
|
});
|
|
}
|
|
|
|
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() {
|
|
const search = document.getElementById('search-input').value;
|
|
const state = document.getElementById('filter-state').value;
|
|
const stack = document.getElementById('filter-stack').value;
|
|
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');
|
|
|
|
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 => (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 ────────────────────────────────────────────────────────────
|
|
|
|
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}`;
|
|
}
|