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)
217 lines
5.1 KiB
JavaScript
217 lines
5.1 KiB
JavaScript
// 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;
|
|
}
|
|
}
|