Files
rclone_gui/webgui/web/assets/js/views/configure.js
T
ci eb32fd5e22 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)
2026-06-19 12:42:35 +08:00

446 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>Couldnt 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 cant 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);
}