init: 初始化 rclone-webgui 项目结构

外层仓库管理 webgui 源码、Docker 编排与项目文档;rclone 作为
git submodule 锁定在上游 master HEAD(59c86b01b),不携带任何
我们的改动。

- webgui/: webgui 源码(原本位于 rclone/cmd/webgui/)
  - web/: 原生 HTML/CSS/JS 静态前端(Anthropic 设计语言)
  - webgui.go: Go 子命令源码,仅当自行构建 rclone 二进制时需要
  - rclone-cmd-all-add-webgui-import.patch: 把 webgui 注册进
    rclone 的 cmd/all/all.go 的补丁,留作 fork 时使用
- rclone/: submodule → github.com/rclone/rclone,纯净不改动
- Dockerfile.webgui: 基于 nginx:1.27-alpine,从 ./webgui/web/
  COPY 静态资源
- docker/nginx.conf: SPA 静态托管 + 反向代理 RC API
  (/config/、/operations/、/sync/、/job/ 等) 与文件下载
  (/<remote>:<path>) 到 rclone rcd 容器,前端同源访问无 CORS
- docker-compose.yml: rclone (官方镜像 + rcd --rc-no-auth
  --rc-serve) + gui (nginx) 双服务编排,config 走 bind mount
  持久化
- DESIGN.md / CLAUDE.md / README.md: 文档
- .gitignore / .dockerignore: 排除 rclone.conf 等敏感文件,
  Docker 构建上下文只剩 webgui/web/ + nginx 配置(几十 KB)
This commit is contained in:
ci
2026-06-19 12:42:35 +08:00
commit eb32fd5e22
25 changed files with 3872 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
// app.js — entry point. Routes hash changes to view renderers and
// keeps the top-nav active state in sync.
import { onRoute } from "./state.js";
import { renderRemotes } from "./views/remotes.js";
import { renderBrowse } from "./views/browser.js";
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js";
import {
renderConfigureNew,
renderConfigureEdit,
} from "./views/configure.js";
const views = {
remotes: renderRemotes,
browse: renderBrowse,
jobs: renderJobs,
"jobs-new": renderNewJob,
"configure-new": renderConfigureNew,
"configure-edit": renderConfigureEdit,
};
function setActiveNav(routeName) {
const links = document.querySelectorAll("#nav-links a");
for (const a of links) {
const target = a.dataset.route;
let isActive = target === routeName;
// "Configure" is active on both configure-new and configure-edit
if (target === "configure" && routeName.startsWith("configure-")) {
isActive = true;
}
a.classList.toggle("active", isActive);
}
}
onRoute(async (route) => {
setActiveNav(route.name);
// Stop jobs polling when leaving the jobs view
if (route.name !== "jobs") {
stopJobPolling();
}
const renderer = views[route.name] || renderRemotes;
try {
await renderer(route.params);
} catch (e) {
console.error("view error", e);
const app = document.getElementById("app");
if (app) {
app.innerHTML = `<div class="empty"><h3>Something went wrong</h3><p>${escapeHtml(e.message)}</p></div>`;
}
}
});
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+105
View File
@@ -0,0 +1,105 @@
// rc.js — minimal client for rclone's RC HTTP API.
// The RC base URL is discovered from the ?url= query param that the
// webgui command appends to the GUI URL on launch. If absent (e.g. when
// developing the SPA from another server), fall back to same-origin.
const params = new URLSearchParams(location.search);
const QUERY_URL = params.get("url");
const RC_BASE = QUERY_URL
? QUERY_URL.replace(/\/$/, "")
: location.origin;
const AUTH_USER = params.get("user");
const AUTH_PASS = params.get("pass");
let authHeader = null;
if (AUTH_USER && AUTH_PASS) {
authHeader = "Basic " + btoa(`${AUTH_USER}:${AUTH_PASS}`);
}
export function rcURL() {
return RC_BASE;
}
// Are we running with basic auth configured from the launch URL?
export function hasAuth() {
return authHeader !== null;
}
export function isNoAuth() {
return !authHeader;
}
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
export async function post(path, body = {}) {
const headers = { "Content-Type": "application/json" };
if (authHeader) headers["Authorization"] = authHeader;
const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), {
method: "POST",
headers,
body: JSON.stringify(body),
});
return parseResponse(res, path);
}
// POST JSON and request an async job. Returns { jobid, executeId }.
export async function postAsync(path, body = {}) {
return post(path, { ...body, _async: true });
}
// Upload one or more files via multipart form-data.
// Matches operations/uploadfile: form fields `fs`, `remote`, and one
// file part per uploaded file. The server uses the part's filename.
export async function uploadFile(fs, remote, files) {
const form = new FormData();
form.set("fs", fs);
form.set("remote", remote);
for (const file of files) {
form.append("file", file, file.name);
}
const headers = {};
if (authHeader) headers["Authorization"] = authHeader;
// Do NOT set Content-Type — the browser sets multipart boundary.
const res = await fetch(RC_BASE + "/operations/uploadfile", {
method: "POST",
headers,
body: form,
});
return parseResponse(res, "/operations/uploadfile");
}
// Build a download URL for a file. Requires opt.Serve = true on the rc server.
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;
}
async function parseResponse(res, path) {
let body = null;
const ct = res.headers.get("Content-Type") || "";
if (ct.includes("application/json")) {
body = await res.json();
} else {
const text = await res.text();
body = text ? { raw: text } : {};
}
if (!res.ok) {
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}`;
const err = new Error(`${path}: ${msg}`);
err.status = res.status;
err.body = body;
throw err;
}
if (body && body.error) {
throw new Error(`${path}: ${body.error}`);
}
return body;
}
+216
View File
@@ -0,0 +1,216 @@
// state.js — minimal pub/sub store + hash router + localStorage-backed
// 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 state = {
remotes: [], // [{ name, type }]
jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst }
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() {
return state;
}
export function setState(patch) {
Object.assign(state, patch);
for (const l of listeners) {
try {
l(state);
} catch (e) {
console.error("state listener error", e);
}
}
}
export function subscribe(fn) {
listeners.add(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 ---
// Hash-based routes:
// #/remotes
// #/browse/<remote>/<path...>
// #/jobs
// #/jobs/new
// #/configure/new
// #/configure/new/<providerName>
// #/configure/edit/<remoteName>
const routeListeners = new Set();
export function onRoute(fn) {
routeListeners.add(fn);
return () => routeListeners.delete(fn);
}
function parseHash(hash) {
const raw = hash.replace(/^#\/?/, "");
const parts = raw.split("/").filter(Boolean);
if (parts.length === 0) {
return { name: "remotes", params: {} };
}
switch (parts[0]) {
case "remotes":
return { name: "remotes", params: {} };
case "browse":
return {
name: "browse",
params: {
remote: decodeURIComponent(parts[1] || ""),
path: parts.slice(2).map(decodeURIComponent).join("/"),
},
};
case "jobs":
if (parts[1] === "new") {
return { name: "jobs-new", params: {} };
}
return { name: "jobs", params: {} };
case "configure":
if (parts[1] === "new") {
return {
name: "configure-new",
params: { provider: parts[2] ? decodeURIComponent(parts[2]) : "" },
};
}
if (parts[1] === "edit") {
return {
name: "configure-edit",
params: { remote: decodeURIComponent(parts[2] || "") },
};
}
return { name: "configure-new", params: { provider: "" } };
default:
return { name: "remotes", params: {} };
}
}
export function navigate(hash) {
if (location.hash !== hash) {
location.hash = hash;
} else {
dispatch();
}
}
function dispatch() {
const route = parseHash(location.hash);
for (const l of routeListeners) {
try {
l(route);
} catch (e) {
console.error("route listener error", e);
}
}
}
window.addEventListener("hashchange", dispatch);
window.addEventListener("load", dispatch);
// --- Toast ---
export function toast(message, kind = "default", ttl = 4000) {
const stack = document.getElementById("toast-stack");
if (!stack) return;
const el = document.createElement("div");
el.className = `toast toast-${kind}`;
el.textContent = message;
stack.appendChild(el);
setTimeout(() => el.remove(), ttl);
}
// --- Format helpers ---
export function formatBytes(n) {
if (n == null || isNaN(n)) return "—";
if (n < 1024) return `${n} B`;
const units = ["KB", "MB", "GB", "TB", "PB"];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(1)} ${units[i]}`;
}
export function formatSpeed(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s";
}
export function formatDuration(seconds) {
if (seconds == null || !isFinite(seconds)) return "—";
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
export function formatTime(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleString();
} catch {
return iso;
}
}
+308
View File
@@ -0,0 +1,308 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
import { post, uploadFile, downloadURL } from "../rc.js";
import { toast, formatBytes, formatTime } from "../state.js";
export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app");
if (!remote) {
app.innerHTML = `<div class="empty"><h3>No remote selected</h3><p><a href="#/remotes">Pick a remote</a> to browse.</p></div>`;
return;
}
const fs = `${remote}:`;
app.innerHTML = `
<div class="section-head">
<div>
<h2>${escapeHtml(remote)}</h2>
<p class="subtitle">${escapeHtml(path || "(root)")}</p>
</div>
<div class="toolbar">
<input type="file" id="upload-input" multiple style="display:none">
<button class="btn btn-secondary btn-sm" data-action="mkdir">New folder</button>
<button class="btn btn-secondary btn-sm" data-action="upload">Upload</button>
</div>
</div>
<div id="breadcrumbs" class="breadcrumbs"></div>
<div id="browser-card" class="card-outline">
<p class="empty">Loading…</p>
</div>
`;
const breadcrumbsEl = document.getElementById("breadcrumbs");
renderBreadcrumbs(breadcrumbsEl, 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>Couldn’t list</h3><p>${escapeHtml(e.message)}</p></div>`;
toast(`List failed: ${e.message}`, "error");
}
// --- Toolbar handlers ---
app.querySelector('[data-action="mkdir"]').addEventListener("click", () => {
openModal(
"New folder",
[
{ name: "name", label: "Folder name", type: "text", placeholder: "new-folder" },
],
async ({ name }) => {
if (!name) return;
const target = path ? `${path}/${name}` : name;
await post("operations/mkdir", { fs, remote: target });
toast(`Created ${name}`, "success");
},
).catch((e) => toast(`mkdir failed: ${e.message}`, "error"));
});
app.querySelector('[data-action="upload"]').addEventListener("click", () => {
fileInput.value = "";
fileInput.click();
});
fileInput.addEventListener("change", async () => {
const files = Array.from(fileInput.files || []);
if (files.length === 0) return;
try {
await uploadFile(fs, path || "", files);
toast(`Uploaded ${files.length} file(s)`, "success");
// Re-render list
location.reload();
} catch (e) {
toast(`Upload failed: ${e.message}`, "error");
}
});
}
function renderBreadcrumbs(el, remote, path) {
const segments = (path || "").split("/").filter(Boolean);
let html = `<a href="#/remotes">Remotes</a><span class="sep">/</span>`;
html += `<a href="#/browse/${encodeURIComponent(remote)}">${escapeHtml(remote)}</a>`;
let acc = "";
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
acc = acc ? `${acc}/${seg}` : seg;
const isLast = i === segments.length - 1;
html += `<span class="sep">/</span>`;
if (isLast) {
html += `<span class="current">${escapeHtml(seg)}</span>`;
} else {
html += `<a href="#/browse/${encodeURIComponent(remote)}/${acc.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(seg)}</a>`;
}
}
el.innerHTML = html;
}
function renderTable(card, fs, path, items) {
if (!items || items.length === 0) {
card.innerHTML = `
<div class="empty">
<h3>Empty folder</h3>
<p>No files here. Use <strong>Upload</strong> in the toolbar to add some.</p>
</div>
`;
return;
}
// Sort: directories first, then files; alphabetical within each group.
items.sort((a, b) => {
if (a.IsDir !== b.IsDir) return a.IsDir ? -1 : 1;
return a.Name.localeCompare(b.Name);
});
const parentHref = parentLink(fs, path);
const rows = items.map((item) => row(fs, path, item)).join("");
card.innerHTML = `
<table class="table">
<thead>
<tr>
<th>Name</th>
<th class="col-num">Size</th>
<th>Modified</th>
<th class="col-num">Actions</th>
</tr>
</thead>
<tbody>
${parentHref ? `<tr class="row-dir"><td class="col-name"><a href="${parentHref}">../</a></td><td></td><td></td><td></td></tr>` : ""}
${rows}
</tbody>
</table>
`;
// Wire row action buttons
card.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", () => onDelete(fs, path, btn.dataset.delete));
});
card.querySelectorAll("[data-rename]").forEach((btn) => {
btn.addEventListener("click", () => onRename(fs, path, btn.dataset.rename));
});
}
function row(fs, path, item) {
const itemPath = path ? `${path}/${item.Name}` : item.Name;
if (item.IsDir) {
const href = `#/browse/${fs.replace(/:$/, "")}/${itemPath.split("/").map(encodeURIComponent).join("/")}`;
return `
<tr class="row-dir">
<td class="col-name"><a href="${href}">${icon("dir")} ${escapeHtml(item.Name)}/</a></td>
<td class="col-num col-mono">—</td>
<td class="col-mono">${escapeHtml(formatTime(item.ModTime))}</td>
<td class="col-num"></td>
</tr>
`;
}
return `
<tr>
<td class="col-name">
<a href="${downloadURL(fs, path, item.Name)}" download="${escapeHtml(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>
<td class="col-num">
<button class="btn btn-secondary btn-sm" data-rename="${escapeHtml(itemPath)}">Rename</button>
<button class="btn btn-danger btn-sm" data-delete="${escapeHtml(itemPath)}">Delete</button>
</td>
</tr>
`;
}
function icon(kind) {
if (kind === "dir") {
return `<span style="display:inline-block;width:1em;color:var(--color-primary)">▸</span>`;
}
return `<span style="display:inline-block;width:1em;color:var(--color-muted-soft)">📄</span>`;
}
function parentLink(fs, path) {
if (!path) return "";
const segments = path.split("/").filter(Boolean);
segments.pop();
const parent = segments.join("/");
const remote = fs.replace(/:$/, "");
if (!parent) {
return `#/browse/${encodeURIComponent(remote)}`;
}
return `#/browse/${encodeURIComponent(remote)}/${parent.split("/").map(encodeURIComponent).join("/")}`;
}
async function onDelete(fs, path, itemPath) {
// itemPath is relative to fs root
if (!confirm(`Delete ${itemPath}? This cannot be undone.`)) return;
try {
await post("operations/deletefile", { fs, remote: itemPath });
toast(`Deleted ${itemPath}`, "success");
location.reload();
} catch (e) {
toast(`Delete failed: ${e.message}`, "error");
}
}
async function onRename(fs, path, itemPath) {
const segments = itemPath.split("/");
const oldName = segments.pop();
try {
const result = await openModal(
`Rename ${oldName}`,
[{ name: "name", label: "New name", type: "text", value: oldName }],
async ({ name }) => {
if (!name || name === oldName) return;
const dir = segments.join("/");
const dst = dir ? `${dir}/${name}` : name;
await post("operations/movefile", {
srcFs: fs,
srcRemote: itemPath,
dstFs: fs,
dstRemote: dst,
});
toast(`Renamed to ${name}`, "success");
},
);
// Modal succeeded → reload to refresh list
location.reload();
} catch (e) {
toast(`Rename failed: ${e.message}`, "error");
}
}
// --- Modal helper ---
let modalResolver = null;
export function openModal(title, fields, onSubmit) {
return new Promise((resolve) => {
const root = document.getElementById("modal-root");
const formHtml = fields
.map(
(f) => `
<div class="field">
<label>${escapeHtml(f.label)}</label>
<input class="input" name="${escapeHtml(f.name)}" type="${escapeHtml(f.type || "text")}" value="${escapeHtml(f.value || "")}" placeholder="${escapeHtml(f.placeholder || "")}">
</div>`,
)
.join("");
root.innerHTML = `
<div class="modal-overlay">
<form class="modal">
<h3>${escapeHtml(title)}</h3>
${formHtml}
<div class="modal-actions">
<button type="button" class="btn btn-secondary" data-cancel>Cancel</button>
<button type="submit" class="btn btn-primary">OK</button>
</div>
</form>
</div>
`;
const overlay = root.querySelector(".modal-overlay");
const form = root.querySelector("form");
const close = () => {
root.innerHTML = "";
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
form.querySelector("[data-cancel]").addEventListener("click", () => {
close();
resolve(null);
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const data = {};
for (const f of fields) {
data[f.name] = form.elements[f.name].value;
}
try {
await onSubmit(data);
close();
resolve(data);
} catch (err) {
toast(err.message, "error");
}
});
// Focus first input
const first = form.elements[fields[0].name];
if (first) first.focus();
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+445
View File
@@ -0,0 +1,445 @@
// views/configure.js — dynamic remote form builder.
//
// Two modes:
// #/configure/new → provider picker (searchable grid)
// #/configure/new/<provider> → form for that backend
// #/configure/edit/<remote> → form pre-filled from /config/get
//
// Pulls backend metadata from /config/providers (cached in app state).
// 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";
import { getState, setState, toast } from "../state.js";
// --- Route entrypoints ---
export async function renderConfigureNew({ provider = "" }) {
if (!provider) {
return renderProviderPicker();
}
const providers = await ensureProviders();
const info = providers.find((p) => p.Name === provider);
if (!info) {
document.getElementById("app").innerHTML = `
<div class="empty">
<h3>Unknown backend</h3>
<p>No provider named <code>${escapeHtml(provider)}</code>.</p>
<p><a href="#/configure/new">← Back to provider picker</a></p>
</div>`;
return;
}
return renderForm({ provider: info, mode: "create" });
}
export async function renderConfigureEdit({ remote }) {
if (!remote) {
location.hash = "#/remotes";
return;
}
const app = document.getElementById("app");
app.innerHTML = `<div class="empty"><p>Loading remote <code>${escapeHtml(remote)}</code>…</p></div>`;
// Find the remote's type from /config/dump
let typeName;
try {
const dump = await post("config/dump");
typeName = dump && dump[remote] && dump[remote].type;
} catch (e) {
toast(`Couldn't read remote: ${e.message}`, "error");
return;
}
if (!typeName) {
app.innerHTML = `
<div class="empty">
<h3>Remote not found</h3>
<p>No remote named <code>${escapeHtml(remote)}</code>.</p>
<p><a href="#/remotes">← Back to remotes</a></p>
</div>`;
return;
}
const providers = await ensureProviders();
const info = providers.find((p) => p.Name === typeName);
if (!info) {
toast(`Backend ${typeName} not found in registry`, "error");
return;
}
let currentValues = {};
try {
currentValues = await post("config/get", { name: remote });
} catch (e) {
toast(`Couldn't read config: ${e.message}`, "error");
}
return renderForm({
provider: info,
mode: "edit",
remoteName: remote,
currentValues,
});
}
// --- Provider picker ---
async function renderProviderPicker() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="section-head">
<div>
<h2>New remote</h2>
<p class="subtitle">Pick a storage backend to configure.</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
</div>
<div class="provider-search">
<input id="provider-filter" class="input" type="search" placeholder="Filter backends (e.g. s3, sftp, local)…" autocomplete="off">
</div>
<div id="provider-grid" class="connector-grid">
<p class="empty" style="grid-column:1/-1">Loading backends…</p>
</div>
`;
const grid = document.getElementById("provider-grid");
const filter = document.getElementById("provider-filter");
let providers;
try {
providers = await ensureProviders();
} catch (e) {
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>Couldn’t load backends</h3><p>${escapeHtml(e.message)}</p></div>`;
return;
}
// Skip hidden + alias/all wrapper backends
const visible = providers.filter(
(p) => !p.Hide && p.Name !== "all" && p.Name !== "alias",
);
function paint(list) {
if (list.length === 0) {
grid.innerHTML = `<p class="empty" style="grid-column:1/-1">No backends match.</p>`;
return;
}
grid.innerHTML = list
.map(
(p) => `
<a class="connector-tile" href="#/configure/new/${encodeURIComponent(p.Name)}">
<span class="tile-name">${escapeHtml(p.Name)}</span>
<span class="tile-type">${escapeHtml(p.Description || "")}</span>
</a>`,
)
.join("");
}
paint(visible);
filter.addEventListener("input", () => {
const q = filter.value.trim().toLowerCase();
if (!q) return paint(visible);
paint(
visible.filter((p) =>
(p.Name + " " + (p.Description || "")).toLowerCase().includes(q),
),
);
});
filter.focus();
}
// --- Dynamic form ---
async function renderForm({ provider, mode, remoteName = "", currentValues = {} }) {
const app = document.getElementById("app");
const isEdit = mode === "edit";
const requiresOAuth = provider.Options.some(
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
);
// Partition options into required, optional-basic, optional-advanced.
const required = provider.Options.filter((o) => o.Required && !o.Hide);
const basic = provider.Options.filter((o) => !o.Required && !o.Advanced && !o.Hide);
const advanced = provider.Options.filter((o) => o.Advanced && !o.Hide);
app.innerHTML = `
<div class="section-head">
<div>
<h2>${isEdit ? "Edit" : "New"} ${escapeHtml(provider.Name)} remote</h2>
<p class="subtitle">${escapeHtml(provider.Description || "")}</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
</div>
<form id="remote-form" class="card-outline" style="display:flex;flex-direction:column;gap:16px;max-width:760px">
${requiresOAuth ? oauthBanner(provider.Name) : ""}
<div class="form-grid">
<div class="field field-full">
<label>Remote name <span class="field-required">*</span></label>
<input
class="input"
name="_remote_name"
type="text"
required
${isEdit ? `value="${escapeHtml(remoteName)}" readonly` : 'placeholder="my-remote"'}
autocomplete="off"
>
</div>
${required.length > 0 ? `<div class="form-section-title">Required</div>` : ""}
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${basic.length > 0 ? `<div class="form-section-title">Options</div>` : ""}
${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${advanced.length > 0 ? `
<div class="advanced-toggle-wrap">
<button type="button" class="btn btn-secondary btn-sm" id="advanced-toggle">
Show advanced options (${advanced.length})
</button>
</div>
<div id="advanced-section" class="advanced-section hidden">
<div class="form-section-title">Advanced</div>
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
</div>
` : ""}
</div>
<div class="toolbar">
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}>
${isEdit ? "Save changes" : "Create remote"}
</button>
<a class="btn btn-secondary" href="#/remotes">Cancel</a>
</div>
${isEdit ? `
<div class="danger-zone">
<h4>Delete this remote</h4>
<p>Permanently remove <code>${escapeHtml(remoteName)}</code> from rclone.conf.</p>
<button type="button" class="btn btn-danger" id="delete-btn">Delete remote</button>
</div>
` : ""}
</form>
`;
const form = document.getElementById("remote-form");
// Wire up "Custom…" reveal on <select> fields that have it.
wireCustomSelects(form);
// Advanced toggle
const advToggle = document.getElementById("advanced-toggle");
const advSection = document.getElementById("advanced-section");
if (advToggle && advSection) {
advToggle.addEventListener("click", () => {
const hidden = advSection.classList.toggle("hidden");
advToggle.textContent = hidden
? `Show advanced options (${advanced.length})`
: `Hide advanced options (${advanced.length})`;
});
}
// Delete button
const deleteBtn = document.getElementById("delete-btn");
if (deleteBtn) {
deleteBtn.addEventListener("click", async () => {
if (!confirm(`Delete remote ${remoteName}? This cannot be undone.`)) return;
try {
await post("config/delete", { name: remoteName });
toast(`Deleted ${remoteName}`, "success");
location.hash = "#/remotes";
} catch (e) {
toast(`Delete failed: ${e.message}`, "error");
}
});
}
// Submit
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (requiresOAuth) return;
const name = form.elements._remote_name.value.trim();
if (!name) return;
const parameters = collectParameters(form, provider.Options, isEdit);
try {
const body = {
name,
parameters,
opt: { nonInteractive: true },
};
if (!isEdit) body.type = provider.Name;
const endpoint = isEdit ? "config/update" : "config/create";
await post(endpoint, body);
toast(`${isEdit ? "Updated" : "Created"} ${name}`, "success");
location.hash = "#/remotes";
} catch (err) {
toast(`Save failed: ${err.message}`, "error");
}
});
}
// --- Helpers ---
function oauthBanner(providerName) {
return `
<div class="banner banner-warning">
<div>
<strong>${escapeHtml(providerName)}</strong> requires OAuth authorization,
which this web GUI can’t complete inside a container. Please configure it
from a terminal first:
<code>rclone config</code>
Once the remote exists, you can edit its non-secret options here.
</div>
</div>
`;
}
function fieldHtml(opt, currentValues, isEdit) {
const isFull = opt.Type === "bool";
const classes = ["field"];
if (isFull) classes.push("field-bool");
if (opt.Examples && opt.Examples.length > 0 && opt.Type !== "bool") {
// Examples go full-width to fit the dropdown + custom override
classes.push("field-full");
} else if (opt.Help && opt.Help.length > 60) {
classes.push("field-full");
}
const labelHtml = `${escapeHtml(opt.Name)}${opt.Required ? '<span class="field-required">*</span>' : ""}`;
const helpHtml = opt.Help ? `<span class="field-help">${escapeHtml(opt.Help)}</span>` : "";
const inputHtml = inputHtmlFor(opt, currentValues[opt.Name], isEdit);
return `
<div class="${classes.join(" ")}">
<label>${labelHtml}</label>
${inputHtml}
${helpHtml}
</div>
`;
}
function inputHtmlFor(opt, value, isEdit) {
// In edit mode, never pre-fill password fields — server returns them
// already obscured and resubmitting would double-obscure. Show empty
// with a "(unchanged)" placeholder.
if (opt.IsPassword && isEdit) {
return `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="(unchanged)">`;
}
const name = `opt_${escapeHtml(opt.Name)}`;
const currentValue = value != null ? String(value) : "";
if (opt.Type === "bool") {
const checked = value === true || value === "true" ? "checked" : "";
return `<input type="checkbox" name="${name}" ${checked}>`;
}
if (opt.Type === "int" || opt.Type === "int64" || opt.Type === "Duration") {
return `<input class="input" name="${name}" type="number" value="${escapeAttr(currentValue)}" autocomplete="off">`;
}
if (opt.IsPassword) {
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="secret">`;
}
// Examples → select with custom override
if (opt.Examples && opt.Examples.length > 0) {
const opts = ['<option value="">— pick —</option>']
.concat(
opt.Examples.map(
(ex) =>
`<option value="${escapeAttr(ex.Value)}"${ex.Value === currentValue ? " selected" : ""}>${escapeHtml(ex.Help || ex.Value)}${ex.Provider ? ` (${escapeHtml(ex.Provider)})` : ""}</option>`,
),
)
.join("");
return `
<select class="select" name="${name}" data-has-custom="1">
${opts}
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>Custom…</option>
</select>
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="custom value" autocomplete="off">
`;
}
return `<input class="input" name="${name}" type="text" value="${escapeAttr(currentValue)}" autocomplete="off">`;
}
function collectParameters(form, options, isEdit) {
const params = {};
for (const opt of options) {
if (opt.Hide) continue;
const baseName = `opt_${opt.Name}`;
const el = form.elements[baseName];
if (!el) continue;
let val;
if (opt.Type === "bool") {
val = el.checked ? "true" : "false";
} else if (el.dataset && el.dataset.hasCustom === "1" || el.getAttribute("data-has-custom") === "1") {
// It's a <select> with a sibling text override
if (el.value === "__custom__") {
const customEl = form.elements[`${baseName}__custom`];
val = customEl ? customEl.value.trim() : "";
} else {
val = el.value;
}
} else {
val = el.value.trim();
}
// In edit mode, skip empty password fields (leave unchanged).
if (isEdit && opt.IsPassword && val === "") continue;
// Skip empty non-required fields.
if (val === "" && !opt.Required) continue;
params[opt.Name] = val;
}
return params;
}
// Wire up the "Custom…" reveal on selects with data-has-custom.
// Called from app.js after every form render via a MutationObserver-
// free approach: we attach the listener at form-render time inside
// renderForm. Use event delegation for simplicity.
export function wireCustomSelects(container) {
container.querySelectorAll('select[data-has-custom="1"]').forEach((sel) => {
const customName = sel.name + "__custom";
const customEl = container.elements
? container.elements[customName]
: container.querySelector(`[name="${CSS.escape(customName)}"]`);
if (!customEl) return;
const sync = () => {
customEl.style.display = sel.value === "__custom__" ? "block" : "none";
};
sync();
sel.addEventListener("change", sync);
});
}
async function ensureProviders() {
const cached = getState().providers;
if (cached) return cached;
const res = await post("config/providers");
const providers = (res && res.providers) || [];
setState({ 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) {
return escapeHtml(s);
}
+304
View File
@@ -0,0 +1,304 @@
// views/jobs.js — submit sync/copy/move jobs and poll their progress.
// Job src/dst metadata is persisted to localStorage (via state.rememberJob)
// so the table can show what each job is doing even after a page reload.
import { post, postAsync } from "../rc.js";
import {
toast,
rememberJob,
getJobMeta,
formatBytes,
formatSpeed,
formatDuration,
} from "../state.js";
let pollTimer = null;
export async function renderJobs() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="section-head">
<div>
<h2>Jobs</h2>
<p class="subtitle">Running and recently completed transfers.</p>
</div>
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
</div>
<div id="jobs-card" class="card-outline">
<p class="empty">Loading jobs…</p>
</div>
`;
await refreshJobs();
startPolling();
}
export async function renderNewJob() {
const app = document.getElementById("app");
// Pull remotes for the dropdowns
let remotes = [];
try {
const res = await post("config/listremotes");
remotes = (res && res.remotes) || [];
} catch (e) {
toast(`Couldn’t load remotes: ${e.message}`, "error");
}
const remoteOpts = remotes
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`)
.join("");
app.innerHTML = `
<div class="section-head">
<div>
<h2>New Job</h2>
<p class="subtitle">Copy, sync, or move between remotes.</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/jobs">Cancel</a>
</div>
<form id="new-job-form" class="card-outline" style="display:grid;gap:16px;max-width:640px">
<div class="field">
<label>Action</label>
<select name="action" class="select">
<option value="copy">copy (mirror src → dst, keep both)</option>
<option value="sync">sync (mirror src → dst, delete extras on dst)</option>
<option value="move">move (mirror src → dst, delete src after)</option>
</select>
</div>
<div class="form-grid">
<div class="field">
<label>Source remote</label>
<select name="srcRemote" class="select">
${remoteOpts || '<option value="">(no remotes)</option>'}
</select>
</div>
<div class="field">
<label>Source path (optional)</label>
<input class="input" name="srcPath" placeholder="folder/subfolder" autocomplete="off">
</div>
<div class="field">
<label>Destination remote</label>
<select name="dstRemote" class="select">
${remoteOpts || '<option value="">(no remotes)</option>'}
</select>
</div>
<div class="field">
<label>Destination path (optional)</label>
<input class="input" name="dstPath" placeholder="folder/subfolder" autocomplete="off">
</div>
</div>
<div class="toolbar">
<button type="submit" class="btn btn-primary">Start Job</button>
</div>
</form>
`;
// Prefill first remote in both selectors for convenience
if (remotes.length > 0) {
app.querySelector('select[name="srcRemote"]').value = `${remotes[0]}:`;
app.querySelector('select[name="dstRemote"]').value = `${remotes[0]}:`;
}
const form = document.getElementById("new-job-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const action = form.elements.action.value;
const srcRemote = form.elements.srcRemote.value;
const dstRemote = form.elements.dstRemote.value;
const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, "");
const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, "");
if (!srcRemote || !dstRemote) {
toast("Pick source and destination remotes", "error");
return;
}
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote;
try {
const body = { srcFs: src, dstFs: dst };
if (action === "move") body.deleteEmptySrcDirs = true;
const res = await postAsync(`sync/${action}`, body);
const jobid = res && res.jobid;
if (jobid != null) {
rememberJob(jobid, { action, src, dst });
}
toast(`Started ${action} job #${jobid}`, "success");
location.hash = "#/jobs";
} catch (e) {
toast(`Job start failed: ${e.message}`, "error");
}
});
}
async function refreshJobs() {
const card = document.getElementById("jobs-card");
if (!card) return; // user navigated away
let jobIds = [];
try {
const list = await post("job/list");
const running = (list && list.jobids) || [];
const finished = (list && list.finishedIds) || [];
// Show both running and recently-finished. Sort happens in render.
jobIds = [...new Set([...running, ...finished])];
} catch (e) {
card.innerHTML = `<div class="empty"><h3>Couldn’t load jobs</h3><p>${escapeHtml(e.message)}</p></div>`;
return;
}
if (jobIds.length === 0) {
card.innerHTML = `
<div class="empty">
<h3>No jobs yet</h3>
<p>Use <a href="#/jobs/new">New Job</a> to start a copy, sync, or move.</p>
</div>
`;
return;
}
// Fetch each job's status in parallel
const statuses = await Promise.all(
jobIds.map((id) =>
post("job/status", { jobid: id }).catch((e) => ({
jobid: id,
error: e.message,
finished: true,
})),
),
);
card.innerHTML = renderJobTable(statuses);
card.querySelectorAll("[data-stop]").forEach((btn) => {
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
});
}
function renderJobTable(statuses) {
// Newest jobid first
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
const rows = statuses.map(renderJobRow).join("");
return `
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Job</th>
<th>Status</th>
<th class="col-num">Progress</th>
<th class="col-num">Speed</th>
<th class="col-num">ETA</th>
<th class="col-num">Files</th>
<th class="col-num">Errors</th>
<th class="col-num">Action</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
function renderJobRow(s) {
const p = s.progress || {};
const id = s.jobid;
const finished = s.finished;
const success = s.success;
const errored = !!s.error;
let badge;
if (!finished) {
badge = `<span class="badge">running</span>`;
} else if (errored || (!success && errored)) {
badge = `<span class="badge badge-error">failed</span>`;
} else if (success) {
badge = `<span class="badge badge-success">done</span>`;
} else {
badge = `<span class="badge badge-warning">finished</span>`;
}
const meta = getJobMeta(id);
const jobCell = meta
? `
<div class="job-cell">
<span class="job-action">${escapeHtml(meta.action)}</span>
<span class="job-paths">
<code>${escapeHtml(meta.src)}</code>
<span class="arrow">→</span>
<code>${escapeHtml(meta.dst)}</code>
</span>
</div>`
: `<span class="col-mono" style="color:var(--color-muted-soft)">— submitted via CLI —</span>`;
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
const progress = `
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px">
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
<span class="col-mono" style="font-size:11px;color:var(--color-muted)">
${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)}
</span>
</div>
`;
const stopBtn = !finished
? `<button class="btn btn-danger btn-sm" data-stop="${id}">Stop</button>`
: "";
return `
<tr>
<td class="col-mono">${id}</td>
<td>${jobCell}</td>
<td>${badge}</td>
<td class="col-num">${progress}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))}</td>
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))}</td>
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td>
<td class="col-num">${stopBtn}</td>
</tr>
`;
}
async function onStop(jobid) {
if (!confirm(`Stop job #${jobid}?`)) return;
try {
await post("job/stop", { jobid });
toast(`Stopped job #${jobid}`, "success");
await refreshJobs();
} catch (e) {
toast(`Stop failed: ${e.message}`, "error");
}
}
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
if (!document.getElementById("jobs-card")) {
stopPolling();
return;
}
await refreshJobs();
}, 1500);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
export function stopJobPolling() {
stopPolling();
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+105
View File
@@ -0,0 +1,105 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
export async function renderRemotes() {
const app = document.getElementById("app");
if (!app) return;
app.innerHTML = `
<div class="section-head">
<div>
<h2>Remotes</h2>
<p class="subtitle">Configured cloud storage providers. Click a tile to browse, hover for edit/delete.</p>
</div>
<a class="btn btn-primary btn-sm" href="#/configure/new">New remote</a>
</div>
<div id="remote-grid" class="connector-grid">
<p class="empty" style="grid-column:1/-1">Loading remotes…</p>
</div>
`;
const grid = document.getElementById("remote-grid");
try {
const [listRes, dumpRes] = await Promise.all([
post("config/listremotes"),
post("config/dump"),
]);
const names = (listRes && listRes.remotes) || [];
const dump = dumpRes || {};
const remotes = names.map((name) => ({
name,
type: (dump[name] && dump[name].type) || "unknown",
}));
setState({ remotes });
if (remotes.length === 0) {
grid.innerHTML = `
<div class="empty" style="grid-column:1/-1">
<h3>No remotes configured</h3>
<p>Click <strong>New remote</strong> above to add one from this GUI.</p>
</div>
`;
return;
}
grid.innerHTML = remotes
.map(
(r) => `
<a class="connector-tile tile-with-actions" href="#/browse/${encodeURIComponent(r.name)}">
<span class="tile-name">${escapeHtml(r.name)}</span>
<span class="badge">${escapeHtml(r.type)}</span>
<div class="tile-actions">
<button type="button" title="Edit" data-edit="${escapeHtml(r.name)}">✎</button>
<button type="button" title="Delete" class="danger" data-delete="${escapeHtml(r.name)}">🗑</button>
</div>
</a>
`,
)
.join("");
// Wire action buttons
grid.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
location.hash = `#/configure/edit/${encodeURIComponent(btn.dataset.edit)}`;
});
});
grid.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const name = btn.dataset.delete;
if (!confirm(`Delete remote ${name}? This cannot be undone.`)) return;
try {
await post("config/delete", { name });
toast(`Deleted ${name}`, "success");
await renderRemotes();
} catch (err) {
toast(`Delete failed: ${err.message}`, "error");
}
});
});
} catch (e) {
grid.innerHTML = `
<div class="empty" style="grid-column:1/-1">
<h3>Couldn’t reach rclone</h3>
<p>${escapeHtml(e.message)}</p>
</div>
`;
toast(`Failed to load remotes: ${e.message}`, "error");
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}