eb32fd5e22
外层仓库管理 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)
446 lines
14 KiB
JavaScript
446 lines
14 KiB
JavaScript
// 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function escapeAttr(s) {
|
||
return escapeHtml(s);
|
||
}
|