Compare commits

..
9 Commits
Author SHA1 Message Date
wuxu 8a06236dab fix: 优化任务列表布局 2026-06-20 13:54:22 +08:00
wuxu a1b791e6ef fix: 收紧 GUI 安全边界 2026-06-20 13:51:06 +08:00
wuxu ff73470142 fix: 修复任务空状态和浏览器刷新 2026-06-20 13:14:47 +08:00
wuxu c2289f889e fix: 显示任务传输状态 2026-06-20 12:50:43 +08:00
wuxu 0e3dc9a96f fix: 支持配置局域网访问 2026-06-20 12:43:26 +08:00
wuxu ba9d0977e3 add: 持久化所有任务记录 2026-06-20 12:20:39 +08:00
wuxu cd051900b5 add: 显示单次任务记录 2026-06-20 12:10:32 +08:00
wuxu 161f266829 add: 添加每月最后一天调度 2026-06-20 11:46:50 +08:00
wuxu bce0c6188e fix: 收紧 WebGUI 默认安全配置 2026-06-20 11:34:07 +08:00
18 changed files with 1223 additions and 296 deletions
+3
View File
@@ -0,0 +1,3 @@
WEBGUI_BIND_ADDR=127.0.0.1
JOBS_API_BIND_ADDR=127.0.0.1
JOBS_API_ALLOWED_ORIGINS=http://localhost:5580,http://127.0.0.1:5580
+1
View File
@@ -2,6 +2,7 @@
config/rclone/rclone.conf config/rclone/rclone.conf
# Docker build artifacts / runtime # Docker build artifacts / runtime
.env
.docker/ .docker/
webgui/data/ webgui/data/
+63 -8
View File
@@ -11,13 +11,15 @@
``` ```
. .
├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里) ├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里)
│ ├── api/server.py # SQLite-backed recurring jobs API / scheduler
│ ├── data/ # jobs.sqlite 数据目录(不提交)
│ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要) │ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要)
│ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all │ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all
│ └── web/ # 静态前端(rclone rcd 直接服务) │ └── web/ # 静态前端(rclone rcd 直接服务)
│ ├── index.html │ ├── index.html
│ └── assets/ │ └── assets/
├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交) ├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交)
├── docker-compose.yml # 单容器 rclone rcd ├── docker-compose.yml # rclone rcd + jobs-api sidecar
├── DESIGN.md # UI 设计系统规范 ├── DESIGN.md # UI 设计系统规范
├── CLAUDE.md # Claude Code 协作指引 ├── CLAUDE.md # Claude Code 协作指引
└── rclone/ # submodule → github.com/rclone/rclone,纯净不改动 └── rclone/ # submodule → github.com/rclone/rclone,纯净不改动
@@ -29,28 +31,64 @@
`/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。 `/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。
- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete / - **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete /
rename / download。 rename / download。
- **同步任务** — copy / sync / move 异步任务,1.5 秒轮询进度(速度、 - **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、
ETA、已传输 / 总量、错误计数),任务元信息(src→dst)持久化到 ETA、已传输 / 总量、错误计数)。单次任务和固定循环任务都保存到
localStorage,刷新页面不丢。 SQLite;固定循环任务由 jobs-api sidecar 调度。
> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅, > OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅,
> 引导用户在终端跑 `rclone config` 完成授权。 > 引导用户在终端跑 `rclone config` 完成授权。
## 快速开始 ## 快速开始
> 安全提示:默认 Docker 编排使用 `--rc-no-auth`,RC API 可以读写和删除
> 已配置 remote 上的数据。Compose 文件默认只绑定到 `127.0.0.1`。不要把
> `5580` / `5581` 直接暴露到 LAN 或公网;需要远程访问时,请先加带认证
> 和 TLS 的反向代理。
```bash ```bash
# 1. 拉取子模块(rcd 流程用不到,自行构建 rclone 二进制时才需要) # 1. 拉取子模块(rcd 流程用不到,自行构建 rclone 二进制时才需要)
git clone --recurse-submodules <your-fork-url> git clone --recurse-submodules <your-fork-url>
# 2. 启动堆栈(单容器,无需 build) # 2. 启动堆栈(无需 build)
docker compose up -d docker compose up -d
# 3. 打开 http://localhost:5580 # 3. 打开 http://localhost:5580
``` ```
### 局域网访问
不建议把 `5580` / `5581` 直接暴露到不受信网络。`JOBS_API_ALLOWED_ORIGINS`
只限制浏览器 CORS;不带 `Origin` 的脚本或命令行客户端仍然可以直接调用
jobs-api。需要多人或跨网段访问时,优先用带认证和 TLS 的反向代理保护两个
端口。
如果只是在受信局域网临时访问,例如 `http://10.0.0.138:5580`,需要同时让
`rclone` 和 `jobs-api` 两个端口监听这个地址,并把页面 Origin 加进
jobs-api 白名单:
```bash
cp .env.example .env
cat > .env <<'EOF'
WEBGUI_BIND_ADDR=10.0.0.138
JOBS_API_BIND_ADDR=10.0.0.138
JOBS_API_ALLOWED_ORIGINS=http://localhost:5580,http://127.0.0.1:5580,http://10.0.0.138:5580
EOF
docker compose up -d
```
前端页面会从 `10.0.0.138:5580` 调用 `10.0.0.138:5581`,所以只改
`JOBS_API_ALLOWED_ORIGINS` 不够,`5581` 也必须发布到同一个可访问地址。
## 架构 ## 架构
只用一个 rclone rcd 容器,一个端口(5580),同时承担: Docker 编排包含两个服务:
| 服务 | 本机 URL | 职责 |
|---|---|---|
| `rclone` | `http://localhost:5580` | 静态前端、RC API、远端文件下载 |
| `jobs-api` | `http://localhost:5581` | SQLite 循环任务 API 和调度器 |
`rclone` 容器通过 `rclone rcd` 同时承担:
| 职责 | URL | 配置项 | | 职责 | URL | 配置项 |
|---|---|---| |---|---|---|
@@ -58,8 +96,20 @@ docker compose up -d
| RC API | `POST /config/*`、`/operations/*`、`/sync/*`、`/job/*` | 内置 | | RC API | `POST /config/*`、`/operations/*`、`/sync/*`、`/job/*` | 内置 |
| 远端文件下载 | `GET /<remote>:<path>` | `--rc-serve` | | 远端文件下载 | `GET /<remote>:<path>` | `--rc-serve` |
浏览器同源访问 → 不需要 CORS、不需要 nginx、不需要双端口。 浏览器从静态前端调用 `5580` 的 RC API,并调用 `5581` 的 jobs-api 管理
要加 TLS 或自定义 header 时,**在前面套你自己的反代**即可。 固定循环任务。jobs-api 默认只允许 `http://localhost:5580` 和
`http://127.0.0.1:5580` 这两个 Origin。反代或改端口时,同时调整
`JOBS_API_ALLOWED_ORIGINS`。
## 开发检查
```bash
find webgui/web/assets/js -name '*.js' -exec sh -c 'for f; do node --input-type=module --check < "$f" || exit 1; done' sh {} +
python3 -m py_compile webgui/api/server.py
python3 -m unittest discover -s webgui/api -p '*_test.py'
curl -sS http://127.0.0.1:5580/
curl -sS http://127.0.0.1:5581/health
```
## 自行构建 rclone(可选) ## 自行构建 rclone(可选)
@@ -67,6 +117,11 @@ Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即
如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui` 如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui`
能像 `rclone gui` 那样独立运行),可以: 能像 `rclone gui` 那样独立运行),可以:
注意:内置 `rclone webgui` 只启动静态 GUI 和 rclone RC,不会启动
`jobs-api` sidecar。当前 Jobs 页依赖 `jobs-api` 的 SQLite 接口,所以完整
的单次/循环任务体验请使用 Docker 编排,或自行启动兼容的 jobs-api 并通过
`?jobsApi=<url>` 指给前端。
```bash ```bash
# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下 # 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下
ln -s ../../webgui rclone/cmd/webgui ln -s ../../webgui rclone/cmd/webgui
+7 -3
View File
@@ -7,7 +7,10 @@
# #
# The jobs-api sidecar stores recurring task definitions in SQLite and starts # The jobs-api sidecar stores recurring task definitions in SQLite and starts
# scheduled runs through rclone RC. The browser calls it on port 5581. # 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. # Default port bindings are loopback-only because the RC API runs without auth.
# For LAN/public access, either put TLS/authentication in a reverse proxy first,
# or set WEBGUI_BIND_ADDR, JOBS_API_BIND_ADDR, and JOBS_API_ALLOWED_ORIGINS in
# a local .env file.
# #
# 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:
@@ -50,7 +53,7 @@ services:
- XDG_CONFIG_HOME=/config - XDG_CONFIG_HOME=/config
- RCLONE_CACHE_DIR=/cache - RCLONE_CACHE_DIR=/cache
ports: ports:
- "5580:8080" - "${WEBGUI_BIND_ADDR:-127.0.0.1}:5580:8080"
restart: unless-stopped restart: unless-stopped
jobs-api: jobs-api:
@@ -67,9 +70,10 @@ services:
- JOBS_API_HOST=0.0.0.0 - JOBS_API_HOST=0.0.0.0
- JOBS_API_PORT=8081 - JOBS_API_PORT=8081
- JOBS_SCHEDULER_INTERVAL=15 - JOBS_SCHEDULER_INTERVAL=15
- JOBS_API_ALLOWED_ORIGINS=${JOBS_API_ALLOWED_ORIGINS:-http://localhost:5580,http://127.0.0.1:5580}
- RCLONE_RC_URL=http://rclone:8080 - RCLONE_RC_URL=http://rclone:8080
ports: ports:
- "5581:8081" - "${JOBS_API_BIND_ADDR:-127.0.0.1}:5581:8081"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+357 -25
View File
@@ -16,6 +16,7 @@ import traceback
import urllib.error import urllib.error
import urllib.request import urllib.request
from calendar import monthrange from calendar import monthrange
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
@@ -27,8 +28,18 @@ RCLONE_RC_URL = os.environ.get("RCLONE_RC_URL", "http://rclone:8080").rstrip("/"
HOST = os.environ.get("JOBS_API_HOST", "0.0.0.0") HOST = os.environ.get("JOBS_API_HOST", "0.0.0.0")
PORT = int(os.environ.get("JOBS_API_PORT", "8081")) PORT = int(os.environ.get("JOBS_API_PORT", "8081"))
SCHEDULER_INTERVAL_SECONDS = int(os.environ.get("JOBS_SCHEDULER_INTERVAL", "15")) SCHEDULER_INTERVAL_SECONDS = int(os.environ.get("JOBS_SCHEDULER_INTERVAL", "15"))
MAX_BODY_BYTES = int(os.environ.get("JOBS_API_MAX_BODY_BYTES", str(64 * 1024)))
ALLOWED_ORIGINS = {
origin.strip().rstrip("/")
for origin in os.environ.get(
"JOBS_API_ALLOWED_ORIGINS",
"http://localhost:5580,http://127.0.0.1:5580",
).split(",")
if origin.strip()
}
VALID_ACTIONS = {"copy", "sync", "move"} VALID_ACTIONS = {"copy", "sync", "move"}
VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"} VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"}
MONTHDAY_LAST = 0
db_lock = threading.RLock() db_lock = threading.RLock()
scheduler_lock = threading.RLock() scheduler_lock = threading.RLock()
@@ -133,13 +144,15 @@ def compute_next_run_at(
return iso(local_to_utc(candidate, offset_minutes)) return iso(local_to_utc(candidate, offset_minutes))
if kind == "monthly": if kind == "monthly":
if monthday is None or monthday < 1 or monthday > 31: if monthday is None or monthday < MONTHDAY_LAST or monthday > 31:
raise ValueError("scheduleMonthday must be 1-31") raise ValueError("scheduleMonthday must be 0-31")
for offset_months in range(0, 36): for offset_months in range(0, 36):
year, month = add_months(local_after.year, local_after.month, offset_months) year, month = add_months(local_after.year, local_after.month, offset_months)
if monthday > monthrange(year, month)[1]: days_in_month = monthrange(year, month)[1]
candidate_day = days_in_month if monthday == MONTHDAY_LAST else monthday
if candidate_day > days_in_month:
continue continue
candidate = datetime(year, month, monthday, hour, minute) candidate = datetime(year, month, candidate_day, hour, minute)
if candidate > local_after: if candidate > local_after:
return iso(local_to_utc(candidate, offset_minutes)) return iso(local_to_utc(candidate, offset_minutes))
raise ValueError("could not compute next monthly run") raise ValueError("could not compute next monthly run")
@@ -166,11 +179,22 @@ def connect() -> sqlite3.Connection:
return conn return conn
@contextmanager
def db_connect():
with db_lock:
conn = connect()
try:
with conn:
yield conn
finally:
conn.close()
def init_db() -> None: def init_db() -> None:
db_dir = os.path.dirname(DB_PATH) db_dir = os.path.dirname(DB_PATH)
if db_dir: if db_dir:
os.makedirs(db_dir, exist_ok=True) os.makedirs(db_dir, exist_ok=True)
with db_lock, connect() as conn: with db_connect() as conn:
conn.executescript( conn.executescript(
""" """
CREATE TABLE IF NOT EXISTS job_schedules ( CREATE TABLE IF NOT EXISTS job_schedules (
@@ -211,10 +235,27 @@ def init_db() -> None:
FOREIGN KEY(schedule_id) REFERENCES job_schedules(id) ON DELETE CASCADE FOREIGN KEY(schedule_id) REFERENCES job_schedules(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS one_time_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
src TEXT NOT NULL,
dst TEXT NOT NULL,
jobid INTEGER,
status TEXT NOT NULL,
error TEXT,
started_at TEXT,
finished_at TEXT,
status_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_job_schedules_due CREATE INDEX IF NOT EXISTS idx_job_schedules_due
ON job_schedules(enabled, current_jobid, next_run_at); ON job_schedules(enabled, current_jobid, next_run_at);
CREATE INDEX IF NOT EXISTS idx_job_runs_schedule CREATE INDEX IF NOT EXISTS idx_job_runs_schedule
ON job_runs(schedule_id, created_at DESC); ON job_runs(schedule_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_one_time_jobs_updated
ON one_time_jobs(updated_at DESC);
""" """
) )
ensure_column(conn, "job_schedules", "schedule_kind", "TEXT NOT NULL DEFAULT 'daily'") ensure_column(conn, "job_schedules", "schedule_kind", "TEXT NOT NULL DEFAULT 'daily'")
@@ -266,8 +307,34 @@ def row_to_job(row: sqlite3.Row) -> dict[str, Any]:
} }
def row_to_one_time_job(row: sqlite3.Row) -> dict[str, Any]:
snapshot = None
if row["status_json"]:
try:
snapshot = json.loads(row["status_json"])
except json.JSONDecodeError:
snapshot = None
return {
"id": row["id"],
"action": row["action"],
"src": row["src"],
"dst": row["dst"],
"jobid": row["jobid"],
"lastJobid": row["jobid"],
"currentJobid": row["jobid"] if row["status"] == "running" else None,
"status": row["status"],
"error": row["error"],
"startedAt": row["started_at"],
"finishedAt": row["finished_at"],
"running": row["status"] == "running",
"statusSnapshot": snapshot,
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def fetch_job(schedule_id: int) -> dict[str, Any]: def fetch_job(schedule_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -276,11 +343,26 @@ def fetch_job(schedule_id: int) -> dict[str, Any]:
def list_jobs() -> list[dict[str, Any]]: def list_jobs() -> list[dict[str, Any]]:
refresh_running_jobs() refresh_running_jobs()
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute("SELECT * FROM job_schedules ORDER BY id DESC").fetchall() rows = conn.execute("SELECT * FROM job_schedules ORDER BY id DESC").fetchall()
return [row_to_job(row) for row in rows] return [row_to_job(row) for row in rows]
def fetch_one_time_job(job_id: int) -> dict[str, Any]:
with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
raise ApiError(404, "job not found")
return row_to_one_time_job(row)
def list_one_time_jobs() -> list[dict[str, Any]]:
refresh_one_time_jobs()
with db_connect() as conn:
rows = conn.execute("SELECT * FROM one_time_jobs ORDER BY updated_at DESC, id DESC").fetchall()
return [row_to_one_time_job(row) for row in rows]
def create_job(payload: dict[str, Any]) -> dict[str, Any]: def create_job(payload: dict[str, Any]) -> dict[str, Any]:
action = str(payload.get("action", "")).strip() action = str(payload.get("action", "")).strip()
src = str(payload.get("src", "")).strip() src = str(payload.get("src", "")).strip()
@@ -313,7 +395,7 @@ def create_job(payload: dict[str, Any]) -> dict[str, Any]:
interval_seconds = legacy_interval_seconds(schedule_kind) interval_seconds = legacy_interval_seconds(schedule_kind)
now = iso(utcnow()) now = iso(utcnow())
with db_lock, connect() as conn: with db_connect() as conn:
cur = conn.execute( cur = conn.execute(
""" """
INSERT INTO job_schedules INSERT INTO job_schedules
@@ -343,6 +425,30 @@ def create_job(payload: dict[str, Any]) -> dict[str, Any]:
return fetch_job(schedule_id) return fetch_job(schedule_id)
def create_one_time_job(payload: dict[str, Any]) -> dict[str, Any]:
action = str(payload.get("action", "")).strip()
src = str(payload.get("src", "")).strip()
dst = str(payload.get("dst", "")).strip()
if action not in VALID_ACTIONS:
raise ApiError(400, "invalid action")
if not src or not dst:
raise ApiError(400, "src and dst are required")
now = iso(utcnow())
with db_connect() as conn:
cur = conn.execute(
"""
INSERT INTO one_time_jobs
(action, src, dst, status, created_at, updated_at)
VALUES (?, ?, ?, 'starting', ?, ?)
""",
(action, src, dst, now, now),
)
job_id = int(cur.lastrowid)
start_one_time_job(job_id)
return fetch_one_time_job(job_id)
def parse_optional_int(value: Any) -> int | None: def parse_optional_int(value: Any) -> int | None:
if value in (None, ""): if value in (None, ""):
return None return None
@@ -358,7 +464,7 @@ def legacy_interval_seconds(schedule_kind: str) -> int:
def delete_job(schedule_id: int) -> None: def delete_job(schedule_id: int) -> None:
with db_lock, connect() as conn: with db_connect() as conn:
cur = conn.execute("DELETE FROM job_schedules WHERE id=?", (schedule_id,)) cur = conn.execute("DELETE FROM job_schedules WHERE id=?", (schedule_id,))
if cur.rowcount == 0: if cur.rowcount == 0:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -367,7 +473,7 @@ def delete_job(schedule_id: int) -> None:
def manual_run(schedule_id: int) -> dict[str, Any]: def manual_run(schedule_id: int) -> dict[str, Any]:
with scheduler_lock: with scheduler_lock:
refresh_running_jobs() refresh_running_jobs()
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -378,7 +484,7 @@ def manual_run(schedule_id: int) -> dict[str, Any]:
def stop_job(schedule_id: int) -> dict[str, Any]: def stop_job(schedule_id: int) -> dict[str, Any]:
with db_lock, connect() as conn: with db_connect() as conn:
row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone() row = conn.execute("SELECT * FROM job_schedules WHERE id=?", (schedule_id,)).fetchone()
if row is None: if row is None:
raise ApiError(404, "job not found") raise ApiError(404, "job not found")
@@ -391,7 +497,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
now = iso(now_dt) now = iso(now_dt)
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":")) snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE job_schedules UPDATE job_schedules
@@ -417,6 +523,52 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
return fetch_job(schedule_id) return fetch_job(schedule_id)
def manual_run_one_time_job(job_id: int) -> dict[str, Any]:
with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
raise ApiError(404, "job not found")
if row["status"] == "running":
raise ApiError(409, "job is already running")
start_one_time_job(job_id)
return fetch_one_time_job(job_id)
def stop_one_time_job(job_id: int) -> dict[str, Any]:
with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
raise ApiError(404, "job not found")
jobid = row["jobid"]
if row["status"] != "running" or jobid is None:
return row_to_one_time_job(row)
rc_post("job/stop", {"jobid": jobid})
now = iso(utcnow())
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
with db_connect() as conn:
conn.execute(
"""
UPDATE one_time_jobs
SET status='stopped',
error=NULL,
finished_at=?,
status_json=?,
updated_at=?
WHERE id=?
""",
(now, snapshot, now, job_id),
)
return fetch_one_time_job(job_id)
def delete_one_time_job(job_id: int) -> None:
with db_connect() as conn:
cur = conn.execute("DELETE FROM one_time_jobs WHERE id=?", (job_id,))
if cur.rowcount == 0:
raise ApiError(404, "job not found")
def rc_post( def rc_post(
path: str, path: str,
payload: dict[str, Any], payload: dict[str, Any],
@@ -465,7 +617,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
"srcFs": row["src"], "srcFs": row["src"],
"dstFs": row["dst"], "dstFs": row["dst"],
"_async": True, "_async": True,
"_group": f"webgui/recurring/{schedule_id}/{action}", "_group": transfer_group("recurring", schedule_id, action),
} }
if action == "move": if action == "move":
payload["deleteEmptySrcDirs"] = True payload["deleteEmptySrcDirs"] = True
@@ -479,7 +631,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
error = str(exc) error = str(exc)
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":")) snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO job_runs INSERT INTO job_runs
@@ -507,7 +659,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":")) snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO job_runs INSERT INTO job_runs
@@ -534,6 +686,66 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
) )
def start_one_time_job(job_id: int) -> None:
with db_connect() as conn:
row = conn.execute("SELECT * FROM one_time_jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
raise ApiError(404, "job not found")
now = iso(utcnow())
action = row["action"]
payload: dict[str, Any] = {
"srcFs": row["src"],
"dstFs": row["dst"],
"_async": True,
"_group": transfer_group("once", job_id, action),
}
if action == "move":
payload["deleteEmptySrcDirs"] = True
try:
res = rc_post(f"sync/{action}", payload)
jobid = res.get("jobid")
if jobid is None:
raise RcloneError(f"sync/{action}: missing jobid")
except Exception as exc:
error = str(exc)
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
with db_connect() as conn:
conn.execute(
"""
UPDATE one_time_jobs
SET jobid=NULL,
status='failed',
error=?,
started_at=?,
finished_at=?,
status_json=?,
updated_at=?
WHERE id=?
""",
(error, now, now, snapshot, now, job_id),
)
return
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
with db_connect() as conn:
conn.execute(
"""
UPDATE one_time_jobs
SET jobid=?,
status='running',
error=NULL,
started_at=?,
finished_at=NULL,
status_json=?,
updated_at=?
WHERE id=?
""",
(jobid, now, snapshot, now, job_id),
)
def status_name(status: dict[str, Any]) -> str: def status_name(status: dict[str, Any]) -> str:
if not status.get("finished"): if not status.get("finished"):
return "running" return "running"
@@ -544,20 +756,50 @@ def status_name(status: dict[str, Any]) -> str:
return "finished" return "finished"
def transfer_group(kind: str, record_id: int, action: str) -> str:
return f"webgui/{kind}/{record_id}/{action}"
def status_with_stats(kind: str, row: sqlite3.Row, jobid: int) -> dict[str, Any]:
status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True)
group = transfer_group(kind, int(row["id"]), row["action"])
try:
stats = rc_post("core/stats", {"group": group}, timeout=15, allow_error_body=True)
except Exception as exc:
stats = {"error": str(exc)}
status["stats"] = stats
if "group" not in status:
status["group"] = group
return status
def refresh_running_jobs() -> None: def refresh_running_jobs() -> None:
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall() rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall()
for row in rows: for row in rows:
schedule_id = int(row["id"]) schedule_id = int(row["id"])
jobid = int(row["current_jobid"]) jobid = int(row["current_jobid"])
try: try:
status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True) status = status_with_stats("recurring", row, jobid)
except Exception as exc: except Exception as exc:
status = {"finished": True, "error": str(exc), "jobid": jobid} status = {"finished": True, "error": str(exc), "jobid": jobid}
apply_status(row, status) apply_status(row, status)
def refresh_one_time_jobs() -> None:
with db_connect() as conn:
rows = conn.execute("SELECT * FROM one_time_jobs WHERE status='running' AND jobid IS NOT NULL").fetchall()
for row in rows:
jobid = int(row["jobid"])
try:
status = status_with_stats("once", row, jobid)
except Exception as exc:
status = {"finished": True, "error": str(exc), "jobid": jobid}
apply_one_time_status(row, status)
def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None: def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
schedule_id = int(row["id"]) schedule_id = int(row["id"])
jobid = int(row["current_jobid"]) jobid = int(row["current_jobid"])
@@ -567,7 +809,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
name = status_name(status) name = status_name(status)
if name == "running": if name == "running":
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE job_schedules UPDATE job_schedules
@@ -591,7 +833,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
error = status.get("error") error = status.get("error")
next_run_at = compute_row_next_run_at(row, now_dt) next_run_at = compute_row_next_run_at(row, now_dt)
with db_lock, connect() as conn: with db_connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE job_schedules UPDATE job_schedules
@@ -616,10 +858,48 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
) )
def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
job_id = int(row["id"])
jobid = int(row["jobid"])
now = iso(utcnow())
snapshot = json.dumps(status, separators=(",", ":"), ensure_ascii=False)
name = status_name(status)
if name == "running":
with db_connect() as conn:
conn.execute(
"""
UPDATE one_time_jobs
SET status='running',
error=NULL,
status_json=?,
updated_at=?
WHERE id=? AND jobid=? AND status='running'
""",
(snapshot, now, job_id, jobid),
)
return
error = status.get("error")
with db_connect() as conn:
conn.execute(
"""
UPDATE one_time_jobs
SET status=?,
error=?,
finished_at=?,
status_json=?,
updated_at=?
WHERE id=? AND jobid=? AND status='running'
""",
(name, error, now, snapshot, now, job_id, jobid),
)
def run_due_jobs() -> None: def run_due_jobs() -> None:
with scheduler_lock: with scheduler_lock:
now = iso(utcnow()) now = iso(utcnow())
with db_lock, connect() as conn: with db_connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT * FROM job_schedules SELECT * FROM job_schedules
@@ -635,6 +915,7 @@ def run_due_jobs() -> None:
def scheduler_tick() -> None: def scheduler_tick() -> None:
refresh_running_jobs() refresh_running_jobs()
refresh_one_time_jobs()
run_due_jobs() run_due_jobs()
@@ -646,11 +927,22 @@ def scheduler_loop() -> None:
traceback.print_exc() traceback.print_exc()
def scheduler_initial_tick() -> None:
try:
scheduler_tick()
except Exception:
traceback.print_exc()
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" protocol_version = "HTTP/1.1"
def do_OPTIONS(self) -> None: def do_OPTIONS(self) -> None:
try:
self.require_allowed_origin()
self.send_json(204, None) self.send_json(204, None)
except ApiError as exc:
self.send_json(exc.status, {"error": exc.message})
def do_GET(self) -> None: def do_GET(self) -> None:
self.handle_request("GET") self.handle_request("GET")
@@ -663,6 +955,7 @@ class Handler(BaseHTTPRequestHandler):
def handle_request(self, method: str) -> None: def handle_request(self, method: str) -> None:
try: try:
self.require_allowed_origin()
parsed = urlparse(self.path) parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/" path = parsed.path.rstrip("/") or "/"
parts = [p for p in path.split("/") if p] parts = [p for p in path.split("/") if p]
@@ -679,6 +972,14 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(201, {"job": create_job(self.read_json())}) self.send_json(201, {"job": create_job(self.read_json())})
return return
if parts == ["api", "one-time-jobs"] and method == "GET":
self.send_json(200, {"jobs": list_one_time_jobs()})
return
if parts == ["api", "one-time-jobs"] and method == "POST":
self.send_json(201, {"job": create_one_time_job(self.read_json())})
return
if len(parts) == 3 and parts[:2] == ["api", "jobs"]: if len(parts) == 3 and parts[:2] == ["api", "jobs"]:
schedule_id = parse_id(parts[2]) schedule_id = parse_id(parts[2])
if method == "DELETE": if method == "DELETE":
@@ -686,6 +987,13 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(200, {"ok": True}) self.send_json(200, {"ok": True})
return return
if len(parts) == 3 and parts[:2] == ["api", "one-time-jobs"]:
job_id = parse_id(parts[2])
if method == "DELETE":
delete_one_time_job(job_id)
self.send_json(200, {"ok": True})
return
if len(parts) == 4 and parts[:2] == ["api", "jobs"] and method == "POST": if len(parts) == 4 and parts[:2] == ["api", "jobs"] and method == "POST":
schedule_id = parse_id(parts[2]) schedule_id = parse_id(parts[2])
if parts[3] == "run": if parts[3] == "run":
@@ -695,6 +1003,15 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(200, {"job": stop_job(schedule_id)}) self.send_json(200, {"job": stop_job(schedule_id)})
return return
if len(parts) == 4 and parts[:2] == ["api", "one-time-jobs"] and method == "POST":
job_id = parse_id(parts[2])
if parts[3] == "run":
self.send_json(200, {"job": manual_run_one_time_job(job_id)})
return
if parts[3] == "stop":
self.send_json(200, {"job": stop_one_time_job(job_id)})
return
raise ApiError(404, "not found") raise ApiError(404, "not found")
except ApiError as exc: except ApiError as exc:
self.send_json(exc.status, {"error": exc.message}) self.send_json(exc.status, {"error": exc.message})
@@ -704,8 +1021,22 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc() traceback.print_exc()
self.send_json(500, {"error": str(exc)}) self.send_json(500, {"error": str(exc)})
def require_allowed_origin(self) -> None:
origin = self.headers.get("Origin")
if not origin:
return
if origin.rstrip("/") not in ALLOWED_ORIGINS:
raise ApiError(403, "origin not allowed")
def read_json(self) -> dict[str, Any]: def read_json(self) -> dict[str, Any]:
try:
length = int(self.headers.get("Content-Length") or "0") length = int(self.headers.get("Content-Length") or "0")
except ValueError:
raise ApiError(400, "invalid Content-Length") from None
if length < 0:
raise ApiError(400, "invalid Content-Length")
if length > MAX_BODY_BYTES:
raise ApiError(413, "request body too large")
if length == 0: if length == 0:
return {} return {}
raw = self.rfile.read(length) raw = self.rfile.read(length)
@@ -719,8 +1050,11 @@ class Handler(BaseHTTPRequestHandler):
def send_json(self, status: int, body: Any) -> None: 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") raw = b"" if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
origin = self.headers.get("Origin")
self.send_response(status) self.send_response(status)
self.send_header("Access-Control-Allow-Origin", "*") if origin and origin.rstrip("/") in ALLOWED_ORIGINS:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") 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-Type", "application/json; charset=utf-8")
@@ -747,10 +1081,8 @@ def main() -> None:
init_db() init_db()
thread = threading.Thread(target=scheduler_loop, name="job-scheduler", daemon=True) thread = threading.Thread(target=scheduler_loop, name="job-scheduler", daemon=True)
thread.start() thread.start()
try: initial_thread = threading.Thread(target=scheduler_initial_tick, name="job-scheduler-initial", daemon=True)
scheduler_tick() initial_thread.start()
except Exception:
traceback.print_exc()
server = ThreadingHTTPServer((HOST, PORT), Handler) server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"jobs api listening on {HOST}:{PORT}, db={DB_PATH}, rclone={RCLONE_RC_URL}") print(f"jobs api listening on {HOST}:{PORT}, db={DB_PATH}, rclone={RCLONE_RC_URL}")
try: try:
+190
View File
@@ -0,0 +1,190 @@
import unittest
import tempfile
import os
from io import BytesIO
from datetime import datetime, timezone
import server
from server import ApiError, Handler, MAX_BODY_BYTES, compute_next_run_at
class ComputeNextRunAtTest(unittest.TestCase):
def test_daily_uses_same_day_when_time_is_future(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:30", 0, after),
"2026-06-20T23:30:00Z",
)
def test_daily_moves_to_next_day_when_time_has_passed(self):
after = datetime(2026, 6, 20, 23, 30, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:30", 0, after),
"2026-06-21T23:30:00Z",
)
def test_weekly_uses_next_target_weekday(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) # Saturday
self.assertEqual(
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
"2026-06-22T09:00:00Z",
)
def test_weekly_rolls_forward_a_week_after_same_day_time_passed(self):
after = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc) # Monday
self.assertEqual(
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
"2026-06-29T09:00:00Z",
)
def test_monthly_uses_next_valid_monthday(self):
after = datetime(2026, 1, 30, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
"2026-01-31T23:00:00Z",
)
def test_monthly_skips_short_months_for_day_31(self):
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
"2026-03-31T23:00:00Z",
)
def test_monthly_last_day_uses_current_month_end(self):
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=0),
"2026-02-28T23:00:00Z",
)
def test_monthly_last_day_rolls_forward_when_current_month_end_passed(self):
after = datetime(2026, 2, 28, 23, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=0),
"2026-03-31T23:00:00Z",
)
def test_timezone_offset_is_applied_from_local_schedule_time(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:00", 8 * 60, after),
"2026-06-20T15:00:00Z",
)
def test_invalid_monthday_raises(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
with self.assertRaises(ValueError):
compute_next_run_at("monthly", "23:00", 0, after, monthday=32)
class HandlerSecurityTest(unittest.TestCase):
def test_allowed_origin_is_accepted(self):
handler = type("DummyHandler", (), {"headers": {"Origin": "http://localhost:5580"}})()
Handler.require_allowed_origin(handler)
def test_disallowed_origin_is_rejected(self):
handler = type("DummyHandler", (), {"headers": {"Origin": "http://example.invalid"}})()
with self.assertRaises(ApiError) as ctx:
Handler.require_allowed_origin(handler)
self.assertEqual(ctx.exception.status, 403)
def test_request_body_size_is_limited(self):
handler = type(
"DummyHandler",
(),
{
"headers": {"Content-Length": str(MAX_BODY_BYTES + 1)},
"rfile": BytesIO(),
},
)()
with self.assertRaises(ApiError) as ctx:
Handler.read_json(handler)
self.assertEqual(ctx.exception.status, 413)
def test_read_json_accepts_small_object(self):
body = b'{"ok":true}'
handler = type(
"DummyHandler",
(),
{
"headers": {"Content-Length": str(len(body))},
"rfile": BytesIO(body),
},
)()
self.assertEqual(Handler.read_json(handler), {"ok": True})
class OneTimeJobTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.old_db_path = server.DB_PATH
self.old_rc_post = server.rc_post
server.DB_PATH = os.path.join(self.tmp.name, "jobs.sqlite")
self.next_jobid = 100
def fake_rc_post(path, payload, timeout=30, allow_error_body=False):
if path.startswith("sync/"):
self.next_jobid += 1
return {"jobid": self.next_jobid}
if path == "job/status":
return {"finished": False, "jobid": payload["jobid"]}
if path == "core/stats":
return {
"bytes": 1,
"totalBytes": 2,
"speed": 3,
"eta": 4,
"transfers": 5,
"totalTransfers": 6,
"errors": 7,
"group": payload["group"],
}
if path == "job/stop":
return {}
raise AssertionError(path)
server.rc_post = fake_rc_post
server.init_db()
def tearDown(self):
server.rc_post = self.old_rc_post
server.DB_PATH = self.old_db_path
self.tmp.cleanup()
def test_create_and_list_one_time_job(self):
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
self.assertEqual(job["id"], 1)
self.assertEqual(job["jobid"], 101)
self.assertEqual(job["status"], "running")
self.assertEqual(job["src"], "/tmp/a")
jobs = server.list_one_time_jobs()
self.assertEqual(len(jobs), 1)
self.assertEqual(jobs[0]["id"], 1)
self.assertEqual(jobs[0]["status"], "running")
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["bytes"], 1)
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["totalBytes"], 2)
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["speed"], 3)
def test_stop_one_time_job_persists_stopped_status(self):
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
stopped = server.stop_one_time_job(job["id"])
self.assertEqual(stopped["status"], "stopped")
self.assertFalse(stopped["running"])
self.assertIsNotNone(stopped["statusSnapshot"])
def test_run_one_time_job_again_reuses_record_with_new_jobid(self):
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
server.stop_one_time_job(job["id"])
restarted = server.manual_run_one_time_job(job["id"])
self.assertEqual(restarted["id"], job["id"])
self.assertEqual(restarted["jobid"], 102)
self.assertEqual(restarted["status"], "running")
if __name__ == "__main__":
unittest.main()
+6 -15
View File
@@ -2,15 +2,15 @@
// the top-nav active state in sync, and re-renders chrome strings when // the top-nav active state in sync, and re-renders chrome strings when
// the locale changes. // the locale changes.
import { onRoute } from "./state.js?v=recurring-jobs-5"; import { escapeHtml, onRoute } from "./state.js?v=job-layout-1";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=recurring-jobs-5"; import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-layout-1";
import { renderRemotes } from "./views/remotes.js?v=recurring-jobs-5"; import { renderRemotes } from "./views/remotes.js?v=job-layout-1";
import { renderBrowse } from "./views/browser.js?v=recurring-jobs-5"; import { renderBrowse } from "./views/browser.js?v=job-layout-1";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=recurring-jobs-5"; import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-layout-1";
import { import {
renderConfigureNew, renderConfigureNew,
renderConfigureEdit, renderConfigureEdit,
} from "./views/configure.js?v=recurring-jobs-5"; } from "./views/configure.js?v=job-layout-1";
const views = { const views = {
remotes: renderRemotes, remotes: renderRemotes,
@@ -94,12 +94,3 @@ onLocale(() => {
// Initial chrome paint + switcher wiring. // Initial chrome paint + switcher wiring.
applyChromeStrings(); applyChromeStrings();
wireLangSwitcher(); wireLangSwitcher();
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+42 -10
View File
@@ -155,7 +155,7 @@ const STRINGS = {
// jobs view // jobs view
"jobs.title": "Jobs", "jobs.title": "Jobs",
"jobs.subtitle": "Saved recurring transfers and their latest runs.", "jobs.subtitle": "Saved transfers and their latest runs.",
"jobs.new_btn": "New Job", "jobs.new_btn": "New Job",
"jobs.cancel": "Cancel", "jobs.cancel": "Cancel",
"jobs.new_title": "New Job", "jobs.new_title": "New Job",
@@ -167,7 +167,7 @@ const STRINGS = {
"jobs.schedule_type": "Job type", "jobs.schedule_type": "Job type",
"jobs.schedule_once": "One-time", "jobs.schedule_once": "One-time",
"jobs.schedule_recurring": "Fixed recurring", "jobs.schedule_recurring": "Fixed recurring",
"jobs.schedule_help": "One-time jobs are submitted to rclone and not saved. Fixed recurring jobs are stored in SQLite.", "jobs.schedule_help": "One-time and fixed recurring jobs are stored in SQLite.",
"jobs.recurrence_kind": "Recurring plan", "jobs.recurrence_kind": "Recurring plan",
"jobs.recurrence_daily": "Every day", "jobs.recurrence_daily": "Every day",
"jobs.recurrence_weekly": "Every week", "jobs.recurrence_weekly": "Every week",
@@ -184,11 +184,13 @@ const STRINGS = {
"jobs.weekday_saturday": "Saturday", "jobs.weekday_saturday": "Saturday",
"jobs.weekday_sunday": "Sunday", "jobs.weekday_sunday": "Sunday",
"jobs.monthday": (n) => `Day ${n}`, "jobs.monthday": (n) => `Day ${n}`,
"jobs.monthday_last": "Last day",
"jobs.schedule_label_daily": (time) => `Every day at ${time}`, "jobs.schedule_label_daily": (time) => `Every day at ${time}`,
"jobs.schedule_label_weekly": (day, 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.schedule_label_monthly": (day, time) => `Day ${day} of every month at ${time}`,
"jobs.schedule_label_monthly_last": (time) => `Last day of every month at ${time}`,
"jobs.local_option": "Local filesystem", "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.local_path_help": "For Local filesystem, enter a path visible inside the rclone process, such as /root/source in this Docker setup. For remotes, enter a path inside the selected remote.",
"jobs.source_remote": "Source location", "jobs.source_remote": "Source location",
"jobs.source_path": "Source path", "jobs.source_path": "Source path",
"jobs.dest_remote": "Destination location", "jobs.dest_remote": "Destination location",
@@ -201,10 +203,15 @@ const STRINGS = {
"jobs.pick_empty": "No folders here.", "jobs.pick_empty": "No folders here.",
"jobs.start": "Start Job", "jobs.start": "Start Job",
"jobs.no_remotes_option": "(no remotes)", "jobs.no_remotes_option": "(no remotes)",
"jobs.view_filter": "Job view",
"jobs.view_once": "One-time jobs",
"jobs.view_recurring": "Recurring jobs",
"jobs.empty_title": "No jobs yet", "jobs.empty_title": "No jobs yet",
"jobs.empty_body": "Use New Job to start a copy, sync, or move.", "jobs.empty_once_title": "No one-time jobs yet",
"jobs.empty_recurring_title": "No recurring jobs yet",
"jobs.empty_body": (link) => `Use ${link} 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.started_once": (a, id) => `Started one-time ${a} job #${id}`,
"jobs.recurring_saved": (id) => `Saved recurring job #${id}`, "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}?`,
@@ -212,6 +219,7 @@ const STRINGS = {
"jobs.stop_failed": "Stop failed", "jobs.stop_failed": "Stop failed",
"jobs.start_again": "Start again", "jobs.start_again": "Start again",
"jobs.restarted": (id) => `Started recurring job #${id}`, "jobs.restarted": (id) => `Started recurring job #${id}`,
"jobs.restarted_once": (id) => `Started one-time job #${id}`,
"jobs.restart_failed": "Start again failed", "jobs.restart_failed": "Start again failed",
"jobs.details": "Details", "jobs.details": "Details",
"jobs.hide_details": "Hide", "jobs.hide_details": "Hide",
@@ -219,6 +227,7 @@ const STRINGS = {
"jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`, "jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`,
"jobs.delete_failed": "Delete failed", "jobs.delete_failed": "Delete failed",
"jobs.detail_schedule_id": "Schedule ID", "jobs.detail_schedule_id": "Schedule ID",
"jobs.detail_record_id": "Record",
"jobs.detail_id": "Job ID", "jobs.detail_id": "Job ID",
"jobs.detail_status": "Status", "jobs.detail_status": "Status",
"jobs.detail_error": "Error", "jobs.detail_error": "Error",
@@ -229,6 +238,11 @@ const STRINGS = {
"jobs.detail_schedule": "Schedule", "jobs.detail_schedule": "Schedule",
"jobs.detail_group": "Group", "jobs.detail_group": "Group",
"jobs.detail_execute_id": "Execution", "jobs.detail_execute_id": "Execution",
"jobs.detail_bytes": "Bytes",
"jobs.detail_speed": "Speed",
"jobs.detail_eta": "ETA",
"jobs.detail_files": "Files",
"jobs.detail_errors": "Errors",
"jobs.detail_output": "Output", "jobs.detail_output": "Output",
"jobs.detail_none": "None", "jobs.detail_none": "None",
"jobs.col.id": "#", "jobs.col.id": "#",
@@ -248,6 +262,8 @@ const STRINGS = {
"jobs.status.scheduled": "scheduled", "jobs.status.scheduled": "scheduled",
"jobs.status.stopped": "stopped", "jobs.status.stopped": "stopped",
"jobs.stop": "Stop", "jobs.stop": "Stop",
"jobs.once_id": (id) => `once:${id}`,
"jobs.schedule_label_once": "One-time run",
"jobs.submitted_cli": "— submitted via CLI —", "jobs.submitted_cli": "— submitted via CLI —",
}, },
@@ -369,7 +385,7 @@ const STRINGS = {
"configure.example_custom_placeholder": "自定义值", "configure.example_custom_placeholder": "自定义值",
"jobs.title": "任务", "jobs.title": "任务",
"jobs.subtitle": "已保存的循环传输任务及其最近运行状态。", "jobs.subtitle": "已保存的传输任务及其最近运行状态。",
"jobs.new_btn": "新建任务", "jobs.new_btn": "新建任务",
"jobs.cancel": "取消", "jobs.cancel": "取消",
"jobs.new_title": "新建任务", "jobs.new_title": "新建任务",
@@ -381,7 +397,7 @@ const STRINGS = {
"jobs.schedule_type": "任务类型", "jobs.schedule_type": "任务类型",
"jobs.schedule_once": "单次任务", "jobs.schedule_once": "单次任务",
"jobs.schedule_recurring": "固定循环", "jobs.schedule_recurring": "固定循环",
"jobs.schedule_help": "单次任务只提交给 rclone,不保存记录。固定循环任务会保存到 SQLite。", "jobs.schedule_help": "单次任务和固定循环任务都会保存到 SQLite。",
"jobs.recurrence_kind": "循环计划", "jobs.recurrence_kind": "循环计划",
"jobs.recurrence_daily": "每天", "jobs.recurrence_daily": "每天",
"jobs.recurrence_weekly": "每周", "jobs.recurrence_weekly": "每周",
@@ -398,11 +414,13 @@ const STRINGS = {
"jobs.weekday_saturday": "星期六", "jobs.weekday_saturday": "星期六",
"jobs.weekday_sunday": "星期日", "jobs.weekday_sunday": "星期日",
"jobs.monthday": (n) => `${n} 号`, "jobs.monthday": (n) => `${n} 号`,
"jobs.monthday_last": "最后一天",
"jobs.schedule_label_daily": (time) => `每天 ${time}`, "jobs.schedule_label_daily": (time) => `每天 ${time}`,
"jobs.schedule_label_weekly": (day, time) => `每周${day.replace(/^星期/, "")} ${time}`, "jobs.schedule_label_weekly": (day, time) => `每周${day.replace(/^星期/, "")} ${time}`,
"jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`, "jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`,
"jobs.schedule_label_monthly_last": (time) => `每月最后一天 ${time}`,
"jobs.local_option": "本地文件系统", "jobs.local_option": "本地文件系统",
"jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。", "jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /root/source。选择远程存储时填写该远程内的路径。",
"jobs.source_remote": "源位置", "jobs.source_remote": "源位置",
"jobs.source_path": "源路径", "jobs.source_path": "源路径",
"jobs.dest_remote": "目标位置", "jobs.dest_remote": "目标位置",
@@ -415,10 +433,15 @@ const STRINGS = {
"jobs.pick_empty": "这里没有文件夹。", "jobs.pick_empty": "这里没有文件夹。",
"jobs.start": "开始任务", "jobs.start": "开始任务",
"jobs.no_remotes_option": "(无远程存储)", "jobs.no_remotes_option": "(无远程存储)",
"jobs.view_filter": "任务视图",
"jobs.view_once": "单次任务",
"jobs.view_recurring": "循环任务",
"jobs.empty_title": "尚无任务", "jobs.empty_title": "尚无任务",
"jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。", "jobs.empty_once_title": "尚无单次任务",
"jobs.empty_recurring_title": "尚无循环任务",
"jobs.empty_body": (link) => `用${link}开始一个 copy、sync 或 move。`,
"jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`, "jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`,
"jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id},不会保存记录。`, "jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`,
"jobs.recurring_saved": (id) => `已保存循环任务 #${id}`, "jobs.recurring_saved": (id) => `已保存循环任务 #${id}`,
"jobs.start_failed": "任务启动失败", "jobs.start_failed": "任务启动失败",
"jobs.stop_confirm": (id) => `停止任务 #${id}?`, "jobs.stop_confirm": (id) => `停止任务 #${id}?`,
@@ -426,6 +449,7 @@ const STRINGS = {
"jobs.stop_failed": "停止失败", "jobs.stop_failed": "停止失败",
"jobs.start_again": "再次启动", "jobs.start_again": "再次启动",
"jobs.restarted": (id) => `已启动循环任务 #${id}`, "jobs.restarted": (id) => `已启动循环任务 #${id}`,
"jobs.restarted_once": (id) => `已启动单次任务 #${id}`,
"jobs.restart_failed": "再次启动失败", "jobs.restart_failed": "再次启动失败",
"jobs.details": "详情", "jobs.details": "详情",
"jobs.hide_details": "收起", "jobs.hide_details": "收起",
@@ -433,6 +457,7 @@ const STRINGS = {
"jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`, "jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`,
"jobs.delete_failed": "删除失败", "jobs.delete_failed": "删除失败",
"jobs.detail_schedule_id": "调度 ID", "jobs.detail_schedule_id": "调度 ID",
"jobs.detail_record_id": "记录",
"jobs.detail_id": "任务 ID", "jobs.detail_id": "任务 ID",
"jobs.detail_status": "状态", "jobs.detail_status": "状态",
"jobs.detail_error": "错误", "jobs.detail_error": "错误",
@@ -443,6 +468,11 @@ const STRINGS = {
"jobs.detail_schedule": "固定计划", "jobs.detail_schedule": "固定计划",
"jobs.detail_group": "分组", "jobs.detail_group": "分组",
"jobs.detail_execute_id": "执行实例", "jobs.detail_execute_id": "执行实例",
"jobs.detail_bytes": "字节",
"jobs.detail_speed": "速度",
"jobs.detail_eta": "预计剩余",
"jobs.detail_files": "文件",
"jobs.detail_errors": "错误",
"jobs.detail_output": "输出", "jobs.detail_output": "输出",
"jobs.detail_none": "无", "jobs.detail_none": "无",
"jobs.col.id": "#", "jobs.col.id": "#",
@@ -462,6 +492,8 @@ const STRINGS = {
"jobs.status.scheduled": "已调度", "jobs.status.scheduled": "已调度",
"jobs.status.stopped": "已停止", "jobs.status.stopped": "已停止",
"jobs.stop": "停止", "jobs.stop": "停止",
"jobs.once_id": (id) => `单次:${id}`,
"jobs.schedule_label_once": "单次运行",
"jobs.submitted_cli": "— 通过命令行提交 —", "jobs.submitted_cli": "— 通过命令行提交 —",
}, },
}; };
+33
View File
@@ -44,6 +44,39 @@ export async function deleteRecurringJob(id) {
}); });
} }
export async function listOneTimeJobs() {
const res = await request("/api/one-time-jobs");
return (res && res.jobs) || [];
}
export async function createOneTimeJob(job) {
const res = await request("/api/one-time-jobs", {
method: "POST",
body: job,
});
return res && res.job;
}
export async function runOneTimeJobNow(id) {
const res = await request(`/api/one-time-jobs/${encodeURIComponent(id)}/run`, {
method: "POST",
});
return res && res.job;
}
export async function stopOneTimeJob(id) {
const res = await request(`/api/one-time-jobs/${encodeURIComponent(id)}/stop`, {
method: "POST",
});
return res && res.job;
}
export async function deleteOneTimeJob(id) {
return request(`/api/one-time-jobs/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
async function request(path, { method = "GET", body } = {}) { async function request(path, { method = "GET", body } = {}) {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
+19 -12
View File
@@ -15,6 +15,7 @@ const AUTH_PASS = params.get("pass");
let authHeader = null; let authHeader = null;
if (AUTH_USER && AUTH_PASS) { if (AUTH_USER && AUTH_PASS) {
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`); authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
scrubAuthQuery();
} }
export function rcURL() { export function rcURL() {
@@ -30,8 +31,15 @@ export function isNoAuth() {
return !authHeader; return !authHeader;
} }
function scrubAuthQuery() {
const clean = new URL(location.href);
clean.searchParams.delete("user");
clean.searchParams.delete("pass");
history.replaceState(null, "", `${clean.pathname}${clean.search}${clean.hash}`);
}
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws. // POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
export async function post(path, body = {}) { export async function post(path, body = {}, options = {}) {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
if (authHeader) headers["Authorization"] = authHeader; if (authHeader) headers["Authorization"] = authHeader;
const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), { const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), {
@@ -39,7 +47,7 @@ export async function post(path, body = {}) {
headers, headers,
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
return parseResponse(res, path); return parseResponse(res, path, options);
} }
// POST JSON and request an async job. Returns { jobid, executeId }. // POST JSON and request an async job. Returns { jobid, executeId }.
@@ -72,17 +80,16 @@ export async function uploadFile(fs, remote, files) {
export function downloadURL(remoteFs, remotePath, fileName) { export function downloadURL(remoteFs, remotePath, fileName) {
const base = RC_BASE.replace(/\/$/, ""); const base = RC_BASE.replace(/\/$/, "");
// rc server serves remote files at /<remote>:<path> // rc server serves remote files at /<remote>:<path>
const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, ""); const path = [remoteFs, remotePath, fileName]
const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`; .filter(Boolean)
let url = `${base}/${path}`; .flatMap((part) => String(part).split("/"))
if (authHeader) { .filter((part) => part !== "")
// Embed basic auth into the URL so the browser can fetch it directly. .map(encodeURIComponent)
url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`); .join("/");
} return `${base}/${path}`;
return url;
} }
async function parseResponse(res, path) { async function parseResponse(res, path, { allowError = false } = {}) {
let body = null; let body = null;
const ct = res.headers.get("Content-Type") || ""; const ct = res.headers.get("Content-Type") || "";
if (ct.includes("application/json")) { if (ct.includes("application/json")) {
@@ -98,7 +105,7 @@ async function parseResponse(res, path) {
err.body = body; err.body = body;
throw err; throw err;
} }
if (body && body.error) { if (body && body.error && !allowError) {
throw new Error(`${path}: ${body.error}`); throw new Error(`${path}: ${body.error}`);
} }
return body; return body;
+9
View File
@@ -156,3 +156,12 @@ export function formatTime(iso) {
return iso; return iso;
} }
} }
export function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+28 -26
View File
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. // views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js"; import { post, uploadFile, downloadURL } from "../rc.js?v=job-layout-1";
import { toast, formatBytes, formatTime } from "../state.js?v=recurring-jobs-5"; import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=recurring-jobs-5"; import { t } from "../i18n.js?v=job-layout-1";
export async function renderBrowse({ remote, path }) { export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -37,18 +37,7 @@ export async function renderBrowse({ remote, path }) {
const card = document.getElementById("browser-card"); const card = document.getElementById("browser-card");
const fileInput = document.getElementById("upload-input"); const fileInput = document.getElementById("upload-input");
try { await refreshListing(card, fs, path);
const res = await post("operations/list", {
fs,
remote: path || "",
opt: { noModTime: false, noMimeType: true, showHash: false },
});
const items = (res && res.list) || [];
renderTable(card, fs, path, items);
} catch (e) {
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
toast(`${t("browser.browse_failed")}: ${e.message}`, "error");
}
app.querySelector('[data-action="mkdir"]').addEventListener("click", () => { app.querySelector('[data-action="mkdir"]').addEventListener("click", () => {
openModal( openModal(
@@ -61,6 +50,7 @@ export async function renderBrowse({ remote, path }) {
const target = path ? `${path}/${name}` : name; const target = path ? `${path}/${name}` : name;
await post("operations/mkdir", { fs, remote: target }); await post("operations/mkdir", { fs, remote: target });
toast(t("browser.mkdir_created", name), "success"); toast(t("browser.mkdir_created", name), "success");
await refreshListing(card, fs, path);
}, },
).catch((e) => toast(`${t("browser.mkdir_failed")}: ${e.message}`, "error")); ).catch((e) => toast(`${t("browser.mkdir_failed")}: ${e.message}`, "error"));
}); });
@@ -76,13 +66,30 @@ export async function renderBrowse({ remote, path }) {
try { try {
await uploadFile(fs, path || "", files); await uploadFile(fs, path || "", files);
toast(t("browser.uploaded", files.length), "success"); toast(t("browser.uploaded", files.length), "success");
location.reload(); await refreshListing(card, fs, path);
} catch (e) { } catch (e) {
toast(`${t("browser.upload_failed")}: ${e.message}`, "error"); toast(`${t("browser.upload_failed")}: ${e.message}`, "error");
} }
}); });
} }
async function refreshListing(card, fs, path) {
if (!card) return;
card.innerHTML = `<p class="empty">${t("loading.title")}…</p>`;
try {
const res = await post("operations/list", {
fs,
remote: path || "",
opt: { noModTime: false, noMimeType: true, showHash: false },
});
const items = (res && res.list) || [];
renderTable(card, fs, path, items);
} catch (e) {
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
toast(`${t("browser.browse_failed")}: ${e.message}`, "error");
}
}
function renderBreadcrumbs(el, remote, path) { function renderBreadcrumbs(el, remote, path) {
const segments = (path || "").split("/").filter(Boolean); const segments = (path || "").split("/").filter(Boolean);
let html = `<a href="#/remotes">${t("nav.remotes")}</a><span class="sep">/</span>`; let html = `<a href="#/remotes">${t("nav.remotes")}</a><span class="sep">/</span>`;
@@ -162,7 +169,7 @@ function row(fs, path, item) {
return ` return `
<tr> <tr>
<td class="col-name"> <td class="col-name">
<a href="${downloadURL(fs, path, item.Name)}" download="${escapeHtml(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a> <a href="${escapeAttr(downloadURL(fs, path, item.Name))}" download="${escapeAttr(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a>
</td> </td>
<td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td> <td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td> <td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
@@ -198,7 +205,7 @@ async function onDelete(fs, path, itemPath) {
try { try {
await post("operations/deletefile", { fs, remote: itemPath }); await post("operations/deletefile", { fs, remote: itemPath });
toast(t("browser.deleted", itemPath), "success"); toast(t("browser.deleted", itemPath), "success");
location.reload(); await refreshListing(document.getElementById("browser-card"), fs, path);
} catch (e) { } catch (e) {
toast(`${t("browser.delete_file_failed")}: ${e.message}`, "error"); toast(`${t("browser.delete_file_failed")}: ${e.message}`, "error");
} }
@@ -224,7 +231,7 @@ async function onRename(fs, path, itemPath) {
toast(t("browser.renamed", name), "success"); toast(t("browser.renamed", name), "success");
}, },
); );
location.reload(); await refreshListing(document.getElementById("browser-card"), fs, path);
} catch (e) { } catch (e) {
toast(`${t("browser.rename_failed")}: ${e.message}`, "error"); toast(`${t("browser.rename_failed")}: ${e.message}`, "error");
} }
@@ -290,11 +297,6 @@ export function openModal(title, fields, onSubmit) {
}); });
} }
function escapeHtml(s) { function escapeAttr(s) {
return String(s) return escapeHtml(s);
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
} }
+8 -16
View File
@@ -9,9 +9,9 @@
// OAuth backends (option named "token" with IsPassword) get a banner // OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal. // and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js"; import { post } from "../rc.js?v=job-layout-1";
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5"; import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=recurring-jobs-5"; import { t } from "../i18n.js?v=job-layout-1";
// --- Route entrypoints --- // --- Route entrypoints ---
@@ -25,7 +25,7 @@ export async function renderConfigureNew({ provider = "" }) {
document.getElementById("app").innerHTML = ` document.getElementById("app").innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("error.unknown_remote")}</h3> <h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_provider", provider)}</p> <p>${t("error.no_such_provider", escapeHtml(provider))}</p>
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p> <p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
</div>`; </div>`;
return; return;
@@ -53,7 +53,7 @@ export async function renderConfigureEdit({ remote }) {
app.innerHTML = ` app.innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("error.unknown_remote")}</h3> <h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_remote", remote)}</p> <p>${t("error.no_such_remote", escapeHtml(remote))}</p>
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p> <p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
</div>`; </div>`;
return; return;
@@ -155,6 +155,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
const requiresOAuth = provider.Options.some( const requiresOAuth = provider.Options.some(
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive), (o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
); );
const blocksCreate = requiresOAuth && !isEdit;
// Partition options into required, optional-basic, optional-advanced. // Partition options into required, optional-basic, optional-advanced.
const required = provider.Options.filter((o) => o.Required && !o.Hide); const required = provider.Options.filter((o) => o.Required && !o.Hide);
@@ -206,7 +207,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
</div> </div>
<div class="toolbar"> <div class="toolbar">
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}> <button type="submit" class="btn btn-primary" ${blocksCreate ? "disabled" : ""}>
${isEdit ? t("configure.save") : t("configure.create")} ${isEdit ? t("configure.save") : t("configure.create")}
</button> </button>
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a> <a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
@@ -255,7 +256,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
if (requiresOAuth) return; if (blocksCreate) return;
const name = form.elements._remote_name.value.trim(); const name = form.elements._remote_name.value.trim();
if (!name) return; if (!name) return;
@@ -426,15 +427,6 @@ async function ensureProviders() {
return providers; return providers;
} }
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttr(s) { function escapeAttr(s) {
return escapeHtml(s); return escapeHtml(s);
} }
+268 -126
View File
@@ -1,27 +1,33 @@
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. // views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
import { post, postAsync } from "../rc.js"; import { post } from "../rc.js?v=job-layout-1";
import { import {
toast, toast,
formatBytes, formatBytes,
formatSpeed, formatSpeed,
formatDuration, formatDuration,
} from "../state.js?v=recurring-jobs-5"; escapeHtml,
import { t } from "../i18n.js?v=recurring-jobs-5"; } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=job-layout-1";
import { import {
createOneTimeJob,
createRecurringJob, createRecurringJob,
deleteOneTimeJob,
deleteRecurringJob, deleteRecurringJob,
jobsApiURL, jobsApiURL,
listOneTimeJobs,
listRecurringJobs, listRecurringJobs,
runOneTimeJobNow,
runRecurringJobNow, runRecurringJobNow,
stopOneTimeJob,
stopRecurringJob, stopRecurringJob,
} from "../jobs_api.js?v=recurring-jobs-5"; } from "../jobs_api.js?v=job-layout-1";
let pollTimer = null; let pollTimer = null;
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
const LOCAL_FS_VALUE = "__local__"; const LOCAL_FS_VALUE = "__local__";
const expandedJobs = new Set(); const expandedJobs = new Set();
const LOCAL_PICKER_ROOT = "/root"; const LOCAL_PICKER_ROOT = "/root";
let currentJobsView = "once";
export async function renderJobs() { export async function renderJobs() {
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -33,11 +39,35 @@ export async function renderJobs() {
</div> </div>
<a class="btn btn-primary btn-sm" href="#/jobs/new">${t("jobs.new_btn")}</a> <a class="btn btn-primary btn-sm" href="#/jobs/new">${t("jobs.new_btn")}</a>
</div> </div>
<div class="toolbar">
<div class="segmented" role="tablist" aria-label="${t("jobs.view_filter")}">
<label>
<input type="radio" name="jobsView" value="once" ${currentJobsView === "once" ? "checked" : ""}>
<span>${t("jobs.view_once")}</span>
</label>
<label>
<input type="radio" name="jobsView" value="recurring" ${currentJobsView === "recurring" ? "checked" : ""}>
<span>${t("jobs.view_recurring")}</span>
</label>
</div>
</div>
<div id="jobs-card" class="card-outline"> <div id="jobs-card" class="card-outline">
<p class="empty">${t("loading.title")}…</p> <p class="empty">${t("loading.title")}…</p>
</div> </div>
`; `;
app.querySelectorAll('input[name="jobsView"]').forEach((input) => {
input.addEventListener("change", async () => {
currentJobsView = input.value;
const hasRunning = await refreshJobs();
if (hasRunning) {
startPolling();
} else {
stopPolling();
}
});
});
const hasRunning = await refreshJobs(); const hasRunning = await refreshJobs();
if (hasRunning) { if (hasRunning) {
startPolling(); startPolling();
@@ -246,14 +276,18 @@ export async function renderNewJob() {
...schedule, ...schedule,
}); });
toast(t("jobs.recurring_saved", job && job.id), "success"); toast(t("jobs.recurring_saved", job && job.id), "success");
currentJobsView = "recurring";
location.hash = "#/jobs"; location.hash = "#/jobs";
return; return;
} }
const body = buildTransferBody(action, src, dst); const job = await createOneTimeJob({
const res = await postAsync(`sync/${action}`, body); action,
const jobid = res && res.jobid; src,
toast(t("jobs.started_once", action, jobid), "success"); dst,
});
toast(t("jobs.started_once", action, job && (job.jobid || job.id)), "success");
currentJobsView = "once";
location.hash = "#/jobs"; location.hash = "#/jobs";
} catch (e) { } catch (e) {
toast(`${t("jobs.start_failed")}: ${e.message}`, "error"); toast(`${t("jobs.start_failed")}: ${e.message}`, "error");
@@ -276,20 +310,12 @@ function renderWeekdayOptions() {
} }
function renderMonthdayOptions() { function renderMonthdayOptions() {
return Array.from({ length: 31 }, (_, index) => { const lastDay = `<option value="0">${t("jobs.monthday_last")}</option>`;
const numberedDays = Array.from({ length: 31 }, (_, index) => {
const day = index + 1; const day = index + 1;
return `<option value="${day}">${t("jobs.monthday", day)}</option>`; return `<option value="${day}">${t("jobs.monthday", day)}</option>`;
}).join(""); }).join("");
} return lastDay + numberedDays;
function buildTransferBody(action, src, dst) {
const body = {
srcFs: src,
dstFs: dst,
_group: `${WEBGUI_JOB_GROUP_PREFIX}/${action}`,
};
if (action === "move") body.deleteEmptySrcDirs = true;
return body;
} }
function buildJobFs(location, rawPath) { function buildJobFs(location, rawPath) {
@@ -322,7 +348,7 @@ function readRecurringSchedule(form) {
} }
if (scheduleKind === "monthly") { if (scheduleKind === "monthly") {
schedule.scheduleMonthday = Number(form.elements.scheduleMonthday.value); schedule.scheduleMonthday = Number(form.elements.scheduleMonthday.value);
if (!Number.isInteger(schedule.scheduleMonthday) || schedule.scheduleMonthday < 1 || schedule.scheduleMonthday > 31) { if (!Number.isInteger(schedule.scheduleMonthday) || schedule.scheduleMonthday < 0 || schedule.scheduleMonthday > 31) {
throw new Error(t("error.no_schedule_selected")); throw new Error(t("error.no_schedule_selected"));
} }
} }
@@ -457,7 +483,11 @@ async function refreshJobs() {
let jobs = []; let jobs = [];
try { try {
jobs = await listRecurringJobs(); if (currentJobsView === "once") {
jobs = (await listOneTimeJobs()).map(normalizeOneTimeJob);
} else {
jobs = (await listRecurringJobs()).map(normalizeRecurringJob);
}
} catch (e) { } catch (e) {
card.innerHTML = ` card.innerHTML = `
<div class="empty"> <div class="empty">
@@ -472,8 +502,8 @@ async function refreshJobs() {
if (jobs.length === 0) { if (jobs.length === 0) {
card.innerHTML = ` card.innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("jobs.empty_title")}</h3> <h3>${currentJobsView === "once" ? t("jobs.empty_once_title") : t("jobs.empty_recurring_title")}</h3>
<p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p> <p>${emptyJobsBody()}</p>
</div> </div>
`; `;
return false; return false;
@@ -481,129 +511,123 @@ async function refreshJobs() {
card.innerHTML = renderJobTable(jobs); card.innerHTML = renderJobTable(jobs);
card.querySelectorAll("[data-detail]").forEach((btn) => { card.querySelectorAll("[data-detail]").forEach((btn) => {
btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10))); btn.addEventListener("click", () => onToggleDetails(btn.dataset.detail));
}); });
card.querySelectorAll("[data-start]").forEach((btn) => { card.querySelectorAll("[data-start]").forEach((btn) => {
btn.addEventListener("click", () => onStartAgain(parseInt(btn.dataset.start, 10))); btn.addEventListener("click", () => onStartAgain(btn.dataset.start));
}); });
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(btn.dataset.stop));
}); });
card.querySelectorAll("[data-delete]").forEach((btn) => { card.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", () => onDelete(parseInt(btn.dataset.delete, 10))); btn.addEventListener("click", () => onDelete(btn.dataset.delete));
}); });
return jobs.length > 0; return jobs.some((job) => job.running);
}
function emptyJobsBody() {
const link = `<a href="#/jobs/new">${escapeHtml(t("jobs.new_btn"))}</a>`;
return t("jobs.empty_body", link);
} }
function renderJobTable(statuses) { function renderJobTable(statuses) {
statuses.sort((a, b) => (b.id ?? 0) - (a.id ?? 0)); statuses.sort((a, b) => {
const bTime = Date.parse(b.updatedAt || b.createdAt || b.startedAt || 0) || 0;
const aTime = Date.parse(a.updatedAt || a.createdAt || a.startedAt || 0) || 0;
return bTime - aTime;
});
const rows = statuses.map(renderJobRows).join(""); const rows = statuses.map(renderJobRow).join("");
return ` return `
<table class="table"> <div class="job-list">
<thead>
<tr>
<th>${t("jobs.col.id")}</th>
<th>${t("jobs.col.job")}</th>
<th>${t("jobs.col.status")}</th>
<th>${t("jobs.col.next_run")}</th>
<th class="col-num">${t("jobs.col.progress")}</th>
<th class="col-num">${t("jobs.col.speed")}</th>
<th class="col-num">${t("jobs.col.eta")}</th>
<th class="col-num">${t("jobs.col.files")}</th>
<th class="col-num">${t("jobs.col.errors")}</th>
<th class="col-num">${t("jobs.col.action")}</th>
</tr>
</thead>
<tbody>
${rows} ${rows}
</tbody> </div>
</table>
`; `;
} }
function renderJobRows(s) {
const row = renderJobRow(s);
if (!expandedJobs.has(Number(s.id))) {
return row;
}
return row + renderJobDetailsRow(s);
}
function renderJobRow(job) { function renderJobRow(job) {
const snapshot = job.statusSnapshot || {}; const snapshot = job.statusSnapshot || {};
const p = snapshot.progress || {}; const stats = transferStats(snapshot);
const id = job.id; const key = jobKey(job);
const running = !!job.running; const running = !!job.running;
const jobid = job.currentJobid || job.lastJobid; const jobid = job.currentJobid || job.lastJobid;
const displayedSpeed = stats.speed || averageSpeed(stats);
const displayedEta = running ? stats.eta : 0;
const expanded = expandedJobs.has(key);
const badge = renderStatusBadge(job.status); const badge = renderStatusBadge(job.status);
const jobCell = ` const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0;
<div class="job-cell">
<span class="job-action">${escapeHtml(job.action)}</span>
<span class="job-paths">
<code>${escapeHtml(job.src)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(job.dst)}</code>
</span>
</div>`;
const scheduleCell = `
<div class="job-cell">
<span class="job-action">${escapeHtml(formatDateTime(job.nextRunAt))}</span>
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
</div>
`;
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
const progress = ` const progress = `
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px"> <div class="job-progress">
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div> <div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
<span class="col-mono" style="font-size:11px;color:var(--color-muted)"> <span class="job-progress-text">
${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)} ${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}
</span> </span>
</div> </div>
`; `;
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 detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${escapeAttr(key)}">${expanded ? t("jobs.hide_details") : t("jobs.details")}</button>`;
const startBtn = running const startBtn = running
? "" ? ""
: `<button class="btn btn-secondary btn-sm" data-start="${id}">${t("jobs.start_again")}</button>`; : `<button class="btn btn-secondary btn-sm" data-start="${escapeAttr(key)}">${t("jobs.start_again")}</button>`;
const stopBtn = running 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="${escapeAttr(key)}">${t("jobs.stop")}</button>`
: ""; : "";
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${id}">${t("jobs.delete_record")}</button>`; const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${escapeAttr(key)}">${t("jobs.delete_record")}</button>`;
return ` return `
<tr> <article class="job-item">
<td class="col-mono"> <div class="job-main">
${id} <div class="job-head">
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""} <div class="job-id">
</td> <span>${escapeHtml(displayJobId(job))}</span>
<td>${jobCell}</td> ${jobid ? `<small>job ${escapeHtml(jobid)}</small>` : ""}
<td>${badge}</td> </div>
<td>${scheduleCell}</td> ${badge}
<td class="col-num">${progress}</td> </div>
<td class="col-num col-mono">${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"}</td> <div class="job-route">
<td class="col-num col-mono">${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"}</td> <div class="job-route-line">
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td> <span>${escapeHtml(job.action)}</span>
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td> <code>${escapeHtml(job.src)}</code>
<td class="col-num"> </div>
<div class="job-route-line">
<span>→</span>
<code>${escapeHtml(job.dst)}</code>
</div>
</div>
<div class="job-schedule">
<span>${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}</span>
<span>${escapeHtml(formatSchedule(job))}</span>
</div>
</div>
<div class="job-side">
<div class="job-progress-wrap">
<span class="job-metric-label">${t("jobs.col.progress")}</span>
${progress}
</div>
<div class="job-metrics">
${metricHtml(t("jobs.col.speed"), formatSpeed(displayedSpeed))}
${metricHtml(t("jobs.col.eta"), formatDuration(displayedEta))}
${metricHtml(t("jobs.col.files"), formatTransferCount(stats))}
${metricHtml(t("jobs.col.errors"), String(stats.errors || 0))}
</div>
<div class="job-actions"> <div class="job-actions">
${detailBtn} ${detailBtn}
${startBtn} ${startBtn}
${stopBtn} ${stopBtn}
${deleteBtn} ${deleteBtn}
</div> </div>
</td> </div>
</tr> ${expanded ? renderJobDetails(job) : ""}
</article>
`; `;
} }
function renderStatusBadge(status) { function renderStatusBadge(status) {
switch (status) { switch (status) {
case "running": case "running":
case "starting":
return `<span class="badge">${t("jobs.status.running")}</span>`; return `<span class="badge">${t("jobs.status.running")}</span>`;
case "failed": case "failed":
return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`; return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
@@ -618,12 +642,21 @@ function renderStatusBadge(status) {
} }
} }
function renderJobDetailsRow(job) { function metricHtml(label, value) {
const id = job.id; return `
<div class="job-metric">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</div>
`;
}
function renderJobDetails(job) {
const snapshot = job.statusSnapshot || {}; const snapshot = job.statusSnapshot || {};
const stats = transferStats(snapshot);
const jobid = job.currentJobid || job.lastJobid || ""; const jobid = job.currentJobid || job.lastJobid || "";
const details = [ const details = [
[t("jobs.detail_schedule_id"), id], [job.kind === "once" ? t("jobs.detail_record_id") : t("jobs.detail_schedule_id"), displayJobId(job)],
[t("jobs.detail_id"), jobid], [t("jobs.detail_id"), jobid],
[t("jobs.detail_status"), detailStatus(job)], [t("jobs.detail_status"), detailStatus(job)],
[t("jobs.detail_error"), job.error || snapshot.error || t("jobs.detail_none")], [t("jobs.detail_error"), job.error || snapshot.error || t("jobs.detail_none")],
@@ -634,15 +667,17 @@ function renderJobDetailsRow(job) {
[t("jobs.detail_schedule"), formatSchedule(job)], [t("jobs.detail_schedule"), formatSchedule(job)],
[t("jobs.detail_group"), snapshot.group || ""], [t("jobs.detail_group"), snapshot.group || ""],
[t("jobs.detail_execute_id"), snapshot.executeId || ""], [t("jobs.detail_execute_id"), snapshot.executeId || ""],
[t("jobs.detail_bytes"), `${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}`],
[t("jobs.detail_speed"), formatSpeed(stats.speed || averageSpeed(stats))],
[t("jobs.detail_eta"), formatDuration(job.running ? stats.eta : 0)],
[t("jobs.detail_files"), formatTransferCount(stats)],
[t("jobs.detail_errors"), String(stats.errors || 0)],
]; ];
const output = snapshot && Object.keys(snapshot).length > 0 const output = snapshot && Object.keys(snapshot).length > 0
? JSON.stringify(snapshot, null, 2) ? JSON.stringify(snapshot, null, 2)
: ""; : "";
return ` return `
<tr class="job-detail-row">
<td></td>
<td colspan="9">
<div class="job-detail"> <div class="job-detail">
<dl> <dl>
${details.map(([label, value]) => ` ${details.map(([label, value]) => `
@@ -659,11 +694,57 @@ function renderJobDetailsRow(job) {
</div> </div>
` : ""} ` : ""}
</div> </div>
</td>
</tr>
`; `;
} }
function transferStats(snapshot) {
const stats = snapshot.stats || snapshot.progress || snapshot || {};
return {
bytes: numberOrZero(stats.bytes),
totalBytes: numberOrZero(stats.totalBytes),
speed: numberOrZero(stats.speed),
eta: numberOrNull(stats.eta),
transfers: numberOrZero(stats.transfers),
totalTransfers: numberOrZero(stats.totalTransfers),
checks: numberOrZero(stats.checks),
totalChecks: numberOrZero(stats.totalChecks),
transferTime: numberOrZero(stats.transferTime),
errors: normalizeErrors(stats.errors),
};
}
function averageSpeed(stats) {
if (stats.bytes > 0 && stats.transferTime > 0) {
return stats.bytes / stats.transferTime;
}
return 0;
}
function formatTransferCount(stats) {
if (stats.totalTransfers > 0) {
return `${stats.transfers} / ${stats.totalTransfers}`;
}
if (stats.totalChecks > 0) {
return `${stats.checks} / ${stats.totalChecks}`;
}
return String(stats.transfers || stats.checks || 0);
}
function normalizeErrors(errors) {
if (Array.isArray(errors)) return errors.length;
return numberOrZero(errors);
}
function numberOrZero(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function numberOrNull(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function detailStatus(job) { function detailStatus(job) {
const status = job.status || "scheduled"; const status = job.status || "scheduled";
const key = `jobs.status.${status}`; const key = `jobs.status.${status}`;
@@ -671,10 +752,16 @@ function detailStatus(job) {
} }
async function onStop(jobid) { async function onStop(jobid) {
if (!confirm(t("jobs.stop_confirm", jobid))) return; const key = String(jobid);
const label = displayKey(key);
if (!confirm(t("jobs.stop_confirm", label))) return;
try { try {
await stopRecurringJob(jobid); if (key.startsWith("once:")) {
toast(t("jobs.stopped", jobid), "success"); await stopOneTimeJob(oneTimeIdFromKey(key));
} else {
await stopRecurringJob(recurringIdFromKey(key));
}
toast(t("jobs.stopped", label), "success");
await refreshJobs(); await refreshJobs();
} catch (e) { } catch (e) {
toast(`${t("jobs.stop_failed")}: ${e.message}`, "error"); toast(`${t("jobs.stop_failed")}: ${e.message}`, "error");
@@ -682,18 +769,26 @@ async function onStop(jobid) {
} }
async function onToggleDetails(jobid) { async function onToggleDetails(jobid) {
if (expandedJobs.has(jobid)) { const key = String(jobid);
expandedJobs.delete(jobid); if (expandedJobs.has(key)) {
expandedJobs.delete(key);
} else { } else {
expandedJobs.add(jobid); expandedJobs.add(key);
} }
await refreshJobs(); await refreshJobs();
} }
async function onStartAgain(jobid) { async function onStartAgain(jobid) {
const key = String(jobid);
try { try {
await runRecurringJobNow(jobid); if (key.startsWith("once:")) {
toast(t("jobs.restarted", jobid), "success"); const job = await runOneTimeJobNow(oneTimeIdFromKey(key));
toast(t("jobs.restarted_once", job && (job.jobid || job.id)), "success");
} else {
const id = recurringIdFromKey(key);
await runRecurringJobNow(id);
toast(t("jobs.restarted", id), "success");
}
await refreshJobs(); await refreshJobs();
} catch (e) { } catch (e) {
toast(`${t("jobs.restart_failed")}: ${e.message}`, "error"); toast(`${t("jobs.restart_failed")}: ${e.message}`, "error");
@@ -701,10 +796,16 @@ async function onStartAgain(jobid) {
} }
async function onDelete(jobid) { async function onDelete(jobid) {
if (!confirm(t("jobs.delete_confirm", jobid))) return; const key = String(jobid);
const label = displayKey(key);
if (!confirm(t("jobs.delete_confirm", label))) return;
try { try {
await deleteRecurringJob(jobid); if (key.startsWith("once:")) {
expandedJobs.delete(jobid); await deleteOneTimeJob(oneTimeIdFromKey(key));
} else {
await deleteRecurringJob(recurringIdFromKey(key));
}
expandedJobs.delete(key);
await refreshJobs(); await refreshJobs();
} catch (e) { } catch (e) {
toast(`${t("jobs.delete_failed")}: ${e.message}`, "error"); toast(`${t("jobs.delete_failed")}: ${e.message}`, "error");
@@ -736,13 +837,48 @@ export function stopJobPolling() {
stopPolling(); stopPolling();
} }
function escapeHtml(s) { function normalizeRecurringJob(job) {
return String(s) return {
.replace(/&/g, "&amp;") ...job,
.replace(/</g, "&lt;") kind: "recurring",
.replace(/>/g, "&gt;") id: `recurring:${job.id}`,
.replace(/"/g, "&quot;") scheduleId: job.id,
.replace(/'/g, "&#39;"); };
}
function normalizeOneTimeJob(job) {
return {
...job,
kind: "once",
id: `once:${job.id}`,
recordId: job.id,
};
}
function jobKey(job) {
return String(job.id);
}
function displayJobId(job) {
if (job.kind === "once") {
return t("jobs.once_id", job.recordId || String(job.id).replace(/^once:/, ""));
}
return job.scheduleId || String(job.id).replace(/^recurring:/, "");
}
function displayKey(key) {
if (key.startsWith("once:")) {
return t("jobs.once_id", key.replace(/^once:/, ""));
}
return key.replace(/^recurring:/, "");
}
function recurringIdFromKey(key) {
return Number(String(key).replace(/^recurring:/, ""));
}
function oneTimeIdFromKey(key) {
return Number(String(key).replace(/^once:/, ""));
} }
function escapeAttr(s) { function escapeAttr(s) {
@@ -757,6 +893,9 @@ function formatDateTime(value) {
} }
function formatSchedule(job) { function formatSchedule(job) {
if (job.kind === "once") {
return t("jobs.schedule_label_once");
}
const time = job.scheduleTime || ""; const time = job.scheduleTime || "";
switch (job.scheduleKind) { switch (job.scheduleKind) {
case "daily": case "daily":
@@ -764,6 +903,9 @@ function formatSchedule(job) {
case "weekly": case "weekly":
return t("jobs.schedule_label_weekly", weekdayName(job.scheduleWeekday), time); return t("jobs.schedule_label_weekly", weekdayName(job.scheduleWeekday), time);
case "monthly": case "monthly":
if (Number(job.scheduleMonthday) === 0) {
return t("jobs.schedule_label_monthly_last", time);
}
return t("jobs.schedule_label_monthly", job.scheduleMonthday, time); return t("jobs.schedule_label_monthly", job.scheduleMonthday, time);
default: default:
return ""; return "";
+3 -12
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD. // views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js"; import { post } from "../rc.js?v=job-layout-1";
import { getState, setState, toast } from "../state.js?v=recurring-jobs-5"; import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=recurring-jobs-5"; import { t } from "../i18n.js?v=job-layout-1";
export async function renderRemotes() { export async function renderRemotes() {
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -94,12 +94,3 @@ export async function renderRemotes() {
toast(`${t("remotes.load_failed")}: ${e.message}`, "error"); toast(`${t("remotes.load_failed")}: ${e.message}`, "error");
} }
} }
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+145 -18
View File
@@ -865,46 +865,148 @@
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
/* ---------- Job cell with action + src→dst ---------- */ /* ---------- Jobs ---------- */
.job-cell { .job-list {
display: grid;
gap: var(--space-sm);
}
.job-item {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
gap: var(--space-lg);
padding: var(--space-lg);
border-bottom: 1px solid var(--color-hairline-soft);
}
.job-item:last-child {
border-bottom: none;
}
.job-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-md);
min-width: 0;
}
.job-head {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; align-items: flex-start;
min-width: 200px; gap: var(--space-xs);
} }
.job-cell .job-action { .job-id {
display: grid;
gap: 2px;
min-width: 58px;
color: var(--color-muted);
font: var(--typo-code);
}
.job-id small {
color: var(--color-muted-soft);
font: var(--typo-caption);
}
.job-route {
display: grid;
gap: var(--space-xs);
min-width: 0;
}
.job-route-line {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: start;
gap: var(--space-xs);
}
.job-route-line span {
color: var(--color-muted);
font: var(--typo-caption-uppercase); font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking); letter-spacing: var(--typo-caption-uppercase-tracking);
color: var(--color-muted); text-transform: uppercase;
} }
.job-cell .job-paths { .job-route-line code {
font: var(--typo-body-sm);
color: var(--color-body); color: var(--color-body);
word-break: break-all; font: var(--typo-code);
white-space: normal;
overflow-wrap: anywhere;
} }
.job-cell .job-paths .arrow { .job-schedule {
color: var(--color-muted-soft); grid-column: 2;
margin: 0 var(--space-xxs); display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
color: var(--color-muted);
font: var(--typo-caption);
}
.job-side {
display: grid;
align-content: start;
gap: var(--space-sm);
min-width: 0;
}
.job-progress-wrap {
display: grid;
gap: var(--space-xs);
}
.job-metric-label,
.job-metric span {
color: var(--color-muted);
font: var(--typo-caption);
}
.job-progress {
display: grid;
gap: 4px;
}
.job-progress-text {
color: var(--color-muted);
font: var(--typo-code);
}
.job-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-xs);
}
.job-metric {
display: grid;
gap: 2px;
min-width: 0;
}
.job-metric strong {
color: var(--color-body);
font: var(--typo-code);
font-weight: 400;
overflow-wrap: anywhere;
} }
.job-actions { .job-actions {
display: inline-flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-xs); gap: var(--space-xs);
flex-wrap: wrap; flex-wrap: wrap;
} }
.job-detail-row {
background-color: var(--color-surface-soft);
}
.job-detail { .job-detail {
grid-column: 1 / -1;
display: grid; display: grid;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-sm) 0; padding: var(--space-md);
background-color: var(--color-surface-soft);
border-radius: var(--radius-sm);
} }
.job-detail dl { .job-detail dl {
@@ -964,6 +1066,15 @@
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.job-item {
grid-template-columns: 1fr;
}
.job-side {
grid-template-columns: 1fr;
}
.job-actions {
justify-content: flex-start;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -982,4 +1093,20 @@
.top-nav nav { .top-nav nav {
display: none; display: none;
} }
.job-item {
padding: var(--space-md);
}
.job-main {
grid-template-columns: 1fr;
}
.job-schedule {
grid-column: 1;
}
.job-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.job-detail dl,
.job-detail dl div {
grid-template-columns: 1fr;
}
} }
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css"> <link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css"> <link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css?v=recurring-jobs-5"> <link rel="stylesheet" href="/assets/styles/components.css?v=job-layout-1">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
@@ -80,6 +80,6 @@
<div id="toast-stack" class="toast-stack"></div> <div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div> <div id="modal-root"></div>
<script type="module" src="/assets/js/app.js?v=recurring-jobs-5"></script> <script type="module" src="/assets/js/app.js?v=job-layout-1"></script>
</body> </body>
</html> </html>
+21 -5
View File
@@ -171,7 +171,7 @@ network.
return fmt.Errorf("failed to make password: %w", err) return fmt.Errorf("failed to make password: %w", err)
} }
opt.Auth.BasicPass = randomPass opt.Auth.BasicPass = randomPass
fs.Infof(nil, "No password specified. Using random password: %s", randomPass) fs.Infof(nil, "No password specified. Using random password for this session")
} }
} }
@@ -200,10 +200,13 @@ network.
fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL) fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL)
// Build the launch URL: always pass ?url=<rcURL> so the SPA can // Build the launch URL: always pass ?url=<rcURL> so the SPA can
// discover the RC base; embed user/pass only when auth is on. // discover the RC base.
loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth)
fs.Logf(nil, "GUI available at %s", loginURL) fs.Logf(nil, "GUI available at %s", safeLoginURL(loginURL))
if !opt.NoAuth {
fs.Logf(nil, "GUI authentication user: %s", opt.Auth.BasicUser)
}
if !noOpenBrowser { if !noOpenBrowser {
if err := open.Start(loginURL); err != nil { if err := open.Start(loginURL); err != nil {
fs.Errorf(nil, "failed to open GUI in browser: %v", err) fs.Errorf(nil, "failed to open GUI in browser: %v", err)
@@ -287,8 +290,9 @@ func guiHandler(srcFS iofs.FS) http.Handler {
// buildLoginURL constructs the URL the browser should open. The query // buildLoginURL constructs the URL the browser should open. The query
// string always carries the RC API base URL so the SPA can find it. // string always carries the RC API base URL so the SPA can find it.
// When auth is enabled, user/pass and a /login hash are added so the // When auth is enabled, user/pass and a /login hash are added so the SPA can
// SPA can present credentials to the cross-port RC server. // present credentials to the cross-port RC server, then remove them from the
// visible address bar on load.
func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u, err := url.Parse(guiBaseURL) u, err := url.Parse(guiBaseURL)
if err != nil { if err != nil {
@@ -308,3 +312,15 @@ func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
return u.String() return u.String()
} }
func safeLoginURL(loginURL string) string {
u, err := url.Parse(loginURL)
if err != nil {
return loginURL
}
q := u.Query()
q.Del("user")
q.Del("pass")
u.RawQuery = q.Encode()
return u.String()
}