add: 持久化所有任务记录
This commit is contained in:
@@ -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` 完成授权。
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": "— 通过命令行提交 —",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +480,14 @@ 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") {
|
||||||
|
jobs = (await listOneTimeJobs()).map(normalizeOneTimeJob);
|
||||||
|
} else {
|
||||||
|
jobs = (await listRecurringJobs()).map(normalizeRecurringJob);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (oneTimeJobs.length === 0) {
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h3>${t("error.couldnt_load_jobs")}</h3>
|
<h3>${t("error.couldnt_load_jobs")}</h3>
|
||||||
@@ -490,14 +497,11 @@ async function refreshJobs() {
|
|||||||
`;
|
`;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
toast(`${t("error.couldnt_load_jobs")}: ${e.message}`, "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user