Compare commits
9
Commits
aff11b77b6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a06236dab | ||
|
|
a1b791e6ef | ||
|
|
ff73470142 | ||
|
|
c2289f889e | ||
|
|
0e3dc9a96f | ||
|
|
ba9d0977e3 | ||
|
|
cd051900b5 | ||
|
|
161f266829 | ||
|
|
bce0c6188e |
@@ -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
|
||||||
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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": "— 通过命令行提交 —",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -156,3 +156,12 @@ export function formatTime(iso) {
|
|||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAttr(s) {
|
function escapeAttr(s) {
|
||||||
return escapeHtml(s);
|
return escapeHtml(s);
|
||||||
}
|
}
|
||||||
|
|||||||
+268
-126
@@ -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, "&")
|
...job,
|
||||||
.replace(/</g, "<")
|
kind: "recurring",
|
||||||
.replace(/>/g, ">")
|
id: `recurring:${job.id}`,
|
||||||
.replace(/"/g, """)
|
scheduleId: job.id,
|
||||||
.replace(/'/g, "'");
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "";
|
||||||
|
|||||||
@@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user