|
|
|
@@ -1,28 +1,32 @@
|
|
|
|
|
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
|
|
|
|
|
|
|
|
|
import { post, postAsync } from "../rc.js?v=one-time-visible-1";
|
|
|
|
|
import { post } from "../rc.js?v=persist-all-jobs-1";
|
|
|
|
|
import {
|
|
|
|
|
toast,
|
|
|
|
|
formatBytes,
|
|
|
|
|
formatSpeed,
|
|
|
|
|
formatDuration,
|
|
|
|
|
} from "../state.js?v=one-time-visible-1";
|
|
|
|
|
import { t } from "../i18n.js?v=one-time-visible-1";
|
|
|
|
|
} 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=one-time-visible-1";
|
|
|
|
|
} from "../jobs_api.js?v=persist-all-jobs-1";
|
|
|
|
|
|
|
|
|
|
let pollTimer = null;
|
|
|
|
|
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
|
|
|
|
|
const LOCAL_FS_VALUE = "__local__";
|
|
|
|
|
const expandedJobs = new Set();
|
|
|
|
|
const LOCAL_PICKER_ROOT = "/root";
|
|
|
|
|
const ONE_TIME_JOBS_KEY = "rclone.webgui.oneTimeJobs.v1";
|
|
|
|
|
let currentJobsView = "once";
|
|
|
|
|
|
|
|
|
|
export async function renderJobs() {
|
|
|
|
|
const app = document.getElementById("app");
|
|
|
|
@@ -34,11 +38,35 @@ export async function renderJobs() {
|
|
|
|
|
</div>
|
|
|
|
|
<a class="btn btn-primary btn-sm" href="#/jobs/new">${t("jobs.new_btn")}</a>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<div class="segmented" role="tablist" aria-label="${t("jobs.view_filter")}">
|
|
|
|
|
<label>
|
|
|
|
|
<input type="radio" name="jobsView" value="once" ${currentJobsView === "once" ? "checked" : ""}>
|
|
|
|
|
<span>${t("jobs.view_once")}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label>
|
|
|
|
|
<input type="radio" name="jobsView" value="recurring" ${currentJobsView === "recurring" ? "checked" : ""}>
|
|
|
|
|
<span>${t("jobs.view_recurring")}</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="jobs-card" class="card-outline">
|
|
|
|
|
<p class="empty">${t("loading.title")}…</p>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
@@ -247,31 +275,18 @@ export async function renderNewJob() {
|
|
|
|
|
...schedule,
|
|
|
|
|
});
|
|
|
|
|
toast(t("jobs.recurring_saved", job && job.id), "success");
|
|
|
|
|
currentJobsView = "recurring";
|
|
|
|
|
location.hash = "#/jobs";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const body = buildTransferBody(action, src, dst);
|
|
|
|
|
const res = await postAsync(`sync/${action}`, body);
|
|
|
|
|
const jobid = res && res.jobid;
|
|
|
|
|
if (jobid == null) {
|
|
|
|
|
throw new Error("missing jobid");
|
|
|
|
|
}
|
|
|
|
|
saveOneTimeJob({
|
|
|
|
|
id: `once:${jobid}`,
|
|
|
|
|
kind: "once",
|
|
|
|
|
const job = await createOneTimeJob({
|
|
|
|
|
action,
|
|
|
|
|
src,
|
|
|
|
|
dst,
|
|
|
|
|
currentJobid: jobid,
|
|
|
|
|
lastJobid: jobid,
|
|
|
|
|
status: "running",
|
|
|
|
|
running: true,
|
|
|
|
|
statusSnapshot: { finished: false, jobid },
|
|
|
|
|
startedAt: new Date().toISOString(),
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
toast(t("jobs.started_once", action, jobid), "success");
|
|
|
|
|
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");
|
|
|
|
@@ -302,16 +317,6 @@ function renderMonthdayOptions() {
|
|
|
|
|
return lastDay + numberedDays;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTransferBody(action, src, dst) {
|
|
|
|
|
const body = {
|
|
|
|
|
srcFs: src,
|
|
|
|
|
dstFs: dst,
|
|
|
|
|
_group: `${WEBGUI_JOB_GROUP_PREFIX}/${action}`,
|
|
|
|
|
};
|
|
|
|
|
if (action === "move") body.deleteEmptySrcDirs = true;
|
|
|
|
|
return body;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildJobFs(location, rawPath) {
|
|
|
|
|
if (location === LOCAL_FS_VALUE) {
|
|
|
|
|
return rawPath.trim();
|
|
|
|
@@ -475,29 +480,28 @@ async function refreshJobs() {
|
|
|
|
|
const card = document.getElementById("jobs-card");
|
|
|
|
|
if (!card) return false;
|
|
|
|
|
|
|
|
|
|
let recurringJobs = [];
|
|
|
|
|
const oneTimeJobs = await refreshOneTimeJobs();
|
|
|
|
|
let jobs = [];
|
|
|
|
|
try {
|
|
|
|
|
recurringJobs = await listRecurringJobs();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (oneTimeJobs.length === 0) {
|
|
|
|
|
card.innerHTML = `
|
|
|
|
|
<div class="empty">
|
|
|
|
|
<h3>${t("error.couldnt_load_jobs")}</h3>
|
|
|
|
|
<p>${escapeHtml(e.message)}</p>
|
|
|
|
|
<p class="col-mono">${escapeHtml(jobsApiURL())}</p>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
return false;
|
|
|
|
|
if (currentJobsView === "once") {
|
|
|
|
|
jobs = (await listOneTimeJobs()).map(normalizeOneTimeJob);
|
|
|
|
|
} else {
|
|
|
|
|
jobs = (await listRecurringJobs()).map(normalizeRecurringJob);
|
|
|
|
|
}
|
|
|
|
|
toast(`${t("error.couldnt_load_jobs")}: ${e.message}`, "error");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
card.innerHTML = `
|
|
|
|
|
<div class="empty">
|
|
|
|
|
<h3>${t("error.couldnt_load_jobs")}</h3>
|
|
|
|
|
<p>${escapeHtml(e.message)}</p>
|
|
|
|
|
<p class="col-mono">${escapeHtml(jobsApiURL())}</p>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const jobs = [...oneTimeJobs, ...recurringJobs.map(normalizeRecurringJob)];
|
|
|
|
|
if (jobs.length === 0) {
|
|
|
|
|
card.innerHTML = `
|
|
|
|
|
<div class="empty">
|
|
|
|
|
<h3>${t("jobs.empty_title")}</h3>
|
|
|
|
|
<h3>${currentJobsView === "once" ? t("jobs.empty_once_title") : t("jobs.empty_recurring_title")}</h3>
|
|
|
|
|
<p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
@@ -634,6 +638,7 @@ function renderJobRow(job) {
|
|
|
|
|
function renderStatusBadge(status) {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case "running":
|
|
|
|
|
case "starting":
|
|
|
|
|
return `<span class="badge">${t("jobs.status.running")}</span>`;
|
|
|
|
|
case "failed":
|
|
|
|
|
return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
|
|
|
|
@@ -701,20 +706,11 @@ function detailStatus(job) {
|
|
|
|
|
|
|
|
|
|
async function onStop(jobid) {
|
|
|
|
|
const key = String(jobid);
|
|
|
|
|
const oneTimeJob = findOneTimeJob(key);
|
|
|
|
|
const label = displayKey(key);
|
|
|
|
|
if (!confirm(t("jobs.stop_confirm", label))) return;
|
|
|
|
|
try {
|
|
|
|
|
if (oneTimeJob) {
|
|
|
|
|
await post("job/stop", { jobid: oneTimeJob.currentJobid || oneTimeJob.lastJobid });
|
|
|
|
|
saveOneTimeJob({
|
|
|
|
|
...oneTimeJob,
|
|
|
|
|
running: false,
|
|
|
|
|
status: "stopped",
|
|
|
|
|
currentJobid: null,
|
|
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
if (key.startsWith("once:")) {
|
|
|
|
|
await stopOneTimeJob(oneTimeIdFromKey(key));
|
|
|
|
|
} else {
|
|
|
|
|
await stopRecurringJob(recurringIdFromKey(key));
|
|
|
|
|
}
|
|
|
|
@@ -737,30 +733,10 @@ async function onToggleDetails(jobid) {
|
|
|
|
|
|
|
|
|
|
async function onStartAgain(jobid) {
|
|
|
|
|
const key = String(jobid);
|
|
|
|
|
const oneTimeJob = findOneTimeJob(key);
|
|
|
|
|
try {
|
|
|
|
|
if (oneTimeJob) {
|
|
|
|
|
const body = buildTransferBody(oneTimeJob.action, oneTimeJob.src, oneTimeJob.dst);
|
|
|
|
|
const res = await postAsync(`sync/${oneTimeJob.action}`, body);
|
|
|
|
|
const newJobid = res && res.jobid;
|
|
|
|
|
if (newJobid == null) {
|
|
|
|
|
throw new Error("missing jobid");
|
|
|
|
|
}
|
|
|
|
|
saveOneTimeJob({
|
|
|
|
|
...oneTimeJob,
|
|
|
|
|
id: `once:${newJobid}`,
|
|
|
|
|
currentJobid: newJobid,
|
|
|
|
|
lastJobid: newJobid,
|
|
|
|
|
status: "running",
|
|
|
|
|
running: true,
|
|
|
|
|
statusSnapshot: { finished: false, jobid: newJobid },
|
|
|
|
|
error: null,
|
|
|
|
|
startedAt: new Date().toISOString(),
|
|
|
|
|
finishedAt: null,
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
removeOneTimeJob(key);
|
|
|
|
|
toast(t("jobs.restarted_once", newJobid), "success");
|
|
|
|
|
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);
|
|
|
|
@@ -777,8 +753,8 @@ async function onDelete(jobid) {
|
|
|
|
|
const label = displayKey(key);
|
|
|
|
|
if (!confirm(t("jobs.delete_confirm", label))) return;
|
|
|
|
|
try {
|
|
|
|
|
if (findOneTimeJob(key)) {
|
|
|
|
|
removeOneTimeJob(key);
|
|
|
|
|
if (key.startsWith("once:")) {
|
|
|
|
|
await deleteOneTimeJob(oneTimeIdFromKey(key));
|
|
|
|
|
} else {
|
|
|
|
|
await deleteRecurringJob(recurringIdFromKey(key));
|
|
|
|
|
}
|
|
|
|
@@ -814,57 +790,6 @@ export function stopJobPolling() {
|
|
|
|
|
stopPolling();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshOneTimeJobs() {
|
|
|
|
|
const jobs = loadOneTimeJobs();
|
|
|
|
|
let changed = false;
|
|
|
|
|
for (const job of jobs) {
|
|
|
|
|
const jobid = job.currentJobid || job.lastJobid;
|
|
|
|
|
if (!jobid || !job.running) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const status = await post("job/status", { jobid }, { allowError: true });
|
|
|
|
|
Object.assign(job, oneTimeStatusToJob(job, status));
|
|
|
|
|
changed = true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
Object.assign(job, {
|
|
|
|
|
status: "failed",
|
|
|
|
|
running: false,
|
|
|
|
|
currentJobid: null,
|
|
|
|
|
error: e.message,
|
|
|
|
|
statusSnapshot: { finished: true, error: e.message, jobid },
|
|
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (changed) {
|
|
|
|
|
saveOneTimeJobs(jobs);
|
|
|
|
|
}
|
|
|
|
|
return jobs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function oneTimeStatusToJob(job, status) {
|
|
|
|
|
const name = statusName(status);
|
|
|
|
|
return {
|
|
|
|
|
status: name,
|
|
|
|
|
running: name === "running",
|
|
|
|
|
currentJobid: name === "running" ? job.currentJobid : null,
|
|
|
|
|
error: status.error || null,
|
|
|
|
|
statusSnapshot: status,
|
|
|
|
|
finishedAt: name === "running" ? null : new Date().toISOString(),
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusName(status) {
|
|
|
|
|
if (!status.finished) return "running";
|
|
|
|
|
if (status.error) return "failed";
|
|
|
|
|
if (status.success) return "done";
|
|
|
|
|
return "finished";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeRecurringJob(job) {
|
|
|
|
|
return {
|
|
|
|
|
...job,
|
|
|
|
@@ -874,13 +799,22 @@ function normalizeRecurringJob(job) {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.lastJobid || job.currentJobid || "");
|
|
|
|
|
return t("jobs.once_id", job.recordId || String(job.id).replace(/^once:/, ""));
|
|
|
|
|
}
|
|
|
|
|
return job.scheduleId || String(job.id).replace(/^recurring:/, "");
|
|
|
|
|
}
|
|
|
|
@@ -896,32 +830,8 @@ function recurringIdFromKey(key) {
|
|
|
|
|
return Number(String(key).replace(/^recurring:/, ""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadOneTimeJobs() {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(ONE_TIME_JOBS_KEY);
|
|
|
|
|
const jobs = raw ? JSON.parse(raw) : [];
|
|
|
|
|
return Array.isArray(jobs) ? jobs : [];
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveOneTimeJobs(jobs) {
|
|
|
|
|
localStorage.setItem(ONE_TIME_JOBS_KEY, JSON.stringify(jobs.slice(0, 50)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveOneTimeJob(job) {
|
|
|
|
|
const jobs = loadOneTimeJobs().filter((item) => item.id !== job.id);
|
|
|
|
|
jobs.unshift(job);
|
|
|
|
|
saveOneTimeJobs(jobs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findOneTimeJob(key) {
|
|
|
|
|
return loadOneTimeJobs().find((job) => job.id === key) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeOneTimeJob(key) {
|
|
|
|
|
saveOneTimeJobs(loadOneTimeJobs().filter((job) => job.id !== key));
|
|
|
|
|
function oneTimeIdFromKey(key) {
|
|
|
|
|
return Number(String(key).replace(/^once:/, ""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(s) {
|
|
|
|
|