Compare commits
3
Commits
c2289f889e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a06236dab | ||
|
|
a1b791e6ef | ||
|
|
ff73470142 |
@@ -57,7 +57,12 @@ docker compose up -d
|
||||
|
||||
### 局域网访问
|
||||
|
||||
如果要从另一台机器访问,例如 `http://10.0.0.138:5580`,需要同时让
|
||||
不建议把 `5580` / `5581` 直接暴露到不受信网络。`JOBS_API_ALLOWED_ORIGINS`
|
||||
只限制浏览器 CORS;不带 `Origin` 的脚本或命令行客户端仍然可以直接调用
|
||||
jobs-api。需要多人或跨网段访问时,优先用带认证和 TLS 的反向代理保护两个
|
||||
端口。
|
||||
|
||||
如果只是在受信局域网临时访问,例如 `http://10.0.0.138:5580`,需要同时让
|
||||
`rclone` 和 `jobs-api` 两个端口监听这个地址,并把页面 Origin 加进
|
||||
jobs-api 白名单:
|
||||
|
||||
@@ -99,7 +104,7 @@ Docker 编排包含两个服务:
|
||||
## 开发检查
|
||||
|
||||
```bash
|
||||
find webgui/web/assets/js -name '*.js' -exec node --check {} \;
|
||||
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/
|
||||
@@ -112,6 +117,11 @@ Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即
|
||||
如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui`
|
||||
能像 `rclone gui` 那样独立运行),可以:
|
||||
|
||||
注意:内置 `rclone webgui` 只启动静态 GUI 和 rclone RC,不会启动
|
||||
`jobs-api` sidecar。当前 Jobs 页依赖 `jobs-api` 的 SQLite 接口,所以完整
|
||||
的单次/循环任务体验请使用 Docker 编排,或自行启动兼容的 jobs-api 并通过
|
||||
`?jobsApi=<url>` 指给前端。
|
||||
|
||||
```bash
|
||||
# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下
|
||||
ln -s ../../webgui rclone/cmd/webgui
|
||||
|
||||
+39
-27
@@ -16,6 +16,7 @@ import traceback
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from calendar import monthrange
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
@@ -178,11 +179,22 @@ def connect() -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_connect():
|
||||
with db_lock:
|
||||
conn = connect()
|
||||
try:
|
||||
with conn:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
db_dir = os.path.dirname(DB_PATH)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS job_schedules (
|
||||
@@ -322,7 +334,7 @@ def row_to_one_time_job(row: sqlite3.Row) -> 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()
|
||||
if row is None:
|
||||
raise ApiError(404, "job not found")
|
||||
@@ -331,13 +343,13 @@ def fetch_job(schedule_id: int) -> dict[str, Any]:
|
||||
|
||||
def list_jobs() -> list[dict[str, Any]]:
|
||||
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()
|
||||
return [row_to_job(row) for row in rows]
|
||||
|
||||
|
||||
def fetch_one_time_job(job_id: int) -> dict[str, Any]:
|
||||
with db_lock, connect() as conn:
|
||||
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")
|
||||
@@ -346,7 +358,7 @@ def fetch_one_time_job(job_id: int) -> dict[str, Any]:
|
||||
|
||||
def list_one_time_jobs() -> list[dict[str, Any]]:
|
||||
refresh_one_time_jobs()
|
||||
with db_lock, connect() as conn:
|
||||
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]
|
||||
|
||||
@@ -383,7 +395,7 @@ def create_job(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
interval_seconds = legacy_interval_seconds(schedule_kind)
|
||||
|
||||
now = iso(utcnow())
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO job_schedules
|
||||
@@ -423,7 +435,7 @@ def create_one_time_job(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise ApiError(400, "src and dst are required")
|
||||
|
||||
now = iso(utcnow())
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO one_time_jobs
|
||||
@@ -452,7 +464,7 @@ def legacy_interval_seconds(schedule_kind: str) -> int:
|
||||
|
||||
|
||||
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,))
|
||||
if cur.rowcount == 0:
|
||||
raise ApiError(404, "job not found")
|
||||
@@ -461,7 +473,7 @@ def delete_job(schedule_id: int) -> None:
|
||||
def manual_run(schedule_id: int) -> dict[str, Any]:
|
||||
with scheduler_lock:
|
||||
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()
|
||||
if row is None:
|
||||
raise ApiError(404, "job not found")
|
||||
@@ -472,7 +484,7 @@ def manual_run(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()
|
||||
if row is None:
|
||||
raise ApiError(404, "job not found")
|
||||
@@ -485,7 +497,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
|
||||
now = iso(now_dt)
|
||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE job_schedules
|
||||
@@ -512,7 +524,7 @@ def stop_job(schedule_id: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def manual_run_one_time_job(job_id: int) -> dict[str, Any]:
|
||||
with db_lock, connect() as conn:
|
||||
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")
|
||||
@@ -523,7 +535,7 @@ def manual_run_one_time_job(job_id: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def stop_one_time_job(job_id: int) -> dict[str, Any]:
|
||||
with db_lock, connect() as conn:
|
||||
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")
|
||||
@@ -534,7 +546,7 @@ def stop_one_time_job(job_id: int) -> dict[str, Any]:
|
||||
rc_post("job/stop", {"jobid": jobid})
|
||||
now = iso(utcnow())
|
||||
snapshot = json.dumps({"finished": True, "error": "stopped", "jobid": jobid}, separators=(",", ":"))
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE one_time_jobs
|
||||
@@ -551,7 +563,7 @@ def stop_one_time_job(job_id: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def delete_one_time_job(job_id: int) -> None:
|
||||
with db_lock, connect() as conn:
|
||||
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")
|
||||
@@ -619,7 +631,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
|
||||
error = str(exc)
|
||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO job_runs
|
||||
@@ -647,7 +659,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
|
||||
|
||||
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
|
||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO job_runs
|
||||
@@ -675,7 +687,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
|
||||
|
||||
|
||||
def start_one_time_job(job_id: int) -> None:
|
||||
with db_lock, connect() as conn:
|
||||
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")
|
||||
@@ -699,7 +711,7 @@ def start_one_time_job(job_id: int) -> None:
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
snapshot = json.dumps({"finished": True, "error": error}, separators=(",", ":"))
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE one_time_jobs
|
||||
@@ -717,7 +729,7 @@ def start_one_time_job(job_id: int) -> None:
|
||||
return
|
||||
|
||||
snapshot = json.dumps({"finished": False, "jobid": jobid}, separators=(",", ":"))
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE one_time_jobs
|
||||
@@ -762,7 +774,7 @@ def status_with_stats(kind: str, row: sqlite3.Row, jobid: int) -> dict[str, Any]
|
||||
|
||||
|
||||
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()
|
||||
|
||||
for row in rows:
|
||||
@@ -776,7 +788,7 @@ def refresh_running_jobs() -> None:
|
||||
|
||||
|
||||
def refresh_one_time_jobs() -> None:
|
||||
with db_lock, connect() as conn:
|
||||
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:
|
||||
@@ -797,7 +809,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
||||
name = status_name(status)
|
||||
|
||||
if name == "running":
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE job_schedules
|
||||
@@ -821,7 +833,7 @@ def apply_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
||||
|
||||
error = status.get("error")
|
||||
next_run_at = compute_row_next_run_at(row, now_dt)
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE job_schedules
|
||||
@@ -854,7 +866,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
||||
name = status_name(status)
|
||||
|
||||
if name == "running":
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE one_time_jobs
|
||||
@@ -869,7 +881,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
error = status.get("error")
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE one_time_jobs
|
||||
@@ -887,7 +899,7 @@ def apply_one_time_status(row: sqlite3.Row, status: dict[str, Any]) -> None:
|
||||
def run_due_jobs() -> None:
|
||||
with scheduler_lock:
|
||||
now = iso(utcnow())
|
||||
with db_lock, connect() as conn:
|
||||
with db_connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM job_schedules
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
// the top-nav active state in sync, and re-renders chrome strings when
|
||||
// the locale changes.
|
||||
|
||||
import { onRoute } from "./state.js?v=job-stats-1";
|
||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-stats-1";
|
||||
import { renderRemotes } from "./views/remotes.js?v=job-stats-1";
|
||||
import { renderBrowse } from "./views/browser.js?v=job-stats-1";
|
||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-stats-1";
|
||||
import { escapeHtml, onRoute } from "./state.js?v=job-layout-1";
|
||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-layout-1";
|
||||
import { renderRemotes } from "./views/remotes.js?v=job-layout-1";
|
||||
import { renderBrowse } from "./views/browser.js?v=job-layout-1";
|
||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-layout-1";
|
||||
import {
|
||||
renderConfigureNew,
|
||||
renderConfigureEdit,
|
||||
} from "./views/configure.js?v=job-stats-1";
|
||||
} from "./views/configure.js?v=job-layout-1";
|
||||
|
||||
const views = {
|
||||
remotes: renderRemotes,
|
||||
@@ -94,12 +94,3 @@ onLocale(() => {
|
||||
// Initial chrome paint + switcher wiring.
|
||||
applyChromeStrings();
|
||||
wireLangSwitcher();
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ const STRINGS = {
|
||||
"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 /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_path": "Source path",
|
||||
"jobs.dest_remote": "Destination location",
|
||||
@@ -209,7 +209,7 @@ const STRINGS = {
|
||||
"jobs.empty_title": "No jobs yet",
|
||||
"jobs.empty_once_title": "No one-time jobs yet",
|
||||
"jobs.empty_recurring_title": "No recurring jobs yet",
|
||||
"jobs.empty_body": "Use New Job to start a copy, sync, or move.",
|
||||
"jobs.empty_body": (link) => `Use ${link} to start a copy, sync, or move.`,
|
||||
"jobs.started": (a, id) => `Started ${a} job #${id}`,
|
||||
"jobs.started_once": (a, id) => `Started one-time ${a} job #${id}`,
|
||||
"jobs.recurring_saved": (id) => `Saved recurring job #${id}`,
|
||||
@@ -420,7 +420,7 @@ const STRINGS = {
|
||||
"jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`,
|
||||
"jobs.schedule_label_monthly_last": (time) => `每月最后一天 ${time}`,
|
||||
"jobs.local_option": "本地文件系统",
|
||||
"jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。",
|
||||
"jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /root/source。选择远程存储时填写该远程内的路径。",
|
||||
"jobs.source_remote": "源位置",
|
||||
"jobs.source_path": "源路径",
|
||||
"jobs.dest_remote": "目标位置",
|
||||
@@ -439,7 +439,7 @@ const STRINGS = {
|
||||
"jobs.empty_title": "尚无任务",
|
||||
"jobs.empty_once_title": "尚无单次任务",
|
||||
"jobs.empty_recurring_title": "尚无循环任务",
|
||||
"jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。",
|
||||
"jobs.empty_body": (link) => `用${link}开始一个 copy、sync 或 move。`,
|
||||
"jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`,
|
||||
"jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`,
|
||||
"jobs.recurring_saved": (id) => `已保存循环任务 #${id}`,
|
||||
|
||||
@@ -15,6 +15,7 @@ const AUTH_PASS = params.get("pass");
|
||||
let authHeader = null;
|
||||
if (AUTH_USER && AUTH_PASS) {
|
||||
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
|
||||
scrubAuthQuery();
|
||||
}
|
||||
|
||||
export function rcURL() {
|
||||
@@ -30,6 +31,13 @@ export function isNoAuth() {
|
||||
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.
|
||||
export async function post(path, body = {}, options = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
@@ -72,14 +80,13 @@ export async function uploadFile(fs, remote, files) {
|
||||
export function downloadURL(remoteFs, remotePath, fileName) {
|
||||
const base = RC_BASE.replace(/\/$/, "");
|
||||
// rc server serves remote files at /<remote>:<path>
|
||||
const trimmed = (remotePath || "").replace(/^\/+|\/+$/g, "");
|
||||
const path = trimmed ? `${remoteFs}/${trimmed}/${fileName}` : `${remoteFs}/${fileName}`;
|
||||
let url = `${base}/${path}`;
|
||||
if (authHeader) {
|
||||
// Embed basic auth into the URL so the browser can fetch it directly.
|
||||
url = url.replace(/^(https?:\/\/)/, `$1${encodeURIComponent(AUTH_USER)}:${encodeURIComponent(AUTH_PASS)}@`);
|
||||
}
|
||||
return url;
|
||||
const path = [remoteFs, remotePath, fileName]
|
||||
.filter(Boolean)
|
||||
.flatMap((part) => String(part).split("/"))
|
||||
.filter((part) => part !== "")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
return `${base}/${path}`;
|
||||
}
|
||||
|
||||
async function parseResponse(res, path, { allowError = false } = {}) {
|
||||
|
||||
@@ -156,3 +156,12 @@ export function formatTime(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.
|
||||
|
||||
import { post, uploadFile, downloadURL } from "../rc.js?v=job-stats-1";
|
||||
import { toast, formatBytes, formatTime } from "../state.js?v=job-stats-1";
|
||||
import { t } from "../i18n.js?v=job-stats-1";
|
||||
import { post, uploadFile, downloadURL } from "../rc.js?v=job-layout-1";
|
||||
import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=job-layout-1";
|
||||
import { t } from "../i18n.js?v=job-layout-1";
|
||||
|
||||
export async function renderBrowse({ remote, path }) {
|
||||
const app = document.getElementById("app");
|
||||
@@ -37,18 +37,7 @@ export async function renderBrowse({ remote, path }) {
|
||||
const card = document.getElementById("browser-card");
|
||||
const fileInput = document.getElementById("upload-input");
|
||||
|
||||
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");
|
||||
}
|
||||
await refreshListing(card, fs, path);
|
||||
|
||||
app.querySelector('[data-action="mkdir"]').addEventListener("click", () => {
|
||||
openModal(
|
||||
@@ -61,6 +50,7 @@ export async function renderBrowse({ remote, path }) {
|
||||
const target = path ? `${path}/${name}` : name;
|
||||
await post("operations/mkdir", { fs, remote: target });
|
||||
toast(t("browser.mkdir_created", name), "success");
|
||||
await refreshListing(card, fs, path);
|
||||
},
|
||||
).catch((e) => toast(`${t("browser.mkdir_failed")}: ${e.message}`, "error"));
|
||||
});
|
||||
@@ -76,13 +66,30 @@ export async function renderBrowse({ remote, path }) {
|
||||
try {
|
||||
await uploadFile(fs, path || "", files);
|
||||
toast(t("browser.uploaded", files.length), "success");
|
||||
location.reload();
|
||||
await refreshListing(card, fs, path);
|
||||
} catch (e) {
|
||||
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) {
|
||||
const segments = (path || "").split("/").filter(Boolean);
|
||||
let html = `<a href="#/remotes">${t("nav.remotes")}</a><span class="sep">/</span>`;
|
||||
@@ -162,7 +169,7 @@ function row(fs, path, item) {
|
||||
return `
|
||||
<tr>
|
||||
<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 class="col-num col-mono">${escapeHtml(formatBytes(item.Size))}</td>
|
||||
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
|
||||
@@ -198,7 +205,7 @@ async function onDelete(fs, path, itemPath) {
|
||||
try {
|
||||
await post("operations/deletefile", { fs, remote: itemPath });
|
||||
toast(t("browser.deleted", itemPath), "success");
|
||||
location.reload();
|
||||
await refreshListing(document.getElementById("browser-card"), fs, path);
|
||||
} catch (e) {
|
||||
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");
|
||||
},
|
||||
);
|
||||
location.reload();
|
||||
await refreshListing(document.getElementById("browser-card"), fs, path);
|
||||
} catch (e) {
|
||||
toast(`${t("browser.rename_failed")}: ${e.message}`, "error");
|
||||
}
|
||||
@@ -290,11 +297,6 @@ export function openModal(title, fields, onSubmit) {
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
// OAuth backends (option named "token" with IsPassword) get a banner
|
||||
// and disabled submit — user must run `rclone config` in a terminal.
|
||||
|
||||
import { post } from "../rc.js?v=job-stats-1";
|
||||
import { getState, setState, toast } from "../state.js?v=job-stats-1";
|
||||
import { t } from "../i18n.js?v=job-stats-1";
|
||||
import { post } from "../rc.js?v=job-layout-1";
|
||||
import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
|
||||
import { t } from "../i18n.js?v=job-layout-1";
|
||||
|
||||
// --- Route entrypoints ---
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function renderConfigureNew({ provider = "" }) {
|
||||
document.getElementById("app").innerHTML = `
|
||||
<div class="empty">
|
||||
<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>
|
||||
</div>`;
|
||||
return;
|
||||
@@ -53,7 +53,7 @@ export async function renderConfigureEdit({ remote }) {
|
||||
app.innerHTML = `
|
||||
<div class="empty">
|
||||
<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>
|
||||
</div>`;
|
||||
return;
|
||||
@@ -155,6 +155,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
||||
const requiresOAuth = provider.Options.some(
|
||||
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
|
||||
);
|
||||
const blocksCreate = requiresOAuth && !isEdit;
|
||||
|
||||
// Partition options into required, optional-basic, optional-advanced.
|
||||
const required = provider.Options.filter((o) => o.Required && !o.Hide);
|
||||
@@ -206,7 +207,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
|
||||
</div>
|
||||
|
||||
<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")}
|
||||
</button>
|
||||
<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) => {
|
||||
e.preventDefault();
|
||||
if (requiresOAuth) return;
|
||||
if (blocksCreate) return;
|
||||
|
||||
const name = form.elements._remote_name.value.trim();
|
||||
if (!name) return;
|
||||
@@ -426,15 +427,6 @@ async function ensureProviders() {
|
||||
return providers;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
||||
|
||||
import { post } from "../rc.js?v=job-stats-1";
|
||||
import { post } from "../rc.js?v=job-layout-1";
|
||||
import {
|
||||
toast,
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
formatDuration,
|
||||
} from "../state.js?v=job-stats-1";
|
||||
import { t } from "../i18n.js?v=job-stats-1";
|
||||
escapeHtml,
|
||||
} from "../state.js?v=job-layout-1";
|
||||
import { t } from "../i18n.js?v=job-layout-1";
|
||||
import {
|
||||
createOneTimeJob,
|
||||
createRecurringJob,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
runRecurringJobNow,
|
||||
stopOneTimeJob,
|
||||
stopRecurringJob,
|
||||
} from "../jobs_api.js?v=job-stats-1";
|
||||
} from "../jobs_api.js?v=job-layout-1";
|
||||
|
||||
let pollTimer = null;
|
||||
const LOCAL_FS_VALUE = "__local__";
|
||||
@@ -502,7 +503,7 @@ async function refreshJobs() {
|
||||
card.innerHTML = `
|
||||
<div class="empty">
|
||||
<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>
|
||||
`;
|
||||
return false;
|
||||
@@ -524,6 +525,11 @@ async function refreshJobs() {
|
||||
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) {
|
||||
statuses.sort((a, b) => {
|
||||
const bTime = Date.parse(b.updatedAt || b.createdAt || b.startedAt || 0) || 0;
|
||||
@@ -531,78 +537,37 @@ function renderJobTable(statuses) {
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
const rows = statuses.map(renderJobRows).join("");
|
||||
const rows = statuses.map(renderJobRow).join("");
|
||||
|
||||
return `
|
||||
<table class="table">
|
||||
<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}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="job-list">
|
||||
${rows}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderJobRows(s) {
|
||||
const row = renderJobRow(s);
|
||||
if (!expandedJobs.has(jobKey(s))) {
|
||||
return row;
|
||||
}
|
||||
return row + renderJobDetailsRow(s);
|
||||
}
|
||||
|
||||
function renderJobRow(job) {
|
||||
const snapshot = job.statusSnapshot || {};
|
||||
const stats = transferStats(snapshot);
|
||||
const id = job.id;
|
||||
const key = jobKey(job);
|
||||
const running = !!job.running;
|
||||
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 jobCell = `
|
||||
<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(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}</span>
|
||||
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0;
|
||||
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>
|
||||
<span class="col-mono" style="font-size:11px;color:var(--color-muted)">
|
||||
<span class="job-progress-text">
|
||||
${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${escapeAttr(key)}">${expandedJobs.has(key) ? 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
|
||||
? ""
|
||||
: `<button class="btn btn-secondary btn-sm" data-start="${escapeAttr(key)}">${t("jobs.start_again")}</button>`;
|
||||
@@ -612,28 +577,50 @@ function renderJobRow(job) {
|
||||
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${escapeAttr(key)}">${t("jobs.delete_record")}</button>`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="col-mono">
|
||||
${escapeHtml(displayJobId(job))}
|
||||
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""}
|
||||
</td>
|
||||
<td>${jobCell}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${scheduleCell}</td>
|
||||
<td class="col-num">${progress}</td>
|
||||
<td class="col-num col-mono">${escapeHtml(formatSpeed(displayedSpeed))}</td>
|
||||
<td class="col-num col-mono">${escapeHtml(formatDuration(displayedEta))}</td>
|
||||
<td class="col-num col-mono">${escapeHtml(formatTransferCount(stats))}</td>
|
||||
<td class="col-num col-mono">${escapeHtml(String(stats.errors || 0))}</td>
|
||||
<td class="col-num">
|
||||
<article class="job-item">
|
||||
<div class="job-main">
|
||||
<div class="job-head">
|
||||
<div class="job-id">
|
||||
<span>${escapeHtml(displayJobId(job))}</span>
|
||||
${jobid ? `<small>job ${escapeHtml(jobid)}</small>` : ""}
|
||||
</div>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="job-route">
|
||||
<div class="job-route-line">
|
||||
<span>${escapeHtml(job.action)}</span>
|
||||
<code>${escapeHtml(job.src)}</code>
|
||||
</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">
|
||||
${detailBtn}
|
||||
${startBtn}
|
||||
${stopBtn}
|
||||
${deleteBtn}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
${expanded ? renderJobDetails(job) : ""}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -655,7 +642,16 @@ function renderStatusBadge(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobDetailsRow(job) {
|
||||
function metricHtml(label, value) {
|
||||
return `
|
||||
<div class="job-metric">
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderJobDetails(job) {
|
||||
const snapshot = job.statusSnapshot || {};
|
||||
const stats = transferStats(snapshot);
|
||||
const jobid = job.currentJobid || job.lastJobid || "";
|
||||
@@ -682,27 +678,22 @@ function renderJobDetailsRow(job) {
|
||||
: "";
|
||||
|
||||
return `
|
||||
<tr class="job-detail-row">
|
||||
<td></td>
|
||||
<td colspan="9">
|
||||
<div class="job-detail">
|
||||
<dl>
|
||||
${details.map(([label, value]) => `
|
||||
<div>
|
||||
<dt>${escapeHtml(label)}</dt>
|
||||
<dd>${escapeHtml(value)}</dd>
|
||||
</div>
|
||||
`).join("")}
|
||||
</dl>
|
||||
${output ? `
|
||||
<div class="job-detail-output">
|
||||
<span>${t("jobs.detail_output")}</span>
|
||||
<pre>${escapeHtml(output)}</pre>
|
||||
</div>
|
||||
` : ""}
|
||||
<div class="job-detail">
|
||||
<dl>
|
||||
${details.map(([label, value]) => `
|
||||
<div>
|
||||
<dt>${escapeHtml(label)}</dt>
|
||||
<dd>${escapeHtml(value)}</dd>
|
||||
</div>
|
||||
`).join("")}
|
||||
</dl>
|
||||
${output ? `
|
||||
<div class="job-detail-output">
|
||||
<span>${t("jobs.detail_output")}</span>
|
||||
<pre>${escapeHtml(output)}</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -890,15 +881,6 @@ function oneTimeIdFromKey(key) {
|
||||
return Number(String(key).replace(/^once:/, ""));
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
|
||||
|
||||
import { post } from "../rc.js?v=job-stats-1";
|
||||
import { getState, setState, toast } from "../state.js?v=job-stats-1";
|
||||
import { t } from "../i18n.js?v=job-stats-1";
|
||||
import { post } from "../rc.js?v=job-layout-1";
|
||||
import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
|
||||
import { t } from "../i18n.js?v=job-layout-1";
|
||||
|
||||
export async function renderRemotes() {
|
||||
const app = document.getElementById("app");
|
||||
@@ -94,12 +94,3 @@ export async function renderRemotes() {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ---------- Job cell with action + src→dst ---------- */
|
||||
.job-cell {
|
||||
/* ---------- Jobs ---------- */
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
align-items: flex-start;
|
||||
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);
|
||||
letter-spacing: var(--typo-caption-uppercase-tracking);
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.job-cell .job-paths {
|
||||
font: var(--typo-body-sm);
|
||||
.job-route-line code {
|
||||
color: var(--color-body);
|
||||
word-break: break-all;
|
||||
font: var(--typo-code);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.job-cell .job-paths .arrow {
|
||||
color: var(--color-muted-soft);
|
||||
margin: 0 var(--space-xxs);
|
||||
.job-schedule {
|
||||
grid-column: 2;
|
||||
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: inline-flex;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.job-detail-row {
|
||||
background-color: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.job-detail {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
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 {
|
||||
@@ -964,6 +1066,15 @@
|
||||
.form-grid {
|
||||
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) {
|
||||
@@ -982,4 +1093,20 @@
|
||||
.top-nav nav {
|
||||
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="stylesheet" href="/assets/styles/tokens.css">
|
||||
<link rel="stylesheet" href="/assets/styles/base.css">
|
||||
<link rel="stylesheet" href="/assets/styles/components.css?v=job-stats-1">
|
||||
<link rel="stylesheet" href="/assets/styles/components.css?v=job-layout-1">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
@@ -80,6 +80,6 @@
|
||||
<div id="toast-stack" class="toast-stack"></div>
|
||||
<div id="modal-root"></div>
|
||||
|
||||
<script type="module" src="/assets/js/app.js?v=job-stats-1"></script>
|
||||
<script type="module" src="/assets/js/app.js?v=job-layout-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+21
-5
@@ -171,7 +171,7 @@ network.
|
||||
return fmt.Errorf("failed to make password: %w", err)
|
||||
}
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
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 err := open.Start(loginURL); err != nil {
|
||||
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
|
||||
// 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
|
||||
// SPA can present credentials to the cross-port RC server.
|
||||
// When auth is enabled, user/pass and a /login hash are added so the SPA can
|
||||
// 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 {
|
||||
u, err := url.Parse(guiBaseURL)
|
||||
if err != nil {
|
||||
@@ -308,3 +312,15 @@ func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
|
||||
u.RawQuery = q.Encode()
|
||||
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