add: 持久化所有任务记录

This commit is contained in:
2026-06-20 12:20:39 +08:00
parent cd051900b5
commit ba9d0977e3
11 changed files with 465 additions and 189 deletions
+6 -6
View File
@@ -2,15 +2,15 @@
// the top-nav active state in sync, and re-renders chrome strings when
// the locale changes.
import { onRoute } from "./state.js?v=one-time-visible-1";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=one-time-visible-1";
import { renderRemotes } from "./views/remotes.js?v=one-time-visible-1";
import { renderBrowse } from "./views/browser.js?v=one-time-visible-1";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=one-time-visible-1";
import { onRoute } from "./state.js?v=persist-all-jobs-1";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=persist-all-jobs-1";
import { renderRemotes } from "./views/remotes.js?v=persist-all-jobs-1";
import { renderBrowse } from "./views/browser.js?v=persist-all-jobs-1";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=persist-all-jobs-1";
import {
renderConfigureNew,
renderConfigureEdit,
} from "./views/configure.js?v=one-time-visible-1";
} from "./views/configure.js?v=persist-all-jobs-1";
const views = {
remotes: renderRemotes,
+16 -6
View File
@@ -155,7 +155,7 @@ const STRINGS = {
// jobs view
"jobs.title": "Jobs",
"jobs.subtitle": "Saved recurring transfers and their latest runs.",
"jobs.subtitle": "Saved transfers and their latest runs.",
"jobs.new_btn": "New Job",
"jobs.cancel": "Cancel",
"jobs.new_title": "New Job",
@@ -167,7 +167,7 @@ const STRINGS = {
"jobs.schedule_type": "Job type",
"jobs.schedule_once": "One-time",
"jobs.schedule_recurring": "Fixed recurring",
"jobs.schedule_help": "One-time jobs are visible in this browser and not saved to SQLite. Fixed recurring jobs are stored in SQLite.",
"jobs.schedule_help": "One-time and fixed recurring jobs are stored in SQLite.",
"jobs.recurrence_kind": "Recurring plan",
"jobs.recurrence_daily": "Every day",
"jobs.recurrence_weekly": "Every week",
@@ -203,7 +203,12 @@ const STRINGS = {
"jobs.pick_empty": "No folders here.",
"jobs.start": "Start Job",
"jobs.no_remotes_option": "(no remotes)",
"jobs.view_filter": "Job view",
"jobs.view_once": "One-time jobs",
"jobs.view_recurring": "Recurring jobs",
"jobs.empty_title": "No jobs yet",
"jobs.empty_once_title": "No one-time jobs yet",
"jobs.empty_recurring_title": "No recurring jobs yet",
"jobs.empty_body": "Use New Job to start a copy, sync, or move.",
"jobs.started": (a, id) => `Started ${a} job #${id}`,
"jobs.started_once": (a, id) => `Started one-time ${a} job #${id}`,
@@ -253,7 +258,7 @@ const STRINGS = {
"jobs.status.stopped": "stopped",
"jobs.stop": "Stop",
"jobs.once_id": (id) => `once:${id}`,
"jobs.schedule_label_once": "Visible in this browser",
"jobs.schedule_label_once": "One-time run",
"jobs.submitted_cli": "— submitted via CLI —",
},
@@ -375,7 +380,7 @@ const STRINGS = {
"configure.example_custom_placeholder": "自定义值",
"jobs.title": "任务",
"jobs.subtitle": "已保存的循环传输任务及其最近运行状态。",
"jobs.subtitle": "已保存的传输任务及其最近运行状态。",
"jobs.new_btn": "新建任务",
"jobs.cancel": "取消",
"jobs.new_title": "新建任务",
@@ -387,7 +392,7 @@ const STRINGS = {
"jobs.schedule_type": "任务类型",
"jobs.schedule_once": "单次任务",
"jobs.schedule_recurring": "固定循环",
"jobs.schedule_help": "单次任务会显示在当前浏览器,但不会保存到 SQLite。固定循环任务会保存到 SQLite。",
"jobs.schedule_help": "单次任务固定循环任务会保存到 SQLite。",
"jobs.recurrence_kind": "循环计划",
"jobs.recurrence_daily": "每天",
"jobs.recurrence_weekly": "每周",
@@ -423,7 +428,12 @@ const STRINGS = {
"jobs.pick_empty": "这里没有文件夹。",
"jobs.start": "开始任务",
"jobs.no_remotes_option": "(无远程存储)",
"jobs.view_filter": "任务视图",
"jobs.view_once": "单次任务",
"jobs.view_recurring": "循环任务",
"jobs.empty_title": "尚无任务",
"jobs.empty_once_title": "尚无单次任务",
"jobs.empty_recurring_title": "尚无循环任务",
"jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。",
"jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`,
"jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`,
@@ -473,7 +483,7 @@ const STRINGS = {
"jobs.status.stopped": "已停止",
"jobs.stop": "停止",
"jobs.once_id": (id) => `单次:${id}`,
"jobs.schedule_label_once": "仅当前浏览器可见",
"jobs.schedule_label_once": "单次运行",
"jobs.submitted_cli": "— 通过命令行提交 —",
},
};
+33
View File
@@ -44,6 +44,39 @@ export async function deleteRecurringJob(id) {
});
}
export async function listOneTimeJobs() {
const res = await request("/api/one-time-jobs");
return (res && res.jobs) || [];
}
export async function createOneTimeJob(job) {
const res = await request("/api/one-time-jobs", {
method: "POST",
body: job,
});
return res && res.job;
}
export async function runOneTimeJobNow(id) {
const res = await request(`/api/one-time-jobs/${encodeURIComponent(id)}/run`, {
method: "POST",
});
return res && res.job;
}
export async function stopOneTimeJob(id) {
const res = await request(`/api/one-time-jobs/${encodeURIComponent(id)}/stop`, {
method: "POST",
});
return res && res.job;
}
export async function deleteOneTimeJob(id) {
return request(`/api/one-time-jobs/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
async function request(path, { method = "GET", body } = {}) {
const headers = { "Content-Type": "application/json" };
const res = await fetch(`${API_BASE}${path}`, {
+3 -3
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?v=one-time-visible-1";
import { toast, formatBytes, formatTime } from "../state.js?v=one-time-visible-1";
import { t } from "../i18n.js?v=one-time-visible-1";
import { post, uploadFile, downloadURL } from "../rc.js?v=persist-all-jobs-1";
import { toast, formatBytes, formatTime } from "../state.js?v=persist-all-jobs-1";
import { t } from "../i18n.js?v=persist-all-jobs-1";
export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app");
+3 -3
View File
@@ -9,9 +9,9 @@
// OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js?v=one-time-visible-1";
import { getState, setState, toast } from "../state.js?v=one-time-visible-1";
import { t } from "../i18n.js?v=one-time-visible-1";
import { post } from "../rc.js?v=persist-all-jobs-1";
import { getState, setState, toast } from "../state.js?v=persist-all-jobs-1";
import { t } from "../i18n.js?v=persist-all-jobs-1";
// --- Route entrypoints ---
+73 -163
View File
@@ -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) {
+3 -3
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js?v=one-time-visible-1";
import { getState, setState, toast } from "../state.js?v=one-time-visible-1";
import { t } from "../i18n.js?v=one-time-visible-1";
import { post } from "../rc.js?v=persist-all-jobs-1";
import { getState, setState, toast } from "../state.js?v=persist-all-jobs-1";
import { t } from "../i18n.js?v=persist-all-jobs-1";
export async function renderRemotes() {
const app = document.getElementById("app");
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css?v=one-time-visible-1">
<link rel="stylesheet" href="/assets/styles/components.css?v=persist-all-jobs-1">
</head>
<body>
<header class="top-nav">
@@ -80,6 +80,6 @@
<div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div>
<script type="module" src="/assets/js/app.js?v=one-time-visible-1"></script>
<script type="module" src="/assets/js/app.js?v=persist-all-jobs-1"></script>
</body>
</html>