// 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"; const LOCAL_FS_VALUE = "__local__"; const expandedJobs = new Set(); const LOCAL_PICKER_ROOT = "/root"; 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 locationOpts = [ ...remotes.map((r) => ``), ``, ] .join(""); app.innerHTML = `

${t("jobs.new_title")}

${t("jobs.new_subtitle")}

${t("jobs.cancel")}
${t("jobs.local_path_help")}
`; 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.querySelector('[data-pick="src"]').addEventListener("click", () => { openFolderPicker({ location: form.elements.srcRemote.value, pathInput: form.elements.srcPath, title: t("jobs.pick_source_title"), }); }); form.querySelector('[data-pick="dst"]').addEventListener("click", () => { openFolderPicker({ location: form.elements.dstRemote.value, pathInput: form.elements.dstPath, title: t("jobs.pick_dest_title"), }); }); 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(); const dstPath = form.elements.dstPath.value.trim(); try { const src = buildJobFs(srcRemote, srcPath); const dst = buildJobFs(dstRemote, dstPath); if (!src || !dst) { toast(t("error.no_paths_selected"), "error"); return; } if ((srcRemote === LOCAL_FS_VALUE && !srcPath) || (dstRemote === LOCAL_FS_VALUE && !dstPath)) { toast(t("error.no_paths_selected"), "error"); return; } 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"); } }); } function buildJobFs(location, rawPath) { if (location === LOCAL_FS_VALUE) { return rawPath.trim(); } const remotePath = rawPath.trim().replace(/^\/+|\/+$/g, ""); return remotePath ? `${location}${remotePath}` : location; } async function openFolderPicker({ location, pathInput, title }) { const root = document.getElementById("modal-root"); let current = initialPickerPath(location, pathInput.value); const close = () => { root.innerHTML = ""; }; const choose = () => { pathInput.value = current; close(); }; const render = async () => { root.innerHTML = ` `; const overlay = root.querySelector(".modal-overlay"); const list = root.querySelector(".folder-picker-list"); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); root.querySelector("[data-cancel]").addEventListener("click", close); root.querySelector("[data-select]").addEventListener("click", choose); try { const dirs = await listDirs(location, current); const parent = parentPickerPath(location, current); list.innerHTML = ` ${dirs.length === 0 ? `

${t("jobs.pick_empty")}

` : dirs.map((dir) => ` `).join("")} `; list.querySelectorAll("[data-path]").forEach((btn) => { btn.addEventListener("click", () => { if (btn.disabled) return; current = btn.dataset.path; render(); }); }); } catch (e) { list.innerHTML = `

${t("error.couldnt_list")}

${escapeHtml(e.message)}

`; } }; await render(); } async function listDirs(location, current) { const isLocal = location === LOCAL_FS_VALUE; const body = isLocal ? { fs: current || LOCAL_PICKER_ROOT, remote: "" } : { fs: location, remote: trimRemotePath(current) }; const res = await post("operations/list", { ...body, opt: { dirsOnly: true, noModTime: true, noMimeType: true, showHash: false }, }); const items = (res && res.list) || []; return items .filter((item) => item.IsDir) .sort((a, b) => a.Name.localeCompare(b.Name)); } function initialPickerPath(location, rawPath) { const value = rawPath.trim(); if (location === LOCAL_FS_VALUE) { return value || LOCAL_PICKER_ROOT; } return trimRemotePath(value); } function displayPickerPath(location, current) { if (location === LOCAL_FS_VALUE) { return current || "/"; } return current ? `${location}${current}` : location; } function parentPickerPath(location, current) { if (location === LOCAL_FS_VALUE) { if (!current || current === "/") return null; const normalized = current.replace(/\/+$/g, ""); const parent = normalized.slice(0, normalized.lastIndexOf("/")) || "/"; return parent; } if (!current) return null; const parts = trimRemotePath(current).split("/").filter(Boolean); parts.pop(); return parts.join("/"); } function joinPickerPath(current, name) { if (!current || current === "/") return current === "/" ? `/${name}` : name; return `${current.replace(/\/+$/g, "")}/${name}`; } function trimRemotePath(path) { return path.trim().replace(/^\/+|\/+$/g, ""); } 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 }) .then((status) => ({ ...status, jobid: id })) .catch((e) => { if (e.message.includes("job not found")) { forgetJob(id); expandedJobs.delete(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-detail]").forEach((btn) => { btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10))); }); card.querySelectorAll("[data-stop]").forEach((btn) => { btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10))); }); card.querySelectorAll("[data-delete]").forEach((btn) => { btn.addEventListener("click", () => onDelete(parseInt(btn.dataset.delete, 10))); }); return tracked.some((status) => !status.finished); } function renderJobTable(statuses) { statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0)); const rows = statuses.map(renderJobRows).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 renderJobRows(s) { const row = renderJobRow(s); if (!expandedJobs.has(Number(s.jobid))) { return row; } return row + renderJobDetailsRow(s); } 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 detailBtn = ``; const stopBtn = !finished ? `` : ""; const deleteBtn = ``; 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}
${detailBtn} ${stopBtn} ${deleteBtn}
`; } function renderJobDetailsRow(s) { const id = s.jobid; const details = [ [t("jobs.detail_id"), id], [t("jobs.detail_status"), detailStatus(s)], [t("jobs.detail_error"), s.error || t("jobs.detail_none")], [t("jobs.detail_started"), formatDateTime(s.startTime)], [t("jobs.detail_finished"), s.finished ? formatDateTime(s.endTime) : t("jobs.status.running")], [t("jobs.detail_duration"), formatDuration(s.duration || 0)], [t("jobs.detail_group"), s.group || ""], [t("jobs.detail_execute_id"), s.executeId || ""], ]; const output = s.output && Object.keys(s.output).length > 0 ? JSON.stringify(s.output, null, 2) : ""; return `
${details.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join("")}
${output ? `
${t("jobs.detail_output")}
${escapeHtml(output)}
` : ""}
`; } function detailStatus(s) { if (!s.finished) return t("jobs.status.running"); if (s.error) return t("jobs.status.failed"); if (s.success) return t("jobs.status.done"); return t("jobs.status.finished"); } 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"); } } async function onToggleDetails(jobid) { if (expandedJobs.has(jobid)) { expandedJobs.delete(jobid); } else { expandedJobs.add(jobid); } await refreshJobs(); } async function onDelete(jobid) { if (!confirm(t("jobs.delete_confirm", jobid))) return; forgetJob(jobid); expandedJobs.delete(jobid); await refreshJobs(); } 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, "'"); } function escapeAttr(s) { return escapeHtml(s); } function formatDateTime(value) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleString(); }