diff --git a/README.md b/README.md index ea7bc90..3baf161 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ - **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete / rename / download。 - **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、 - ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC; - 固定循环任务保存到 SQLite,并由 jobs-api sidecar 调度。 + ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC,并在 + 当前浏览器显示;固定循环任务保存到 SQLite,并由 jobs-api sidecar + 调度。 > OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅, > 引导用户在终端跑 `rclone config` 完成授权。 diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index 546844f..fd12c10 100644 --- a/webgui/web/assets/js/app.js +++ b/webgui/web/assets/js/app.js @@ -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=monthly-last-day-1"; -import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=monthly-last-day-1"; -import { renderRemotes } from "./views/remotes.js?v=monthly-last-day-1"; -import { renderBrowse } from "./views/browser.js?v=monthly-last-day-1"; -import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=monthly-last-day-1"; +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 { renderConfigureNew, renderConfigureEdit, -} from "./views/configure.js?v=monthly-last-day-1"; +} from "./views/configure.js?v=one-time-visible-1"; const views = { remotes: renderRemotes, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index acc270d..7e32a64 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -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 submitted to rclone and not saved. Fixed recurring jobs are stored in SQLite.", + "jobs.schedule_help": "One-time jobs are visible in this browser and not saved to SQLite. Fixed recurring jobs are stored in SQLite.", "jobs.recurrence_kind": "Recurring plan", "jobs.recurrence_daily": "Every day", "jobs.recurrence_weekly": "Every week", @@ -206,7 +206,7 @@ const STRINGS = { "jobs.empty_title": "No 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}. It was not saved.`, + "jobs.started_once": (a, id) => `Started one-time ${a} job #${id}`, "jobs.recurring_saved": (id) => `Saved recurring job #${id}`, "jobs.start_failed": "Job start failed", "jobs.stop_confirm": (id) => `Stop job #${id}?`, @@ -214,6 +214,7 @@ const STRINGS = { "jobs.stop_failed": "Stop failed", "jobs.start_again": "Start again", "jobs.restarted": (id) => `Started recurring job #${id}`, + "jobs.restarted_once": (id) => `Started one-time job #${id}`, "jobs.restart_failed": "Start again failed", "jobs.details": "Details", "jobs.hide_details": "Hide", @@ -221,6 +222,7 @@ const STRINGS = { "jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`, "jobs.delete_failed": "Delete failed", "jobs.detail_schedule_id": "Schedule ID", + "jobs.detail_record_id": "Record", "jobs.detail_id": "Job ID", "jobs.detail_status": "Status", "jobs.detail_error": "Error", @@ -250,6 +252,8 @@ const STRINGS = { "jobs.status.scheduled": "scheduled", "jobs.status.stopped": "stopped", "jobs.stop": "Stop", + "jobs.once_id": (id) => `once:${id}`, + "jobs.schedule_label_once": "Visible in this browser", "jobs.submitted_cli": "— submitted via CLI —", }, @@ -383,7 +387,7 @@ const STRINGS = { "jobs.schedule_type": "任务类型", "jobs.schedule_once": "单次任务", "jobs.schedule_recurring": "固定循环", - "jobs.schedule_help": "单次任务只提交给 rclone,不保存记录。固定循环任务会保存到 SQLite。", + "jobs.schedule_help": "单次任务会显示在当前浏览器,但不会保存到 SQLite。固定循环任务会保存到 SQLite。", "jobs.recurrence_kind": "循环计划", "jobs.recurrence_daily": "每天", "jobs.recurrence_weekly": "每周", @@ -422,7 +426,7 @@ const STRINGS = { "jobs.empty_title": "尚无任务", "jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。", "jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`, - "jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id},不会保存记录。`, + "jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`, "jobs.recurring_saved": (id) => `已保存循环任务 #${id}`, "jobs.start_failed": "任务启动失败", "jobs.stop_confirm": (id) => `停止任务 #${id}?`, @@ -430,6 +434,7 @@ const STRINGS = { "jobs.stop_failed": "停止失败", "jobs.start_again": "再次启动", "jobs.restarted": (id) => `已启动循环任务 #${id}`, + "jobs.restarted_once": (id) => `已启动单次任务 #${id}`, "jobs.restart_failed": "再次启动失败", "jobs.details": "详情", "jobs.hide_details": "收起", @@ -437,6 +442,7 @@ const STRINGS = { "jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`, "jobs.delete_failed": "删除失败", "jobs.detail_schedule_id": "调度 ID", + "jobs.detail_record_id": "记录", "jobs.detail_id": "任务 ID", "jobs.detail_status": "状态", "jobs.detail_error": "错误", @@ -466,6 +472,8 @@ const STRINGS = { "jobs.status.scheduled": "已调度", "jobs.status.stopped": "已停止", "jobs.stop": "停止", + "jobs.once_id": (id) => `单次:${id}`, + "jobs.schedule_label_once": "仅当前浏览器可见", "jobs.submitted_cli": "— 通过命令行提交 —", }, }; diff --git a/webgui/web/assets/js/rc.js b/webgui/web/assets/js/rc.js index 774dedb..b309bec 100644 --- a/webgui/web/assets/js/rc.js +++ b/webgui/web/assets/js/rc.js @@ -31,7 +31,7 @@ export function isNoAuth() { } // POST JSON to an RC endpoint. Returns the parsed JSON response, or throws. -export async function post(path, body = {}) { +export async function post(path, body = {}, options = {}) { const headers = { "Content-Type": "application/json" }; if (authHeader) headers["Authorization"] = authHeader; const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), { @@ -39,7 +39,7 @@ export async function post(path, body = {}) { headers, body: JSON.stringify(body), }); - return parseResponse(res, path); + return parseResponse(res, path, options); } // POST JSON and request an async job. Returns { jobid, executeId }. @@ -82,7 +82,7 @@ export function downloadURL(remoteFs, remotePath, fileName) { return url; } -async function parseResponse(res, path) { +async function parseResponse(res, path, { allowError = false } = {}) { let body = null; const ct = res.headers.get("Content-Type") || ""; if (ct.includes("application/json")) { @@ -98,7 +98,7 @@ async function parseResponse(res, path) { err.body = body; throw err; } - if (body && body.error) { + if (body && body.error && !allowError) { throw new Error(`${path}: ${body.error}`); } return body; diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index 65e20ac..f273bad 100644 --- a/webgui/web/assets/js/views/browser.js +++ b/webgui/web/assets/js/views/browser.js @@ -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"); diff --git a/webgui/web/assets/js/views/configure.js b/webgui/web/assets/js/views/configure.js index 9961e71..9c5a74d 100644 --- a/webgui/web/assets/js/views/configure.js +++ b/webgui/web/assets/js/views/configure.js @@ -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 --- diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index 4a15fc1..8f62315 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -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 = ` -
-

${t("error.couldnt_load_jobs")}

-

${escapeHtml(e.message)}

-

${escapeHtml(jobsApiURL())}

-
- `; - return false; + if (oneTimeJobs.length === 0) { + card.innerHTML = ` +
+

${t("error.couldnt_load_jobs")}

+

${escapeHtml(e.message)}

+

${escapeHtml(jobsApiURL())}

+
+ `; + return false; + } + toast(`${t("error.couldnt_load_jobs")}: ${e.message}`, "error"); } + const jobs = [...oneTimeJobs, ...recurringJobs.map(normalizeRecurringJob)]; if (jobs.length === 0) { card.innerHTML = `
@@ -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 = `
- ${escapeHtml(formatDateTime(job.nextRunAt))} + ${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))} ${escapeHtml(formatSchedule(job))}
`; @@ -568,19 +596,19 @@ function renderJobRow(job) {
`; - const detailBtn = ``; + const detailBtn = ``; const startBtn = running ? "" - : ``; + : ``; const stopBtn = running - ? `` + ? `` : ""; - const deleteBtn = ``; + const deleteBtn = ``; return ` - ${id} + ${escapeHtml(displayJobId(job))} ${jobid ? `
job ${escapeHtml(jobid)}` : ""} ${jobCell} @@ -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, "&") @@ -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": diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js index 72fdaa3..522b95f 100644 --- a/webgui/web/assets/js/views/remotes.js +++ b/webgui/web/assets/js/views/remotes.js @@ -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"); diff --git a/webgui/web/index.html b/webgui/web/index.html index 46bc8c3..456eae1 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- +