// 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
`;
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) => `${escapeHtml(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 `
#
Job
Status
Progress
Speed
ETA
Files
Errors
Action
${rows}
`;
}
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
? `Stop `
: "";
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, "'");
}