Compare commits
6
Commits
73dfe12c95
...
aff11b77b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aff11b77b6 | ||
|
|
1b15ef30b3 | ||
|
|
a759b1e835 | ||
|
|
104a69dcb4 | ||
|
|
228289cf2e | ||
|
|
14cd626b0d |
@@ -3,6 +3,7 @@ config/rclone/rclone.conf
|
|||||||
|
|
||||||
# Docker build artifacts / runtime
|
# Docker build artifacts / runtime
|
||||||
.docker/
|
.docker/
|
||||||
|
webgui/data/
|
||||||
|
|
||||||
# Editor / OS cruft
|
# Editor / OS cruft
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
+27
-4
@@ -1,12 +1,13 @@
|
|||||||
# Docker Compose stack for the rclone webgui.
|
# 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 the static frontend from webgui/web/ (--rc-files)
|
||||||
# - Serves RC API endpoints at POST /* (built-in)
|
# - Serves RC API endpoints at POST /* (built-in)
|
||||||
# - Serves remote files at GET /<remote>:<path> (--rc-serve)
|
# - Serves remote files at GET /<remote>:<path> (--rc-serve)
|
||||||
#
|
#
|
||||||
# Same-origin from the browser, so no CORS / no nginx / no double ports.
|
# The jobs-api sidecar stores recurring task definitions in SQLite and starts
|
||||||
# For TLS or custom headers, put a reverse proxy of your choice in front.
|
# 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).
|
# rclone config lives in ./config/rclone/rclone.conf (bind-mounted).
|
||||||
# If it doesn't exist yet, create your remotes with:
|
# If it doesn't exist yet, create your remotes with:
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# docker compose up -d # start
|
# docker compose up -d # start
|
||||||
# open http://localhost:5580
|
# 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
|
# docker compose down # stop
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -42,6 +44,8 @@ services:
|
|||||||
- rclone-cache:/cache
|
- rclone-cache:/cache
|
||||||
# Scratch space for any local-FS remotes that point here.
|
# Scratch space for any local-FS remotes that point here.
|
||||||
- rclone-data:/data
|
- rclone-data:/data
|
||||||
|
# Expose the host /root tree to local filesystem transfer jobs.
|
||||||
|
- /root:/root
|
||||||
environment:
|
environment:
|
||||||
- XDG_CONFIG_HOME=/config
|
- XDG_CONFIG_HOME=/config
|
||||||
- RCLONE_CACHE_DIR=/cache
|
- RCLONE_CACHE_DIR=/cache
|
||||||
@@ -49,6 +53,25 @@ services:
|
|||||||
- "5580:8080"
|
- "5580:8080"
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
rclone-cache:
|
rclone-cache:
|
||||||
rclone-data:
|
rclone-data:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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";
|
import { onRoute } from "./state.js?v=recurring-jobs-5";
|
||||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js";
|
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=recurring-jobs-5";
|
||||||
import { renderRemotes } from "./views/remotes.js";
|
import { renderRemotes } from "./views/remotes.js?v=recurring-jobs-5";
|
||||||
import { renderBrowse } from "./views/browser.js";
|
import { renderBrowse } from "./views/browser.js?v=recurring-jobs-5";
|
||||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js";
|
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=recurring-jobs-5";
|
||||||
import {
|
import {
|
||||||
renderConfigureNew,
|
renderConfigureNew,
|
||||||
renderConfigureEdit,
|
renderConfigureEdit,
|
||||||
} from "./views/configure.js";
|
} from "./views/configure.js?v=recurring-jobs-5";
|
||||||
|
|
||||||
const views = {
|
const views = {
|
||||||
remotes: renderRemotes,
|
remotes: renderRemotes,
|
||||||
|
|||||||
+133
-15
@@ -47,6 +47,9 @@ const STRINGS = {
|
|||||||
"error.couldnt_list": "Couldn't list",
|
"error.couldnt_list": "Couldn't list",
|
||||||
"error.couldnt_load_jobs": "Couldn't load jobs",
|
"error.couldnt_load_jobs": "Couldn't load jobs",
|
||||||
"error.no_remotes_selected": "Pick source and destination remotes",
|
"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",
|
"error.not_connected": "Couldn't connect to rclone",
|
||||||
|
|
||||||
// footer
|
// footer
|
||||||
@@ -152,32 +155,86 @@ const STRINGS = {
|
|||||||
|
|
||||||
// jobs view
|
// jobs view
|
||||||
"jobs.title": "Jobs",
|
"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.new_btn": "New Job",
|
||||||
"jobs.cancel": "Cancel",
|
"jobs.cancel": "Cancel",
|
||||||
"jobs.new_title": "New Job",
|
"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": "Action",
|
||||||
"jobs.action_copy": "copy (mirror src → dst, keep both)",
|
"jobs.action_copy": "copy (mirror src → dst, keep both)",
|
||||||
"jobs.action_sync": "sync (mirror src → dst, delete extras on dst)",
|
"jobs.action_sync": "sync (mirror src → dst, delete extras on dst)",
|
||||||
"jobs.action_move": "move (mirror src → dst, delete src after)",
|
"jobs.action_move": "move (mirror src → dst, delete src after)",
|
||||||
"jobs.source_remote": "Source remote",
|
"jobs.schedule_type": "Job type",
|
||||||
"jobs.source_path": "Source path (optional)",
|
"jobs.schedule_once": "One-time",
|
||||||
"jobs.dest_remote": "Destination remote",
|
"jobs.schedule_recurring": "Fixed recurring",
|
||||||
"jobs.dest_path": "Destination path (optional)",
|
"jobs.schedule_help": "One-time jobs are submitted to rclone and not saved. Fixed recurring jobs are stored in SQLite.",
|
||||||
"jobs.path_placeholder": "folder/subfolder",
|
"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.start": "Start Job",
|
||||||
"jobs.no_remotes_option": "(no remotes)",
|
"jobs.no_remotes_option": "(no remotes)",
|
||||||
"jobs.empty_title": "No jobs yet",
|
"jobs.empty_title": "No 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}. It was not saved.`,
|
||||||
|
"jobs.recurring_saved": (id) => `Saved recurring job #${id}`,
|
||||||
"jobs.start_failed": "Job start failed",
|
"jobs.start_failed": "Job start failed",
|
||||||
"jobs.stop_confirm": (id) => `Stop job #${id}?`,
|
"jobs.stop_confirm": (id) => `Stop job #${id}?`,
|
||||||
"jobs.stopped": (id) => `Stopped job #${id}`,
|
"jobs.stopped": (id) => `Stopped job #${id}`,
|
||||||
"jobs.stop_failed": "Stop failed",
|
"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.id": "#",
|
||||||
"jobs.col.job": "Job",
|
"jobs.col.job": "Job",
|
||||||
"jobs.col.status": "Status",
|
"jobs.col.status": "Status",
|
||||||
|
"jobs.col.next_run": "Next run",
|
||||||
"jobs.col.progress": "Progress",
|
"jobs.col.progress": "Progress",
|
||||||
"jobs.col.speed": "Speed",
|
"jobs.col.speed": "Speed",
|
||||||
"jobs.col.eta": "ETA",
|
"jobs.col.eta": "ETA",
|
||||||
@@ -188,6 +245,8 @@ const STRINGS = {
|
|||||||
"jobs.status.failed": "failed",
|
"jobs.status.failed": "failed",
|
||||||
"jobs.status.done": "done",
|
"jobs.status.done": "done",
|
||||||
"jobs.status.finished": "finished",
|
"jobs.status.finished": "finished",
|
||||||
|
"jobs.status.scheduled": "scheduled",
|
||||||
|
"jobs.status.stopped": "stopped",
|
||||||
"jobs.stop": "Stop",
|
"jobs.stop": "Stop",
|
||||||
"jobs.submitted_cli": "— submitted via CLI —",
|
"jobs.submitted_cli": "— submitted via CLI —",
|
||||||
},
|
},
|
||||||
@@ -209,7 +268,10 @@ const STRINGS = {
|
|||||||
"error.couldnt_load_remotes": "无法加载远程存储列表",
|
"error.couldnt_load_remotes": "无法加载远程存储列表",
|
||||||
"error.couldnt_list": "无法列出",
|
"error.couldnt_list": "无法列出",
|
||||||
"error.couldnt_load_jobs": "无法加载任务列表",
|
"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",
|
"error.not_connected": "无法连接到 rclone",
|
||||||
|
|
||||||
"footer.product": "产品",
|
"footer.product": "产品",
|
||||||
@@ -307,32 +369,86 @@ 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": "新建任务",
|
||||||
"jobs.new_subtitle": "在远程存储之间复制、同步或移动。",
|
"jobs.new_subtitle": "在远程存储和本地路径之间复制、同步或移动。",
|
||||||
"jobs.action": "操作",
|
"jobs.action": "操作",
|
||||||
"jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)",
|
"jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)",
|
||||||
"jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)",
|
"jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)",
|
||||||
"jobs.action_move": "move(镜像 源 → 目标,完成后删除源)",
|
"jobs.action_move": "move(镜像 源 → 目标,完成后删除源)",
|
||||||
"jobs.source_remote": "源远程存储",
|
"jobs.schedule_type": "任务类型",
|
||||||
"jobs.source_path": "源路径(可选)",
|
"jobs.schedule_once": "单次任务",
|
||||||
"jobs.dest_remote": "目标远程存储",
|
"jobs.schedule_recurring": "固定循环",
|
||||||
"jobs.dest_path": "目标路径(可选)",
|
"jobs.schedule_help": "单次任务只提交给 rclone,不保存记录。固定循环任务会保存到 SQLite。",
|
||||||
"jobs.path_placeholder": "文件夹/子文件夹",
|
"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.start": "开始任务",
|
||||||
"jobs.no_remotes_option": "(无远程存储)",
|
"jobs.no_remotes_option": "(无远程存储)",
|
||||||
"jobs.empty_title": "尚无任务",
|
"jobs.empty_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.recurring_saved": (id) => `已保存循环任务 #${id}`,
|
||||||
"jobs.start_failed": "任务启动失败",
|
"jobs.start_failed": "任务启动失败",
|
||||||
"jobs.stop_confirm": (id) => `停止任务 #${id}?`,
|
"jobs.stop_confirm": (id) => `停止任务 #${id}?`,
|
||||||
"jobs.stopped": (id) => `已停止任务 #${id}`,
|
"jobs.stopped": (id) => `已停止任务 #${id}`,
|
||||||
"jobs.stop_failed": "停止失败",
|
"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.id": "#",
|
||||||
"jobs.col.job": "任务",
|
"jobs.col.job": "任务",
|
||||||
"jobs.col.status": "状态",
|
"jobs.col.status": "状态",
|
||||||
|
"jobs.col.next_run": "下次启动",
|
||||||
"jobs.col.progress": "进度",
|
"jobs.col.progress": "进度",
|
||||||
"jobs.col.speed": "速度",
|
"jobs.col.speed": "速度",
|
||||||
"jobs.col.eta": "预计剩余",
|
"jobs.col.eta": "预计剩余",
|
||||||
@@ -343,6 +459,8 @@ const STRINGS = {
|
|||||||
"jobs.status.failed": "失败",
|
"jobs.status.failed": "失败",
|
||||||
"jobs.status.done": "完成",
|
"jobs.status.done": "完成",
|
||||||
"jobs.status.finished": "已结束",
|
"jobs.status.finished": "已结束",
|
||||||
|
"jobs.status.scheduled": "已调度",
|
||||||
|
"jobs.status.stopped": "已停止",
|
||||||
"jobs.stop": "停止",
|
"jobs.stop": "停止",
|
||||||
"jobs.submitted_cli": "— 通过命令行提交 —",
|
"jobs.submitted_cli": "— 通过命令行提交 —",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,49 +1,12 @@
|
|||||||
// state.js — minimal pub/sub store + hash router + localStorage-backed
|
// state.js — minimal pub/sub store + hash router. Vanilla, no framework.
|
||||||
// job metadata cache. Vanilla, no framework.
|
|
||||||
|
|
||||||
const JOB_META_KEY = "webgui:jobMeta";
|
|
||||||
const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
||||||
|
|
||||||
const listeners = new Set();
|
const listeners = new Set();
|
||||||
const state = {
|
const state = {
|
||||||
remotes: [], // [{ name, type }]
|
remotes: [], // [{ name, type }]
|
||||||
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
|
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
|
||||||
providers: null, // cached /config/providers response
|
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() {
|
export function getState() {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -64,27 +27,6 @@ export function subscribe(fn) {
|
|||||||
return () => listeners.delete(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 ---
|
// --- Router ---
|
||||||
// Hash-based routes:
|
// Hash-based routes:
|
||||||
// #/remotes
|
// #/remotes
|
||||||
|
|||||||
@@ -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";
|
import { post, uploadFile, downloadURL } from "../rc.js";
|
||||||
import { toast, formatBytes, formatTime } from "../state.js";
|
import { toast, formatBytes, formatTime } from "../state.js?v=recurring-jobs-5";
|
||||||
import { t } from "../i18n.js";
|
import { t } from "../i18n.js?v=recurring-jobs-5";
|
||||||
|
|
||||||
export async function renderBrowse({ remote, path }) {
|
export async function renderBrowse({ remote, path }) {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
// 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";
|
import { post } from "../rc.js";
|
||||||
import { getState, setState, toast } from "../state.js";
|
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5";
|
||||||
import { t } from "../i18n.js";
|
import { t } from "../i18n.js?v=recurring-jobs-5";
|
||||||
|
|
||||||
// --- Route entrypoints ---
|
// --- Route entrypoints ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
// views/jobs.js — submit sync/copy/move jobs and poll their progress.
|
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
||||||
// 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.
|
|
||||||
|
|
||||||
import { post, postAsync } from "../rc.js";
|
import { post, postAsync } from "../rc.js";
|
||||||
import {
|
import {
|
||||||
toast,
|
toast,
|
||||||
rememberJob,
|
|
||||||
getJobMeta,
|
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatSpeed,
|
formatSpeed,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
} from "../state.js";
|
} from "../state.js?v=recurring-jobs-5";
|
||||||
import { t } from "../i18n.js";
|
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;
|
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() {
|
export async function renderJobs() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
@@ -30,8 +38,12 @@ export async function renderJobs() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await refreshJobs();
|
const hasRunning = await refreshJobs();
|
||||||
|
if (hasRunning) {
|
||||||
startPolling();
|
startPolling();
|
||||||
|
} else {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderNewJob() {
|
export async function renderNewJob() {
|
||||||
@@ -45,8 +57,10 @@ export async function renderNewJob() {
|
|||||||
toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
|
toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteOpts = remotes
|
const locationOpts = [
|
||||||
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`)
|
...remotes.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`),
|
||||||
|
`<option value="${LOCAL_FS_VALUE}">${t("jobs.local_option")}</option>`,
|
||||||
|
]
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
@@ -66,26 +80,89 @@ export async function renderNewJob() {
|
|||||||
<option value="move">${t("jobs.action_move")}</option>
|
<option value="move">${t("jobs.action_move")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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="form-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.source_remote")}</label>
|
<label>${t("jobs.source_remote")}</label>
|
||||||
<select name="srcRemote" class="select">
|
<select name="srcRemote" class="select">
|
||||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
${locationOpts}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.source_path")}</label>
|
<label>${t("jobs.source_path")}</label>
|
||||||
|
<div class="input-action">
|
||||||
<input class="input" name="srcPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.dest_remote")}</label>
|
<label>${t("jobs.dest_remote")}</label>
|
||||||
<select name="dstRemote" class="select">
|
<select name="dstRemote" class="select">
|
||||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
${locationOpts}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.dest_path")}</label>
|
<label>${t("jobs.dest_path")}</label>
|
||||||
|
<div class="input-action">
|
||||||
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
<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>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -100,28 +177,83 @@ export async function renderNewJob() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = document.getElementById("new-job-form");
|
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) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const action = form.elements.action.value;
|
const action = form.elements.action.value;
|
||||||
const srcRemote = form.elements.srcRemote.value;
|
const srcRemote = form.elements.srcRemote.value;
|
||||||
const dstRemote = form.elements.dstRemote.value;
|
const dstRemote = form.elements.dstRemote.value;
|
||||||
const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, "");
|
const srcPath = form.elements.srcPath.value.trim();
|
||||||
const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, "");
|
const dstPath = form.elements.dstPath.value.trim();
|
||||||
if (!srcRemote || !dstRemote) {
|
const scheduleType = form.elements.scheduleType.value;
|
||||||
toast(t("error.no_remotes_selected"), "error");
|
try {
|
||||||
|
const src = buildJobFs(srcRemote, srcPath);
|
||||||
|
const dst = buildJobFs(dstRemote, dstPath);
|
||||||
|
if (!src || !dst) {
|
||||||
|
toast(t("error.no_paths_selected"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
|
if ((srcRemote === LOCAL_FS_VALUE && !srcPath) || (dstRemote === LOCAL_FS_VALUE && !dstPath)) {
|
||||||
const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote;
|
toast(t("error.no_paths_selected"), "error");
|
||||||
try {
|
return;
|
||||||
const body = { srcFs: src, dstFs: dst };
|
}
|
||||||
if (action === "move") body.deleteEmptySrcDirs = true;
|
|
||||||
|
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 res = await postAsync(`sync/${action}`, body);
|
||||||
const jobid = res && res.jobid;
|
const jobid = res && res.jobid;
|
||||||
if (jobid != null) {
|
toast(t("jobs.started_once", action, jobid), "success");
|
||||||
rememberJob(jobid, { action, src, dst });
|
|
||||||
}
|
|
||||||
toast(t("jobs.started", action, jobid), "success");
|
|
||||||
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");
|
||||||
@@ -129,51 +261,244 @@ export async function renderNewJob() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshJobs() {
|
function renderWeekdayOptions() {
|
||||||
const card = document.getElementById("jobs-card");
|
return [
|
||||||
if (!card) return;
|
[1, t("jobs.weekday_monday")],
|
||||||
|
[2, t("jobs.weekday_tuesday")],
|
||||||
let jobIds = [];
|
[3, t("jobs.weekday_wednesday")],
|
||||||
try {
|
[4, t("jobs.weekday_thursday")],
|
||||||
const list = await post("job/list");
|
[5, t("jobs.weekday_friday")],
|
||||||
const running = (list && list.jobids) || [];
|
[6, t("jobs.weekday_saturday")],
|
||||||
const finished = (list && list.finishedIds) || [];
|
[7, t("jobs.weekday_sunday")],
|
||||||
jobIds = [...new Set([...running, ...finished])];
|
]
|
||||||
} catch (e) {
|
.map(([value, label]) => `<option value="${value}">${label}</option>`)
|
||||||
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_load_jobs")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
.join("");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jobIds.length === 0) {
|
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 false;
|
||||||
|
|
||||||
|
let jobs = [];
|
||||||
|
try {
|
||||||
|
jobs = await listRecurringJobs();
|
||||||
|
} catch (e) {
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="empty">
|
||||||
|
<h3>${t("error.couldnt_load_jobs")}</h3>
|
||||||
|
<p>${escapeHtml(e.message)}</p>
|
||||||
|
<p class="col-mono">${escapeHtml(jobsApiURL())}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h3>${t("jobs.empty_title")}</h3>
|
<h3>${t("jobs.empty_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>
|
||||||
`;
|
`;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statuses = await Promise.all(
|
card.innerHTML = renderJobTable(jobs);
|
||||||
jobIds.map((id) =>
|
card.querySelectorAll("[data-detail]").forEach((btn) => {
|
||||||
post("job/status", { jobid: id }).catch((e) => ({
|
btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10)));
|
||||||
jobid: id,
|
});
|
||||||
error: e.message,
|
card.querySelectorAll("[data-start]").forEach((btn) => {
|
||||||
finished: true,
|
btn.addEventListener("click", () => onStartAgain(parseInt(btn.dataset.start, 10)));
|
||||||
})),
|
});
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
card.innerHTML = renderJobTable(statuses);
|
|
||||||
card.querySelectorAll("[data-stop]").forEach((btn) => {
|
card.querySelectorAll("[data-stop]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
|
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) {
|
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 `
|
return `
|
||||||
<table class="table">
|
<table class="table">
|
||||||
@@ -182,6 +507,7 @@ function renderJobTable(statuses) {
|
|||||||
<th>${t("jobs.col.id")}</th>
|
<th>${t("jobs.col.id")}</th>
|
||||||
<th>${t("jobs.col.job")}</th>
|
<th>${t("jobs.col.job")}</th>
|
||||||
<th>${t("jobs.col.status")}</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.progress")}</th>
|
||||||
<th class="col-num">${t("jobs.col.speed")}</th>
|
<th class="col-num">${t("jobs.col.speed")}</th>
|
||||||
<th class="col-num">${t("jobs.col.eta")}</th>
|
<th class="col-num">${t("jobs.col.eta")}</th>
|
||||||
@@ -197,36 +523,38 @@ function renderJobTable(statuses) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobRow(s) {
|
function renderJobRows(s) {
|
||||||
const p = s.progress || {};
|
const row = renderJobRow(s);
|
||||||
const id = s.jobid;
|
if (!expandedJobs.has(Number(s.id))) {
|
||||||
const finished = s.finished;
|
return row;
|
||||||
const success = s.success;
|
}
|
||||||
const errored = !!s.error;
|
return row + renderJobDetailsRow(s);
|
||||||
|
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = getJobMeta(id);
|
function renderJobRow(job) {
|
||||||
const jobCell = meta
|
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">
|
<div class="job-cell">
|
||||||
<span class="job-action">${escapeHtml(meta.action)}</span>
|
<span class="job-action">${escapeHtml(job.action)}</span>
|
||||||
<span class="job-paths">
|
<span class="job-paths">
|
||||||
<code>${escapeHtml(meta.src)}</code>
|
<code>${escapeHtml(job.src)}</code>
|
||||||
<span class="arrow">→</span>
|
<span class="arrow">→</span>
|
||||||
<code>${escapeHtml(meta.dst)}</code>
|
<code>${escapeHtml(job.dst)}</code>
|
||||||
</span>
|
</span>
|
||||||
</div>`
|
</div>`;
|
||||||
: `<span class="col-mono" style="color:var(--color-muted-soft)">${t("jobs.submitted_cli")}</span>`;
|
|
||||||
|
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 pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
|
||||||
const progress = `
|
const progress = `
|
||||||
@@ -238,29 +566,114 @@ function renderJobRow(s) {
|
|||||||
</div>
|
</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>`
|
? `<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 `
|
return `
|
||||||
<tr>
|
<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>${jobCell}</td>
|
||||||
<td>${badge}</td>
|
<td>${badge}</td>
|
||||||
|
<td>${scheduleCell}</td>
|
||||||
<td class="col-num">${progress}</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">${running ? 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(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.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
|
||||||
<td class="col-num col-mono">${(p.errors && p.errors.length) || 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>
|
</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) {
|
async function onStop(jobid) {
|
||||||
if (!confirm(t("jobs.stop_confirm", jobid))) return;
|
if (!confirm(t("jobs.stop_confirm", jobid))) return;
|
||||||
try {
|
try {
|
||||||
await post("job/stop", { jobid });
|
await stopRecurringJob(jobid);
|
||||||
toast(t("jobs.stopped", jobid), "success");
|
toast(t("jobs.stopped", jobid), "success");
|
||||||
await refreshJobs();
|
await refreshJobs();
|
||||||
} catch (e) {
|
} 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() {
|
function startPolling() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
@@ -275,8 +718,11 @@ function startPolling() {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshJobs();
|
const hasRunning = await refreshJobs();
|
||||||
}, 1500);
|
if (!hasRunning) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
@@ -298,3 +744,41 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)] || "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
import { post } from "../rc.js";
|
||||||
import { getState, setState, toast } from "../state.js";
|
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5";
|
||||||
import { t } from "../i18n.js";
|
import { t } from "../i18n.js?v=recurring-jobs-5";
|
||||||
|
|
||||||
export async function renderRemotes() {
|
export async function renderRemotes() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/* Component library — all UI primitives map to DESIGN.md tokens. */
|
/* Component library — all UI primitives map to DESIGN.md tokens. */
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Top Navigation ---------- */
|
/* ---------- Top Navigation ---------- */
|
||||||
.top-nav {
|
.top-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -303,6 +307,78 @@
|
|||||||
color: var(--color-muted);
|
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 ---------- */
|
/* ---------- Badges ---------- */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -580,6 +656,49 @@
|
|||||||
border-color: transparent;
|
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 ---------- */
|
||||||
.footer {
|
.footer {
|
||||||
background-color: var(--color-surface-dark);
|
background-color: var(--color-surface-dark);
|
||||||
@@ -771,6 +890,69 @@
|
|||||||
margin: 0 var(--space-xxs);
|
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 ---------- */
|
/* ---------- Responsive ---------- */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.connector-grid {
|
.connector-grid {
|
||||||
|
|||||||
@@ -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">
|
<link rel="stylesheet" href="/assets/styles/components.css?v=recurring-jobs-5">
|
||||||
</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"></script>
|
<script type="module" src="/assets/js/app.js?v=recurring-jobs-5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user