// 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 = `
`;
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 = `
`;
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 = `
${escapeHtml(title)}
${escapeHtml(displayPickerPath(location, current))}
`;
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 `
| ${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")} |
${rows}
`;
}
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)] || "";
}