add: 持久化所有任务记录

This commit is contained in:
2026-06-20 12:20:39 +08:00
parent cd051900b5
commit ba9d0977e3
11 changed files with 465 additions and 189 deletions
+2 -3
View File
@@ -32,9 +32,8 @@
- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete / - **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete /
rename / download。 rename / download。
- **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、 - **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、
ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC,并在 ETA、已传输 / 总量、错误计数)。单次任务和固定循环任务都保存到
当前浏览器显示;固定循环任务保存到 SQLite,并由 jobs-api sidecar SQLite;固定循环任务由 jobs-api sidecar 调度。
调度。
> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅, > OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅,
> 引导用户在终端跑 `rclone config` 完成授权。 > 引导用户在终端跑 `rclone config` 完成授权。
+264
View File
@@ -223,10 +223,27 @@ def init_db() -> None:
FOREIGN KEY(schedule_id) REFERENCES job_schedules(id) ON DELETE CASCADE 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 CREATE INDEX IF NOT EXISTS idx_job_schedules_due
ON job_schedules(enabled, current_jobid, next_run_at); ON job_schedules(enabled, current_jobid, next_run_at);
CREATE INDEX IF NOT EXISTS idx_job_runs_schedule CREATE INDEX IF NOT EXISTS idx_job_runs_schedule
ON job_runs(schedule_id, created_at DESC); 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'") 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]: def fetch_job(schedule_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_lock, connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() 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] 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]: def create_job(payload: dict[str, Any]) -> dict[str, Any]:
action = str(payload.get("action", "")).strip() action = str(payload.get("action", "")).strip()
src = str(payload.get("src", "")).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) 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: def parse_optional_int(value: Any) -> int | None:
if value in (None, ""): if value in (None, ""):
return None return None
@@ -429,6 +511,52 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
return fetch_job(schedule_id) 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( def rc_post(
path: str, path: str,
payload: dict[str, Any], 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: def status_name(status: dict[str, Any]) -> str:
if not status.get("finished"): if not status.get("finished"):
return "running" return "running"
@@ -570,6 +758,19 @@ def refresh_running_jobs() -> None:
apply_status(row, status) 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: def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
schedule_id = int(row["id"]) schedule_id = int(row["id"])
jobid = int(row["current_jobid"]) 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: def run_due_jobs() -> None:
with scheduler_lock: with scheduler_lock:
now = iso(utcnow()) now = iso(utcnow())
@@ -647,6 +886,7 @@ def run_due_jobs() -> None:
def scheduler_tick() -> None: def scheduler_tick() -> None:
refresh_running_jobs() refresh_running_jobs()
refresh_one_time_jobs()
run_due_jobs() run_due_jobs()
@@ -703,6 +943,14 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(201, {"job": create_job(self.read_json())}) self.send_json(201, {"job": create_job(self.read_json())})
return 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"]: if len(parts) == 3 and parts[:2] == ["api", "jobs"]:
schedule_id = parse_id(parts[2]) schedule_id = parse_id(parts[2])
if method == "DELETE": if method == "DELETE":
@@ -710,6 +958,13 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(200, {"ok": True}) self.send_json(200, {"ok": True})
return 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": if len(parts) == 4 and parts[:2] == ["api", "jobs"] and method == "POST":
schedule_id = parse_id(parts[2]) schedule_id = parse_id(parts[2])
if parts[3] == "run": if parts[3] == "run":
@@ -719,6 +974,15 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(200, {"job": stop_job(schedule_id)}) self.send_json(200, {"job": stop_job(schedule_id)})
return 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") raise ApiError(404, "not found")
except ApiError as exc: except ApiError as exc:
self.send_json(exc.status, {"error": exc.message}) self.send_json(exc.status, {"error": exc.message})
+60
View File
@@ -1,7 +1,10 @@
import unittest import unittest
import tempfile
import os
from io import BytesIO from io import BytesIO
from datetime import datetime, timezone from datetime import datetime, timezone
import server
from server import ApiError, Handler, MAX_BODY_BYTES, compute_next_run_at 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}) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+6 -6
View File
@@ -2,15 +2,15 @@
// the top-nav active state in sync, and re-renders chrome strings when // the top-nav active state in sync, and re-renders chrome strings when
// the locale changes. // the locale changes.
import { onRoute } from "./state.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=one-time-visible-1"; import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=persist-all-jobs-1";
import { renderRemotes } from "./views/remotes.js?v=one-time-visible-1"; import { renderRemotes } from "./views/remotes.js?v=persist-all-jobs-1";
import { renderBrowse } from "./views/browser.js?v=one-time-visible-1"; import { renderBrowse } from "./views/browser.js?v=persist-all-jobs-1";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=one-time-visible-1"; import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=persist-all-jobs-1";
import { import {
renderConfigureNew, renderConfigureNew,
renderConfigureEdit, renderConfigureEdit,
} from "./views/configure.js?v=one-time-visible-1"; } from "./views/configure.js?v=persist-all-jobs-1";
const views = { const views = {
remotes: renderRemotes, remotes: renderRemotes,
+16 -6
View File
@@ -155,7 +155,7 @@ const STRINGS = {
// jobs view // jobs view
"jobs.title": "Jobs", "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.new_btn": "New Job",
"jobs.cancel": "Cancel", "jobs.cancel": "Cancel",
"jobs.new_title": "New Job", "jobs.new_title": "New Job",
@@ -167,7 +167,7 @@ const STRINGS = {
"jobs.schedule_type": "Job type", "jobs.schedule_type": "Job type",
"jobs.schedule_once": "One-time", "jobs.schedule_once": "One-time",
"jobs.schedule_recurring": "Fixed recurring", "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_kind": "Recurring plan",
"jobs.recurrence_daily": "Every day", "jobs.recurrence_daily": "Every day",
"jobs.recurrence_weekly": "Every week", "jobs.recurrence_weekly": "Every week",
@@ -203,7 +203,12 @@ const STRINGS = {
"jobs.pick_empty": "No folders here.", "jobs.pick_empty": "No folders here.",
"jobs.start": "Start Job", "jobs.start": "Start Job",
"jobs.no_remotes_option": "(no remotes)", "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_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.empty_body": "Use New Job to start a copy, sync, or move.",
"jobs.started": (a, id) => `Started ${a} job #${id}`, "jobs.started": (a, id) => `Started ${a} job #${id}`,
"jobs.started_once": (a, id) => `Started one-time ${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.status.stopped": "stopped",
"jobs.stop": "Stop", "jobs.stop": "Stop",
"jobs.once_id": (id) => `once:${id}`, "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 —", "jobs.submitted_cli": "— submitted via CLI —",
}, },
@@ -375,7 +380,7 @@ const STRINGS = {
"configure.example_custom_placeholder": "自定义值", "configure.example_custom_placeholder": "自定义值",
"jobs.title": "任务", "jobs.title": "任务",
"jobs.subtitle": "已保存的循环传输任务及其最近运行状态。", "jobs.subtitle": "已保存的传输任务及其最近运行状态。",
"jobs.new_btn": "新建任务", "jobs.new_btn": "新建任务",
"jobs.cancel": "取消", "jobs.cancel": "取消",
"jobs.new_title": "新建任务", "jobs.new_title": "新建任务",
@@ -387,7 +392,7 @@ const STRINGS = {
"jobs.schedule_type": "任务类型", "jobs.schedule_type": "任务类型",
"jobs.schedule_once": "单次任务", "jobs.schedule_once": "单次任务",
"jobs.schedule_recurring": "固定循环", "jobs.schedule_recurring": "固定循环",
"jobs.schedule_help": "单次任务会显示在当前浏览器,但不会保存到 SQLite。固定循环任务会保存到 SQLite。", "jobs.schedule_help": "单次任务和固定循环任务都会保存到 SQLite。",
"jobs.recurrence_kind": "循环计划", "jobs.recurrence_kind": "循环计划",
"jobs.recurrence_daily": "每天", "jobs.recurrence_daily": "每天",
"jobs.recurrence_weekly": "每周", "jobs.recurrence_weekly": "每周",
@@ -423,7 +428,12 @@ const STRINGS = {
"jobs.pick_empty": "这里没有文件夹。", "jobs.pick_empty": "这里没有文件夹。",
"jobs.start": "开始任务", "jobs.start": "开始任务",
"jobs.no_remotes_option": "(无远程存储)", "jobs.no_remotes_option": "(无远程存储)",
"jobs.view_filter": "任务视图",
"jobs.view_once": "单次任务",
"jobs.view_recurring": "循环任务",
"jobs.empty_title": "尚无任务", "jobs.empty_title": "尚无任务",
"jobs.empty_once_title": "尚无单次任务",
"jobs.empty_recurring_title": "尚无循环任务",
"jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。", "jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。",
"jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`, "jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`,
"jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`, "jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`,
@@ -473,7 +483,7 @@ const STRINGS = {
"jobs.status.stopped": "已停止", "jobs.status.stopped": "已停止",
"jobs.stop": "停止", "jobs.stop": "停止",
"jobs.once_id": (id) => `单次:${id}`, "jobs.once_id": (id) => `单次:${id}`,
"jobs.schedule_label_once": "仅当前浏览器可见", "jobs.schedule_label_once": "单次运行",
"jobs.submitted_cli": "— 通过命令行提交 —", "jobs.submitted_cli": "— 通过命令行提交 —",
}, },
}; };
+33
View File
@@ -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 } = {}) { async function request(path, { method = "GET", body } = {}) {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
+3 -3
View File
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. // 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 { post, uploadFile, downloadURL } from "../rc.js?v=persist-all-jobs-1";
import { toast, formatBytes, formatTime } from "../state.js?v=one-time-visible-1"; import { toast, formatBytes, formatTime } from "../state.js?v=persist-all-jobs-1";
import { t } from "../i18n.js?v=one-time-visible-1"; import { t } from "../i18n.js?v=persist-all-jobs-1";
export async function renderBrowse({ remote, path }) { export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app"); const app = document.getElementById("app");
+3 -3
View File
@@ -9,9 +9,9 @@
// OAuth backends (option named "token" with IsPassword) get a banner // OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal. // and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js?v=one-time-visible-1"; import { post } from "../rc.js?v=persist-all-jobs-1";
import { getState, setState, toast } from "../state.js?v=one-time-visible-1"; import { getState, setState, toast } from "../state.js?v=persist-all-jobs-1";
import { t } from "../i18n.js?v=one-time-visible-1"; import { t } from "../i18n.js?v=persist-all-jobs-1";
// --- Route entrypoints --- // --- Route entrypoints ---
+73 -163
View File
@@ -1,28 +1,32 @@
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. // 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 { import {
toast, toast,
formatBytes, formatBytes,
formatSpeed, formatSpeed,
formatDuration, formatDuration,
} from "../state.js?v=one-time-visible-1"; } from "../state.js?v=persist-all-jobs-1";
import { t } from "../i18n.js?v=one-time-visible-1"; import { t } from "../i18n.js?v=persist-all-jobs-1";
import { import {
createOneTimeJob,
createRecurringJob, createRecurringJob,
deleteOneTimeJob,
deleteRecurringJob, deleteRecurringJob,
jobsApiURL, jobsApiURL,
listOneTimeJobs,
listRecurringJobs, listRecurringJobs,
runOneTimeJobNow,
runRecurringJobNow, runRecurringJobNow,
stopOneTimeJob,
stopRecurringJob, stopRecurringJob,
} from "../jobs_api.js?v=one-time-visible-1"; } from "../jobs_api.js?v=persist-all-jobs-1";
let pollTimer = null; let pollTimer = null;
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
const LOCAL_FS_VALUE = "__local__"; const LOCAL_FS_VALUE = "__local__";
const expandedJobs = new Set(); const expandedJobs = new Set();
const LOCAL_PICKER_ROOT = "/root"; const LOCAL_PICKER_ROOT = "/root";
const ONE_TIME_JOBS_KEY = "rclone.webgui.oneTimeJobs.v1"; let currentJobsView = "once";
export async function renderJobs() { export async function renderJobs() {
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -34,11 +38,35 @@ export async function renderJobs() {
</div> </div>
<a class="btn btn-primary btn-sm" href="#/jobs/new">${t("jobs.new_btn")}</a> <a class="btn btn-primary btn-sm" href="#/jobs/new">${t("jobs.new_btn")}</a>
</div> </div>
<div class="toolbar">
<div class="segmented" role="tablist" aria-label="${t("jobs.view_filter")}">
<label>
<input type="radio" name="jobsView" value="once" ${currentJobsView === "once" ? "checked" : ""}>
<span>${t("jobs.view_once")}</span>
</label>
<label>
<input type="radio" name="jobsView" value="recurring" ${currentJobsView === "recurring" ? "checked" : ""}>
<span>${t("jobs.view_recurring")}</span>
</label>
</div>
</div>
<div id="jobs-card" class="card-outline"> <div id="jobs-card" class="card-outline">
<p class="empty">${t("loading.title")}…</p> <p class="empty">${t("loading.title")}…</p>
</div> </div>
`; `;
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(); const hasRunning = await refreshJobs();
if (hasRunning) { if (hasRunning) {
startPolling(); startPolling();
@@ -247,31 +275,18 @@ export async function renderNewJob() {
...schedule, ...schedule,
}); });
toast(t("jobs.recurring_saved", job && job.id), "success"); toast(t("jobs.recurring_saved", job && job.id), "success");
currentJobsView = "recurring";
location.hash = "#/jobs"; location.hash = "#/jobs";
return; return;
} }
const body = buildTransferBody(action, src, dst); const job = await createOneTimeJob({
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, action,
src, src,
dst, 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"; location.hash = "#/jobs";
} catch (e) { } catch (e) {
toast(`${t("jobs.start_failed")}: ${e.message}`, "error"); toast(`${t("jobs.start_failed")}: ${e.message}`, "error");
@@ -302,16 +317,6 @@ function renderMonthdayOptions() {
return lastDay + numberedDays; 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) { function buildJobFs(location, rawPath) {
if (location === LOCAL_FS_VALUE) { if (location === LOCAL_FS_VALUE) {
return rawPath.trim(); return rawPath.trim();
@@ -475,29 +480,28 @@ async function refreshJobs() {
const card = document.getElementById("jobs-card"); const card = document.getElementById("jobs-card");
if (!card) return false; if (!card) return false;
let recurringJobs = []; let jobs = [];
const oneTimeJobs = await refreshOneTimeJobs();
try { try {
recurringJobs = await listRecurringJobs(); if (currentJobsView === "once") {
} catch (e) { jobs = (await listOneTimeJobs()).map(normalizeOneTimeJob);
if (oneTimeJobs.length === 0) { } else {
card.innerHTML = ` jobs = (await listRecurringJobs()).map(normalizeRecurringJob);
<div class="empty">
<h3>${t("error.couldnt_load_jobs")}</h3>
<p>${escapeHtml(e.message)}</p>
<p class="col-mono">${escapeHtml(jobsApiURL())}</p>
</div>
`;
return false;
} }
toast(`${t("error.couldnt_load_jobs")}: ${e.message}`, "error"); } catch (e) {
card.innerHTML = `
<div class="empty">
<h3>${t("error.couldnt_load_jobs")}</h3>
<p>${escapeHtml(e.message)}</p>
<p class="col-mono">${escapeHtml(jobsApiURL())}</p>
</div>
`;
return false;
} }
const jobs = [...oneTimeJobs, ...recurringJobs.map(normalizeRecurringJob)];
if (jobs.length === 0) { if (jobs.length === 0) {
card.innerHTML = ` card.innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("jobs.empty_title")}</h3> <h3>${currentJobsView === "once" ? t("jobs.empty_once_title") : t("jobs.empty_recurring_title")}</h3>
<p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p> <p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p>
</div> </div>
`; `;
@@ -634,6 +638,7 @@ function renderJobRow(job) {
function renderStatusBadge(status) { function renderStatusBadge(status) {
switch (status) { switch (status) {
case "running": case "running":
case "starting":
return `<span class="badge">${t("jobs.status.running")}</span>`; return `<span class="badge">${t("jobs.status.running")}</span>`;
case "failed": case "failed":
return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`; return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
@@ -701,20 +706,11 @@ function detailStatus(job) {
async function onStop(jobid) { async function onStop(jobid) {
const key = String(jobid); const key = String(jobid);
const oneTimeJob = findOneTimeJob(key);
const label = displayKey(key); const label = displayKey(key);
if (!confirm(t("jobs.stop_confirm", label))) return; if (!confirm(t("jobs.stop_confirm", label))) return;
try { try {
if (oneTimeJob) { if (key.startsWith("once:")) {
await post("job/stop", { jobid: oneTimeJob.currentJobid || oneTimeJob.lastJobid }); await stopOneTimeJob(oneTimeIdFromKey(key));
saveOneTimeJob({
...oneTimeJob,
running: false,
status: "stopped",
currentJobid: null,
finishedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
} else { } else {
await stopRecurringJob(recurringIdFromKey(key)); await stopRecurringJob(recurringIdFromKey(key));
} }
@@ -737,30 +733,10 @@ async function onToggleDetails(jobid) {
async function onStartAgain(jobid) { async function onStartAgain(jobid) {
const key = String(jobid); const key = String(jobid);
const oneTimeJob = findOneTimeJob(key);
try { try {
if (oneTimeJob) { if (key.startsWith("once:")) {
const body = buildTransferBody(oneTimeJob.action, oneTimeJob.src, oneTimeJob.dst); const job = await runOneTimeJobNow(oneTimeIdFromKey(key));
const res = await postAsync(`sync/${oneTimeJob.action}`, body); toast(t("jobs.restarted_once", job && (job.jobid || job.id)), "success");
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 { } else {
const id = recurringIdFromKey(key); const id = recurringIdFromKey(key);
await runRecurringJobNow(id); await runRecurringJobNow(id);
@@ -777,8 +753,8 @@ async function onDelete(jobid) {
const label = displayKey(key); const label = displayKey(key);
if (!confirm(t("jobs.delete_confirm", label))) return; if (!confirm(t("jobs.delete_confirm", label))) return;
try { try {
if (findOneTimeJob(key)) { if (key.startsWith("once:")) {
removeOneTimeJob(key); await deleteOneTimeJob(oneTimeIdFromKey(key));
} else { } else {
await deleteRecurringJob(recurringIdFromKey(key)); await deleteRecurringJob(recurringIdFromKey(key));
} }
@@ -814,57 +790,6 @@ export function stopJobPolling() {
stopPolling(); 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) { function normalizeRecurringJob(job) {
return { return {
...job, ...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) { function jobKey(job) {
return String(job.id); return String(job.id);
} }
function displayJobId(job) { function displayJobId(job) {
if (job.kind === "once") { 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:/, ""); return job.scheduleId || String(job.id).replace(/^recurring:/, "");
} }
@@ -896,32 +830,8 @@ function recurringIdFromKey(key) {
return Number(String(key).replace(/^recurring:/, "")); return Number(String(key).replace(/^recurring:/, ""));
} }
function loadOneTimeJobs() { function oneTimeIdFromKey(key) {
try { return Number(String(key).replace(/^once:/, ""));
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) { function escapeHtml(s) {
+3 -3
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD. // views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js?v=one-time-visible-1"; import { post } from "../rc.js?v=persist-all-jobs-1";
import { getState, setState, toast } from "../state.js?v=one-time-visible-1"; import { getState, setState, toast } from "../state.js?v=persist-all-jobs-1";
import { t } from "../i18n.js?v=one-time-visible-1"; import { t } from "../i18n.js?v=persist-all-jobs-1";
export async function renderRemotes() { export async function renderRemotes() {
const app = document.getElementById("app"); const app = document.getElementById("app");
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css"> <link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css"> <link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css?v=one-time-visible-1"> <link rel="stylesheet" href="/assets/styles/components.css?v=persist-all-jobs-1">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
@@ -80,6 +80,6 @@
<div id="toast-stack" class="toast-stack"></div> <div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div> <div id="modal-root"></div>
<script type="module" src="/assets/js/app.js?v=one-time-visible-1"></script> <script type="module" src="/assets/js/app.js?v=persist-all-jobs-1"></script>
</body> </body>
</html> </html>