diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index de93e34..1bf3efc 100644 --- a/webgui/web/assets/js/app.js +++ b/webgui/web/assets/js/app.js @@ -6,7 +6,7 @@ import { onRoute } from "./state.js"; import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; import { renderRemotes } from "./views/remotes.js"; import { renderBrowse } from "./views/browser.js"; -import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js"; +import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-details-1"; import { renderConfigureNew, renderConfigureEdit, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index 47c1f90..a721ffd 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -47,6 +47,7 @@ const STRINGS = { "error.couldnt_list": "Couldn't list", "error.couldnt_load_jobs": "Couldn't load jobs", "error.no_remotes_selected": "Pick source and destination remotes", + "error.no_paths_selected": "Enter source and destination paths", "error.not_connected": "Couldn't connect to rclone", // footer @@ -156,16 +157,18 @@ const STRINGS = { "jobs.new_btn": "New Job", "jobs.cancel": "Cancel", "jobs.new_title": "New Job", - "jobs.new_subtitle": "Copy, sync, or move between remotes.", + "jobs.new_subtitle": "Copy, sync, or move between remotes and local paths.", "jobs.action": "Action", "jobs.action_copy": "copy (mirror src → dst, keep both)", "jobs.action_sync": "sync (mirror src → dst, delete extras on dst)", "jobs.action_move": "move (mirror src → dst, delete src after)", - "jobs.source_remote": "Source remote", - "jobs.source_path": "Source path (optional)", - "jobs.dest_remote": "Destination remote", - "jobs.dest_path": "Destination path (optional)", - "jobs.path_placeholder": "folder/subfolder", + "jobs.local_option": "Local filesystem", + "jobs.local_path_help": "For Local filesystem, enter a path visible inside the rclone process, such as /data/source in this Docker setup. For remotes, enter a path inside the selected remote.", + "jobs.source_remote": "Source location", + "jobs.source_path": "Source path", + "jobs.dest_remote": "Destination location", + "jobs.dest_path": "Destination path", + "jobs.path_placeholder": "folder/subfolder or /local/path", "jobs.start": "Start Job", "jobs.no_remotes_option": "(no remotes)", "jobs.empty_title": "No jobs yet", @@ -175,6 +178,20 @@ const STRINGS = { "jobs.stop_confirm": (id) => `Stop job #${id}?`, "jobs.stopped": (id) => `Stopped job #${id}`, "jobs.stop_failed": "Stop failed", + "jobs.details": "Details", + "jobs.hide_details": "Hide", + "jobs.delete_record": "Delete", + "jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`, + "jobs.detail_id": "Job ID", + "jobs.detail_status": "Status", + "jobs.detail_error": "Error", + "jobs.detail_started": "Started", + "jobs.detail_finished": "Finished", + "jobs.detail_duration": "Duration", + "jobs.detail_group": "Group", + "jobs.detail_execute_id": "Execution", + "jobs.detail_output": "Output", + "jobs.detail_none": "None", "jobs.col.id": "#", "jobs.col.job": "Job", "jobs.col.status": "Status", @@ -209,7 +226,8 @@ const STRINGS = { "error.couldnt_load_remotes": "无法加载远程存储列表", "error.couldnt_list": "无法列出", "error.couldnt_load_jobs": "无法加载任务列表", - "error.no_remotes_selected": "请选择源和目标远程存储", + "error.no_remotes_selected": "请选择源和目标位置", + "error.no_paths_selected": "请输入源路径和目标路径", "error.not_connected": "无法连接到 rclone", "footer.product": "产品", @@ -311,16 +329,18 @@ const STRINGS = { "jobs.new_btn": "新建任务", "jobs.cancel": "取消", "jobs.new_title": "新建任务", - "jobs.new_subtitle": "在远程存储之间复制、同步或移动。", + "jobs.new_subtitle": "在远程存储和本地路径之间复制、同步或移动。", "jobs.action": "操作", "jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)", "jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)", "jobs.action_move": "move(镜像 源 → 目标,完成后删除源)", - "jobs.source_remote": "源远程存储", - "jobs.source_path": "源路径(可选)", - "jobs.dest_remote": "目标远程存储", - "jobs.dest_path": "目标路径(可选)", - "jobs.path_placeholder": "文件夹/子文件夹", + "jobs.local_option": "本地文件系统", + "jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。", + "jobs.source_remote": "源位置", + "jobs.source_path": "源路径", + "jobs.dest_remote": "目标位置", + "jobs.dest_path": "目标路径", + "jobs.path_placeholder": "文件夹/子文件夹 或 /local/path", "jobs.start": "开始任务", "jobs.no_remotes_option": "(无远程存储)", "jobs.empty_title": "尚无任务", @@ -330,6 +350,20 @@ const STRINGS = { "jobs.stop_confirm": (id) => `停止任务 #${id}?`, "jobs.stopped": (id) => `已停止任务 #${id}`, "jobs.stop_failed": "停止失败", + "jobs.details": "详情", + "jobs.hide_details": "收起", + "jobs.delete_record": "删除", + "jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`, + "jobs.detail_id": "任务 ID", + "jobs.detail_status": "状态", + "jobs.detail_error": "错误", + "jobs.detail_started": "开始时间", + "jobs.detail_finished": "结束时间", + "jobs.detail_duration": "耗时", + "jobs.detail_group": "分组", + "jobs.detail_execute_id": "执行实例", + "jobs.detail_output": "输出", + "jobs.detail_none": "无", "jobs.col.id": "#", "jobs.col.job": "任务", "jobs.col.status": "状态", diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index 487228a..6636ffe 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -17,6 +17,8 @@ import { t } from "../i18n.js"; let pollTimer = null; const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer"; +const LOCAL_FS_VALUE = "__local__"; +const expandedJobs = new Set(); export async function renderJobs() { const app = document.getElementById("app"); @@ -52,8 +54,10 @@ export async function renderNewJob() { toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error"); } - const remoteOpts = remotes - .map((r) => ``) + const locationOpts = [ + ...remotes.map((r) => ``), + ``, + ] .join(""); app.innerHTML = ` @@ -77,7 +81,7 @@ export async function renderNewJob() {
@@ -87,13 +91,16 @@ export async function renderNewJob() {
+
+ ${t("jobs.local_path_help")} +
@@ -112,15 +119,19 @@ export async function renderNewJob() { const action = form.elements.action.value; const srcRemote = form.elements.srcRemote.value; const dstRemote = form.elements.dstRemote.value; - const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, ""); - const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, ""); - if (!srcRemote || !dstRemote) { - toast(t("error.no_remotes_selected"), "error"); - return; - } - const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote; - const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote; + const srcPath = form.elements.srcPath.value.trim(); + const dstPath = form.elements.dstPath.value.trim(); try { + const src = buildJobFs(srcRemote, srcPath); + const dst = buildJobFs(dstRemote, dstPath); + if (!src || !dst) { + toast(t("error.no_paths_selected"), "error"); + return; + } + if ((srcRemote === LOCAL_FS_VALUE && !srcPath) || (dstRemote === LOCAL_FS_VALUE && !dstPath)) { + toast(t("error.no_paths_selected"), "error"); + return; + } const body = { srcFs: src, dstFs: dst, @@ -140,6 +151,14 @@ export async function renderNewJob() { }); } +function buildJobFs(location, rawPath) { + if (location === LOCAL_FS_VALUE) { + return rawPath.trim(); + } + const remotePath = rawPath.trim().replace(/^\/+|\/+$/g, ""); + return remotePath ? `${location}${remotePath}` : location; +} + async function refreshJobs() { const card = document.getElementById("jobs-card"); if (!card) return false; @@ -159,17 +178,20 @@ async function refreshJobs() { const statuses = await Promise.all( jobIds.map((id) => - post("job/status", { jobid: id }).catch((e) => { - if (e.message.includes("job not found")) { - forgetJob(id); - return null; - } - return { - jobid: id, - error: e.message, - finished: true, - }; - }), + 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, + }; + }), ), ); @@ -185,16 +207,22 @@ async function refreshJobs() { } card.innerHTML = renderJobTable(tracked); + card.querySelectorAll("[data-detail]").forEach((btn) => { + btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 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); } function renderJobTable(statuses) { statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0)); - const rows = statuses.map(renderJobRow).join(""); + const rows = statuses.map(renderJobRows).join(""); return ` @@ -218,6 +246,14 @@ function renderJobTable(statuses) { `; } +function renderJobRows(s) { + const row = renderJobRow(s); + if (!expandedJobs.has(Number(s.jobid))) { + return row; + } + return row + renderJobDetailsRow(s); +} + function renderJobRow(s) { const p = s.progress || {}; const id = s.jobid; @@ -259,9 +295,11 @@ function renderJobRow(s) { `; + const detailBtn = ``; const stopBtn = !finished ? `` : ""; + const deleteBtn = ``; return ` @@ -273,11 +311,65 @@ function renderJobRow(s) { - + `; } +function renderJobDetailsRow(s) { + const id = s.jobid; + 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 || ""], + ]; + const output = s.output && Object.keys(s.output).length > 0 + ? JSON.stringify(s.output, null, 2) + : ""; + + return ` + + + + + `; +} + +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"); +} + async function onStop(jobid) { if (!confirm(t("jobs.stop_confirm", jobid))) return; try { @@ -289,6 +381,22 @@ async function onStop(jobid) { } } +async function onToggleDetails(jobid) { + if (expandedJobs.has(jobid)) { + expandedJobs.delete(jobid); + } else { + expandedJobs.add(jobid); + } + await refreshJobs(); +} + +async function onDelete(jobid) { + if (!confirm(t("jobs.delete_confirm", jobid))) return; + forgetJob(jobid); + expandedJobs.delete(jobid); + await refreshJobs(); +} + function startPolling() { stopPolling(); pollTimer = setInterval(async () => { @@ -322,3 +430,10 @@ function escapeHtml(s) { .replace(/"/g, """) .replace(/'/g, "'"); } + +function formatDateTime(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(); +} diff --git a/webgui/web/assets/styles/components.css b/webgui/web/assets/styles/components.css index 9eafddb..abd23f9 100644 --- a/webgui/web/assets/styles/components.css +++ b/webgui/web/assets/styles/components.css @@ -771,6 +771,69 @@ margin: 0 var(--space-xxs); } +.job-actions { + display: inline-flex; + justify-content: flex-end; + gap: var(--space-xs); + flex-wrap: wrap; +} + +.job-detail-row { + background-color: var(--color-surface-soft); +} + +.job-detail { + display: grid; + gap: var(--space-sm); + padding: var(--space-sm) 0; +} + +.job-detail dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-xs) var(--space-lg); + margin: 0; +} + +.job-detail dl div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: var(--space-sm); +} + +.job-detail dt { + color: var(--color-muted); + font: var(--typo-caption); +} + +.job-detail dd { + margin: 0; + color: var(--color-body); + font: var(--typo-code); + word-break: break-word; +} + +.job-detail-output span { + display: block; + margin-bottom: var(--space-xs); + color: var(--color-muted); + font: var(--typo-caption); +} + +.job-detail-output pre { + max-height: 220px; + margin: 0; + padding: var(--space-sm); + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + background-color: var(--color-canvas); + border: 1px solid var(--color-hairline-soft); + border-radius: var(--radius-sm); + color: var(--color-body); + font: var(--typo-code); +} + /* ---------- Responsive ---------- */ @media (max-width: 1024px) { .connector-grid { diff --git a/webgui/web/index.html b/webgui/web/index.html index 1f2efff..22c053b 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- +
${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))} ${p.transfers ?? 0} / ${p.totalTransfers ?? 0} ${(p.errors && p.errors.length) || 0}${stopBtn} +
+ ${detailBtn} + ${stopBtn} + ${deleteBtn} +
+
+
+
+ ${details.map(([label, value]) => ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+ `).join("")} +
+ ${output ? ` +
+ ${t("jobs.detail_output")} +
${escapeHtml(output)}
+
+ ` : ""} +
+