// 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, listJobMeta, forgetJob, formatBytes, formatSpeed, formatDuration, } from "../state.js"; import { t } from "../i18n.js"; let pollTimer = null; const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer"; export async function renderJobs() { const app = document.getElementById("app"); app.innerHTML = `

${t("jobs.title")}

${t("jobs.subtitle")}

${t("jobs.new_btn")}

${t("loading.title")}…

`; const hasRunning = await refreshJobs(); if (hasRunning) { startPolling(); } else { stopPolling(); } } export async function renderNewJob() { const app = document.getElementById("app"); let remotes = []; try { const res = await post("config/listremotes"); remotes = (res && res.remotes) || []; } catch (e) { toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error"); } const remoteOpts = remotes .map((r) => ``) .join(""); app.innerHTML = `

${t("jobs.new_title")}

${t("jobs.new_subtitle")}

${t("jobs.cancel")}
`; 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(t("error.no_remotes_selected"), "error"); return; } const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote; const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote; try { const body = { srcFs: src, dstFs: dst, _group: `${WEBGUI_JOB_GROUP_PREFIX}/${action}`, }; 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(t("jobs.started", action, jobid), "success"); location.hash = "#/jobs"; } catch (e) { toast(`${t("jobs.start_failed")}: ${e.message}`, "error"); } }); } async function refreshJobs() { const card = document.getElementById("jobs-card"); if (!card) return false; const trackedJobs = listJobMeta(); const jobIds = trackedJobs.map((job) => job.jobid); if (jobIds.length === 0) { card.innerHTML = `

${t("jobs.empty_title")}

${t("jobs.empty_body").replace("New Job", `${t("jobs.new_btn")}`)}

`; return false; } const statuses = await Promise.all( jobIds.map((id) => post("job/status", { jobid: id }).catch((e) => { if (e.message.includes("job not found")) { forgetJob(id); return null; } return { jobid: id, error: e.message, finished: true, }; }), ), ); const tracked = statuses.filter(Boolean); if (tracked.length === 0) { card.innerHTML = `

${t("jobs.empty_title")}

${t("jobs.empty_body").replace("New Job", `${t("jobs.new_btn")}`)}

`; return false; } card.innerHTML = renderJobTable(tracked); card.querySelectorAll("[data-stop]").forEach((btn) => { btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10))); }); return tracked.some((status) => !status.finished); } function renderJobTable(statuses) { statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0)); const rows = statuses.map(renderJobRow).join(""); return ` ${rows}
${t("jobs.col.id")} ${t("jobs.col.job")} ${t("jobs.col.status")} ${t("jobs.col.progress")} ${t("jobs.col.speed")} ${t("jobs.col.eta")} ${t("jobs.col.files")} ${t("jobs.col.errors")} ${t("jobs.col.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 = `${t("jobs.status.running")}`; } else if (errored || (!success && errored)) { badge = `${t("jobs.status.failed")}`; } else if (success) { badge = `${t("jobs.status.done")}`; } else { badge = `${t("jobs.status.finished")}`; } const meta = getJobMeta(id); const jobCell = meta ? `
${escapeHtml(meta.action)} ${escapeHtml(meta.src)} → ${escapeHtml(meta.dst)}
` : `${t("jobs.submitted_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(t("jobs.stop_confirm", jobid))) return; try { await post("job/stop", { jobid }); toast(t("jobs.stopped", jobid), "success"); await refreshJobs(); } catch (e) { toast(`${t("jobs.stop_failed")}: ${e.message}`, "error"); } } function startPolling() { stopPolling(); pollTimer = setInterval(async () => { if (!document.getElementById("jobs-card")) { stopPolling(); return; } const hasRunning = await refreshJobs(); if (!hasRunning) { stopPolling(); } }, 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, "'"); }