From ba9d0977e387ed9bac586415c6fe193e2eed4460 Mon Sep 17 00:00:00 2001 From: wuxu Date: Sat, 20 Jun 2026 12:20:39 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E6=8C=81=E4=B9=85=E5=8C=96=E6=89=80?= =?UTF-8?q?=E6=9C=89=E4=BB=BB=E5=8A=A1=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- webgui/api/server.py | 264 ++++++++++++++++++++++++ webgui/api/server_test.py | 60 ++++++ webgui/web/assets/js/app.js | 12 +- webgui/web/assets/js/i18n.js | 22 +- webgui/web/assets/js/jobs_api.js | 33 +++ webgui/web/assets/js/views/browser.js | 6 +- webgui/web/assets/js/views/configure.js | 6 +- webgui/web/assets/js/views/jobs.js | 236 +++++++-------------- webgui/web/assets/js/views/remotes.js | 6 +- webgui/web/index.html | 4 +- 11 files changed, 465 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index 3baf161..fd3eccd 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,8 @@ - **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete / rename / download。 - **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、 - ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC,并在 - 当前浏览器显示;固定循环任务保存到 SQLite,并由 jobs-api sidecar - 调度。 + ETA、已传输 / 总量、错误计数)。单次任务和固定循环任务都保存到 + SQLite;固定循环任务由 jobs-api sidecar 调度。 > OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅, > 引导用户在终端跑 `rclone config` 完成授权。 diff --git a/webgui/api/server.py b/webgui/api/server.py index dfc0f0f..55b2598 100644 --- a/webgui/api/server.py +++ b/webgui/api/server.py @@ -223,10 +223,27 @@ def init_db() -> None: FOREIGN KEY(schedule_id) REFERENCES job_schedules(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS one_time_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + src TEXT NOT NULL, + dst TEXT NOT NULL, + jobid INTEGER, + status TEXT NOT NULL, + error TEXT, + started_at TEXT, + finished_at TEXT, + status_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_job_schedules_due ON job_schedules(enabled, current_jobid, next_run_at); CREATE INDEX IF NOT EXISTS idx_job_runs_schedule ON job_runs(schedule_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_one_time_jobs_updated + ON one_time_jobs(updated_at DESC); """ ) ensure_column(conn, "job_schedules", "schedule_kind", "TEXT NOT NULL DEFAULT 'daily'") @@ -278,6 +295,32 @@ def row_to_job(row: sqlite3.Row) -> dict[str, Any]: } +def row_to_one_time_job(row: sqlite3.Row) -> dict[str, Any]: + snapshot = None + if row["status_json"]: + try: + snapshot = json.loads(row["status_json"]) + except json.JSONDecodeError: + snapshot = None + return { + "id": row["id"], + "action": row["action"], + "src": row["src"], + "dst": row["dst"], + "jobid": row["jobid"], + "lastJobid": row["jobid"], + "currentJobid": row["jobid"] if row["status"] == "running" else None, + "status": row["status"], + "error": row["error"], + "startedAt": row["started_at"], + "finishedAt": row["finished_at"], + "running": row["status"] == "running", + "statusSnapshot": snapshot, + "createdAt": row["created_at"], + "updatedAt": row["updated_at"], + } + + def fetch_job(schedule_id: int) -> dict[str, Any]: with db_lock, connect() as conn: row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() @@ -293,6 +336,21 @@ def list_jobs() -> list[dict[str, Any]]: return [row_to_job(row) for row in rows] +def fetch_one_time_job(job_id: int) -> dict[str, Any]: + with db_lock, connect() as conn: + row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() + if row is None: + raise ApiError(404, "job not found") + return row_to_one_time_job(row) + + +def list_one_time_jobs() -> list[dict[str, Any]]: + refresh_one_time_jobs() + with db_lock, connect() as conn: + rows = conn.execute("SELECT * FROM one_time_jobs ORDER BY updated_at DESC, id DESC").fetchall() + return [row_to_one_time_job(row) for row in rows] + + def create_job(payload: dict[str, Any]) -> dict[str, Any]: action = str(payload.get("action", "")).strip() src = str(payload.get("src", "")).strip() @@ -355,6 +413,30 @@ def create_job(payload: dict[str, Any]) -> dict[str, Any]: return fetch_job(schedule_id) +def create_one_time_job(payload: dict[str, Any]) -> dict[str, Any]: + action = str(payload.get("action", "")).strip() + src = str(payload.get("src", "")).strip() + dst = str(payload.get("dst", "")).strip() + if action not in VALID_ACTIONS: + raise ApiError(400, "invalid action") + if not src or not dst: + raise ApiError(400, "src and dst are required") + + now = iso(utcnow()) + with db_lock, connect() as conn: + cur = conn.execute( + """ + INSERT INTO one_time_jobs + (action, src, dst, status, created_at, updated_at) + VALUES (?, ?, ?, 'starting', ?, ?) + """, + (action, src, dst, now, now), + ) + job_id = int(cur.lastrowid) + start_one_time_job(job_id) + return fetch_one_time_job(job_id) + + def parse_optional_int(value: Any) -> int | None: if value in (None, ""): return None @@ -429,6 +511,52 @@ def stop_job(schedule_id: int) -> dict[str, Any]: return fetch_job(schedule_id) +def manual_run_one_time_job(job_id: int) -> dict[str, Any]: + with db_lock, connect() as conn: + row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() + if row is None: + raise ApiError(404, "job not found") + if row["status"] == "running": + raise ApiError(409, "job is already running") + start_one_time_job(job_id) + return fetch_one_time_job(job_id) + + +def stop_one_time_job(job_id: int) -> dict[str, Any]: + with db_lock, connect() as conn: + row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() + if row is None: + raise ApiError(404, "job not found") + jobid = row["jobid"] + if row["status"] != "running" or jobid is None: + return row_to_one_time_job(row) + + rc_post("job/stop", {"jobid": jobid}) + now = iso(utcnow()) + snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":")) + with db_lock, connect() as conn: + conn.execute( + """ + UPDATE one_time_jobs + SET status='stopped', + error=NULL, + finished_at=?, + status_json=?, + updated_at=? + WHERE id=? + """, + (now, snapshot, now, job_id), + ) + return fetch_one_time_job(job_id) + + +def delete_one_time_job(job_id: int) -> None: + with db_lock, connect() as conn: + cur = conn.execute("DELETE FROM one_time_jobs WHERE id=?", (job_id,)) + if cur.rowcount == 0: + raise ApiError(404, "job not found") + + def rc_post( path: str, payload: dict[str, Any], @@ -546,6 +674,66 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None: ) +def start_one_time_job(job_id: int) -> None: + with db_lock, connect() as conn: + row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone() + if row is None: + raise ApiError(404, "job not found") + + now = iso(utcnow()) + action = row["action"] + payload: dict[str, Any] = { + "srcFs": row["src"], + "dstFs": row["dst"], + "_async": True, + "_group": f"webgui/once/{job_id}/{action}", + } + if action == "move": + payload["deleteEmptySrcDirs"] = True + + try: + res = rc_post(f"sync/{action}", payload) + jobid = res.get("jobid") + if jobid is None: + raise RcloneError(f"sync/{action}: missing jobid") + except Exception as exc: + error = str(exc) + snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":")) + with db_lock, connect() as conn: + conn.execute( + """ + UPDATE one_time_jobs + SET jobid=NULL, + status='failed', + error=?, + started_at=?, + finished_at=?, + status_json=?, + updated_at=? + WHERE id=? + """, + (error, now, now, snapshot, now, job_id), + ) + return + + snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":")) + with db_lock, connect() as conn: + conn.execute( + """ + UPDATE one_time_jobs + SET jobid=?, + status='running', + error=NULL, + started_at=?, + finished_at=NULL, + status_json=?, + updated_at=? + WHERE id=? + """, + (jobid, now, snapshot, now, job_id), + ) + + def status_name(status: dict[str, Any]) -> str: if not status.get("finished"): return "running" @@ -570,6 +758,19 @@ def refresh_running_jobs() -> None: apply_status(row, status) +def refresh_one_time_jobs() -> None: + with db_lock, connect() as conn: + rows = conn.execute("SELECT * FROM one_time_jobs WHERE status='running' AND jobid IS NOT NULL").fetchall() + + for row in rows: + jobid = int(row["jobid"]) + try: + status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True) + except Exception as exc: + status = {"finished": True, "error": str(exc), "jobid": jobid} + apply_one_time_status(row, status) + + def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None: schedule_id = int(row["id"]) jobid = int(row["current_jobid"]) @@ -628,6 +829,44 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None: ) +def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None: + job_id = int(row["id"]) + jobid = int(row["jobid"]) + now = iso(utcnow()) + snapshot = json.dumps(status, separators=(",", ":"), ensure_ascii=False) + name = status_name(status) + + if name == "running": + with db_lock, connect() as conn: + conn.execute( + """ + UPDATE one_time_jobs + SET status='running', + error=NULL, + status_json=?, + updated_at=? + WHERE id=? AND jobid=? AND status='running' + """, + (snapshot, now, job_id, jobid), + ) + return + + error = status.get("error") + with db_lock, connect() as conn: + conn.execute( + """ + UPDATE one_time_jobs + SET status=?, + error=?, + finished_at=?, + status_json=?, + updated_at=? + WHERE id=? AND jobid=? AND status='running' + """, + (name, error, now, snapshot, now, job_id, jobid), + ) + + def run_due_jobs() -> None: with scheduler_lock: now = iso(utcnow()) @@ -647,6 +886,7 @@ def run_due_jobs() -> None: def scheduler_tick() -> None: refresh_running_jobs() + refresh_one_time_jobs() run_due_jobs() @@ -703,6 +943,14 @@ class Handler(BaseHTTPRequestHandler): self.send_json(201, {"job": create_job(self.read_json())}) return + if parts == ["api", "one-time-jobs"] and method == "GET": + self.send_json(200, {"jobs": list_one_time_jobs()}) + return + + if parts == ["api", "one-time-jobs"] and method == "POST": + self.send_json(201, {"job": create_one_time_job(self.read_json())}) + return + if len(parts) == 3 and parts[:2] == ["api", "jobs"]: schedule_id = parse_id(parts[2]) if method == "DELETE": @@ -710,6 +958,13 @@ class Handler(BaseHTTPRequestHandler): self.send_json(200, {"ok": True}) return + if len(parts) == 3 and parts[:2] == ["api", "one-time-jobs"]: + job_id = parse_id(parts[2]) + if method == "DELETE": + delete_one_time_job(job_id) + self.send_json(200, {"ok": True}) + return + if len(parts) == 4 and parts[:2] == ["api", "jobs"] and method == "POST": schedule_id = parse_id(parts[2]) if parts[3] == "run": @@ -719,6 +974,15 @@ class Handler(BaseHTTPRequestHandler): self.send_json(200, {"job": stop_job(schedule_id)}) return + if len(parts) == 4 and parts[:2] == ["api", "one-time-jobs"] and method == "POST": + job_id = parse_id(parts[2]) + if parts[3] == "run": + self.send_json(200, {"job": manual_run_one_time_job(job_id)}) + return + if parts[3] == "stop": + self.send_json(200, {"job": stop_one_time_job(job_id)}) + return + raise ApiError(404, "not found") except ApiError as exc: self.send_json(exc.status, {"error": exc.message}) diff --git a/webgui/api/server_test.py b/webgui/api/server_test.py index 4b0f84b..cb7a269 100644 --- a/webgui/api/server_test.py +++ b/webgui/api/server_test.py @@ -1,7 +1,10 @@ import unittest +import tempfile +import os from io import BytesIO from datetime import datetime, timezone +import server from server import ApiError, Handler, MAX_BODY_BYTES, compute_next_run_at @@ -112,5 +115,62 @@ class HandlerSecurityTest(unittest.TestCase): self.assertEqual(Handler.read_json(handler), {"ok": True}) +class OneTimeJobTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.old_db_path = server.DB_PATH + self.old_rc_post = server.rc_post + server.DB_PATH = os.path.join(self.tmp.name, "jobs.sqlite") + self.next_jobid = 100 + + def fake_rc_post(path, payload, timeout=30, allow_error_body=False): + if path.startswith("sync/"): + self.next_jobid += 1 + return {"jobid": self.next_jobid} + if path == "job/status": + return {"finished": False, "jobid": payload["jobid"], "progress": {"bytes": 1, "totalBytes": 2}} + if path == "job/stop": + return {} + raise AssertionError(path) + + server.rc_post = fake_rc_post + server.init_db() + + def tearDown(self): + server.rc_post = self.old_rc_post + server.DB_PATH = self.old_db_path + self.tmp.cleanup() + + def test_create_and_list_one_time_job(self): + job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"}) + + self.assertEqual(job["id"], 1) + self.assertEqual(job["jobid"], 101) + self.assertEqual(job["status"], "running") + self.assertEqual(job["src"], "/tmp/a") + + jobs = server.list_one_time_jobs() + self.assertEqual(len(jobs), 1) + self.assertEqual(jobs[0]["id"], 1) + self.assertEqual(jobs[0]["status"], "running") + + def test_stop_one_time_job_persists_stopped_status(self): + job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"}) + stopped = server.stop_one_time_job(job["id"]) + + self.assertEqual(stopped["status"], "stopped") + self.assertFalse(stopped["running"]) + self.assertIsNotNone(stopped["statusSnapshot"]) + + def test_run_one_time_job_again_reuses_record_with_new_jobid(self): + job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"}) + server.stop_one_time_job(job["id"]) + restarted = server.manual_run_one_time_job(job["id"]) + + self.assertEqual(restarted["id"], job["id"]) + self.assertEqual(restarted["jobid"], 102) + self.assertEqual(restarted["status"], "running") + + if __name__ == "__main__": unittest.main() diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index fd12c10..4659da3 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=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 { 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 { renderConfigureNew, renderConfigureEdit, -} from "./views/configure.js?v=one-time-visible-1"; +} from "./views/configure.js?v=persist-all-jobs-1"; const views = { remotes: renderRemotes, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index 7e32a64..1507859 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -155,7 +155,7 @@ const STRINGS = { // jobs view "jobs.title": "Jobs", - "jobs.subtitle": "Saved recurring transfers and their latest runs.", + "jobs.subtitle": "Saved transfers and their latest runs.", "jobs.new_btn": "New Job", "jobs.cancel": "Cancel", "jobs.new_title": "New Job", @@ -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 visible in this browser and not saved to SQLite. Fixed recurring jobs are stored in SQLite.", + "jobs.schedule_help": "One-time and fixed recurring jobs are stored in SQLite.", "jobs.recurrence_kind": "Recurring plan", "jobs.recurrence_daily": "Every day", "jobs.recurrence_weekly": "Every week", @@ -203,7 +203,12 @@ const STRINGS = { "jobs.pick_empty": "No folders here.", "jobs.start": "Start Job", "jobs.no_remotes_option": "(no remotes)", + "jobs.view_filter": "Job view", + "jobs.view_once": "One-time jobs", + "jobs.view_recurring": "Recurring jobs", "jobs.empty_title": "No jobs yet", + "jobs.empty_once_title": "No one-time jobs yet", + "jobs.empty_recurring_title": "No recurring 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}`, @@ -253,7 +258,7 @@ const STRINGS = { "jobs.status.stopped": "stopped", "jobs.stop": "Stop", "jobs.once_id": (id) => `once:${id}`, - "jobs.schedule_label_once": "Visible in this browser", + "jobs.schedule_label_once": "One-time run", "jobs.submitted_cli": "— submitted via CLI —", }, @@ -375,7 +380,7 @@ const STRINGS = { "configure.example_custom_placeholder": "自定义值", "jobs.title": "任务", - "jobs.subtitle": "已保存的循环传输任务及其最近运行状态。", + "jobs.subtitle": "已保存的传输任务及其最近运行状态。", "jobs.new_btn": "新建任务", "jobs.cancel": "取消", "jobs.new_title": "新建任务", @@ -387,7 +392,7 @@ const STRINGS = { "jobs.schedule_type": "任务类型", "jobs.schedule_once": "单次任务", "jobs.schedule_recurring": "固定循环", - "jobs.schedule_help": "单次任务会显示在当前浏览器,但不会保存到 SQLite。固定循环任务会保存到 SQLite。", + "jobs.schedule_help": "单次任务和固定循环任务都会保存到 SQLite。", "jobs.recurrence_kind": "循环计划", "jobs.recurrence_daily": "每天", "jobs.recurrence_weekly": "每周", @@ -423,7 +428,12 @@ const STRINGS = { "jobs.pick_empty": "这里没有文件夹。", "jobs.start": "开始任务", "jobs.no_remotes_option": "(无远程存储)", + "jobs.view_filter": "任务视图", + "jobs.view_once": "单次任务", + "jobs.view_recurring": "循环任务", "jobs.empty_title": "尚无任务", + "jobs.empty_once_title": "尚无单次任务", + "jobs.empty_recurring_title": "尚无循环任务", "jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。", "jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`, "jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`, @@ -473,7 +483,7 @@ const STRINGS = { "jobs.status.stopped": "已停止", "jobs.stop": "停止", "jobs.once_id": (id) => `单次:${id}`, - "jobs.schedule_label_once": "仅当前浏览器可见", + "jobs.schedule_label_once": "单次运行", "jobs.submitted_cli": "— 通过命令行提交 —", }, }; diff --git a/webgui/web/assets/js/jobs_api.js b/webgui/web/assets/js/jobs_api.js index 14744f6..1b749c2 100644 --- a/webgui/web/assets/js/jobs_api.js +++ b/webgui/web/assets/js/jobs_api.js @@ -44,6 +44,39 @@ export async function deleteRecurringJob(id) { }); } +export async function listOneTimeJobs() { + const res = await request("/api/one-time-jobs"); + return (res && res.jobs) || []; +} + +export async function createOneTimeJob(job) { + const res = await request("/api/one-time-jobs", { + method: "POST", + body: job, + }); + return res && res.job; +} + +export async function runOneTimeJobNow(id) { + const res = await request(`/api/one-time-jobs/${encodeURIComponent(id)}/run`, { + method: "POST", + }); + return res && res.job; +} + +export async function stopOneTimeJob(id) { + const res = await request(`/api/one-time-jobs/${encodeURIComponent(id)}/stop`, { + method: "POST", + }); + return res && res.job; +} + +export async function deleteOneTimeJob(id) { + return request(`/api/one-time-jobs/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + async function request(path, { method = "GET", body } = {}) { const headers = { "Content-Type": "application/json" }; const res = await fetch(`${API_BASE}${path}`, { diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index f273bad..5134df1 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=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"; +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"; 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 9c5a74d..bba0767 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=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"; +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"; // --- Route entrypoints --- diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index 8f62315..5bc2845 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -1,28 +1,32 @@ // views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. -import { post, postAsync } from "../rc.js?v=one-time-visible-1"; +import { post } from "../rc.js?v=persist-all-jobs-1"; import { toast, formatBytes, formatSpeed, formatDuration, -} from "../state.js?v=one-time-visible-1"; -import { t } from "../i18n.js?v=one-time-visible-1"; +} from "../state.js?v=persist-all-jobs-1"; +import { t } from "../i18n.js?v=persist-all-jobs-1"; import { + createOneTimeJob, createRecurringJob, + deleteOneTimeJob, deleteRecurringJob, jobsApiURL, + listOneTimeJobs, listRecurringJobs, + runOneTimeJobNow, runRecurringJobNow, + stopOneTimeJob, stopRecurringJob, -} from "../jobs_api.js?v=one-time-visible-1"; +} from "../jobs_api.js?v=persist-all-jobs-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"; +let currentJobsView = "once"; export async function renderJobs() { const app = document.getElementById("app"); @@ -34,11 +38,35 @@ export async function renderJobs() { ${t("jobs.new_btn")} +
+
+ + +
+

${t("loading.title")}…

`; + app.querySelectorAll('input[name="jobsView"]').forEach((input) => { + input.addEventListener("change", async () => { + currentJobsView = input.value; + const hasRunning = await refreshJobs(); + if (hasRunning) { + startPolling(); + } else { + stopPolling(); + } + }); + }); + const hasRunning = await refreshJobs(); if (hasRunning) { startPolling(); @@ -247,31 +275,18 @@ export async function renderNewJob() { ...schedule, }); toast(t("jobs.recurring_saved", job && job.id), "success"); + currentJobsView = "recurring"; location.hash = "#/jobs"; return; } - 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", + const job = await createOneTimeJob({ 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"); + toast(t("jobs.started_once", action, job && (job.jobid || job.id)), "success"); + currentJobsView = "once"; location.hash = "#/jobs"; } catch (e) { toast(`${t("jobs.start_failed")}: ${e.message}`, "error"); @@ -302,16 +317,6 @@ function renderMonthdayOptions() { return lastDay + numberedDays; } -function buildTransferBody(action, src, dst) { - const body = { - srcFs: src, - dstFs: dst, - _group: `${WEBGUI_JOB_GROUP_PREFIX}/${action}`, - }; - if (action === "move") body.deleteEmptySrcDirs = true; - return body; -} - function buildJobFs(location, rawPath) { if (location === LOCAL_FS_VALUE) { return rawPath.trim(); @@ -475,29 +480,28 @@ async function refreshJobs() { const card = document.getElementById("jobs-card"); if (!card) return false; - let recurringJobs = []; - const oneTimeJobs = await refreshOneTimeJobs(); + let jobs = []; try { - recurringJobs = await listRecurringJobs(); - } catch (e) { - if (oneTimeJobs.length === 0) { - card.innerHTML = ` -
-

${t("error.couldnt_load_jobs")}

-

${escapeHtml(e.message)}

-

${escapeHtml(jobsApiURL())}

-
- `; - return false; + if (currentJobsView === "once") { + jobs = (await listOneTimeJobs()).map(normalizeOneTimeJob); + } else { + jobs = (await listRecurringJobs()).map(normalizeRecurringJob); } - toast(`${t("error.couldnt_load_jobs")}: ${e.message}`, "error"); + } catch (e) { + card.innerHTML = ` +
+

${t("error.couldnt_load_jobs")}

+

${escapeHtml(e.message)}

+

${escapeHtml(jobsApiURL())}

+
+ `; + return false; } - const jobs = [...oneTimeJobs, ...recurringJobs.map(normalizeRecurringJob)]; if (jobs.length === 0) { card.innerHTML = `
-

${t("jobs.empty_title")}

+

${currentJobsView === "once" ? t("jobs.empty_once_title") : t("jobs.empty_recurring_title")}

${t("jobs.empty_body").replace("New Job", `${t("jobs.new_btn")}`)}

`; @@ -634,6 +638,7 @@ function renderJobRow(job) { function renderStatusBadge(status) { switch (status) { case "running": + case "starting": return `${t("jobs.status.running")}`; case "failed": return `${t("jobs.status.failed")}`; @@ -701,20 +706,11 @@ function detailStatus(job) { async function onStop(jobid) { const key = String(jobid); - const oneTimeJob = findOneTimeJob(key); const label = displayKey(key); if (!confirm(t("jobs.stop_confirm", label))) return; try { - 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(), - }); + if (key.startsWith("once:")) { + await stopOneTimeJob(oneTimeIdFromKey(key)); } else { await stopRecurringJob(recurringIdFromKey(key)); } @@ -737,30 +733,10 @@ async function onToggleDetails(jobid) { async function onStartAgain(jobid) { const key = String(jobid); - const oneTimeJob = findOneTimeJob(key); try { - 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"); + if (key.startsWith("once:")) { + const job = await runOneTimeJobNow(oneTimeIdFromKey(key)); + toast(t("jobs.restarted_once", job && (job.jobid || job.id)), "success"); } else { const id = recurringIdFromKey(key); await runRecurringJobNow(id); @@ -777,8 +753,8 @@ async function onDelete(jobid) { const label = displayKey(key); if (!confirm(t("jobs.delete_confirm", label))) return; try { - if (findOneTimeJob(key)) { - removeOneTimeJob(key); + if (key.startsWith("once:")) { + await deleteOneTimeJob(oneTimeIdFromKey(key)); } else { await deleteRecurringJob(recurringIdFromKey(key)); } @@ -814,57 +790,6 @@ 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, @@ -874,13 +799,22 @@ function normalizeRecurringJob(job) { }; } +function normalizeOneTimeJob(job) { + return { + ...job, + kind: "once", + id: `once:${job.id}`, + recordId: 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 t("jobs.once_id", job.recordId || String(job.id).replace(/^once:/, "")); } return job.scheduleId || String(job.id).replace(/^recurring:/, ""); } @@ -896,32 +830,8 @@ 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 oneTimeIdFromKey(key) { + return Number(String(key).replace(/^once:/, "")); } function escapeHtml(s) { diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js index 522b95f..e1771d1 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=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"; +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"; export async function renderRemotes() { const app = document.getElementById("app"); diff --git a/webgui/web/index.html b/webgui/web/index.html index 456eae1..f6cf115 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- +