diff --git a/webgui/api/server.py b/webgui/api/server.py index 55b2598..e117e7e 100644 --- a/webgui/api/server.py +++ b/webgui/api/server.py @@ -605,7 +605,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None: "srcFs": row["src"], "dstFs": row["dst"], "_async": True, - "_group": f"webgui/recurring/{schedule_id}/{action}", + "_group": transfer_group("recurring", schedule_id, action), } if action == "move": payload["deleteEmptySrcDirs"] = True @@ -686,7 +686,7 @@ def start_one_time_job(job_id: int) -> None: "srcFs": row["src"], "dstFs": row["dst"], "_async": True, - "_group": f"webgui/once/{job_id}/{action}", + "_group": transfer_group("once", job_id, action), } if action == "move": payload["deleteEmptySrcDirs"] = True @@ -744,6 +744,23 @@ def status_name(status: dict[str, Any]) -> str: return "finished" +def transfer_group(kind: str, record_id: int, action: str) -> str: + return f"webgui/{kind}/{record_id}/{action}" + + +def status_with_stats(kind: str, row: sqlite3.Row, jobid: int) -> dict[str, Any]: + status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True) + group = transfer_group(kind, int(row["id"]), row["action"]) + try: + stats = rc_post("core/stats", {"group": group}, timeout=15, allow_error_body=True) + except Exception as exc: + stats = {"error": str(exc)} + status["stats"] = stats + if "group" not in status: + status["group"] = group + return status + + def refresh_running_jobs() -> None: with db_lock, connect() as conn: rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall() @@ -752,7 +769,7 @@ def refresh_running_jobs() -> None: schedule_id = int(row["id"]) jobid = int(row["current_jobid"]) try: - status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True) + status = status_with_stats("recurring", row, jobid) except Exception as exc: status = {"finished": True, "error": str(exc), "jobid": jobid} apply_status(row, status) @@ -765,7 +782,7 @@ def refresh_one_time_jobs() -> None: for row in rows: jobid = int(row["jobid"]) try: - status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True) + status = status_with_stats("once", row, jobid) except Exception as exc: status = {"finished": True, "error": str(exc), "jobid": jobid} apply_one_time_status(row, status) diff --git a/webgui/api/server_test.py b/webgui/api/server_test.py index cb7a269..33c2acf 100644 --- a/webgui/api/server_test.py +++ b/webgui/api/server_test.py @@ -128,7 +128,18 @@ class OneTimeJobTest(unittest.TestCase): self.next_jobid += 1 return {"jobid": self.next_jobid} if path == "job/status": - return {"finished": False, "jobid": payload["jobid"], "progress": {"bytes": 1, "totalBytes": 2}} + return {"finished": False, "jobid": payload["jobid"]} + if path == "core/stats": + return { + "bytes": 1, + "totalBytes": 2, + "speed": 3, + "eta": 4, + "transfers": 5, + "totalTransfers": 6, + "errors": 7, + "group": payload["group"], + } if path == "job/stop": return {} raise AssertionError(path) @@ -153,6 +164,9 @@ class OneTimeJobTest(unittest.TestCase): self.assertEqual(len(jobs), 1) self.assertEqual(jobs[0]["id"], 1) self.assertEqual(jobs[0]["status"], "running") + self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["bytes"], 1) + self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["totalBytes"], 2) + self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["speed"], 3) def test_stop_one_time_job_persists_stopped_status(self): job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"}) diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index 4659da3..affbbf2 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=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 { onRoute } from "./state.js?v=job-stats-1"; +import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-stats-1"; +import { renderRemotes } from "./views/remotes.js?v=job-stats-1"; +import { renderBrowse } from "./views/browser.js?v=job-stats-1"; +import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-stats-1"; import { renderConfigureNew, renderConfigureEdit, -} from "./views/configure.js?v=persist-all-jobs-1"; +} from "./views/configure.js?v=job-stats-1"; const views = { remotes: renderRemotes, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index 1507859..62a24f5 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -238,6 +238,11 @@ const STRINGS = { "jobs.detail_schedule": "Schedule", "jobs.detail_group": "Group", "jobs.detail_execute_id": "Execution", + "jobs.detail_bytes": "Bytes", + "jobs.detail_speed": "Speed", + "jobs.detail_eta": "ETA", + "jobs.detail_files": "Files", + "jobs.detail_errors": "Errors", "jobs.detail_output": "Output", "jobs.detail_none": "None", "jobs.col.id": "#", @@ -463,6 +468,11 @@ const STRINGS = { "jobs.detail_schedule": "固定计划", "jobs.detail_group": "分组", "jobs.detail_execute_id": "执行实例", + "jobs.detail_bytes": "字节", + "jobs.detail_speed": "速度", + "jobs.detail_eta": "预计剩余", + "jobs.detail_files": "文件", + "jobs.detail_errors": "错误", "jobs.detail_output": "输出", "jobs.detail_none": "无", "jobs.col.id": "#", diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index 5134df1..3e8006e 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?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"; +import { post, uploadFile, downloadURL } from "../rc.js?v=job-stats-1"; +import { toast, formatBytes, formatTime } from "../state.js?v=job-stats-1"; +import { t } from "../i18n.js?v=job-stats-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 bba0767..aebd47e 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?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"; +import { post } from "../rc.js?v=job-stats-1"; +import { getState, setState, toast } from "../state.js?v=job-stats-1"; +import { t } from "../i18n.js?v=job-stats-1"; // --- Route entrypoints --- diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index 5bc2845..75bc8c3 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 } from "../rc.js?v=persist-all-jobs-1"; +import { post } from "../rc.js?v=job-stats-1"; import { toast, formatBytes, formatSpeed, formatDuration, -} from "../state.js?v=persist-all-jobs-1"; -import { t } from "../i18n.js?v=persist-all-jobs-1"; +} from "../state.js?v=job-stats-1"; +import { t } from "../i18n.js?v=job-stats-1"; import { createOneTimeJob, createRecurringJob, @@ -20,7 +20,7 @@ import { runRecurringJobNow, stopOneTimeJob, stopRecurringJob, -} from "../jobs_api.js?v=persist-all-jobs-1"; +} from "../jobs_api.js?v=job-stats-1"; let pollTimer = null; const LOCAL_FS_VALUE = "__local__"; @@ -566,11 +566,13 @@ function renderJobRows(s) { function renderJobRow(job) { const snapshot = job.statusSnapshot || {}; - const p = snapshot.progress || {}; + const stats = transferStats(snapshot); const id = job.id; const key = jobKey(job); const running = !!job.running; const jobid = job.currentJobid || job.lastJobid; + const displayedSpeed = stats.speed || averageSpeed(stats); + const displayedEta = running ? stats.eta : 0; const badge = renderStatusBadge(job.status); const jobCell = ` @@ -590,12 +592,12 @@ function renderJobRow(job) { `; - const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0; + const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0; const progress = `
- ${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)} + ${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}
`; @@ -619,10 +621,10 @@ function renderJobRow(job) { ${badge} ${scheduleCell} ${progress} - ${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"} - ${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"} - ${p.transfers ?? 0} / ${p.totalTransfers ?? 0} - ${(p.errors && p.errors.length) || 0} + ${escapeHtml(formatSpeed(displayedSpeed))} + ${escapeHtml(formatDuration(displayedEta))} + ${escapeHtml(formatTransferCount(stats))} + ${escapeHtml(String(stats.errors || 0))}
${detailBtn} @@ -655,6 +657,7 @@ function renderStatusBadge(status) { function renderJobDetailsRow(job) { const snapshot = job.statusSnapshot || {}; + const stats = transferStats(snapshot); const jobid = job.currentJobid || job.lastJobid || ""; const details = [ [job.kind === "once" ? t("jobs.detail_record_id") : t("jobs.detail_schedule_id"), displayJobId(job)], @@ -668,6 +671,11 @@ function renderJobDetailsRow(job) { [t("jobs.detail_schedule"), formatSchedule(job)], [t("jobs.detail_group"), snapshot.group || ""], [t("jobs.detail_execute_id"), snapshot.executeId || ""], + [t("jobs.detail_bytes"), `${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}`], + [t("jobs.detail_speed"), formatSpeed(stats.speed || averageSpeed(stats))], + [t("jobs.detail_eta"), formatDuration(job.running ? stats.eta : 0)], + [t("jobs.detail_files"), formatTransferCount(stats)], + [t("jobs.detail_errors"), String(stats.errors || 0)], ]; const output = snapshot && Object.keys(snapshot).length > 0 ? JSON.stringify(snapshot, null, 2) @@ -698,6 +706,54 @@ function renderJobDetailsRow(job) { `; } +function transferStats(snapshot) { + const stats = snapshot.stats || snapshot.progress || snapshot || {}; + return { + bytes: numberOrZero(stats.bytes), + totalBytes: numberOrZero(stats.totalBytes), + speed: numberOrZero(stats.speed), + eta: numberOrNull(stats.eta), + transfers: numberOrZero(stats.transfers), + totalTransfers: numberOrZero(stats.totalTransfers), + checks: numberOrZero(stats.checks), + totalChecks: numberOrZero(stats.totalChecks), + transferTime: numberOrZero(stats.transferTime), + errors: normalizeErrors(stats.errors), + }; +} + +function averageSpeed(stats) { + if (stats.bytes > 0 && stats.transferTime > 0) { + return stats.bytes / stats.transferTime; + } + return 0; +} + +function formatTransferCount(stats) { + if (stats.totalTransfers > 0) { + return `${stats.transfers} / ${stats.totalTransfers}`; + } + if (stats.totalChecks > 0) { + return `${stats.checks} / ${stats.totalChecks}`; + } + return String(stats.transfers || stats.checks || 0); +} + +function normalizeErrors(errors) { + if (Array.isArray(errors)) return errors.length; + return numberOrZero(errors); +} + +function numberOrZero(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function numberOrNull(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + function detailStatus(job) { const status = job.status || "scheduled"; const key = `jobs.status.${status}`; diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js index e1771d1..c35b23f 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?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"; +import { post } from "../rc.js?v=job-stats-1"; +import { getState, setState, toast } from "../state.js?v=job-stats-1"; +import { t } from "../i18n.js?v=job-stats-1"; export async function renderRemotes() { const app = document.getElementById("app"); diff --git a/webgui/web/index.html b/webgui/web/index.html index f6cf115..da5827e 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- +