Compare commits

...
6 Commits
13 changed files with 1760 additions and 180 deletions
+1
View File
@@ -3,6 +3,7 @@ config/rclone/rclone.conf
# Docker build artifacts / runtime
.docker/
webgui/data/
# Editor / OS cruft
.idea/
+27 -4
View File
@@ -1,12 +1,13 @@
# Docker Compose stack for the rclone webgui.
#
# A single rclone rcd container does all three jobs on one port:
# The rclone rcd container does three jobs on one port:
# - Serves the static frontend from webgui/web/ (--rc-files)
# - Serves RC API endpoints at POST /* (built-in)
# - Serves remote files at GET /<remote>:<path> (--rc-serve)
#
# Same-origin from the browser, so no CORS / no nginx / no double ports.
# For TLS or custom headers, put a reverse proxy of your choice in front.
# The jobs-api sidecar stores recurring task definitions in SQLite and starts
# scheduled runs through rclone RC. The browser calls it on port 5581.
# For TLS, auth, or custom headers, put a reverse proxy of your choice in front.
#
# rclone config lives in ./config/rclone/rclone.conf (bind-mounted).
# If it doesn't exist yet, create your remotes with:
@@ -16,7 +17,8 @@
# Usage:
# docker compose up -d # start
# open http://localhost:5580
# docker compose logs -f rclone # tail logs
# docker compose logs -f rclone # tail rclone logs
# docker compose logs -f jobs-api # tail scheduler/API logs
# docker compose down # stop
services:
@@ -42,6 +44,8 @@ services:
- rclone-cache:/cache
# Scratch space for any local-FS remotes that point here.
- rclone-data:/data
# Expose the host /root tree to local filesystem transfer jobs.
- /root:/root
environment:
- XDG_CONFIG_HOME=/config
- RCLONE_CACHE_DIR=/cache
@@ -49,6 +53,25 @@ services:
- "5580:8080"
restart: unless-stopped
jobs-api:
image: python:3.12-alpine
container_name: rclone-jobs-api
command: ["python", "/app/server.py"]
depends_on:
- rclone
volumes:
- ./webgui/api:/app:ro
- ./webgui/data:/data
environment:
- JOBS_DB=/data/jobs.sqlite
- JOBS_API_HOST=0.0.0.0
- JOBS_API_PORT=8081
- JOBS_SCHEDULER_INTERVAL=15
- RCLONE_RC_URL=http://rclone:8080
ports:
- "5581:8081"
restart: unless-stopped
volumes:
rclone-cache:
rclone-data:
+764
View File
@@ -0,0 +1,764 @@
#!/usr/bin/env python3
"""SQLite-backed recurring job API for the rclone WebGUI.
This service intentionally uses only Python's standard library. It stores
recurring transfer definitions and the latest rclone job status snapshots in
SQLite, then starts due jobs through rclone's RC API.
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import traceback
import urllib.error
import urllib.request
from calendar import monthrange
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import urlparse
DB_PATH = os.environ.get("JOBS_DB", "/data/jobs.sqlite")
RCLONE_RC_URL = os.environ.get("RCLONE_RC_URL", "http://rclone:8080").rstrip("/")
HOST = os.environ.get("JOBS_API_HOST", "0.0.0.0")
PORT = int(os.environ.get("JOBS_API_PORT", "8081"))
SCHEDULER_INTERVAL_SECONDS = int(os.environ.get("JOBS_SCHEDULER_INTERVAL", "15"))
VALID_ACTIONS = {"copy", "sync", "move"}
VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"}
db_lock = threading.RLock()
scheduler_lock = threading.RLock()
stop_event = threading.Event()
class ApiError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
class RcloneError(Exception):
pass
def utcnow() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)
def iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_time(value: Any) -> datetime:
if not isinstance(value, str) or not value.strip():
raise ValueError("expected ISO timestamp")
raw = value.strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).replace(microsecond=0)
def parse_schedule_time(value: Any) -> tuple[int, int]:
if not isinstance(value, str):
raise ValueError("expected HH:MM")
parts = value.strip().split(":")
if len(parts) != 2:
raise ValueError("expected HH:MM")
hour = int(parts[0])
minute = int(parts[1])
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError("expected HH:MM")
return hour, minute
def parse_timezone_offset(value: Any) -> int:
try:
offset = int(value)
except (TypeError, ValueError):
return 0
# Keep this intentionally bounded to real-world UTC offsets.
if offset < -14 * 60 or offset > 14 * 60:
raise ValueError("invalid timezone offset")
return offset
def add_months(year: int, month: int, months: int) -> tuple[int, int]:
index = (year * 12 + (month - 1)) + months
return index // 12, index % 12 + 1
def local_to_utc(local_dt: datetime, offset_minutes: int) -> datetime:
utc = local_dt - timedelta(minutes=offset_minutes)
return utc.replace(tzinfo=timezone.utc)
def compute_next_run_at(
kind: str,
schedule_time: str,
offset_minutes: int,
after_dt: datetime,
weekday: int | None = None,
monthday: int | None = None,
) -> str:
hour, minute = parse_schedule_time(schedule_time)
local_after = (after_dt.astimezone(timezone.utc) + timedelta(minutes=offset_minutes)).replace(tzinfo=None)
if kind == "daily":
candidate = local_after.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= local_after:
candidate += timedelta(days=1)
return iso(local_to_utc(candidate, offset_minutes))
if kind == "weekly":
if weekday is None or weekday < 1 or weekday > 7:
raise ValueError("scheduleWeekday must be 1-7")
target_weekday = weekday - 1
days_ahead = target_weekday - local_after.weekday()
candidate = (local_after + timedelta(days=days_ahead)).replace(
hour=hour,
minute=minute,
second=0,
microsecond=0,
)
if days_ahead < 0 or candidate <= local_after:
candidate += timedelta(days=7)
return iso(local_to_utc(candidate, offset_minutes))
if kind == "monthly":
if monthday is None or monthday < 1 or monthday > 31:
raise ValueError("scheduleMonthday must be 1-31")
for offset_months in range(0, 36):
year, month = add_months(local_after.year, local_after.month, offset_months)
if monthday > monthrange(year, month)[1]:
continue
candidate = datetime(year, month, monthday, hour, minute)
if candidate > local_after:
return iso(local_to_utc(candidate, offset_minutes))
raise ValueError("could not compute next monthly run")
raise ValueError("invalid schedule kind")
def compute_row_next_run_at(row: sqlite3.Row, after_dt: datetime) -> str:
return compute_next_run_at(
row["schedule_kind"],
row["schedule_time"],
int(row["timezone_offset_minutes"] or 0),
after_dt,
row["schedule_weekday"],
row["schedule_monthday"],
)
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db() -> None:
db_dir = os.path.dirname(DB_PATH)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
with db_lock, connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS job_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
src TEXT NOT NULL,
dst TEXT NOT NULL,
interval_seconds INTEGER NOT NULL,
schedule_kind TEXT NOT NULL DEFAULT 'daily',
schedule_time TEXT NOT NULL DEFAULT '00:00',
schedule_weekday INTEGER,
schedule_monthday INTEGER,
timezone_offset_minutes INTEGER NOT NULL DEFAULT 0,
next_run_at TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_jobid INTEGER,
current_jobid INTEGER,
last_status TEXT,
last_error TEXT,
last_started_at TEXT,
last_finished_at TEXT,
last_status_json TEXT
);
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
jobid INTEGER,
trigger TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
started_at TEXT,
finished_at TEXT,
status_json TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(schedule_id) REFERENCES job_schedules(id) ON DELETE CASCADE
);
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);
"""
)
ensure_column(conn, "job_schedules", "schedule_kind", "TEXT NOT NULL DEFAULT 'daily'")
ensure_column(conn, "job_schedules", "schedule_time", "TEXT NOT NULL DEFAULT '00:00'")
ensure_column(conn, "job_schedules", "schedule_weekday", "INTEGER")
ensure_column(conn, "job_schedules", "schedule_monthday", "INTEGER")
ensure_column(conn, "job_schedules", "timezone_offset_minutes", "INTEGER NOT NULL DEFAULT 0")
def ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def row_to_job(row: sqlite3.Row) -> dict[str, Any]:
snapshot = None
if row["last_status_json"]:
try:
snapshot = json.loads(row["last_status_json"])
except json.JSONDecodeError:
snapshot = None
status = row["last_status"]
if not status:
status = "scheduled"
return {
"id": row["id"],
"action": row["action"],
"src": row["src"],
"dst": row["dst"],
"intervalSeconds": row["interval_seconds"],
"scheduleKind": row["schedule_kind"],
"scheduleTime": row["schedule_time"],
"scheduleWeekday": row["schedule_weekday"],
"scheduleMonthday": row["schedule_monthday"],
"timezoneOffsetMinutes": row["timezone_offset_minutes"],
"nextRunAt": row["next_run_at"],
"enabled": bool(row["enabled"]),
"lastJobid": row["last_jobid"],
"currentJobid": row["current_jobid"],
"status": status,
"error": row["last_error"],
"startedAt": row["last_started_at"],
"finishedAt": row["last_finished_at"],
"running": row["current_jobid"] is not None and 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()
if row is None:
raise ApiError(404, "job not found")
return row_to_job(row)
def list_jobs() -> list[dict[str, Any]]:
refresh_running_jobs()
with db_lock, connect() as conn:
rows = conn.execute("SELECT * FROM job_schedules ORDER BY id DESC").fetchall()
return [row_to_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()
dst = str(payload.get("dst", "")).strip()
schedule_kind = str(payload.get("scheduleKind", "")).strip()
schedule_time = str(payload.get("scheduleTime", "")).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")
if schedule_kind not in VALID_SCHEDULE_KINDS:
raise ApiError(400, "scheduleKind must be daily, weekly, or monthly")
try:
parse_schedule_time(schedule_time)
timezone_offset_minutes = parse_timezone_offset(payload.get("timezoneOffsetMinutes"))
schedule_weekday = parse_optional_int(payload.get("scheduleWeekday"))
schedule_monthday = parse_optional_int(payload.get("scheduleMonthday"))
next_run_at = compute_next_run_at(
schedule_kind,
schedule_time,
timezone_offset_minutes,
utcnow(),
schedule_weekday,
schedule_monthday,
)
except (TypeError, ValueError):
raise ApiError(400, "invalid fixed schedule") from None
interval_seconds = legacy_interval_seconds(schedule_kind)
now = iso(utcnow())
with db_lock, connect() as conn:
cur = conn.execute(
"""
INSERT INTO job_schedules
(action, src, dst, interval_seconds, schedule_kind, schedule_time,
schedule_weekday, schedule_monthday, timezone_offset_minutes,
next_run_at, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
""",
(
action,
src,
dst,
interval_seconds,
schedule_kind,
schedule_time,
schedule_weekday,
schedule_monthday,
timezone_offset_minutes,
next_run_at,
now,
now,
),
)
schedule_id = int(cur.lastrowid)
run_due_jobs()
return fetch_job(schedule_id)
def parse_optional_int(value: Any) -> int | None:
if value in (None, ""):
return None
return int(value)
def legacy_interval_seconds(schedule_kind: str) -> int:
if schedule_kind == "weekly":
return 7 * 24 * 60 * 60
if schedule_kind == "monthly":
return 30 * 24 * 60 * 60
return 24 * 60 * 60
def delete_job(schedule_id: int) -> None:
with db_lock, connect() as conn:
cur = conn.execute("DELETE FROM job_schedules WHERE id=?", (schedule_id,))
if cur.rowcount == 0:
raise ApiError(404, "job not found")
def manual_run(schedule_id: int) -> dict[str, Any]:
with scheduler_lock:
refresh_running_jobs()
with db_lock, connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
if row is None:
raise ApiError(404, "job not found")
if row["current_jobid"] is not None:
raise ApiError(409, "job is already running")
start_schedule(row, "manual")
return fetch_job(schedule_id)
def stop_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()
if row is None:
raise ApiError(404, "job not found")
jobid = row["current_jobid"]
if jobid is None:
return row_to_job(row)
rc_post("job/stop", {"jobid": jobid})
now_dt = utcnow()
now = iso(now_dt)
next_run_at = compute_row_next_run_at(row, now_dt)
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
with db_lock, connect() as conn:
conn.execute(
"""
UPDATE job_schedules
SET current_jobid=NULL,
last_status='stopped',
last_error=NULL,
last_finished_at=?,
last_status_json=?,
next_run_at=?,
updated_at=?
WHERE id=?
""",
(now, snapshot, next_run_at, now, schedule_id),
)
conn.execute(
"""
UPDATE job_runs
SET status='stopped', finished_at=?, status_json=?
WHERE schedule_id=? AND jobid=? AND status='running'
""",
(now, snapshot, schedule_id, jobid),
)
return fetch_job(schedule_id)
def rc_post(
path: str,
payload: dict[str, Any],
timeout: int = 30,
allow_error_body: bool = False,
) -> dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{RCLONE_RC_URL}/{path.lstrip('/')}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
raw = res.read()
except urllib.error.HTTPError as exc:
raw = exc.read()
message = raw.decode("utf-8", "replace") if raw else str(exc)
try:
body = json.loads(message)
message = body.get("error") or body.get("message") or message
except json.JSONDecodeError:
pass
raise RcloneError(f"{path}: {message}") from exc
except urllib.error.URLError as exc:
raise RcloneError(f"{path}: {exc.reason}") from exc
if not raw:
return {}
try:
body = json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as exc:
raise RcloneError(f"{path}: invalid JSON response") from exc
if body.get("error") and not allow_error_body:
raise RcloneError(f"{path}: {body['error']}")
return body
def start_schedule(row: sqlite3.Row, trigger: str) -> None:
now_dt = utcnow()
now = iso(now_dt)
schedule_id = int(row["id"])
action = row["action"]
payload: dict[str, Any] = {
"srcFs": row["src"],
"dstFs": row["dst"],
"_async": True,
"_group": f"webgui/recurring/{schedule_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)
next_run_at = compute_row_next_run_at(row, now_dt)
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
with db_lock, connect() as conn:
conn.execute(
"""
INSERT INTO job_runs
(schedule_id, jobid, trigger, status, error, started_at, finished_at, status_json, created_at)
VALUES (?, NULL, ?, 'failed', ?, ?, ?, ?, ?)
""",
(schedule_id, trigger, error, now, now, snapshot, now),
)
conn.execute(
"""
UPDATE job_schedules
SET current_jobid=NULL,
last_status='failed',
last_error=?,
last_started_at=?,
last_finished_at=?,
last_status_json=?,
next_run_at=?,
updated_at=?
WHERE id=?
""",
(error, now, now, snapshot, next_run_at, now, schedule_id),
)
return
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
next_run_at = compute_row_next_run_at(row, now_dt)
with db_lock, connect() as conn:
conn.execute(
"""
INSERT INTO job_runs
(schedule_id, jobid, trigger, status, started_at, status_json, created_at)
VALUES (?, ?, ?, 'running', ?, ?, ?)
""",
(schedule_id, jobid, trigger, now, snapshot, now),
)
conn.execute(
"""
UPDATE job_schedules
SET current_jobid=?,
last_jobid=?,
last_status='running',
last_error=NULL,
last_started_at=?,
last_finished_at=NULL,
last_status_json=?,
next_run_at=?,
updated_at=?
WHERE id=?
""",
(jobid, jobid, now, snapshot, next_run_at, now, schedule_id),
)
def status_name(status: dict[str, Any]) -> str:
if not status.get("finished"):
return "running"
if status.get("error"):
return "failed"
if status.get("success"):
return "done"
return "finished"
def refresh_running_jobs() -> None:
with db_lock, connect() as conn:
rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall()
for row in rows:
schedule_id = int(row["id"])
jobid = int(row["current_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_status(row, status)
def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
schedule_id = int(row["id"])
jobid = int(row["current_jobid"])
now_dt = utcnow()
now = iso(now_dt)
snapshot = json.dumps(status, separators=(",", ":"), ensure_ascii=False)
name = status_name(status)
if name == "running":
with db_lock, connect() as conn:
conn.execute(
"""
UPDATE job_schedules
SET last_status='running',
last_error=NULL,
last_status_json=?,
updated_at=?
WHERE id=? AND current_jobid=?
""",
(snapshot, now, schedule_id, jobid),
)
conn.execute(
"""
UPDATE job_runs
SET status='running', status_json=?
WHERE schedule_id=? AND jobid=? AND status='running'
""",
(snapshot, schedule_id, jobid),
)
return
error = status.get("error")
next_run_at = compute_row_next_run_at(row, now_dt)
with db_lock, connect() as conn:
conn.execute(
"""
UPDATE job_schedules
SET current_jobid=NULL,
last_status=?,
last_error=?,
last_finished_at=?,
last_status_json=?,
next_run_at=?,
updated_at=?
WHERE id=? AND current_jobid=?
""",
(name, error, now, snapshot, next_run_at, now, schedule_id, jobid),
)
conn.execute(
"""
UPDATE job_runs
SET status=?, error=?, finished_at=?, status_json=?
WHERE schedule_id=? AND jobid=? AND status='running'
""",
(name, error, now, snapshot, schedule_id, jobid),
)
def run_due_jobs() -> None:
with scheduler_lock:
now = iso(utcnow())
with db_lock, connect() as conn:
rows = conn.execute(
"""
SELECT * FROM job_schedules
WHERE enabled=1 AND current_jobid IS NULL AND next_run_at <= ?
ORDER BY next_run_at ASC, id ASC
""",
(now,),
).fetchall()
for row in rows:
start_schedule(row, "scheduled")
def scheduler_tick() -> None:
refresh_running_jobs()
run_due_jobs()
def scheduler_loop() -> None:
while not stop_event.wait(SCHEDULER_INTERVAL_SECONDS):
try:
scheduler_tick()
except Exception:
traceback.print_exc()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_OPTIONS(self) -> None:
self.send_json(204, None)
def do_GET(self) -> None:
self.handle_request("GET")
def do_POST(self) -> None:
self.handle_request("POST")
def do_DELETE(self) -> None:
self.handle_request("DELETE")
def handle_request(self, method: str) -> None:
try:
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
parts = [p for p in path.split("/") if p]
if method == "GET" and path == "/health":
self.send_json(200, {"ok": True})
return
if parts == ["api", "jobs"] and method == "GET":
self.send_json(200, {"jobs": list_jobs()})
return
if parts == ["api", "jobs"] and method == "POST":
self.send_json(201, {"job": create_job(self.read_json())})
return
if len(parts) == 3 and parts[:2] == ["api", "jobs"]:
schedule_id = parse_id(parts[2])
if method == "DELETE":
delete_job(schedule_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":
self.send_json(200, {"job": manual_run(schedule_id)})
return
if parts[3] == "stop":
self.send_json(200, {"job": stop_job(schedule_id)})
return
raise ApiError(404, "not found")
except ApiError as exc:
self.send_json(exc.status, {"error": exc.message})
except RcloneError as exc:
self.send_json(502, {"error": str(exc)})
except Exception as exc:
traceback.print_exc()
self.send_json(500, {"error": str(exc)})
def read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length") or "0")
if length == 0:
return {}
raw = self.rfile.read(length)
try:
data = json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
raise ApiError(400, "invalid JSON") from None
if not isinstance(data, dict):
raise ApiError(400, "expected JSON object")
return data
def send_json(self, status: int, body: Any) -> None:
raw = b"" if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if raw:
self.wfile.write(raw)
def log_message(self, fmt: str, *args: Any) -> None:
print(f"{self.address_string()} - {fmt % args}")
def parse_id(value: str) -> int:
try:
result = int(value)
except ValueError:
raise ApiError(404, "not found") from None
if result <= 0:
raise ApiError(404, "not found")
return result
def main() -> None:
init_db()
thread = threading.Thread(target=scheduler_loop, name="job-scheduler", daemon=True)
thread.start()
try:
scheduler_tick()
except Exception:
traceback.print_exc()
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"jobs api listening on {HOST}:{PORT}, db={DB_PATH}, rclone={RCLONE_RC_URL}")
try:
server.serve_forever()
finally:
stop_event.set()
server.server_close()
if __name__ == "__main__":
main()
+6 -6
View File
@@ -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";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js";
import { renderRemotes } from "./views/remotes.js";
import { renderBrowse } from "./views/browser.js";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js";
import { onRoute } from "./state.js?v=recurring-jobs-5";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=recurring-jobs-5";
import { renderRemotes } from "./views/remotes.js?v=recurring-jobs-5";
import { renderBrowse } from "./views/browser.js?v=recurring-jobs-5";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=recurring-jobs-5";
import {
renderConfigureNew,
renderConfigureEdit,
} from "./views/configure.js";
} from "./views/configure.js?v=recurring-jobs-5";
const views = {
remotes: renderRemotes,
+133 -15
View File
@@ -47,6 +47,9 @@ const STRINGS = {
"error.couldnt_list": "Couldn't list",
"error.couldnt_load_jobs": "Couldn't load jobs",
"error.no_remotes_selected": "Pick source and destination remotes",
"error.no_paths_selected": "Enter source and destination paths",
"error.no_schedule_selected": "Pick a fixed recurring schedule",
"error.invalid_schedule_time": "Pick a valid run time",
"error.not_connected": "Couldn't connect to rclone",
// footer
@@ -152,32 +155,86 @@ const STRINGS = {
// jobs view
"jobs.title": "Jobs",
"jobs.subtitle": "Running and recently completed transfers.",
"jobs.subtitle": "Saved recurring transfers and their latest runs.",
"jobs.new_btn": "New Job",
"jobs.cancel": "Cancel",
"jobs.new_title": "New Job",
"jobs.new_subtitle": "Copy, sync, or move between remotes.",
"jobs.new_subtitle": "Copy, sync, or move between remotes and local paths.",
"jobs.action": "Action",
"jobs.action_copy": "copy (mirror src → dst, keep both)",
"jobs.action_sync": "sync (mirror src → dst, delete extras on dst)",
"jobs.action_move": "move (mirror src → dst, delete src after)",
"jobs.source_remote": "Source remote",
"jobs.source_path": "Source path (optional)",
"jobs.dest_remote": "Destination remote",
"jobs.dest_path": "Destination path (optional)",
"jobs.path_placeholder": "folder/subfolder",
"jobs.schedule_type": "Job type",
"jobs.schedule_once": "One-time",
"jobs.schedule_recurring": "Fixed recurring",
"jobs.schedule_help": "One-time jobs are submitted to rclone and not saved. Fixed recurring jobs are stored in SQLite.",
"jobs.recurrence_kind": "Recurring plan",
"jobs.recurrence_daily": "Every day",
"jobs.recurrence_weekly": "Every week",
"jobs.recurrence_monthly": "Every month",
"jobs.schedule_time": "Run time",
"jobs.schedule_weekday": "Weekday",
"jobs.schedule_monthday": "Day of month",
"jobs.fixed_schedule_help": "Runs at the selected fixed local time. If the previous run is still active, the scheduler will not start an overlapping run.",
"jobs.weekday_monday": "Monday",
"jobs.weekday_tuesday": "Tuesday",
"jobs.weekday_wednesday": "Wednesday",
"jobs.weekday_thursday": "Thursday",
"jobs.weekday_friday": "Friday",
"jobs.weekday_saturday": "Saturday",
"jobs.weekday_sunday": "Sunday",
"jobs.monthday": (n) => `Day ${n}`,
"jobs.schedule_label_daily": (time) => `Every day at ${time}`,
"jobs.schedule_label_weekly": (day, time) => `Every ${day} at ${time}`,
"jobs.schedule_label_monthly": (day, time) => `Day ${day} of every month at ${time}`,
"jobs.local_option": "Local filesystem",
"jobs.local_path_help": "For Local filesystem, enter a path visible inside the rclone process, such as /data/source in this Docker setup. For remotes, enter a path inside the selected remote.",
"jobs.source_remote": "Source location",
"jobs.source_path": "Source path",
"jobs.dest_remote": "Destination location",
"jobs.dest_path": "Destination path",
"jobs.path_placeholder": "folder/subfolder or /local/path",
"jobs.pick_folder": "Choose",
"jobs.pick_source_title": "Choose source folder",
"jobs.pick_dest_title": "Choose destination folder",
"jobs.pick_current": "Use current folder",
"jobs.pick_empty": "No folders here.",
"jobs.start": "Start Job",
"jobs.no_remotes_option": "(no remotes)",
"jobs.empty_title": "No 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}. It was not saved.`,
"jobs.recurring_saved": (id) => `Saved recurring job #${id}`,
"jobs.start_failed": "Job start failed",
"jobs.stop_confirm": (id) => `Stop job #${id}?`,
"jobs.stopped": (id) => `Stopped job #${id}`,
"jobs.stop_failed": "Stop failed",
"jobs.start_again": "Start again",
"jobs.restarted": (id) => `Started recurring job #${id}`,
"jobs.restart_failed": "Start again failed",
"jobs.details": "Details",
"jobs.hide_details": "Hide",
"jobs.delete_record": "Delete",
"jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`,
"jobs.delete_failed": "Delete failed",
"jobs.detail_schedule_id": "Schedule ID",
"jobs.detail_id": "Job ID",
"jobs.detail_status": "Status",
"jobs.detail_error": "Error",
"jobs.detail_started": "Started",
"jobs.detail_finished": "Finished",
"jobs.detail_duration": "Duration",
"jobs.detail_next_run": "Next run",
"jobs.detail_schedule": "Schedule",
"jobs.detail_group": "Group",
"jobs.detail_execute_id": "Execution",
"jobs.detail_output": "Output",
"jobs.detail_none": "None",
"jobs.col.id": "#",
"jobs.col.job": "Job",
"jobs.col.status": "Status",
"jobs.col.next_run": "Next run",
"jobs.col.progress": "Progress",
"jobs.col.speed": "Speed",
"jobs.col.eta": "ETA",
@@ -188,6 +245,8 @@ const STRINGS = {
"jobs.status.failed": "failed",
"jobs.status.done": "done",
"jobs.status.finished": "finished",
"jobs.status.scheduled": "scheduled",
"jobs.status.stopped": "stopped",
"jobs.stop": "Stop",
"jobs.submitted_cli": "— submitted via CLI —",
},
@@ -209,7 +268,10 @@ const STRINGS = {
"error.couldnt_load_remotes": "无法加载远程存储列表",
"error.couldnt_list": "无法列出",
"error.couldnt_load_jobs": "无法加载任务列表",
"error.no_remotes_selected": "请选择源和目标远程存储",
"error.no_remotes_selected": "请选择源和目标位置",
"error.no_paths_selected": "请输入源路径和目标路径",
"error.no_schedule_selected": "请选择固定循环计划",
"error.invalid_schedule_time": "请选择有效的运行时间",
"error.not_connected": "无法连接到 rclone",
"footer.product": "产品",
@@ -307,32 +369,86 @@ const STRINGS = {
"configure.example_custom_placeholder": "自定义值",
"jobs.title": "任务",
"jobs.subtitle": "正在运行和最近完成的传输。",
"jobs.subtitle": "已保存的循环传输任务及其最近运行状态。",
"jobs.new_btn": "新建任务",
"jobs.cancel": "取消",
"jobs.new_title": "新建任务",
"jobs.new_subtitle": "在远程存储之间复制、同步或移动。",
"jobs.new_subtitle": "在远程存储和本地路径之间复制、同步或移动。",
"jobs.action": "操作",
"jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)",
"jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)",
"jobs.action_move": "move(镜像 源 → 目标,完成后删除源)",
"jobs.source_remote": "源远程存储",
"jobs.source_path": "源路径(可选)",
"jobs.dest_remote": "目标远程存储",
"jobs.dest_path": "目标路径(可选)",
"jobs.path_placeholder": "文件夹/子文件夹",
"jobs.schedule_type": "任务类型",
"jobs.schedule_once": "单次任务",
"jobs.schedule_recurring": "固定循环",
"jobs.schedule_help": "单次任务只提交给 rclone,不保存记录。固定循环任务会保存到 SQLite。",
"jobs.recurrence_kind": "循环计划",
"jobs.recurrence_daily": "每天",
"jobs.recurrence_weekly": "每周",
"jobs.recurrence_monthly": "每月",
"jobs.schedule_time": "运行时间",
"jobs.schedule_weekday": "星期",
"jobs.schedule_monthday": "每月几号",
"jobs.fixed_schedule_help": "按所选本地固定时间运行;如果上一轮仍在运行,不会重叠启动。",
"jobs.weekday_monday": "星期一",
"jobs.weekday_tuesday": "星期二",
"jobs.weekday_wednesday": "星期三",
"jobs.weekday_thursday": "星期四",
"jobs.weekday_friday": "星期五",
"jobs.weekday_saturday": "星期六",
"jobs.weekday_sunday": "星期日",
"jobs.monthday": (n) => `${n} 号`,
"jobs.schedule_label_daily": (time) => `每天 ${time}`,
"jobs.schedule_label_weekly": (day, time) => `每周${day.replace(/^星期/, "")} ${time}`,
"jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`,
"jobs.local_option": "本地文件系统",
"jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。",
"jobs.source_remote": "源位置",
"jobs.source_path": "源路径",
"jobs.dest_remote": "目标位置",
"jobs.dest_path": "目标路径",
"jobs.path_placeholder": "文件夹/子文件夹 或 /local/path",
"jobs.pick_folder": "选择",
"jobs.pick_source_title": "选择源文件夹",
"jobs.pick_dest_title": "选择目标文件夹",
"jobs.pick_current": "使用当前文件夹",
"jobs.pick_empty": "这里没有文件夹。",
"jobs.start": "开始任务",
"jobs.no_remotes_option": "(无远程存储)",
"jobs.empty_title": "尚无任务",
"jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。",
"jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`,
"jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id},不会保存记录。`,
"jobs.recurring_saved": (id) => `已保存循环任务 #${id}`,
"jobs.start_failed": "任务启动失败",
"jobs.stop_confirm": (id) => `停止任务 #${id}?`,
"jobs.stopped": (id) => `已停止任务 #${id}`,
"jobs.stop_failed": "停止失败",
"jobs.start_again": "再次启动",
"jobs.restarted": (id) => `已启动循环任务 #${id}`,
"jobs.restart_failed": "再次启动失败",
"jobs.details": "详情",
"jobs.hide_details": "收起",
"jobs.delete_record": "删除",
"jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`,
"jobs.delete_failed": "删除失败",
"jobs.detail_schedule_id": "调度 ID",
"jobs.detail_id": "任务 ID",
"jobs.detail_status": "状态",
"jobs.detail_error": "错误",
"jobs.detail_started": "开始时间",
"jobs.detail_finished": "结束时间",
"jobs.detail_duration": "耗时",
"jobs.detail_next_run": "下次启动",
"jobs.detail_schedule": "固定计划",
"jobs.detail_group": "分组",
"jobs.detail_execute_id": "执行实例",
"jobs.detail_output": "输出",
"jobs.detail_none": "无",
"jobs.col.id": "#",
"jobs.col.job": "任务",
"jobs.col.status": "状态",
"jobs.col.next_run": "下次启动",
"jobs.col.progress": "进度",
"jobs.col.speed": "速度",
"jobs.col.eta": "预计剩余",
@@ -343,6 +459,8 @@ const STRINGS = {
"jobs.status.failed": "失败",
"jobs.status.done": "完成",
"jobs.status.finished": "已结束",
"jobs.status.scheduled": "已调度",
"jobs.status.stopped": "已停止",
"jobs.stop": "停止",
"jobs.submitted_cli": "— 通过命令行提交 —",
},
+66
View File
@@ -0,0 +1,66 @@
// jobs_api.js — client for the SQLite-backed recurring jobs sidecar.
const params = new URLSearchParams(location.search);
const QUERY_API = params.get("jobsApi");
const DEFAULT_PORT = "5581";
const API_BASE = QUERY_API
? QUERY_API.replace(/\/$/, "")
: `${location.protocol}//${location.hostname || "127.0.0.1"}:${DEFAULT_PORT}`;
export function jobsApiURL() {
return API_BASE;
}
export async function listRecurringJobs() {
const res = await request("/api/jobs");
return (res && res.jobs) || [];
}
export async function createRecurringJob(job) {
const res = await request("/api/jobs", {
method: "POST",
body: job,
});
return res && res.job;
}
export async function runRecurringJobNow(id) {
const res = await request(`/api/jobs/${encodeURIComponent(id)}/run`, {
method: "POST",
});
return res && res.job;
}
export async function stopRecurringJob(id) {
const res = await request(`/api/jobs/${encodeURIComponent(id)}/stop`, {
method: "POST",
});
return res && res.job;
}
export async function deleteRecurringJob(id) {
return request(`/api/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}`, {
method,
headers,
body: body == null ? undefined : JSON.stringify(body),
});
let data = {};
const ct = res.headers.get("Content-Type") || "";
if (ct.includes("application/json")) {
data = await res.json();
} else {
const text = await res.text();
data = text ? { error: text } : {};
}
if (!res.ok) {
throw new Error(data.error || `HTTP ${res.status}`);
}
return data;
}
+1 -59
View File
@@ -1,49 +1,12 @@
// state.js — minimal pub/sub store + hash router + localStorage-backed
// job metadata cache. Vanilla, no framework.
const JOB_META_KEY = "webgui:jobMeta";
const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// state.js — minimal pub/sub store + hash router. Vanilla, no framework.
const listeners = new Set();
const state = {
remotes: [], // [{ name, type }]
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
providers: null, // cached /config/providers response
// jobMeta is persisted to localStorage so the jobs view can show
// src/dst for jobs after a page reload. Keyed by jobid (number).
jobMeta: loadJobMeta(),
};
function loadJobMeta() {
try {
const raw = localStorage.getItem(JOB_META_KEY);
if (!raw) return new Map();
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return new Map();
const now = Date.now();
const map = new Map();
for (const [id, meta] of arr) {
if (meta && typeof meta.submittedAt === "number") {
if (now - meta.submittedAt < JOB_META_TTL_MS) {
map.set(Number(id), meta);
}
}
}
return map;
} catch {
return new Map();
}
}
function saveJobMeta() {
try {
const arr = Array.from(state.jobMeta.entries());
localStorage.setItem(JOB_META_KEY, JSON.stringify(arr));
} catch {
// localStorage might be unavailable (private mode, quota); ignore.
}
}
export function getState() {
return state;
}
@@ -64,27 +27,6 @@ export function subscribe(fn) {
return () => listeners.delete(fn);
}
// --- Job metadata API ---
export function rememberJob(jobid, { action, src, dst }) {
state.jobMeta.set(Number(jobid), {
action,
src,
dst,
submittedAt: Date.now(),
});
saveJobMeta();
}
export function getJobMeta(jobid) {
return state.jobMeta.get(Number(jobid));
}
export function forgetJob(jobid) {
state.jobMeta.delete(Number(jobid));
saveJobMeta();
}
// --- Router ---
// Hash-based routes:
// #/remotes
+2 -2
View File
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js";
import { toast, formatBytes, formatTime } from "../state.js";
import { t } from "../i18n.js";
import { toast, formatBytes, formatTime } from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app");
+2 -2
View File
@@ -10,8 +10,8 @@
// and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
import { t } from "../i18n.js";
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
// --- Route entrypoints ---
+564 -80
View File
@@ -1,19 +1,27 @@
// views/jobs.js — submit sync/copy/move jobs and poll their progress.
// Job src/dst metadata is persisted to localStorage (via state.rememberJob)
// so the table can show what each job is doing even after a page reload.
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
import { post, postAsync } from "../rc.js";
import {
toast,
rememberJob,
getJobMeta,
formatBytes,
formatSpeed,
formatDuration,
} from "../state.js";
import { t } from "../i18n.js";
} from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
import {
createRecurringJob,
deleteRecurringJob,
jobsApiURL,
listRecurringJobs,
runRecurringJobNow,
stopRecurringJob,
} from "../jobs_api.js?v=recurring-jobs-5";
let pollTimer = null;
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
const LOCAL_FS_VALUE = "__local__";
const expandedJobs = new Set();
const LOCAL_PICKER_ROOT = "/root";
export async function renderJobs() {
const app = document.getElementById("app");
@@ -30,8 +38,12 @@ export async function renderJobs() {
</div>
`;
await refreshJobs();
const hasRunning = await refreshJobs();
if (hasRunning) {
startPolling();
} else {
stopPolling();
}
}
export async function renderNewJob() {
@@ -45,8 +57,10 @@ export async function renderNewJob() {
toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
}
const remoteOpts = remotes
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`)
const locationOpts = [
...remotes.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`),
`<option value="${LOCAL_FS_VALUE}">${t("jobs.local_option")}</option>`,
]
.join("");
app.innerHTML = `
@@ -66,26 +80,89 @@ export async function renderNewJob() {
<option value="move">${t("jobs.action_move")}</option>
</select>
</div>
<div class="field">
<label>${t("jobs.schedule_type")}</label>
<div class="segmented" role="radiogroup" aria-label="${t("jobs.schedule_type")}">
<label>
<input type="radio" name="scheduleType" value="once" checked>
<span>${t("jobs.schedule_once")}</span>
</label>
<label>
<input type="radio" name="scheduleType" value="recurring">
<span>${t("jobs.schedule_recurring")}</span>
</label>
</div>
<span class="field-help">${t("jobs.schedule_help")}</span>
</div>
<div id="recurrence-fields" class="recurrence-fields" hidden>
<div class="form-grid">
<div class="field field-full">
<label>${t("jobs.recurrence_kind")}</label>
<div class="segmented segmented-wide" role="radiogroup" aria-label="${t("jobs.recurrence_kind")}">
<label>
<input type="radio" name="recurrenceKind" value="daily" checked>
<span>${t("jobs.recurrence_daily")}</span>
</label>
<label>
<input type="radio" name="recurrenceKind" value="weekly">
<span>${t("jobs.recurrence_weekly")}</span>
</label>
<label>
<input type="radio" name="recurrenceKind" value="monthly">
<span>${t("jobs.recurrence_monthly")}</span>
</label>
</div>
</div>
<div class="field">
<label>${t("jobs.schedule_time")}</label>
<input class="input" type="time" name="scheduleTime" value="23:00">
</div>
<div class="field" data-weekly-field hidden>
<label>${t("jobs.schedule_weekday")}</label>
<select name="scheduleWeekday" class="select">
${renderWeekdayOptions()}
</select>
</div>
<div class="field" data-monthly-field hidden>
<label>${t("jobs.schedule_monthday")}</label>
<select name="scheduleMonthday" class="select">
${renderMonthdayOptions()}
</select>
</div>
<div class="field field-full">
<span class="field-help">${t("jobs.fixed_schedule_help")}</span>
</div>
</div>
</div>
<div class="form-grid">
<div class="field">
<label>${t("jobs.source_remote")}</label>
<select name="srcRemote" class="select">
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
${locationOpts}
</select>
</div>
<div class="field">
<label>${t("jobs.source_path")}</label>
<div class="input-action">
<input class="input" name="srcPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
<button type="button" class="btn btn-secondary btn-sm" data-pick="src">${t("jobs.pick_folder")}</button>
</div>
</div>
<div class="field">
<label>${t("jobs.dest_remote")}</label>
<select name="dstRemote" class="select">
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
${locationOpts}
</select>
</div>
<div class="field">
<label>${t("jobs.dest_path")}</label>
<div class="input-action">
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
<button type="button" class="btn btn-secondary btn-sm" data-pick="dst">${t("jobs.pick_folder")}</button>
</div>
</div>
<div class="field field-full">
<span class="field-help">${t("jobs.local_path_help")}</span>
</div>
</div>
<div class="toolbar">
@@ -100,28 +177,83 @@ export async function renderNewJob() {
}
const form = document.getElementById("new-job-form");
const recurrenceFields = document.getElementById("recurrence-fields");
const weeklyField = form.querySelector("[data-weekly-field]");
const monthlyField = form.querySelector("[data-monthly-field]");
const recurrenceKindInputs = Array.from(form.querySelectorAll('input[name="recurrenceKind"]'));
const updateScheduleFields = () => {
const isRecurring = form.elements.scheduleType.value === "recurring";
const recurrenceKind = getRecurrenceKind(form);
recurrenceFields.hidden = !isRecurring;
weeklyField.hidden = !isRecurring || recurrenceKind !== "weekly";
monthlyField.hidden = !isRecurring || recurrenceKind !== "monthly";
recurrenceKindInputs.forEach((input) => {
input.required = isRecurring;
});
form.elements.scheduleTime.required = isRecurring;
form.elements.scheduleWeekday.required = isRecurring && recurrenceKind === "weekly";
form.elements.scheduleMonthday.required = isRecurring && recurrenceKind === "monthly";
};
form.querySelectorAll('input[name="scheduleType"]').forEach((input) => {
input.addEventListener("change", updateScheduleFields);
});
recurrenceKindInputs.forEach((input) => {
input.addEventListener("change", updateScheduleFields);
});
updateScheduleFields();
form.querySelector('[data-pick="src"]').addEventListener("click", () => {
openFolderPicker({
location: form.elements.srcRemote.value,
pathInput: form.elements.srcPath,
title: t("jobs.pick_source_title"),
});
});
form.querySelector('[data-pick="dst"]').addEventListener("click", () => {
openFolderPicker({
location: form.elements.dstRemote.value,
pathInput: form.elements.dstPath,
title: t("jobs.pick_dest_title"),
});
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const action = form.elements.action.value;
const srcRemote = form.elements.srcRemote.value;
const dstRemote = form.elements.dstRemote.value;
const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, "");
const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, "");
if (!srcRemote || !dstRemote) {
toast(t("error.no_remotes_selected"), "error");
const srcPath = form.elements.srcPath.value.trim();
const dstPath = form.elements.dstPath.value.trim();
const scheduleType = form.elements.scheduleType.value;
try {
const src = buildJobFs(srcRemote, srcPath);
const dst = buildJobFs(dstRemote, dstPath);
if (!src || !dst) {
toast(t("error.no_paths_selected"), "error");
return;
}
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote;
try {
const body = { srcFs: src, dstFs: dst };
if (action === "move") body.deleteEmptySrcDirs = true;
if ((srcRemote === LOCAL_FS_VALUE && !srcPath) || (dstRemote === LOCAL_FS_VALUE && !dstPath)) {
toast(t("error.no_paths_selected"), "error");
return;
}
if (scheduleType === "recurring") {
const schedule = readRecurringSchedule(form);
const job = await createRecurringJob({
action,
src,
dst,
...schedule,
});
toast(t("jobs.recurring_saved", job && job.id), "success");
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) {
rememberJob(jobid, { action, src, dst });
}
toast(t("jobs.started", action, jobid), "success");
toast(t("jobs.started_once", action, jobid), "success");
location.hash = "#/jobs";
} catch (e) {
toast(`${t("jobs.start_failed")}: ${e.message}`, "error");
@@ -129,51 +261,244 @@ export async function renderNewJob() {
});
}
function renderWeekdayOptions() {
return [
[1, t("jobs.weekday_monday")],
[2, t("jobs.weekday_tuesday")],
[3, t("jobs.weekday_wednesday")],
[4, t("jobs.weekday_thursday")],
[5, t("jobs.weekday_friday")],
[6, t("jobs.weekday_saturday")],
[7, t("jobs.weekday_sunday")],
]
.map(([value, label]) => `<option value="${value}">${label}</option>`)
.join("");
}
function renderMonthdayOptions() {
return Array.from({ length: 31 }, (_, index) => {
const day = index + 1;
return `<option value="${day}">${t("jobs.monthday", day)}</option>`;
}).join("");
}
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();
}
const remotePath = rawPath.trim().replace(/^\/+|\/+$/g, "");
return remotePath ? `${location}${remotePath}` : location;
}
function readRecurringSchedule(form) {
const scheduleKind = getRecurrenceKind(form);
const scheduleTime = form.elements.scheduleTime.value;
if (!["daily", "weekly", "monthly"].includes(scheduleKind)) {
throw new Error(t("error.no_schedule_selected"));
}
if (!/^\d{2}:\d{2}$/.test(scheduleTime)) {
throw new Error(t("error.invalid_schedule_time"));
}
const schedule = {
scheduleKind,
scheduleTime,
timezoneOffsetMinutes: -new Date().getTimezoneOffset(),
};
if (scheduleKind === "weekly") {
schedule.scheduleWeekday = Number(form.elements.scheduleWeekday.value);
if (!Number.isInteger(schedule.scheduleWeekday) || schedule.scheduleWeekday < 1 || schedule.scheduleWeekday > 7) {
throw new Error(t("error.no_schedule_selected"));
}
}
if (scheduleKind === "monthly") {
schedule.scheduleMonthday = Number(form.elements.scheduleMonthday.value);
if (!Number.isInteger(schedule.scheduleMonthday) || schedule.scheduleMonthday < 1 || schedule.scheduleMonthday > 31) {
throw new Error(t("error.no_schedule_selected"));
}
}
return schedule;
}
function getRecurrenceKind(form) {
return form.elements.recurrenceKind.value || "daily";
}
async function openFolderPicker({ location, pathInput, title }) {
const root = document.getElementById("modal-root");
let current = initialPickerPath(location, pathInput.value);
const close = () => {
root.innerHTML = "";
};
const choose = () => {
pathInput.value = current;
close();
};
const render = async () => {
root.innerHTML = `
<div class="modal-overlay">
<div class="modal folder-picker">
<h3>${escapeHtml(title)}</h3>
<div class="folder-picker-path">
<span>${escapeHtml(displayPickerPath(location, current))}</span>
</div>
<div class="folder-picker-list">
<p class="empty">${t("loading.title")}…</p>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" data-cancel>${t("browser.modal.cancel")}</button>
<button type="button" class="btn btn-primary" data-select>${t("jobs.pick_current")}</button>
</div>
</div>
</div>
`;
const overlay = root.querySelector(".modal-overlay");
const list = root.querySelector(".folder-picker-list");
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
root.querySelector("[data-cancel]").addEventListener("click", close);
root.querySelector("[data-select]").addEventListener("click", choose);
try {
const dirs = await listDirs(location, current);
const parent = parentPickerPath(location, current);
list.innerHTML = `
<button type="button" class="folder-picker-row" data-path="${escapeAttr(parent ?? "")}" ${parent == null ? "disabled" : ""}>${t("browser.up")}</button>
${dirs.length === 0 ? `<p class="empty">${t("jobs.pick_empty")}</p>` : dirs.map((dir) => `
<button type="button" class="folder-picker-row" data-path="${escapeAttr(joinPickerPath(current, dir.Name))}">
${escapeHtml(dir.Name)}
</button>
`).join("")}
`;
list.querySelectorAll("[data-path]").forEach((btn) => {
btn.addEventListener("click", () => {
if (btn.disabled) return;
current = btn.dataset.path;
render();
});
});
} catch (e) {
list.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
}
};
await render();
}
async function listDirs(location, current) {
const isLocal = location === LOCAL_FS_VALUE;
const body = isLocal
? { fs: current || LOCAL_PICKER_ROOT, remote: "" }
: { fs: location, remote: trimRemotePath(current) };
const res = await post("operations/list", {
...body,
opt: { dirsOnly: true, noModTime: true, noMimeType: true, showHash: false },
});
const items = (res && res.list) || [];
return items
.filter((item) => item.IsDir)
.sort((a, b) => a.Name.localeCompare(b.Name));
}
function initialPickerPath(location, rawPath) {
const value = rawPath.trim();
if (location === LOCAL_FS_VALUE) {
return value || LOCAL_PICKER_ROOT;
}
return trimRemotePath(value);
}
function displayPickerPath(location, current) {
if (location === LOCAL_FS_VALUE) {
return current || "/";
}
return current ? `${location}${current}` : location;
}
function parentPickerPath(location, current) {
if (location === LOCAL_FS_VALUE) {
if (!current || current === "/") return null;
const normalized = current.replace(/\/+$/g, "");
const parent = normalized.slice(0, normalized.lastIndexOf("/")) || "/";
return parent;
}
if (!current) return null;
const parts = trimRemotePath(current).split("/").filter(Boolean);
parts.pop();
return parts.join("/");
}
function joinPickerPath(current, name) {
if (!current || current === "/") return current === "/" ? `/${name}` : name;
return `${current.replace(/\/+$/g, "")}/${name}`;
}
function trimRemotePath(path) {
return path.trim().replace(/^\/+|\/+$/g, "");
}
async function refreshJobs() {
const card = document.getElementById("jobs-card");
if (!card) return;
if (!card) return false;
let jobIds = [];
let jobs = [];
try {
const list = await post("job/list");
const running = (list && list.jobids) || [];
const finished = (list && list.finishedIds) || [];
jobIds = [...new Set([...running, ...finished])];
jobs = await listRecurringJobs();
} catch (e) {
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_load_jobs")}</h3><p>${escapeHtml(e.message)}</p></div>`;
return;
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;
}
if (jobIds.length === 0) {
if (jobs.length === 0) {
card.innerHTML = `
<div class="empty">
<h3>${t("jobs.empty_title")}</h3>
<p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p>
</div>
`;
return;
return false;
}
const statuses = await Promise.all(
jobIds.map((id) =>
post("job/status", { jobid: id }).catch((e) => ({
jobid: id,
error: e.message,
finished: true,
})),
),
);
card.innerHTML = renderJobTable(statuses);
card.innerHTML = renderJobTable(jobs);
card.querySelectorAll("[data-detail]").forEach((btn) => {
btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10)));
});
card.querySelectorAll("[data-start]").forEach((btn) => {
btn.addEventListener("click", () => onStartAgain(parseInt(btn.dataset.start, 10)));
});
card.querySelectorAll("[data-stop]").forEach((btn) => {
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
});
card.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", () => onDelete(parseInt(btn.dataset.delete, 10)));
});
return jobs.length > 0;
}
function renderJobTable(statuses) {
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
statuses.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
const rows = statuses.map(renderJobRow).join("");
const rows = statuses.map(renderJobRows).join("");
return `
<table class="table">
@@ -182,6 +507,7 @@ function renderJobTable(statuses) {
<th>${t("jobs.col.id")}</th>
<th>${t("jobs.col.job")}</th>
<th>${t("jobs.col.status")}</th>
<th>${t("jobs.col.next_run")}</th>
<th class="col-num">${t("jobs.col.progress")}</th>
<th class="col-num">${t("jobs.col.speed")}</th>
<th class="col-num">${t("jobs.col.eta")}</th>
@@ -197,36 +523,38 @@ function renderJobTable(statuses) {
`;
}
function renderJobRow(s) {
const p = s.progress || {};
const id = s.jobid;
const finished = s.finished;
const success = s.success;
const errored = !!s.error;
let badge;
if (!finished) {
badge = `<span class="badge">${t("jobs.status.running")}</span>`;
} else if (errored || (!success && errored)) {
badge = `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
} else if (success) {
badge = `<span class="badge badge-success">${t("jobs.status.done")}</span>`;
} else {
badge = `<span class="badge badge-warning">${t("jobs.status.finished")}</span>`;
function renderJobRows(s) {
const row = renderJobRow(s);
if (!expandedJobs.has(Number(s.id))) {
return row;
}
return row + renderJobDetailsRow(s);
}
const meta = getJobMeta(id);
const jobCell = meta
? `
function renderJobRow(job) {
const snapshot = job.statusSnapshot || {};
const p = snapshot.progress || {};
const id = job.id;
const running = !!job.running;
const jobid = job.currentJobid || job.lastJobid;
const badge = renderStatusBadge(job.status);
const jobCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(meta.action)}</span>
<span class="job-action">${escapeHtml(job.action)}</span>
<span class="job-paths">
<code>${escapeHtml(meta.src)}</code>
<code>${escapeHtml(job.src)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(meta.dst)}</code>
<code>${escapeHtml(job.dst)}</code>
</span>
</div>`
: `<span class="col-mono" style="color:var(--color-muted-soft)">${t("jobs.submitted_cli")}</span>`;
</div>`;
const scheduleCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(formatDateTime(job.nextRunAt))}</span>
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
</div>
`;
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
const progress = `
@@ -238,29 +566,114 @@ function renderJobRow(s) {
</div>
`;
const stopBtn = !finished
const detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${id}">${expandedJobs.has(Number(id)) ? t("jobs.hide_details") : t("jobs.details")}</button>`;
const startBtn = running
? ""
: `<button class="btn btn-secondary btn-sm" data-start="${id}">${t("jobs.start_again")}</button>`;
const stopBtn = running
? `<button class="btn btn-danger btn-sm" data-stop="${id}">${t("jobs.stop")}</button>`
: "";
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${id}">${t("jobs.delete_record")}</button>`;
return `
<tr>
<td class="col-mono">${id}</td>
<td class="col-mono">
${id}
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""}
</td>
<td>${jobCell}</td>
<td>${badge}</td>
<td>${scheduleCell}</td>
<td class="col-num">${progress}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))}</td>
<td class="col-num col-mono">${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"}</td>
<td class="col-num col-mono">${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"}</td>
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td>
<td class="col-num">${stopBtn}</td>
<td class="col-num">
<div class="job-actions">
${detailBtn}
${startBtn}
${stopBtn}
${deleteBtn}
</div>
</td>
</tr>
`;
}
function renderStatusBadge(status) {
switch (status) {
case "running":
return `<span class="badge">${t("jobs.status.running")}</span>`;
case "failed":
return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
case "done":
return `<span class="badge badge-success">${t("jobs.status.done")}</span>`;
case "stopped":
return `<span class="badge badge-warning">${t("jobs.status.stopped")}</span>`;
case "scheduled":
return `<span class="badge badge-warning">${t("jobs.status.scheduled")}</span>`;
default:
return `<span class="badge badge-warning">${t("jobs.status.finished")}</span>`;
}
}
function renderJobDetailsRow(job) {
const id = job.id;
const snapshot = job.statusSnapshot || {};
const jobid = job.currentJobid || job.lastJobid || "";
const details = [
[t("jobs.detail_schedule_id"), id],
[t("jobs.detail_id"), jobid],
[t("jobs.detail_status"), detailStatus(job)],
[t("jobs.detail_error"), job.error || snapshot.error || t("jobs.detail_none")],
[t("jobs.detail_started"), formatDateTime(job.startedAt || snapshot.startTime)],
[t("jobs.detail_finished"), job.running ? t("jobs.status.running") : formatDateTime(job.finishedAt || snapshot.endTime)],
[t("jobs.detail_duration"), formatDuration(snapshot.duration || 0)],
[t("jobs.detail_next_run"), formatDateTime(job.nextRunAt)],
[t("jobs.detail_schedule"), formatSchedule(job)],
[t("jobs.detail_group"), snapshot.group || ""],
[t("jobs.detail_execute_id"), snapshot.executeId || ""],
];
const output = snapshot && Object.keys(snapshot).length > 0
? JSON.stringify(snapshot, null, 2)
: "";
return `
<tr class="job-detail-row">
<td></td>
<td colspan="9">
<div class="job-detail">
<dl>
${details.map(([label, value]) => `
<div>
<dt>${escapeHtml(label)}</dt>
<dd>${escapeHtml(value)}</dd>
</div>
`).join("")}
</dl>
${output ? `
<div class="job-detail-output">
<span>${t("jobs.detail_output")}</span>
<pre>${escapeHtml(output)}</pre>
</div>
` : ""}
</div>
</td>
</tr>
`;
}
function detailStatus(job) {
const status = job.status || "scheduled";
const key = `jobs.status.${status}`;
return t(key) === key ? status : t(key);
}
async function onStop(jobid) {
if (!confirm(t("jobs.stop_confirm", jobid))) return;
try {
await post("job/stop", { jobid });
await stopRecurringJob(jobid);
toast(t("jobs.stopped", jobid), "success");
await refreshJobs();
} catch (e) {
@@ -268,6 +681,36 @@ async function onStop(jobid) {
}
}
async function onToggleDetails(jobid) {
if (expandedJobs.has(jobid)) {
expandedJobs.delete(jobid);
} else {
expandedJobs.add(jobid);
}
await refreshJobs();
}
async function onStartAgain(jobid) {
try {
await runRecurringJobNow(jobid);
toast(t("jobs.restarted", jobid), "success");
await refreshJobs();
} catch (e) {
toast(`${t("jobs.restart_failed")}: ${e.message}`, "error");
}
}
async function onDelete(jobid) {
if (!confirm(t("jobs.delete_confirm", jobid))) return;
try {
await deleteRecurringJob(jobid);
expandedJobs.delete(jobid);
await refreshJobs();
} catch (e) {
toast(`${t("jobs.delete_failed")}: ${e.message}`, "error");
}
}
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
@@ -275,8 +718,11 @@ function startPolling() {
stopPolling();
return;
}
await refreshJobs();
}, 1500);
const hasRunning = await refreshJobs();
if (!hasRunning) {
stopPolling();
}
}, 5000);
}
function stopPolling() {
@@ -298,3 +744,41 @@ function escapeHtml(s) {
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttr(s) {
return escapeHtml(s);
}
function formatDateTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
function formatSchedule(job) {
const time = job.scheduleTime || "";
switch (job.scheduleKind) {
case "daily":
return t("jobs.schedule_label_daily", time);
case "weekly":
return t("jobs.schedule_label_weekly", weekdayName(job.scheduleWeekday), time);
case "monthly":
return t("jobs.schedule_label_monthly", job.scheduleMonthday, time);
default:
return "";
}
}
function weekdayName(value) {
const names = {
1: t("jobs.weekday_monday"),
2: t("jobs.weekday_tuesday"),
3: t("jobs.weekday_wednesday"),
4: t("jobs.weekday_thursday"),
5: t("jobs.weekday_friday"),
6: t("jobs.weekday_saturday"),
7: t("jobs.weekday_sunday"),
};
return names[Number(value)] || "";
}
+2 -2
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
import { t } from "../i18n.js";
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5";
import { t } from "../i18n.js?v=recurring-jobs-5";
export async function renderRemotes() {
const app = document.getElementById("app");
+182
View File
@@ -1,5 +1,9 @@
/* Component library — all UI primitives map to DESIGN.md tokens. */
[hidden] {
display: none !important;
}
/* ---------- Top Navigation ---------- */
.top-nav {
display: flex;
@@ -303,6 +307,78 @@
color: var(--color-muted);
}
.input-action {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.input-action .input {
flex: 1;
min-width: 0;
}
.input-action .btn {
flex-shrink: 0;
}
.input-action .select {
flex: 0 0 150px;
width: auto;
}
.segmented {
display: inline-flex;
width: fit-content;
padding: 3px;
border: 1px solid var(--color-hairline);
border-radius: var(--radius-md);
background-color: var(--color-surface-soft);
}
.segmented label {
position: relative;
color: var(--color-muted);
}
.segmented input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.segmented span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92px;
height: 32px;
padding: 0 var(--space-sm);
border-radius: var(--radius-sm);
cursor: pointer;
font: var(--typo-button);
}
.segmented input:checked + span {
background-color: var(--color-canvas);
color: var(--color-ink);
box-shadow: 0 1px 2px rgba(20, 20, 19, 0.06);
}
.segmented-wide {
max-width: 100%;
flex-wrap: wrap;
}
.recurrence-fields {
display: grid;
gap: var(--space-md);
}
.recurrence-fields[hidden] {
display: none;
}
/* ---------- Badges ---------- */
.badge {
display: inline-flex;
@@ -580,6 +656,49 @@
border-color: transparent;
}
.folder-picker {
max-width: 640px;
}
.folder-picker-path {
padding: var(--space-sm) var(--space-md);
background-color: var(--color-surface-dark-elevated);
border-radius: var(--radius-md);
color: var(--color-on-dark-soft);
font: var(--typo-code);
word-break: break-all;
}
.folder-picker-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
max-height: 360px;
overflow: auto;
}
.folder-picker-row {
width: 100%;
padding: var(--space-sm) var(--space-md);
background-color: var(--color-surface-dark-elevated);
color: var(--color-on-dark);
border: 1px solid transparent;
border-radius: var(--radius-md);
text-align: left;
font: var(--typo-body-sm);
cursor: pointer;
}
.folder-picker-row:hover {
border-color: var(--color-primary);
}
.folder-picker-row:disabled {
color: var(--color-muted-soft);
cursor: not-allowed;
opacity: 0.6;
}
/* ---------- Footer ---------- */
.footer {
background-color: var(--color-surface-dark);
@@ -771,6 +890,69 @@
margin: 0 var(--space-xxs);
}
.job-actions {
display: inline-flex;
justify-content: flex-end;
gap: var(--space-xs);
flex-wrap: wrap;
}
.job-detail-row {
background-color: var(--color-surface-soft);
}
.job-detail {
display: grid;
gap: var(--space-sm);
padding: var(--space-sm) 0;
}
.job-detail dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-xs) var(--space-lg);
margin: 0;
}
.job-detail dl div {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: var(--space-sm);
}
.job-detail dt {
color: var(--color-muted);
font: var(--typo-caption);
}
.job-detail dd {
margin: 0;
color: var(--color-body);
font: var(--typo-code);
word-break: break-word;
}
.job-detail-output span {
display: block;
margin-bottom: var(--space-xs);
color: var(--color-muted);
font: var(--typo-caption);
}
.job-detail-output pre {
max-height: 220px;
margin: 0;
padding: var(--space-sm);
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background-color: var(--color-canvas);
border: 1px solid var(--color-hairline-soft);
border-radius: var(--radius-sm);
color: var(--color-body);
font: var(--typo-code);
}
/* ---------- Responsive ---------- */
@media (max-width: 1024px) {
.connector-grid {
+2 -2
View File
@@ -7,7 +7,7 @@
<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/base.css">
<link rel="stylesheet" href="/assets/styles/components.css">
<link rel="stylesheet" href="/assets/styles/components.css?v=recurring-jobs-5">
</head>
<body>
<header class="top-nav">
@@ -80,6 +80,6 @@
<div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div>
<script type="module" src="/assets/js/app.js"></script>
<script type="module" src="/assets/js/app.js?v=recurring-jobs-5"></script>
</body>
</html>