Compare commits

...
15 Commits
18 changed files with 2835 additions and 328 deletions
+3
View File
@@ -0,0 +1,3 @@
WEBGUI_BIND_ADDR=127.0.0.1
JOBS_API_BIND_ADDR=127.0.0.1
JOBS_API_ALLOWED_ORIGINS=http://localhost:5580,http://127.0.0.1:5580
+2
View File
@@ -2,7 +2,9 @@
config/rclone/rclone.conf config/rclone/rclone.conf
# Docker build artifacts / runtime # Docker build artifacts / runtime
.env
.docker/ .docker/
webgui/data/
# Editor / OS cruft # Editor / OS cruft
.idea/ .idea/
+63 -8
View File
@@ -11,13 +11,15 @@
``` ```
. .
├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里) ├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里)
│ ├── api/server.py # SQLite-backed recurring jobs API / scheduler
│ ├── data/ # jobs.sqlite 数据目录(不提交)
│ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要) │ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要)
│ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all │ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all
│ └── web/ # 静态前端(rclone rcd 直接服务) │ └── web/ # 静态前端(rclone rcd 直接服务)
│ ├── index.html │ ├── index.html
│ └── assets/ │ └── assets/
├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交) ├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交)
├── docker-compose.yml # 单容器 rclone rcd ├── docker-compose.yml # rclone rcd + jobs-api sidecar
├── DESIGN.md # UI 设计系统规范 ├── DESIGN.md # UI 设计系统规范
├── CLAUDE.md # Claude Code 协作指引 ├── CLAUDE.md # Claude Code 协作指引
└── rclone/ # submodule → github.com/rclone/rclone,纯净不改动 └── rclone/ # submodule → github.com/rclone/rclone,纯净不改动
@@ -29,28 +31,64 @@
`/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。 `/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。
- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete / - **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete /
rename / download。 rename / download。
- **同步任务** — copy / sync / move 异步任务,1.5 秒轮询进度(速度、 - **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、
ETA、已传输 / 总量、错误计数),任务元信息(src→dst)持久化到 ETA、已传输 / 总量、错误计数)。单次任务和固定循环任务都保存到
localStorage,刷新页面不丢。 SQLite;固定循环任务由 jobs-api sidecar 调度。
> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅, > OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅,
> 引导用户在终端跑 `rclone config` 完成授权。 > 引导用户在终端跑 `rclone config` 完成授权。
## 快速开始 ## 快速开始
> 安全提示:默认 Docker 编排使用 `--rc-no-auth`,RC API 可以读写和删除
> 已配置 remote 上的数据。Compose 文件默认只绑定到 `127.0.0.1`。不要把
> `5580` / `5581` 直接暴露到 LAN 或公网;需要远程访问时,请先加带认证
> 和 TLS 的反向代理。
```bash ```bash
# 1. 拉取子模块(rcd 流程用不到,自行构建 rclone 二进制时才需要) # 1. 拉取子模块(rcd 流程用不到,自行构建 rclone 二进制时才需要)
git clone --recurse-submodules <your-fork-url> git clone --recurse-submodules <your-fork-url>
# 2. 启动堆栈(单容器,无需 build) # 2. 启动堆栈(无需 build)
docker compose up -d docker compose up -d
# 3. 打开 http://localhost:5580 # 3. 打开 http://localhost:5580
``` ```
### 局域网访问
不建议把 `5580` / `5581` 直接暴露到不受信网络。`JOBS_API_ALLOWED_ORIGINS`
只限制浏览器 CORS;不带 `Origin` 的脚本或命令行客户端仍然可以直接调用
jobs-api。需要多人或跨网段访问时,优先用带认证和 TLS 的反向代理保护两个
端口。
如果只是在受信局域网临时访问,例如 `http://10.0.0.138:5580`,需要同时让
`rclone` 和 `jobs-api` 两个端口监听这个地址,并把页面 Origin 加进
jobs-api 白名单:
```bash
cp .env.example .env
cat > .env <<'EOF'
WEBGUI_BIND_ADDR=10.0.0.138
JOBS_API_BIND_ADDR=10.0.0.138
JOBS_API_ALLOWED_ORIGINS=http://localhost:5580,http://127.0.0.1:5580,http://10.0.0.138:5580
EOF
docker compose up -d
```
前端页面会从 `10.0.0.138:5580` 调用 `10.0.0.138:5581`,所以只改
`JOBS_API_ALLOWED_ORIGINS` 不够,`5581` 也必须发布到同一个可访问地址。
## 架构 ## 架构
只用一个 rclone rcd 容器,一个端口(5580),同时承担: Docker 编排包含两个服务:
| 服务 | 本机 URL | 职责 |
|---|---|---|
| `rclone` | `http://localhost:5580` | 静态前端、RC API、远端文件下载 |
| `jobs-api` | `http://localhost:5581` | SQLite 循环任务 API 和调度器 |
`rclone` 容器通过 `rclone rcd` 同时承担:
| 职责 | URL | 配置项 | | 职责 | URL | 配置项 |
|---|---|---| |---|---|---|
@@ -58,8 +96,20 @@ docker compose up -d
| RC API | `POST /config/*`、`/operations/*`、`/sync/*`、`/job/*` | 内置 | | RC API | `POST /config/*`、`/operations/*`、`/sync/*`、`/job/*` | 内置 |
| 远端文件下载 | `GET /<remote>:<path>` | `--rc-serve` | | 远端文件下载 | `GET /<remote>:<path>` | `--rc-serve` |
浏览器同源访问 → 不需要 CORS、不需要 nginx、不需要双端口。 浏览器从静态前端调用 `5580` 的 RC API,并调用 `5581` 的 jobs-api 管理
要加 TLS 或自定义 header 时,**在前面套你自己的反代**即可。 固定循环任务。jobs-api 默认只允许 `http://localhost:5580` 和
`http://127.0.0.1:5580` 这两个 Origin。反代或改端口时,同时调整
`JOBS_API_ALLOWED_ORIGINS`。
## 开发检查
```bash
find webgui/web/assets/js -name '*.js' -exec sh -c 'for f; do node --input-type=module --check < "$f" || exit 1; done' sh {} +
python3 -m py_compile webgui/api/server.py
python3 -m unittest discover -s webgui/api -p '*_test.py'
curl -sS http://127.0.0.1:5580/
curl -sS http://127.0.0.1:5581/health
```
## 自行构建 rclone(可选) ## 自行构建 rclone(可选)
@@ -67,6 +117,11 @@ Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即
如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui` 如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui`
能像 `rclone gui` 那样独立运行),可以: 能像 `rclone gui` 那样独立运行),可以:
注意:内置 `rclone webgui` 只启动静态 GUI 和 rclone RC,不会启动
`jobs-api` sidecar。当前 Jobs 页依赖 `jobs-api` 的 SQLite 接口,所以完整
的单次/循环任务体验请使用 Docker 编排,或自行启动兼容的 jobs-api 并通过
`?jobsApi=<url>` 指给前端。
```bash ```bash
# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下 # 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下
ln -s ../../webgui rclone/cmd/webgui ln -s ../../webgui rclone/cmd/webgui
+32 -5
View File
@@ -1,12 +1,16 @@
# Docker Compose stack for the rclone webgui. # Docker Compose stack for the rclone webgui.
# #
# A single rclone rcd container does all three jobs on one port: # The rclone rcd container does three jobs on one port:
# - Serves the static frontend from webgui/web/ (--rc-files) # - Serves the static frontend from webgui/web/ (--rc-files)
# - Serves RC API endpoints at POST /* (built-in) # - Serves RC API endpoints at POST /* (built-in)
# - Serves remote files at GET /<remote>:<path> (--rc-serve) # - Serves remote files at GET /<remote>:<path> (--rc-serve)
# #
# Same-origin from the browser, so no CORS / no nginx / no double ports. # The jobs-api sidecar stores recurring task definitions in SQLite and starts
# For TLS or custom headers, put a reverse proxy of your choice in front. # scheduled runs through rclone RC. The browser calls it on port 5581.
# 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:
@@ -16,7 +20,8 @@
# Usage: # Usage:
# docker compose up -d # start # docker compose up -d # start
# open http://localhost:5580 # open http://localhost:5580
# docker compose logs -f rclone # tail logs # docker compose logs -f rclone # tail rclone logs
# docker compose logs -f jobs-api # tail scheduler/API logs
# docker compose down # stop # docker compose down # stop
services: services:
@@ -42,11 +47,33 @@ services:
- rclone-cache:/cache - rclone-cache:/cache
# Scratch space for any local-FS remotes that point here. # Scratch space for any local-FS remotes that point here.
- rclone-data:/data - rclone-data:/data
# Expose the host /root tree to local filesystem transfer jobs.
- /root:/root
environment: environment:
- XDG_CONFIG_HOME=/config - XDG_CONFIG_HOME=/config
- RCLONE_CACHE_DIR=/cache - RCLONE_CACHE_DIR=/cache
ports: ports:
- "5580:8080" - "${WEBGUI_BIND_ADDR:-127.0.0.1}:5580:8080"
restart: unless-stopped
jobs-api:
image: python:3.12-alpine
container_name: rclone-jobs-api
command: ["python", "/app/server.py"]
depends_on:
- rclone
volumes:
- ./webgui/api:/app:ro
- ./webgui/data:/data
environment:
- JOBS_DB=/data/jobs.sqlite
- JOBS_API_HOST=0.0.0.0
- JOBS_API_PORT=8081
- JOBS_SCHEDULER_INTERVAL=15
- JOBS_API_ALLOWED_ORIGINS=${JOBS_API_ALLOWED_ORIGINS:-http://localhost:5580,http://127.0.0.1:5580}
- RCLONE_RC_URL=http://rclone:8080
ports:
- "${JOBS_API_BIND_ADDR:-127.0.0.1}:5581:8081"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+1096
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import unittest
import tempfile
import os
from io import BytesIO
from datetime import datetime, timezone
import server
from server import ApiError, Handler, MAX_BODY_BYTES, compute_next_run_at
class ComputeNextRunAtTest(unittest.TestCase):
def test_daily_uses_same_day_when_time_is_future(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:30", 0, after),
"2026-06-20T23:30:00Z",
)
def test_daily_moves_to_next_day_when_time_has_passed(self):
after = datetime(2026, 6, 20, 23, 30, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:30", 0, after),
"2026-06-21T23:30:00Z",
)
def test_weekly_uses_next_target_weekday(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) # Saturday
self.assertEqual(
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
"2026-06-22T09:00:00Z",
)
def test_weekly_rolls_forward_a_week_after_same_day_time_passed(self):
after = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc) # Monday
self.assertEqual(
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
"2026-06-29T09:00:00Z",
)
def test_monthly_uses_next_valid_monthday(self):
after = datetime(2026, 1, 30, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
"2026-01-31T23:00:00Z",
)
def test_monthly_skips_short_months_for_day_31(self):
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
"2026-03-31T23:00:00Z",
)
def test_monthly_last_day_uses_current_month_end(self):
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=0),
"2026-02-28T23:00:00Z",
)
def test_monthly_last_day_rolls_forward_when_current_month_end_passed(self):
after = datetime(2026, 2, 28, 23, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("monthly", "23:00", 0, after, monthday=0),
"2026-03-31T23:00:00Z",
)
def test_timezone_offset_is_applied_from_local_schedule_time(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
self.assertEqual(
compute_next_run_at("daily", "23:00", 8 * 60, after),
"2026-06-20T15:00:00Z",
)
def test_invalid_monthday_raises(self):
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
with self.assertRaises(ValueError):
compute_next_run_at("monthly", "23:00", 0, after, monthday=32)
class HandlerSecurityTest(unittest.TestCase):
def test_allowed_origin_is_accepted(self):
handler = type("DummyHandler", (), {"headers": {"Origin": "http://localhost:5580"}})()
Handler.require_allowed_origin(handler)
def test_disallowed_origin_is_rejected(self):
handler = type("DummyHandler", (), {"headers": {"Origin": "http://example.invalid"}})()
with self.assertRaises(ApiError) as ctx:
Handler.require_allowed_origin(handler)
self.assertEqual(ctx.exception.status, 403)
def test_request_body_size_is_limited(self):
handler = type(
"DummyHandler",
(),
{
"headers": {"Content-Length": str(MAX_BODY_BYTES + 1)},
"rfile": BytesIO(),
},
)()
with self.assertRaises(ApiError) as ctx:
Handler.read_json(handler)
self.assertEqual(ctx.exception.status, 413)
def test_read_json_accepts_small_object(self):
body = b'{"ok":true}'
handler = type(
"DummyHandler",
(),
{
"headers": {"Content-Length": str(len(body))},
"rfile": BytesIO(body),
},
)()
self.assertEqual(Handler.read_json(handler), {"ok": True})
class OneTimeJobTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.old_db_path = server.DB_PATH
self.old_rc_post = server.rc_post
server.DB_PATH = os.path.join(self.tmp.name, "jobs.sqlite")
self.next_jobid = 100
def fake_rc_post(path, payload, timeout=30, allow_error_body=False):
if path.startswith("sync/"):
self.next_jobid += 1
return {"jobid": self.next_jobid}
if path == "job/status":
return {"finished": False, "jobid": payload["jobid"]}
if path == "core/stats":
return {
"bytes": 1,
"totalBytes": 2,
"speed": 3,
"eta": 4,
"transfers": 5,
"totalTransfers": 6,
"errors": 7,
"group": payload["group"],
}
if path == "job/stop":
return {}
raise AssertionError(path)
server.rc_post = fake_rc_post
server.init_db()
def tearDown(self):
server.rc_post = self.old_rc_post
server.DB_PATH = self.old_db_path
self.tmp.cleanup()
def test_create_and_list_one_time_job(self):
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
self.assertEqual(job["id"], 1)
self.assertEqual(job["jobid"], 101)
self.assertEqual(job["status"], "running")
self.assertEqual(job["src"], "/tmp/a")
jobs = server.list_one_time_jobs()
self.assertEqual(len(jobs), 1)
self.assertEqual(jobs[0]["id"], 1)
self.assertEqual(jobs[0]["status"], "running")
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["bytes"], 1)
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["totalBytes"], 2)
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["speed"], 3)
def test_stop_one_time_job_persists_stopped_status(self):
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
stopped = server.stop_one_time_job(job["id"])
self.assertEqual(stopped["status"], "stopped")
self.assertFalse(stopped["running"])
self.assertIsNotNone(stopped["statusSnapshot"])
def test_run_one_time_job_again_reuses_record_with_new_jobid(self):
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
server.stop_one_time_job(job["id"])
restarted = server.manual_run_one_time_job(job["id"])
self.assertEqual(restarted["id"], job["id"])
self.assertEqual(restarted["jobid"], 102)
self.assertEqual(restarted["status"], "running")
if __name__ == "__main__":
unittest.main()
+6 -15
View File
@@ -2,15 +2,15 @@
// the top-nav active state in sync, and re-renders chrome strings when // the top-nav active state in sync, and re-renders chrome strings when
// the locale changes. // the locale changes.
import { onRoute } from "./state.js"; import { escapeHtml, onRoute } from "./state.js?v=job-layout-1";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-layout-1";
import { renderRemotes } from "./views/remotes.js"; import { renderRemotes } from "./views/remotes.js?v=job-layout-1";
import { renderBrowse } from "./views/browser.js"; import { renderBrowse } from "./views/browser.js?v=job-layout-1";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js"; import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-layout-1";
import { import {
renderConfigureNew, renderConfigureNew,
renderConfigureEdit, renderConfigureEdit,
} from "./views/configure.js"; } from "./views/configure.js?v=job-layout-1";
const views = { const views = {
remotes: renderRemotes, remotes: renderRemotes,
@@ -94,12 +94,3 @@ onLocale(() => {
// Initial chrome paint + switcher wiring. // Initial chrome paint + switcher wiring.
applyChromeStrings(); applyChromeStrings();
wireLangSwitcher(); wireLangSwitcher();
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+167 -17
View File
@@ -47,6 +47,9 @@ const STRINGS = {
"error.couldnt_list": "Couldn't list", "error.couldnt_list": "Couldn't list",
"error.couldnt_load_jobs": "Couldn't load jobs", "error.couldnt_load_jobs": "Couldn't load jobs",
"error.no_remotes_selected": "Pick source and destination remotes", "error.no_remotes_selected": "Pick source and destination remotes",
"error.no_paths_selected": "Enter source and destination paths",
"error.no_schedule_selected": "Pick a fixed recurring schedule",
"error.invalid_schedule_time": "Pick a valid run time",
"error.not_connected": "Couldn't connect to rclone", "error.not_connected": "Couldn't connect to rclone",
// footer // footer
@@ -152,32 +155,100 @@ const STRINGS = {
// jobs view // jobs view
"jobs.title": "Jobs", "jobs.title": "Jobs",
"jobs.subtitle": "Running and recently completed transfers.", "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",
"jobs.new_subtitle": "Copy, sync, or move between remotes.", "jobs.new_subtitle": "Copy, sync, or move between remotes and local paths.",
"jobs.action": "Action", "jobs.action": "Action",
"jobs.action_copy": "copy (mirror src → dst, keep both)", "jobs.action_copy": "copy (mirror src → dst, keep both)",
"jobs.action_sync": "sync (mirror src → dst, delete extras on dst)", "jobs.action_sync": "sync (mirror src → dst, delete extras on dst)",
"jobs.action_move": "move (mirror src → dst, delete src after)", "jobs.action_move": "move (mirror src → dst, delete src after)",
"jobs.source_remote": "Source remote", "jobs.schedule_type": "Job type",
"jobs.source_path": "Source path (optional)", "jobs.schedule_once": "One-time",
"jobs.dest_remote": "Destination remote", "jobs.schedule_recurring": "Fixed recurring",
"jobs.dest_path": "Destination path (optional)", "jobs.schedule_help": "One-time and fixed recurring jobs are stored in SQLite.",
"jobs.path_placeholder": "folder/subfolder", "jobs.recurrence_kind": "Recurring plan",
"jobs.recurrence_daily": "Every day",
"jobs.recurrence_weekly": "Every week",
"jobs.recurrence_monthly": "Every month",
"jobs.schedule_time": "Run time",
"jobs.schedule_weekday": "Weekday",
"jobs.schedule_monthday": "Day of month",
"jobs.fixed_schedule_help": "Runs at the selected fixed local time. If the previous run is still active, the scheduler will not start an overlapping run.",
"jobs.weekday_monday": "Monday",
"jobs.weekday_tuesday": "Tuesday",
"jobs.weekday_wednesday": "Wednesday",
"jobs.weekday_thursday": "Thursday",
"jobs.weekday_friday": "Friday",
"jobs.weekday_saturday": "Saturday",
"jobs.weekday_sunday": "Sunday",
"jobs.monthday": (n) => `Day ${n}`,
"jobs.monthday_last": "Last day",
"jobs.schedule_label_daily": (time) => `Every day at ${time}`,
"jobs.schedule_label_weekly": (day, time) => `Every ${day} at ${time}`,
"jobs.schedule_label_monthly": (day, time) => `Day ${day} of every month at ${time}`,
"jobs.schedule_label_monthly_last": (time) => `Last day of every month at ${time}`,
"jobs.local_option": "Local filesystem",
"jobs.local_path_help": "For Local filesystem, enter a path visible inside the rclone process, such as /root/source in this Docker setup. For remotes, enter a path inside the selected remote.",
"jobs.source_remote": "Source location",
"jobs.source_path": "Source path",
"jobs.dest_remote": "Destination location",
"jobs.dest_path": "Destination path",
"jobs.path_placeholder": "folder/subfolder or /local/path",
"jobs.pick_folder": "Choose",
"jobs.pick_source_title": "Choose source folder",
"jobs.pick_dest_title": "Choose destination folder",
"jobs.pick_current": "Use current folder",
"jobs.pick_empty": "No folders here.",
"jobs.start": "Start Job", "jobs.start": "Start Job",
"jobs.no_remotes_option": "(no remotes)", "jobs.no_remotes_option": "(no remotes)",
"jobs.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}`,
"jobs.recurring_saved": (id) => `Saved recurring job #${id}`,
"jobs.start_failed": "Job start failed", "jobs.start_failed": "Job start failed",
"jobs.stop_confirm": (id) => `Stop job #${id}?`, "jobs.stop_confirm": (id) => `Stop job #${id}?`,
"jobs.stopped": (id) => `Stopped job #${id}`, "jobs.stopped": (id) => `Stopped job #${id}`,
"jobs.stop_failed": "Stop failed", "jobs.stop_failed": "Stop failed",
"jobs.start_again": "Start again",
"jobs.restarted": (id) => `Started recurring job #${id}`,
"jobs.restarted_once": (id) => `Started one-time job #${id}`,
"jobs.restart_failed": "Start again failed",
"jobs.details": "Details",
"jobs.hide_details": "Hide",
"jobs.delete_record": "Delete",
"jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`,
"jobs.delete_failed": "Delete failed",
"jobs.detail_schedule_id": "Schedule ID",
"jobs.detail_record_id": "Record",
"jobs.detail_id": "Job ID",
"jobs.detail_status": "Status",
"jobs.detail_error": "Error",
"jobs.detail_started": "Started",
"jobs.detail_finished": "Finished",
"jobs.detail_duration": "Duration",
"jobs.detail_next_run": "Next run",
"jobs.detail_schedule": "Schedule",
"jobs.detail_group": "Group",
"jobs.detail_execute_id": "Execution",
"jobs.detail_bytes": "Bytes",
"jobs.detail_speed": "Speed",
"jobs.detail_eta": "ETA",
"jobs.detail_files": "Files",
"jobs.detail_errors": "Errors",
"jobs.detail_output": "Output",
"jobs.detail_none": "None",
"jobs.col.id": "#", "jobs.col.id": "#",
"jobs.col.job": "Job", "jobs.col.job": "Job",
"jobs.col.status": "Status", "jobs.col.status": "Status",
"jobs.col.next_run": "Next run",
"jobs.col.progress": "Progress", "jobs.col.progress": "Progress",
"jobs.col.speed": "Speed", "jobs.col.speed": "Speed",
"jobs.col.eta": "ETA", "jobs.col.eta": "ETA",
@@ -188,7 +259,11 @@ const STRINGS = {
"jobs.status.failed": "failed", "jobs.status.failed": "failed",
"jobs.status.done": "done", "jobs.status.done": "done",
"jobs.status.finished": "finished", "jobs.status.finished": "finished",
"jobs.status.scheduled": "scheduled",
"jobs.status.stopped": "stopped",
"jobs.stop": "Stop", "jobs.stop": "Stop",
"jobs.once_id": (id) => `once:${id}`,
"jobs.schedule_label_once": "One-time run",
"jobs.submitted_cli": "— submitted via CLI —", "jobs.submitted_cli": "— submitted via CLI —",
}, },
@@ -209,7 +284,10 @@ const STRINGS = {
"error.couldnt_load_remotes": "无法加载远程存储列表", "error.couldnt_load_remotes": "无法加载远程存储列表",
"error.couldnt_list": "无法列出", "error.couldnt_list": "无法列出",
"error.couldnt_load_jobs": "无法加载任务列表", "error.couldnt_load_jobs": "无法加载任务列表",
"error.no_remotes_selected": "请选择源和目标远程存储", "error.no_remotes_selected": "请选择源和目标位置",
"error.no_paths_selected": "请输入源路径和目标路径",
"error.no_schedule_selected": "请选择固定循环计划",
"error.invalid_schedule_time": "请选择有效的运行时间",
"error.not_connected": "无法连接到 rclone", "error.not_connected": "无法连接到 rclone",
"footer.product": "产品", "footer.product": "产品",
@@ -307,32 +385,100 @@ const STRINGS = {
"configure.example_custom_placeholder": "自定义值", "configure.example_custom_placeholder": "自定义值",
"jobs.title": "任务", "jobs.title": "任务",
"jobs.subtitle": "正在运行和最近完成的传输。", "jobs.subtitle": "已保存的传输任务及其最近运行状态。",
"jobs.new_btn": "新建任务", "jobs.new_btn": "新建任务",
"jobs.cancel": "取消", "jobs.cancel": "取消",
"jobs.new_title": "新建任务", "jobs.new_title": "新建任务",
"jobs.new_subtitle": "在远程存储之间复制、同步或移动。", "jobs.new_subtitle": "在远程存储和本地路径之间复制、同步或移动。",
"jobs.action": "操作", "jobs.action": "操作",
"jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)", "jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)",
"jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)", "jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)",
"jobs.action_move": "move(镜像 源 → 目标,完成后删除源)", "jobs.action_move": "move(镜像 源 → 目标,完成后删除源)",
"jobs.source_remote": "源远程存储", "jobs.schedule_type": "任务类型",
"jobs.source_path": "源路径(可选)", "jobs.schedule_once": "单次任务",
"jobs.dest_remote": "目标远程存储", "jobs.schedule_recurring": "固定循环",
"jobs.dest_path": "目标路径(可选)", "jobs.schedule_help": "单次任务和固定循环任务都会保存到 SQLite。",
"jobs.path_placeholder": "文件夹/子文件夹", "jobs.recurrence_kind": "循环计划",
"jobs.recurrence_daily": "每天",
"jobs.recurrence_weekly": "每周",
"jobs.recurrence_monthly": "每月",
"jobs.schedule_time": "运行时间",
"jobs.schedule_weekday": "星期",
"jobs.schedule_monthday": "每月几号",
"jobs.fixed_schedule_help": "按所选本地固定时间运行;如果上一轮仍在运行,不会重叠启动。",
"jobs.weekday_monday": "星期一",
"jobs.weekday_tuesday": "星期二",
"jobs.weekday_wednesday": "星期三",
"jobs.weekday_thursday": "星期四",
"jobs.weekday_friday": "星期五",
"jobs.weekday_saturday": "星期六",
"jobs.weekday_sunday": "星期日",
"jobs.monthday": (n) => `${n} 号`,
"jobs.monthday_last": "最后一天",
"jobs.schedule_label_daily": (time) => `每天 ${time}`,
"jobs.schedule_label_weekly": (day, time) => `每周${day.replace(/^星期/, "")} ${time}`,
"jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`,
"jobs.schedule_label_monthly_last": (time) => `每月最后一天 ${time}`,
"jobs.local_option": "本地文件系统",
"jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /root/source。选择远程存储时填写该远程内的路径。",
"jobs.source_remote": "源位置",
"jobs.source_path": "源路径",
"jobs.dest_remote": "目标位置",
"jobs.dest_path": "目标路径",
"jobs.path_placeholder": "文件夹/子文件夹 或 /local/path",
"jobs.pick_folder": "选择",
"jobs.pick_source_title": "选择源文件夹",
"jobs.pick_dest_title": "选择目标文件夹",
"jobs.pick_current": "使用当前文件夹",
"jobs.pick_empty": "这里没有文件夹。",
"jobs.start": "开始任务", "jobs.start": "开始任务",
"jobs.no_remotes_option": "(无远程存储)", "jobs.no_remotes_option": "(无远程存储)",
"jobs.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.recurring_saved": (id) => `已保存循环任务 #${id}`,
"jobs.start_failed": "任务启动失败", "jobs.start_failed": "任务启动失败",
"jobs.stop_confirm": (id) => `停止任务 #${id}?`, "jobs.stop_confirm": (id) => `停止任务 #${id}?`,
"jobs.stopped": (id) => `已停止任务 #${id}`, "jobs.stopped": (id) => `已停止任务 #${id}`,
"jobs.stop_failed": "停止失败", "jobs.stop_failed": "停止失败",
"jobs.start_again": "再次启动",
"jobs.restarted": (id) => `已启动循环任务 #${id}`,
"jobs.restarted_once": (id) => `已启动单次任务 #${id}`,
"jobs.restart_failed": "再次启动失败",
"jobs.details": "详情",
"jobs.hide_details": "收起",
"jobs.delete_record": "删除",
"jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`,
"jobs.delete_failed": "删除失败",
"jobs.detail_schedule_id": "调度 ID",
"jobs.detail_record_id": "记录",
"jobs.detail_id": "任务 ID",
"jobs.detail_status": "状态",
"jobs.detail_error": "错误",
"jobs.detail_started": "开始时间",
"jobs.detail_finished": "结束时间",
"jobs.detail_duration": "耗时",
"jobs.detail_next_run": "下次启动",
"jobs.detail_schedule": "固定计划",
"jobs.detail_group": "分组",
"jobs.detail_execute_id": "执行实例",
"jobs.detail_bytes": "字节",
"jobs.detail_speed": "速度",
"jobs.detail_eta": "预计剩余",
"jobs.detail_files": "文件",
"jobs.detail_errors": "错误",
"jobs.detail_output": "输出",
"jobs.detail_none": "无",
"jobs.col.id": "#", "jobs.col.id": "#",
"jobs.col.job": "任务", "jobs.col.job": "任务",
"jobs.col.status": "状态", "jobs.col.status": "状态",
"jobs.col.next_run": "下次启动",
"jobs.col.progress": "进度", "jobs.col.progress": "进度",
"jobs.col.speed": "速度", "jobs.col.speed": "速度",
"jobs.col.eta": "预计剩余", "jobs.col.eta": "预计剩余",
@@ -343,7 +489,11 @@ const STRINGS = {
"jobs.status.failed": "失败", "jobs.status.failed": "失败",
"jobs.status.done": "完成", "jobs.status.done": "完成",
"jobs.status.finished": "已结束", "jobs.status.finished": "已结束",
"jobs.status.scheduled": "已调度",
"jobs.status.stopped": "已停止",
"jobs.stop": "停止", "jobs.stop": "停止",
"jobs.once_id": (id) => `单次:${id}`,
"jobs.schedule_label_once": "单次运行",
"jobs.submitted_cli": "— 通过命令行提交 —", "jobs.submitted_cli": "— 通过命令行提交 —",
}, },
}; };
+99
View File
@@ -0,0 +1,99 @@
// jobs_api.js — client for the SQLite-backed recurring jobs sidecar.
const params = new URLSearchParams(location.search);
const QUERY_API = params.get("jobsApi");
const DEFAULT_PORT = "5581";
const API_BASE = QUERY_API
? QUERY_API.replace(/\/$/, "")
: `${location.protocol}//${location.hostname || "127.0.0.1"}:${DEFAULT_PORT}`;
export function jobsApiURL() {
return API_BASE;
}
export async function listRecurringJobs() {
const res = await request("/api/jobs");
return (res && res.jobs) || [];
}
export async function createRecurringJob(job) {
const res = await request("/api/jobs", {
method: "POST",
body: job,
});
return res && res.job;
}
export async function runRecurringJobNow(id) {
const res = await request(`/api/jobs/${encodeURIComponent(id)}/run`, {
method: "POST",
});
return res && res.job;
}
export async function stopRecurringJob(id) {
const res = await request(`/api/jobs/${encodeURIComponent(id)}/stop`, {
method: "POST",
});
return res && res.job;
}
export async function deleteRecurringJob(id) {
return request(`/api/jobs/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
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 } = {}) {
const headers = { "Content-Type": "application/json" };
const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body == null ? undefined : JSON.stringify(body),
});
let data = {};
const ct = res.headers.get("Content-Type") || "";
if (ct.includes("application/json")) {
data = await res.json();
} else {
const text = await res.text();
data = text ? { error: text } : {};
}
if (!res.ok) {
throw new Error(data.error || `HTTP ${res.status}`);
}
return data;
}
+19 -12
View File
@@ -15,6 +15,7 @@ const AUTH_PASS = params.get("pass");
let authHeader = null; let authHeader = null;
if (AUTH_USER && AUTH_PASS) { if (AUTH_USER && AUTH_PASS) {
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`); authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
scrubAuthQuery();
} }
export function rcURL() { export function rcURL() {
@@ -30,8 +31,15 @@ export function isNoAuth() {
return !authHeader; return !authHeader;
} }
function scrubAuthQuery() {
const clean = new URL(location.href);
clean.searchParams.delete("user");
clean.searchParams.delete("pass");
history.replaceState(null, "", `${clean.pathname}${clean.search}${clean.hash}`);
}
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws. // POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
export async function post(path, body = {}) { export async function post(path, body = {}, options = {}) {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
if (authHeader) headers["Authorization"] = authHeader; if (authHeader) headers["Authorization"] = authHeader;
const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), { const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), {
@@ -39,7 +47,7 @@ export async function post(path, body = {}) {
headers, headers,
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
return parseResponse(res, path); return parseResponse(res, path, options);
} }
// POST JSON and request an async job. Returns { jobid, executeId }. // POST JSON and request an async job. Returns { jobid, executeId }.
@@ -72,17 +80,16 @@ export async function uploadFile(fs, remote, files) {
export function downloadURL(remoteFs, remotePath, fileName) { export function downloadURL(remoteFs, remotePath, fileName) {
const base = RC_BASE.replace(/\/$/, ""); const base = RC_BASE.replace(/\/$/, "");
// rc server serves remote files at /<remote>:<path> // rc server serves remote files at /<remote>:<path>
const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, ""); const path = [remoteFs, remotePath, fileName]
const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`; .filter(Boolean)
let url = `${base}/${path}`; .flatMap((part) => String(part).split("/"))
if (authHeader) { .filter((part) => part !== "")
// Embed basic auth into the URL so the browser can fetch it directly. .map(encodeURIComponent)
url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`); .join("/");
} return `${base}/${path}`;
return url;
} }
async function parseResponse(res, path) { async function parseResponse(res, path, { allowError = false } = {}) {
let body = null; let body = null;
const ct = res.headers.get("Content-Type") || ""; const ct = res.headers.get("Content-Type") || "";
if (ct.includes("application/json")) { if (ct.includes("application/json")) {
@@ -98,7 +105,7 @@ async function parseResponse(res, path) {
err.body = body; err.body = body;
throw err; throw err;
} }
if (body && body.error) { if (body && body.error && !allowError) {
throw new Error(`${path}: ${body.error}`); throw new Error(`${path}: ${body.error}`);
} }
return body; return body;
+10 -59
View File
@@ -1,49 +1,12 @@
// state.js — minimal pub/sub store + hash router + localStorage-backed // state.js — minimal pub/sub store + hash router. Vanilla, no framework.
// job metadata cache. Vanilla, no framework.
const JOB_META_KEY = "webgui:jobMeta";
const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const listeners = new Set(); const listeners = new Set();
const state = { const state = {
remotes: [], // [{ name, type }] remotes: [], // [{ name, type }]
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst } jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
providers: null, // cached /config/providers response providers: null, // cached /config/providers response
// jobMeta is persisted to localStorage so the jobs view can show
// src/dst for jobs after a page reload. Keyed by jobid (number).
jobMeta: loadJobMeta(),
}; };
function loadJobMeta() {
try {
const raw = localStorage.getItem(JOB_META_KEY);
if (!raw) return new Map();
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return new Map();
const now = Date.now();
const map = new Map();
for (const [id, meta] of arr) {
if (meta && typeof meta.submittedAt === "number") {
if (now - meta.submittedAt < JOB_META_TTL_MS) {
map.set(Number(id), meta);
}
}
}
return map;
} catch {
return new Map();
}
}
function saveJobMeta() {
try {
const arr = Array.from(state.jobMeta.entries());
localStorage.setItem(JOB_META_KEY, JSON.stringify(arr));
} catch {
// localStorage might be unavailable (private mode, quota); ignore.
}
}
export function getState() { export function getState() {
return state; return state;
} }
@@ -64,27 +27,6 @@ export function subscribe(fn) {
return () => listeners.delete(fn); return () => listeners.delete(fn);
} }
// --- Job metadata API ---
export function rememberJob(jobid, { action, src, dst }) {
state.jobMeta.set(Number(jobid), {
action,
src,
dst,
submittedAt: Date.now(),
});
saveJobMeta();
}
export function getJobMeta(jobid) {
return state.jobMeta.get(Number(jobid));
}
export function forgetJob(jobid) {
state.jobMeta.delete(Number(jobid));
saveJobMeta();
}
// --- Router --- // --- Router ---
// Hash-based routes: // Hash-based routes:
// #/remotes // #/remotes
@@ -214,3 +156,12 @@ export function formatTime(iso) {
return iso; return iso;
} }
} }
export function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+28 -26
View File
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. // views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js"; import { post, uploadFile, downloadURL } from "../rc.js?v=job-layout-1";
import { toast, formatBytes, formatTime } from "../state.js"; import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js"; import { t } from "../i18n.js?v=job-layout-1";
export async function renderBrowse({ remote, path }) { export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -37,18 +37,7 @@ export async function renderBrowse({ remote, path }) {
const card = document.getElementById("browser-card"); const card = document.getElementById("browser-card");
const fileInput = document.getElementById("upload-input"); const fileInput = document.getElementById("upload-input");
try { await refreshListing(card, fs, path);
const res = await post("operations/list", {
fs,
remote: path || "",
opt: { noModTime: false, noMimeType: true, showHash: false },
});
const items = (res && res.list) || [];
renderTable(card, fs, path, items);
} catch (e) {
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
toast(`${t("browser.browse_failed")}: ${e.message}`, "error");
}
app.querySelector('[data-action="mkdir"]').addEventListener("click", () => { app.querySelector('[data-action="mkdir"]').addEventListener("click", () => {
openModal( openModal(
@@ -61,6 +50,7 @@ export async function renderBrowse({ remote, path }) {
const target = path ? `${path}/${name}` : name; const target = path ? `${path}/${name}` : name;
await post("operations/mkdir", { fs, remote: target }); await post("operations/mkdir", { fs, remote: target });
toast(t("browser.mkdir_created", name), "success"); toast(t("browser.mkdir_created", name), "success");
await refreshListing(card, fs, path);
}, },
).catch((e) => toast(`${t("browser.mkdir_failed")}: ${e.message}`, "error")); ).catch((e) => toast(`${t("browser.mkdir_failed")}: ${e.message}`, "error"));
}); });
@@ -76,13 +66,30 @@ export async function renderBrowse({ remote, path }) {
try { try {
await uploadFile(fs, path || "", files); await uploadFile(fs, path || "", files);
toast(t("browser.uploaded", files.length), "success"); toast(t("browser.uploaded", files.length), "success");
location.reload(); await refreshListing(card, fs, path);
} catch (e) { } catch (e) {
toast(`${t("browser.upload_failed")}: ${e.message}`, "error"); toast(`${t("browser.upload_failed")}: ${e.message}`, "error");
} }
}); });
} }
async function refreshListing(card, fs, path) {
if (!card) return;
card.innerHTML = `<p class="empty">${t("loading.title")}…</p>`;
try {
const res = await post("operations/list", {
fs,
remote: path || "",
opt: { noModTime: false, noMimeType: true, showHash: false },
});
const items = (res && res.list) || [];
renderTable(card, fs, path, items);
} catch (e) {
card.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
toast(`${t("browser.browse_failed")}: ${e.message}`, "error");
}
}
function renderBreadcrumbs(el, remote, path) { function renderBreadcrumbs(el, remote, path) {
const segments = (path || "").split("/").filter(Boolean); const segments = (path || "").split("/").filter(Boolean);
let html = `<a href="#/remotes">${t("nav.remotes")}</a><span class="sep">/</span>`; let html = `<a href="#/remotes">${t("nav.remotes")}</a><span class="sep">/</span>`;
@@ -162,7 +169,7 @@ function row(fs, path, item) {
return ` return `
<tr> <tr>
<td class="col-name"> <td class="col-name">
<a href="${downloadURL(fs, path, item.Name)}" download="${escapeHtml(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a> <a href="${escapeAttr(downloadURL(fs, path, item.Name))}" download="${escapeAttr(item.Name)}">${icon("file")} ${escapeHtml(item.Name)}</a>
</td> </td>
<td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td> <td class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td> <td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
@@ -198,7 +205,7 @@ async function onDelete(fs, path, itemPath) {
try { try {
await post("operations/deletefile", { fs, remote: itemPath }); await post("operations/deletefile", { fs, remote: itemPath });
toast(t("browser.deleted", itemPath), "success"); toast(t("browser.deleted", itemPath), "success");
location.reload(); await refreshListing(document.getElementById("browser-card"), fs, path);
} catch (e) { } catch (e) {
toast(`${t("browser.delete_file_failed")}: ${e.message}`, "error"); toast(`${t("browser.delete_file_failed")}: ${e.message}`, "error");
} }
@@ -224,7 +231,7 @@ async function onRename(fs, path, itemPath) {
toast(t("browser.renamed", name), "success"); toast(t("browser.renamed", name), "success");
}, },
); );
location.reload(); await refreshListing(document.getElementById("browser-card"), fs, path);
} catch (e) { } catch (e) {
toast(`${t("browser.rename_failed")}: ${e.message}`, "error"); toast(`${t("browser.rename_failed")}: ${e.message}`, "error");
} }
@@ -290,11 +297,6 @@ export function openModal(title, fields, onSubmit) {
}); });
} }
function escapeHtml(s) { function escapeAttr(s) {
return String(s) return escapeHtml(s);
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
} }
+8 -16
View File
@@ -9,9 +9,9 @@
// OAuth backends (option named "token" with IsPassword) get a banner // OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal. // and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js"; import { post } from "../rc.js?v=job-layout-1";
import { getState, setState, toast } from "../state.js"; import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js"; import { t } from "../i18n.js?v=job-layout-1";
// --- Route entrypoints --- // --- Route entrypoints ---
@@ -25,7 +25,7 @@ export async function renderConfigureNew({ provider = "" }) {
document.getElementById("app").innerHTML = ` document.getElementById("app").innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("error.unknown_remote")}</h3> <h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_provider", provider)}</p> <p>${t("error.no_such_provider", escapeHtml(provider))}</p>
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p> <p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
</div>`; </div>`;
return; return;
@@ -53,7 +53,7 @@ export async function renderConfigureEdit({ remote }) {
app.innerHTML = ` app.innerHTML = `
<div class="empty"> <div class="empty">
<h3>${t("error.unknown_remote")}</h3> <h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_remote", remote)}</p> <p>${t("error.no_such_remote", escapeHtml(remote))}</p>
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p> <p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
</div>`; </div>`;
return; return;
@@ -155,6 +155,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
const requiresOAuth = provider.Options.some( const requiresOAuth = provider.Options.some(
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive), (o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
); );
const blocksCreate = requiresOAuth && !isEdit;
// Partition options into required, optional-basic, optional-advanced. // Partition options into required, optional-basic, optional-advanced.
const required = provider.Options.filter((o) => o.Required && !o.Hide); const required = provider.Options.filter((o) => o.Required && !o.Hide);
@@ -206,7 +207,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
</div> </div>
<div class="toolbar"> <div class="toolbar">
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}> <button type="submit" class="btn btn-primary" ${blocksCreate ? "disabled" : ""}>
${isEdit ? t("configure.save") : t("configure.create")} ${isEdit ? t("configure.save") : t("configure.create")}
</button> </button>
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a> <a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
@@ -255,7 +256,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
if (requiresOAuth) return; if (blocksCreate) return;
const name = form.elements._remote_name.value.trim(); const name = form.elements._remote_name.value.trim();
if (!name) return; if (!name) return;
@@ -426,15 +427,6 @@ async function ensureProviders() {
return providers; return providers;
} }
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttr(s) { function escapeAttr(s) {
return escapeHtml(s); return escapeHtml(s);
} }
File diff suppressed because it is too large Load Diff
+3 -12
View File
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD. // views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js"; import { post } from "../rc.js?v=job-layout-1";
import { getState, setState, toast } from "../state.js"; import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js"; import { t } from "../i18n.js?v=job-layout-1";
export async function renderRemotes() { export async function renderRemotes() {
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -94,12 +94,3 @@ export async function renderRemotes() {
toast(`${t("remotes.load_failed")}: ${e.message}`, "error"); toast(`${t("remotes.load_failed")}: ${e.message}`, "error");
} }
} }
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+321 -12
View File
@@ -1,5 +1,9 @@
/* Component library — all UI primitives map to DESIGN.md tokens. */ /* Component library — all UI primitives map to DESIGN.md tokens. */
[hidden] {
display: none !important;
}
/* ---------- Top Navigation ---------- */ /* ---------- Top Navigation ---------- */
.top-nav { .top-nav {
display: flex; display: flex;
@@ -303,6 +307,78 @@
color: var(--color-muted); color: var(--color-muted);
} }
.input-action {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.input-action .input {
flex: 1;
min-width: 0;
}
.input-action .btn {
flex-shrink: 0;
}
.input-action .select {
flex: 0 0 150px;
width: auto;
}
.segmented {
display: inline-flex;
width: fit-content;
padding: 3px;
border: 1px solid var(--color-hairline);
border-radius: var(--radius-md);
background-color: var(--color-surface-soft);
}
.segmented label {
position: relative;
color: var(--color-muted);
}
.segmented input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.segmented span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92px;
height: 32px;
padding: 0 var(--space-sm);
border-radius: var(--radius-sm);
cursor: pointer;
font: var(--typo-button);
}
.segmented input:checked + span {
background-color: var(--color-canvas);
color: var(--color-ink);
box-shadow: 0 1px 2px rgba(20, 20, 19, 0.06);
}
.segmented-wide {
max-width: 100%;
flex-wrap: wrap;
}
.recurrence-fields {
display: grid;
gap: var(--space-md);
}
.recurrence-fields[hidden] {
display: none;
}
/* ---------- Badges ---------- */ /* ---------- Badges ---------- */
.badge { .badge {
display: inline-flex; display: inline-flex;
@@ -580,6 +656,49 @@
border-color: transparent; border-color: transparent;
} }
.folder-picker {
max-width: 640px;
}
.folder-picker-path {
padding: var(--space-sm) var(--space-md);
background-color: var(--color-surface-dark-elevated);
border-radius: var(--radius-md);
color: var(--color-on-dark-soft);
font: var(--typo-code);
word-break: break-all;
}
.folder-picker-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
max-height: 360px;
overflow: auto;
}
.folder-picker-row {
width: 100%;
padding: var(--space-sm) var(--space-md);
background-color: var(--color-surface-dark-elevated);
color: var(--color-on-dark);
border: 1px solid transparent;
border-radius: var(--radius-md);
text-align: left;
font: var(--typo-body-sm);
cursor: pointer;
}
.folder-picker-row:hover {
border-color: var(--color-primary);
}
.folder-picker-row:disabled {
color: var(--color-muted-soft);
cursor: not-allowed;
opacity: 0.6;
}
/* ---------- Footer ---------- */ /* ---------- Footer ---------- */
.footer { .footer {
background-color: var(--color-surface-dark); background-color: var(--color-surface-dark);
@@ -746,29 +865,194 @@
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 {
display: flex;
justify-content: flex-end;
gap: var(--space-xs);
flex-wrap: wrap;
}
.job-detail {
grid-column: 1 / -1;
display: grid;
gap: var(--space-sm);
padding: var(--space-md);
background-color: var(--color-surface-soft);
border-radius: var(--radius-sm);
}
.job-detail dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-xs) var(--space-lg);
margin: 0;
}
.job-detail dl div {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: var(--space-sm);
}
.job-detail dt {
color: var(--color-muted);
font: var(--typo-caption);
}
.job-detail dd {
margin: 0;
color: var(--color-body);
font: var(--typo-code);
word-break: break-word;
}
.job-detail-output span {
display: block;
margin-bottom: var(--space-xs);
color: var(--color-muted);
font: var(--typo-caption);
}
.job-detail-output pre {
max-height: 220px;
margin: 0;
padding: var(--space-sm);
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background-color: var(--color-canvas);
border: 1px solid var(--color-hairline-soft);
border-radius: var(--radius-sm);
color: var(--color-body);
font: var(--typo-code);
} }
/* ---------- Responsive ---------- */ /* ---------- Responsive ---------- */
@@ -782,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) {
@@ -800,4 +1093,20 @@
.top-nav nav { .top-nav nav {
display: none; display: none;
} }
.job-item {
padding: var(--space-md);
}
.job-main {
grid-template-columns: 1fr;
}
.job-schedule {
grid-column: 1;
}
.job-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.job-detail dl,
.job-detail dl div {
grid-template-columns: 1fr;
}
} }
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css"> <link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css"> <link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css"> <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"></script> <script type="module" src="/assets/js/app.js?v=job-layout-1"></script>
</body> </body>
</html> </html>
+21 -5
View File
@@ -171,7 +171,7 @@ network.
return fmt.Errorf("failed to make password: %w", err) return fmt.Errorf("failed to make password: %w", err)
} }
opt.Auth.BasicPass = randomPass opt.Auth.BasicPass = randomPass
fs.Infof(nil, "No password specified. Using random password: %s", randomPass) fs.Infof(nil, "No password specified. Using random password for this session")
} }
} }
@@ -200,10 +200,13 @@ network.
fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL) fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL)
// Build the launch URL: always pass ?url=<rcURL> so the SPA can // Build the launch URL: always pass ?url=<rcURL> so the SPA can
// discover the RC base; embed user/pass only when auth is on. // discover the RC base.
loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth) loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth)
fs.Logf(nil, "GUI available at %s", loginURL) fs.Logf(nil, "GUI available at %s", safeLoginURL(loginURL))
if !opt.NoAuth {
fs.Logf(nil, "GUI authentication user: %s", opt.Auth.BasicUser)
}
if !noOpenBrowser { if !noOpenBrowser {
if err := open.Start(loginURL); err != nil { if err := open.Start(loginURL); err != nil {
fs.Errorf(nil, "failed to open GUI in browser: %v", err) fs.Errorf(nil, "failed to open GUI in browser: %v", err)
@@ -287,8 +290,9 @@ func guiHandler(srcFS iofs.FS) http.Handler {
// buildLoginURL constructs the URL the browser should open. The query // buildLoginURL constructs the URL the browser should open. The query
// string always carries the RC API base URL so the SPA can find it. // string always carries the RC API base URL so the SPA can find it.
// When auth is enabled, user/pass and a /login hash are added so the // When auth is enabled, user/pass and a /login hash are added so the SPA can
// SPA can present credentials to the cross-port RC server. // present credentials to the cross-port RC server, then remove them from the
// visible address bar on load.
func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string { func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u, err := url.Parse(guiBaseURL) u, err := url.Parse(guiBaseURL)
if err != nil { if err != nil {
@@ -308,3 +312,15 @@ func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
return u.String() return u.String()
} }
func safeLoginURL(loginURL string) string {
u, err := url.Parse(loginURL)
if err != nil {
return loginURL
}
q := u.Query()
q.Del("user")
q.Del("pass")
u.RawQuery = q.Encode()
return u.String()
}