add: 更新循环任务固定调度

This commit is contained in:
2026-06-20 11:07:54 +08:00
parent 1b15ef30b3
commit aff11b77b6
9 changed files with 464 additions and 192 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js";
import { toast, formatBytes, formatTime } from "../state.js";
import { t } from "../i18n.js";
import { toast, formatBytes, formatTime } from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app");
+2 -2
View File
@@ -10,8 +10,8 @@
// and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
import { t } from "../i18n.js";
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
// --- Route entrypoints ---
+312 -111
View File
@@ -1,19 +1,21 @@
// 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.
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
import { post, postAsync } from "../rc.js";
import {
toast,
rememberJob,
getJobMeta,
listJobMeta,
forgetJob,
formatBytes,
formatSpeed,
formatDuration,
} from "../state.js";
import { t } from "../i18n.js";
} from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
import {
createRecurringJob,
deleteRecurringJob,
jobsApiURL,
listRecurringJobs,
runRecurringJobNow,
stopRecurringJob,
} from "../jobs_api.js?v=recurring-jobs-5";
let pollTimer = null;
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
@@ -78,6 +80,60 @@ export async function renderNewJob() {
<option value="move">${t("jobs.action_move")}</option>
</select>
</div>
<div class="field">
<label>${t("jobs.schedule_type")}</label>
<div class="segmented" role="radiogroup" aria-label="${t("jobs.schedule_type")}">
<label>
<input type="radio" name="scheduleType" value="once" checked>
<span>${t("jobs.schedule_once")}</span>
</label>
<label>
<input type="radio" name="scheduleType" value="recurring">
<span>${t("jobs.schedule_recurring")}</span>
</label>
</div>
<span class="field-help">${t("jobs.schedule_help")}</span>
</div>
<div id="recurrence-fields" class="recurrence-fields" hidden>
<div class="form-grid">
<div class="field field-full">
<label>${t("jobs.recurrence_kind")}</label>
<div class="segmented segmented-wide" role="radiogroup" aria-label="${t("jobs.recurrence_kind")}">
<label>
<input type="radio" name="recurrenceKind" value="daily" checked>
<span>${t("jobs.recurrence_daily")}</span>
</label>
<label>
<input type="radio" name="recurrenceKind" value="weekly">
<span>${t("jobs.recurrence_weekly")}</span>
</label>
<label>
<input type="radio" name="recurrenceKind" value="monthly">
<span>${t("jobs.recurrence_monthly")}</span>
</label>
</div>
</div>
<div class="field">
<label>${t("jobs.schedule_time")}</label>
<input class="input" type="time" name="scheduleTime" value="23:00">
</div>
<div class="field" data-weekly-field hidden>
<label>${t("jobs.schedule_weekday")}</label>
<select name="scheduleWeekday" class="select">
${renderWeekdayOptions()}
</select>
</div>
<div class="field" data-monthly-field hidden>
<label>${t("jobs.schedule_monthday")}</label>
<select name="scheduleMonthday" class="select">
${renderMonthdayOptions()}
</select>
</div>
<div class="field field-full">
<span class="field-help">${t("jobs.fixed_schedule_help")}</span>
</div>
</div>
</div>
<div class="form-grid">
<div class="field">
<label>${t("jobs.source_remote")}</label>
@@ -121,6 +177,31 @@ export async function renderNewJob() {
}
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,
@@ -143,6 +224,7 @@ export async function renderNewJob() {
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);
@@ -154,18 +236,24 @@ export async function renderNewJob() {
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;
if (scheduleType === "recurring") {
const schedule = readRecurringSchedule(form);
const job = await createRecurringJob({
action,
src,
dst,
...schedule,
});
toast(t("jobs.recurring_saved", job && job.id), "success");
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) {
rememberJob(jobid, { action, src, dst });
}
toast(t("jobs.started", action, jobid), "success");
toast(t("jobs.started_once", action, jobid), "success");
location.hash = "#/jobs";
} catch (e) {
toast(`${t("jobs.start_failed")}: ${e.message}`, "error");
@@ -173,6 +261,37 @@ export async function renderNewJob() {
});
}
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]) => `<option value="${value}">${label}</option>`)
.join("");
}
function renderMonthdayOptions() {
return Array.from({ length: 31 }, (_, index) => {
const day = index + 1;
return `<option value="${day}">${t("jobs.monthday", day)}</option>`;
}).join("");
}
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();
@@ -181,6 +300,39 @@ function buildJobFs(location, rawPath) {
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 < 1 || 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);
@@ -303,10 +455,21 @@ async function refreshJobs() {
const card = document.getElementById("jobs-card");
if (!card) return false;
const trackedJobs = listJobMeta();
const jobIds = trackedJobs.map((job) => job.jobid);
let jobs = [];
try {
jobs = await listRecurringJobs();
} 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;
}
if (jobIds.length === 0) {
if (jobs.length === 0) {
card.innerHTML = `
<div class="empty">
<h3>${t("jobs.empty_title")}</h3>
@@ -316,51 +479,24 @@ async function refreshJobs() {
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 = `
<div class="empty">
<h3>${t("jobs.empty_title")}</h3>
<p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p>
</div>
`;
return false;
}
card.innerHTML = renderJobTable(tracked);
card.innerHTML = renderJobTable(jobs);
card.querySelectorAll("[data-detail]").forEach((btn) => {
btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10)));
});
card.querySelectorAll("[data-start]").forEach((btn) => {
btn.addEventListener("click", () => onStartAgain(parseInt(btn.dataset.start, 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);
return jobs.length > 0;
}
function renderJobTable(statuses) {
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
statuses.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
const rows = statuses.map(renderJobRows).join("");
@@ -371,6 +507,7 @@ function renderJobTable(statuses) {
<th>${t("jobs.col.id")}</th>
<th>${t("jobs.col.job")}</th>
<th>${t("jobs.col.status")}</th>
<th>${t("jobs.col.next_run")}</th>
<th class="col-num">${t("jobs.col.progress")}</th>
<th class="col-num">${t("jobs.col.speed")}</th>
<th class="col-num">${t("jobs.col.eta")}</th>
@@ -388,42 +525,36 @@ function renderJobTable(statuses) {
function renderJobRows(s) {
const row = renderJobRow(s);
if (!expandedJobs.has(Number(s.jobid))) {
if (!expandedJobs.has(Number(s.id))) {
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;
function renderJobRow(job) {
const snapshot = job.statusSnapshot || {};
const p = snapshot.progress || {};
const id = job.id;
const running = !!job.running;
const jobid = job.currentJobid || job.lastJobid;
let badge;
if (!finished) {
badge = `<span class="badge">${t("jobs.status.running")}</span>`;
} else if (errored || (!success && errored)) {
badge = `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
} else if (success) {
badge = `<span class="badge badge-success">${t("jobs.status.done")}</span>`;
} else {
badge = `<span class="badge badge-warning">${t("jobs.status.finished")}</span>`;
}
const badge = renderStatusBadge(job.status);
const jobCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(job.action)}</span>
<span class="job-paths">
<code>${escapeHtml(job.src)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(job.dst)}</code>
</span>
</div>`;
const meta = getJobMeta(id);
const jobCell = meta
? `
<div class="job-cell">
<span class="job-action">${escapeHtml(meta.action)}</span>
<span class="job-paths">
<code>${escapeHtml(meta.src)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(meta.dst)}</code>
</span>
</div>`
: `<span class="col-mono" style="color:var(--color-muted-soft)">${t("jobs.submitted_cli")}</span>`;
const scheduleCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(formatDateTime(job.nextRunAt))}</span>
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
</div>
`;
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
const progress = `
@@ -436,24 +567,32 @@ function renderJobRow(s) {
`;
const detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${id}">${expandedJobs.has(Number(id)) ? t("jobs.hide_details") : t("jobs.details")}</button>`;
const stopBtn = !finished
const startBtn = running
? ""
: `<button class="btn btn-secondary btn-sm" data-start="${id}">${t("jobs.start_again")}</button>`;
const stopBtn = running
? `<button class="btn btn-danger btn-sm" data-stop="${id}">${t("jobs.stop")}</button>`
: "";
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${id}">${t("jobs.delete_record")}</button>`;
return `
<tr>
<td class="col-mono">${id}</td>
<td class="col-mono">
${id}
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""}
</td>
<td>${jobCell}</td>
<td>${badge}</td>
<td>${scheduleCell}</td>
<td class="col-num">${progress}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))}</td>
<td class="col-num col-mono">${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"}</td>
<td class="col-num col-mono">${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"}</td>
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td>
<td class="col-num">
<div class="job-actions">
${detailBtn}
${startBtn}
${stopBtn}
${deleteBtn}
</div>
@@ -462,26 +601,48 @@ function renderJobRow(s) {
`;
}
function renderJobDetailsRow(s) {
const id = s.jobid;
function renderStatusBadge(status) {
switch (status) {
case "running":
return `<span class="badge">${t("jobs.status.running")}</span>`;
case "failed":
return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
case "done":
return `<span class="badge badge-success">${t("jobs.status.done")}</span>`;
case "stopped":
return `<span class="badge badge-warning">${t("jobs.status.stopped")}</span>`;
case "scheduled":
return `<span class="badge badge-warning">${t("jobs.status.scheduled")}</span>`;
default:
return `<span class="badge badge-warning">${t("jobs.status.finished")}</span>`;
}
}
function renderJobDetailsRow(job) {
const id = job.id;
const snapshot = job.statusSnapshot || {};
const jobid = job.currentJobid || job.lastJobid || "";
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 || ""],
[t("jobs.detail_schedule_id"), id],
[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 = s.output && Object.keys(s.output).length > 0
? JSON.stringify(s.output, null, 2)
const output = snapshot && Object.keys(snapshot).length > 0
? JSON.stringify(snapshot, null, 2)
: "";
return `
<tr class="job-detail-row">
<td></td>
<td colspan="8">
<td colspan="9">
<div class="job-detail">
<dl>
${details.map(([label, value]) => `
@@ -503,17 +664,16 @@ function renderJobDetailsRow(s) {
`;
}
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");
function detailStatus(job) {
const status = job.status || "scheduled";
const key = `jobs.status.${status}`;
return t(key) === key ? status : t(key);
}
async function onStop(jobid) {
if (!confirm(t("jobs.stop_confirm", jobid))) return;
try {
await post("job/stop", { jobid });
await stopRecurringJob(jobid);
toast(t("jobs.stopped", jobid), "success");
await refreshJobs();
} catch (e) {
@@ -530,11 +690,25 @@ async function onToggleDetails(jobid) {
await refreshJobs();
}
async function onStartAgain(jobid) {
try {
await runRecurringJobNow(jobid);
toast(t("jobs.restarted", jobid), "success");
await refreshJobs();
} catch (e) {
toast(`${t("jobs.restart_failed")}: ${e.message}`, "error");
}
}
async function onDelete(jobid) {
if (!confirm(t("jobs.delete_confirm", jobid))) return;
forgetJob(jobid);
expandedJobs.delete(jobid);
await refreshJobs();
try {
await deleteRecurringJob(jobid);
expandedJobs.delete(jobid);
await refreshJobs();
} catch (e) {
toast(`${t("jobs.delete_failed")}: ${e.message}`, "error");
}
}
function startPolling() {
@@ -548,7 +722,7 @@ function startPolling() {
if (!hasRunning) {
stopPolling();
}
}, 1500);
}, 5000);
}
function stopPolling() {
@@ -581,3 +755,30 @@ function formatDateTime(value) {
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatSchedule(job) {
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":
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)] || "";
}
+2 -2
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
import { t } from "../i18n.js";
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
export async function renderRemotes() {
const app = document.getElementById("app");