add: 显示单次任务记录

This commit is contained in:
2026-06-20 12:10:32 +08:00
parent 161f266829
commit cd051900b5
9 changed files with 265 additions and 67 deletions
+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";
import { toast, formatBytes, formatTime } from "../state.js?v=monthly-last-day-1";
import { t } from "../i18n.js?v=monthly-last-day-1";
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";
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";
import { getState, setState, toast } from "../state.js?v=monthly-last-day-1";
import { t } from "../i18n.js?v=monthly-last-day-1";
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";
// --- Route entrypoints ---
+229 -40
View File
@@ -1,13 +1,13 @@
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
import { post, postAsync } from "../rc.js";
import { post, postAsync } from "../rc.js?v=one-time-visible-1";
import {
toast,
formatBytes,
formatSpeed,
formatDuration,
} from "../state.js?v=monthly-last-day-1";
import { t } from "../i18n.js?v=monthly-last-day-1";
} from "../state.js?v=one-time-visible-1";
import { t } from "../i18n.js?v=one-time-visible-1";
import {
createRecurringJob,
deleteRecurringJob,
@@ -15,13 +15,14 @@ import {
listRecurringJobs,
runRecurringJobNow,
stopRecurringJob,
} from "../jobs_api.js?v=monthly-last-day-1";
} from "../jobs_api.js?v=one-time-visible-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";
export async function renderJobs() {
const app = document.getElementById("app");
@@ -253,6 +254,23 @@ export async function renderNewJob() {
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",
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");
location.hash = "#/jobs";
} catch (e) {
@@ -457,20 +475,25 @@ async function refreshJobs() {
const card = document.getElementById("jobs-card");
if (!card) return false;
let jobs = [];
let recurringJobs = [];
const oneTimeJobs = await refreshOneTimeJobs();
try {
jobs = await listRecurringJobs();
recurringJobs = 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 (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;
}
toast(`${t("error.couldnt_load_jobs")}: ${e.message}`, "error");
}
const jobs = [...oneTimeJobs, ...recurringJobs.map(normalizeRecurringJob)];
if (jobs.length === 0) {
card.innerHTML = `
<div class="empty">
@@ -483,22 +506,26 @@ async function refreshJobs() {
card.innerHTML = renderJobTable(jobs);
card.querySelectorAll("[data-detail]").forEach((btn) => {
btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10)));
btn.addEventListener("click", () => onToggleDetails(btn.dataset.detail));
});
card.querySelectorAll("[data-start]").forEach((btn) => {
btn.addEventListener("click", () => onStartAgain(parseInt(btn.dataset.start, 10)));
btn.addEventListener("click", () => onStartAgain(btn.dataset.start));
});
card.querySelectorAll("[data-stop]").forEach((btn) => {
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
btn.addEventListener("click", () => onStop(btn.dataset.stop));
});
card.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", () => onDelete(parseInt(btn.dataset.delete, 10)));
btn.addEventListener("click", () => onDelete(btn.dataset.delete));
});
return jobs.length > 0;
return jobs.some((job) => job.running);
}
function renderJobTable(statuses) {
statuses.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
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("");
@@ -527,7 +554,7 @@ function renderJobTable(statuses) {
function renderJobRows(s) {
const row = renderJobRow(s);
if (!expandedJobs.has(Number(s.id))) {
if (!expandedJobs.has(jobKey(s))) {
return row;
}
return row + renderJobDetailsRow(s);
@@ -537,6 +564,7 @@ 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;
@@ -553,7 +581,7 @@ function renderJobRow(job) {
const scheduleCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(formatDateTime(job.nextRunAt))}</span>
<span class="job-action">${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}</span>
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
</div>
`;
@@ -568,19 +596,19 @@ function renderJobRow(job) {
</div>
`;
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 detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${escapeAttr(key)}">${expandedJobs.has(key) ? t("jobs.hide_details") : t("jobs.details")}</button>`;
const startBtn = running
? ""
: `<button class="btn btn-secondary btn-sm" data-start="${id}">${t("jobs.start_again")}</button>`;
: `<button class="btn btn-secondary btn-sm" data-start="${escapeAttr(key)}">${t("jobs.start_again")}</button>`;
const stopBtn = running
? `<button class="btn btn-danger btn-sm" data-stop="${id}">${t("jobs.stop")}</button>`
? `<button class="btn btn-danger btn-sm" data-stop="${escapeAttr(key)}">${t("jobs.stop")}</button>`
: "";
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${id}">${t("jobs.delete_record")}</button>`;
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${escapeAttr(key)}">${t("jobs.delete_record")}</button>`;
return `
<tr>
<td class="col-mono">
${id}
${escapeHtml(displayJobId(job))}
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""}
</td>
<td>${jobCell}</td>
@@ -621,11 +649,10 @@ function renderStatusBadge(status) {
}
function renderJobDetailsRow(job) {
const id = job.id;
const snapshot = job.statusSnapshot || {};
const jobid = job.currentJobid || job.lastJobid || "";
const details = [
[t("jobs.detail_schedule_id"), id],
[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")],
@@ -673,10 +700,25 @@ function detailStatus(job) {
}
async function onStop(jobid) {
if (!confirm(t("jobs.stop_confirm", jobid))) return;
const key = String(jobid);
const oneTimeJob = findOneTimeJob(key);
const label = displayKey(key);
if (!confirm(t("jobs.stop_confirm", label))) return;
try {
await stopRecurringJob(jobid);
toast(t("jobs.stopped", jobid), "success");
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(),
});
} else {
await stopRecurringJob(recurringIdFromKey(key));
}
toast(t("jobs.stopped", label), "success");
await refreshJobs();
} catch (e) {
toast(`${t("jobs.stop_failed")}: ${e.message}`, "error");
@@ -684,18 +726,46 @@ async function onStop(jobid) {
}
async function onToggleDetails(jobid) {
if (expandedJobs.has(jobid)) {
expandedJobs.delete(jobid);
const key = String(jobid);
if (expandedJobs.has(key)) {
expandedJobs.delete(key);
} else {
expandedJobs.add(jobid);
expandedJobs.add(key);
}
await refreshJobs();
}
async function onStartAgain(jobid) {
const key = String(jobid);
const oneTimeJob = findOneTimeJob(key);
try {
await runRecurringJobNow(jobid);
toast(t("jobs.restarted", jobid), "success");
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");
} 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");
@@ -703,10 +773,16 @@ async function onStartAgain(jobid) {
}
async function onDelete(jobid) {
if (!confirm(t("jobs.delete_confirm", jobid))) return;
const key = String(jobid);
const label = displayKey(key);
if (!confirm(t("jobs.delete_confirm", label))) return;
try {
await deleteRecurringJob(jobid);
expandedJobs.delete(jobid);
if (findOneTimeJob(key)) {
removeOneTimeJob(key);
} else {
await deleteRecurringJob(recurringIdFromKey(key));
}
expandedJobs.delete(key);
await refreshJobs();
} catch (e) {
toast(`${t("jobs.delete_failed")}: ${e.message}`, "error");
@@ -738,6 +814,116 @@ 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,
kind: "recurring",
id: `recurring:${job.id}`,
scheduleId: 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 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 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 escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
@@ -759,6 +945,9 @@ function formatDateTime(value) {
}
function formatSchedule(job) {
if (job.kind === "once") {
return t("jobs.schedule_label_once");
}
const time = job.scheduleTime || "";
switch (job.scheduleKind) {
case "daily":
+3 -3
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?v=monthly-last-day-1";
import { t } from "../i18n.js?v=monthly-last-day-1";
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";
export async function renderRemotes() {
const app = document.getElementById("app");