// views/jobs.js — submit sync/copy/move jobs and poll their progress. // Job src/dst metadata is persisted to localStorage (via state.rememberJob) // so the table can show what each job is doing even after a page reload. import { post, postAsync } from "../rc.js"; import { toast, rememberJob, getJobMeta, formatBytes, formatSpeed, formatDuration, } from "../state.js"; let pollTimer = null; export async function renderJobs() { const app = document.getElementById("app"); app.innerHTML = `

Jobs

Running and recently completed transfers.

New Job

Loading jobs…

`; await refreshJobs(); startPolling(); } export async function renderNewJob() { const app = document.getElementById("app"); // Pull remotes for the dropdowns let remotes = []; try { const res = await post("config/listremotes"); remotes = (res && res.remotes) || []; } catch (e) { toast(`Couldn’t load remotes: ${e.message}`, "error"); } const remoteOpts = remotes .map((r) => ``) .join(""); app.innerHTML = `

New Job

Copy, sync, or move between remotes.

Cancel
`; // Prefill first remote in both selectors for convenience if (remotes.length > 0) { app.querySelector('select[name="srcRemote"]').value = `${remotes[0]}:`; app.querySelector('select[name="dstRemote"]').value = `${remotes[0]}:`; } const form = document.getElementById("new-job-form"); form.addEventListener("submit", async (e) => { e.preventDefault(); const action = form.elements.action.value; const srcRemote = form.elements.srcRemote.value; const dstRemote = form.elements.dstRemote.value; const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, ""); const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, ""); if (!srcRemote || !dstRemote) { toast("Pick source and destination remotes", "error"); return; } const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote; const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote; try { const body = { srcFs: src, dstFs: dst }; if (action === "move") body.deleteEmptySrcDirs = true; const res = await postAsync(`sync/${action}`, body); const jobid = res && res.jobid; if (jobid != null) { rememberJob(jobid, { action, src, dst }); } toast(`Started ${action} job #${jobid}`, "success"); location.hash = "#/jobs"; } catch (e) { toast(`Job start failed: ${e.message}`, "error"); } }); } async function refreshJobs() { const card = document.getElementById("jobs-card"); if (!card) return; // user navigated away let jobIds = []; try { const list = await post("job/list"); const running = (list && list.jobids) || []; const finished = (list && list.finishedIds) || []; // Show both running and recently-finished. Sort happens in render. jobIds = [...new Set([...running, ...finished])]; } catch (e) { card.innerHTML = `

Couldn’t load jobs

${escapeHtml(e.message)}

`; return; } if (jobIds.length === 0) { card.innerHTML = `

No jobs yet

Use New Job to start a copy, sync, or move.

`; return; } // Fetch each job's status in parallel const statuses = await Promise.all( jobIds.map((id) => post("job/status", { jobid: id }).catch((e) => ({ jobid: id, error: e.message, finished: true, })), ), ); card.innerHTML = renderJobTable(statuses); card.querySelectorAll("[data-stop]").forEach((btn) => { btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10))); }); } function renderJobTable(statuses) { // Newest jobid first statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0)); const rows = statuses.map(renderJobRow).join(""); return ` ${rows}
# Job Status Progress Speed ETA Files Errors Action
`; } function renderJobRow(s) { const p = s.progress || {}; const id = s.jobid; const finished = s.finished; const success = s.success; const errored = !!s.error; let badge; if (!finished) { badge = `running`; } else if (errored || (!success && errored)) { badge = `failed`; } else if (success) { badge = `done`; } else { badge = `finished`; } const meta = getJobMeta(id); const jobCell = meta ? `
${escapeHtml(meta.action)} ${escapeHtml(meta.src)} → ${escapeHtml(meta.dst)}
` : `— submitted via CLI —`; const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0; const progress = `
${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)}
`; const stopBtn = !finished ? `` : ""; return ` ${id} ${jobCell} ${badge} ${progress} ${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))} ${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))} ${p.transfers ?? 0} / ${p.totalTransfers ?? 0} ${(p.errors && p.errors.length) || 0} ${stopBtn} `; } async function onStop(jobid) { if (!confirm(`Stop job #${jobid}?`)) return; try { await post("job/stop", { jobid }); toast(`Stopped job #${jobid}`, "success"); await refreshJobs(); } catch (e) { toast(`Stop failed: ${e.message}`, "error"); } } function startPolling() { stopPolling(); pollTimer = setInterval(async () => { if (!document.getElementById("jobs-card")) { stopPolling(); return; } await refreshJobs(); }, 1500); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } export function stopJobPolling() { stopPolling(); } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }