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 = `