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:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
Reference in New Issue
Block a user