// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. import { post } from "../rc.js?v=persist-all-jobs-1"; import { toast, formatBytes, formatSpeed, formatDuration, } from "../state.js?v=persist-all-jobs-1"; import { t } from "../i18n.js?v=persist-all-jobs-1"; import { createOneTimeJob, createRecurringJob, deleteOneTimeJob, deleteRecurringJob, jobsApiURL, listOneTimeJobs, listRecurringJobs, runOneTimeJobNow, runRecurringJobNow, stopOneTimeJob, stopRecurringJob, } from "../jobs_api.js?v=persist-all-jobs-1"; let pollTimer = null; const LOCAL_FS_VALUE = "__local__"; const expandedJobs = new Set(); const LOCAL_PICKER_ROOT = "/root"; let currentJobsView = "once"; export async function renderJobs() { const app = document.getElementById("app"); app.innerHTML = `

${t("jobs.title")}

${t("jobs.subtitle")}

${t("jobs.new_btn")}

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

`; app.querySelectorAll('input[name="jobsView"]').forEach((input) => { input.addEventListener("change", async () => { currentJobsView = input.value; const hasRunning = await refreshJobs(); if (hasRunning) { startPolling(); } else { stopPolling(); } }); }); 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.schedule_help")}
${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"); const recurrenceFields = document.getElementById("recurrence-fields"); const weeklyField = form.querySelector("[data-weekly-field]"); const monthlyField = form.querySelector("[data-monthly-field]"); const recurrenceKindInputs = Array.from(form.querySelectorAll('input[name="recurrenceKind"]')); const updateScheduleFields = () => { const isRecurring = form.elements.scheduleType.value === "recurring"; const recurrenceKind = getRecurrenceKind(form); recurrenceFields.hidden = !isRecurring; weeklyField.hidden = !isRecurring || recurrenceKind !== "weekly"; monthlyField.hidden = !isRecurring || recurrenceKind !== "monthly"; recurrenceKindInputs.forEach((input) => { input.required = isRecurring; }); form.elements.scheduleTime.required = isRecurring; form.elements.scheduleWeekday.required = isRecurring && recurrenceKind === "weekly"; form.elements.scheduleMonthday.required = isRecurring && recurrenceKind === "monthly"; }; form.querySelectorAll('input[name="scheduleType"]').forEach((input) => { input.addEventListener("change", updateScheduleFields); }); recurrenceKindInputs.forEach((input) => { input.addEventListener("change", updateScheduleFields); }); updateScheduleFields(); 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(); const scheduleType = form.elements.scheduleType.value; 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; } if (scheduleType === "recurring") { const schedule = readRecurringSchedule(form); const job = await createRecurringJob({ action, src, dst, ...schedule, }); toast(t("jobs.recurring_saved", job && job.id), "success"); currentJobsView = "recurring"; location.hash = "#/jobs"; return; } const job = await createOneTimeJob({ action, src, dst, }); toast(t("jobs.started_once", action, job && (job.jobid || job.id)), "success"); currentJobsView = "once"; location.hash = "#/jobs"; } catch (e) { toast(`${t("jobs.start_failed")}: ${e.message}`, "error"); } }); } function renderWeekdayOptions() { return [ [1, t("jobs.weekday_monday")], [2, t("jobs.weekday_tuesday")], [3, t("jobs.weekday_wednesday")], [4, t("jobs.weekday_thursday")], [5, t("jobs.weekday_friday")], [6, t("jobs.weekday_saturday")], [7, t("jobs.weekday_sunday")], ] .map(([value, label]) => ``) .join(""); } function renderMonthdayOptions() { const lastDay = ``; const numberedDays = Array.from({ length: 31 }, (_, index) => { const day = index + 1; return ``; }).join(""); return lastDay + numberedDays; } function buildJobFs(location, rawPath) { if (location === LOCAL_FS_VALUE) { return rawPath.trim(); } const remotePath = rawPath.trim().replace(/^\/+|\/+$/g, ""); return remotePath ? `${location}${remotePath}` : location; } function readRecurringSchedule(form) { const scheduleKind = getRecurrenceKind(form); const scheduleTime = form.elements.scheduleTime.value; if (!["daily", "weekly", "monthly"].includes(scheduleKind)) { throw new Error(t("error.no_schedule_selected")); } if (!/^\d{2}:\d{2}$/.test(scheduleTime)) { throw new Error(t("error.invalid_schedule_time")); } const schedule = { scheduleKind, scheduleTime, timezoneOffsetMinutes: -new Date().getTimezoneOffset(), }; if (scheduleKind === "weekly") { schedule.scheduleWeekday = Number(form.elements.scheduleWeekday.value); if (!Number.isInteger(schedule.scheduleWeekday) || schedule.scheduleWeekday < 1 || schedule.scheduleWeekday > 7) { throw new Error(t("error.no_schedule_selected")); } } if (scheduleKind === "monthly") { schedule.scheduleMonthday = Number(form.elements.scheduleMonthday.value); if (!Number.isInteger(schedule.scheduleMonthday) || schedule.scheduleMonthday < 0 || schedule.scheduleMonthday > 31) { throw new Error(t("error.no_schedule_selected")); } } return schedule; } function getRecurrenceKind(form) { return form.elements.recurrenceKind.value || "daily"; } 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; let jobs = []; try { if (currentJobsView === "once") { jobs = (await listOneTimeJobs()).map(normalizeOneTimeJob); } else { jobs = (await listRecurringJobs()).map(normalizeRecurringJob); } } catch (e) { card.innerHTML = `

${t("error.couldnt_load_jobs")}

${escapeHtml(e.message)}

${escapeHtml(jobsApiURL())}

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

${currentJobsView === "once" ? t("jobs.empty_once_title") : t("jobs.empty_recurring_title")}

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

`; return false; } card.innerHTML = renderJobTable(jobs); card.querySelectorAll("[data-detail]").forEach((btn) => { btn.addEventListener("click", () => onToggleDetails(btn.dataset.detail)); }); card.querySelectorAll("[data-start]").forEach((btn) => { btn.addEventListener("click", () => onStartAgain(btn.dataset.start)); }); card.querySelectorAll("[data-stop]").forEach((btn) => { btn.addEventListener("click", () => onStop(btn.dataset.stop)); }); card.querySelectorAll("[data-delete]").forEach((btn) => { btn.addEventListener("click", () => onDelete(btn.dataset.delete)); }); return jobs.some((job) => job.running); } function renderJobTable(statuses) { statuses.sort((a, b) => { const bTime = Date.parse(b.updatedAt || b.createdAt || b.startedAt || 0) || 0; const aTime = Date.parse(a.updatedAt || a.createdAt || a.startedAt || 0) || 0; return bTime - aTime; }); const rows = statuses.map(renderJobRows).join(""); return ` ${rows}
${t("jobs.col.id")} ${t("jobs.col.job")} ${t("jobs.col.status")} ${t("jobs.col.next_run")} ${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(jobKey(s))) { return row; } return row + renderJobDetailsRow(s); } function renderJobRow(job) { const snapshot = job.statusSnapshot || {}; const p = snapshot.progress || {}; const id = job.id; const key = jobKey(job); const running = !!job.running; const jobid = job.currentJobid || job.lastJobid; const badge = renderStatusBadge(job.status); const jobCell = `
${escapeHtml(job.action)} ${escapeHtml(job.src)} → ${escapeHtml(job.dst)}
`; const scheduleCell = `
${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))} ${escapeHtml(formatSchedule(job))}
`; 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 startBtn = running ? "" : ``; const stopBtn = running ? `` : ""; const deleteBtn = ``; return ` ${escapeHtml(displayJobId(job))} ${jobid ? `
job ${escapeHtml(jobid)}` : ""} ${jobCell} ${badge} ${scheduleCell} ${progress} ${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"} ${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"} ${p.transfers ?? 0} / ${p.totalTransfers ?? 0} ${(p.errors && p.errors.length) || 0}
${detailBtn} ${startBtn} ${stopBtn} ${deleteBtn}
`; } function renderStatusBadge(status) { switch (status) { case "running": case "starting": return `${t("jobs.status.running")}`; case "failed": return `${t("jobs.status.failed")}`; case "done": return `${t("jobs.status.done")}`; case "stopped": return `${t("jobs.status.stopped")}`; case "scheduled": return `${t("jobs.status.scheduled")}`; default: return `${t("jobs.status.finished")}`; } } function renderJobDetailsRow(job) { const snapshot = job.statusSnapshot || {}; const jobid = job.currentJobid || job.lastJobid || ""; const details = [ [job.kind === "once" ? t("jobs.detail_record_id") : t("jobs.detail_schedule_id"), displayJobId(job)], [t("jobs.detail_id"), jobid], [t("jobs.detail_status"), detailStatus(job)], [t("jobs.detail_error"), job.error || snapshot.error || t("jobs.detail_none")], [t("jobs.detail_started"), formatDateTime(job.startedAt || snapshot.startTime)], [t("jobs.detail_finished"), job.running ? t("jobs.status.running") : formatDateTime(job.finishedAt || snapshot.endTime)], [t("jobs.detail_duration"), formatDuration(snapshot.duration || 0)], [t("jobs.detail_next_run"), formatDateTime(job.nextRunAt)], [t("jobs.detail_schedule"), formatSchedule(job)], [t("jobs.detail_group"), snapshot.group || ""], [t("jobs.detail_execute_id"), snapshot.executeId || ""], ]; const output = snapshot && Object.keys(snapshot).length > 0 ? JSON.stringify(snapshot, null, 2) : ""; return `
${details.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join("")}
${output ? `
${t("jobs.detail_output")}
${escapeHtml(output)}
` : ""}
`; } function detailStatus(job) { const status = job.status || "scheduled"; const key = `jobs.status.${status}`; return t(key) === key ? status : t(key); } async function onStop(jobid) { const key = String(jobid); const label = displayKey(key); if (!confirm(t("jobs.stop_confirm", label))) return; try { if (key.startsWith("once:")) { await stopOneTimeJob(oneTimeIdFromKey(key)); } else { await stopRecurringJob(recurringIdFromKey(key)); } toast(t("jobs.stopped", label), "success"); await refreshJobs(); } catch (e) { toast(`${t("jobs.stop_failed")}: ${e.message}`, "error"); } } async function onToggleDetails(jobid) { const key = String(jobid); if (expandedJobs.has(key)) { expandedJobs.delete(key); } else { expandedJobs.add(key); } await refreshJobs(); } async function onStartAgain(jobid) { const key = String(jobid); try { if (key.startsWith("once:")) { const job = await runOneTimeJobNow(oneTimeIdFromKey(key)); toast(t("jobs.restarted_once", job && (job.jobid || job.id)), "success"); } else { const id = recurringIdFromKey(key); await runRecurringJobNow(id); toast(t("jobs.restarted", id), "success"); } await refreshJobs(); } catch (e) { toast(`${t("jobs.restart_failed")}: ${e.message}`, "error"); } } async function onDelete(jobid) { const key = String(jobid); const label = displayKey(key); if (!confirm(t("jobs.delete_confirm", label))) return; try { if (key.startsWith("once:")) { await deleteOneTimeJob(oneTimeIdFromKey(key)); } else { await deleteRecurringJob(recurringIdFromKey(key)); } expandedJobs.delete(key); await refreshJobs(); } catch (e) { toast(`${t("jobs.delete_failed")}: ${e.message}`, "error"); } } function startPolling() { stopPolling(); pollTimer = setInterval(async () => { if (!document.getElementById("jobs-card")) { stopPolling(); return; } const hasRunning = await refreshJobs(); if (!hasRunning) { stopPolling(); } }, 5000); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } export function stopJobPolling() { stopPolling(); } function normalizeRecurringJob(job) { return { ...job, kind: "recurring", id: `recurring:${job.id}`, scheduleId: job.id, }; } function normalizeOneTimeJob(job) { return { ...job, kind: "once", id: `once:${job.id}`, recordId: job.id, }; } function jobKey(job) { return String(job.id); } function displayJobId(job) { if (job.kind === "once") { return t("jobs.once_id", job.recordId || String(job.id).replace(/^once:/, "")); } return job.scheduleId || String(job.id).replace(/^recurring:/, ""); } function displayKey(key) { if (key.startsWith("once:")) { return t("jobs.once_id", key.replace(/^once:/, "")); } return key.replace(/^recurring:/, ""); } function recurringIdFromKey(key) { return Number(String(key).replace(/^recurring:/, "")); } function oneTimeIdFromKey(key) { return Number(String(key).replace(/^once:/, "")); } 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(); } function formatSchedule(job) { if (job.kind === "once") { return t("jobs.schedule_label_once"); } const time = job.scheduleTime || ""; switch (job.scheduleKind) { case "daily": return t("jobs.schedule_label_daily", time); case "weekly": return t("jobs.schedule_label_weekly", weekdayName(job.scheduleWeekday), time); case "monthly": if (Number(job.scheduleMonthday) === 0) { return t("jobs.schedule_label_monthly_last", time); } return t("jobs.schedule_label_monthly", job.scheduleMonthday, time); default: return ""; } } function weekdayName(value) { const names = { 1: t("jobs.weekday_monday"), 2: t("jobs.weekday_tuesday"), 3: t("jobs.weekday_wednesday"), 4: t("jobs.weekday_thursday"), 5: t("jobs.weekday_friday"), 6: t("jobs.weekday_saturday"), 7: t("jobs.weekday_sunday"), }; return names[Number(value)] || ""; }