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:35:12 +08:00
commit eb32fd5e22
25 changed files with 3872 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Keep the frontend image build context small.
# We only need:
# - webgui/web/ (the static frontend bundle)
# - docker/nginx.conf (the reverse-proxy config)
# - Dockerfile.webgui
# VCS metadata
.git
.gitignore
.gitmodules
.github
# The Go-side webgui command is not needed for the frontend image —
# the official rclone/rclone container handles the backend.
webgui/webgui.go
webgui/*.patch
# Submodule source — the frontend image doesn't need rclone itself.
rclone
# Local rclone config (contains secrets)
config/
# Editor / OS cruft
.idea
.vscode
.history
.devcontainer
*~
_junk
Thumbs.db
.DS_Store
__pycache__
# Docs that don't belong in the runtime image
CLAUDE.md
DESIGN.md
README.md
+17
View File
@@ -0,0 +1,17 @@
# Local rclone config (contains secrets — never commit)
config/rclone/rclone.conf
# Docker build artifacts / runtime
.docker/
# Editor / OS cruft
.idea/
.vscode/
.history/
*~
.DS_Store
Thumbs.db
# Bash / Python
__pycache__/
*.pyc
+3
View File
@@ -0,0 +1,3 @@
[submodule "rclone"]
path = rclone
url = https://github.com/rclone/rclone.git
+100
View File
@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Rclone ("rsync for cloud storage") is a command-line Go program that syncs files and directories to/from 70+ cloud storage providers. Entry point is `rclone.go``cmd.Main()`. Current Go toolchain requirement is in `go.mod` (the `go` directive); check it before assuming a version.
## Build & install
```bash
go build # plain build
make # build with version metadata baked in (preferred on Unix)
make rclone # same as above, also installs into $(go env GOPATH)/bin
```
On Windows, `make rclone` also generates a version-stamped `.syso` resource file via `bin/resource_windows.go` before building, then deletes it. If you see a stray `resource_windows_*.syso` it was left behind by an interrupted build.
`-X github.com/rclone/rclone/fs.Version=$(TAG)` is passed via `-ldflags`; the version string the binary reports is built from `VERSION` + git metadata in the Makefile (`TAG := $(VERSION)$(VERSION_SUFFIX)$(TAG_BRANCH)`). Bump `VERSION` only via the `startdev`/`startstable` Makefile targets, not by hand.
## Tests
Rclone's tests run against **real backends** configured as `Test<Name>:` sections in the rclone config file. Tests requiring a missing section are skipped automatically — a clean `go test ./...` from a fresh checkout just runs the local-FS-only subset.
```bash
# Run a single package's tests
cd backend/drive && go test -v
# Run one test in one package
cd fs/sync && go test -v -run TestSync
# Quick test of everything (no remote creds needed; sets bogus config to force skips)
make quicktest
# Run a package's integration tests against a configured remote
cd fs/sync && go test -v -remote TestDrive:
cd fs/sync && go test -v -remote TestDrive: -fast-list # exercise ListR path
cd fs/operations && go test -v -remote TestDrive:
# Full integration framework from repo root, one backend at a time
go run ./fstest/test_all -backends drive
# Race detector
make racequicktest
```
Backend test remotes are registered in `fstest/test_all/config.yaml`. Some tests there have `ignore:` entries that document intentional skips (e.g. B2 versioning makes certain purge tests impossible).
## Lint / quality
```bash
make check # golangci-lint + markdownlint, same as CI
golangci-lint run ./... # Go only
bin/markdown-lint # Markdown only
```
`.golangci.yml` enables: errcheck, govet, ineffassign, staticcheck, unused, gocritic, misspell, revive, unconvert, plus the `goimports` formatter. `gocritic` ruleguard rules live in `bin/rules.go`. golangci-lint v2 config format is in use.
## Architecture
Layered, with `fs/` as the contract layer:
- **`fs/`** — core interfaces and primitives. `fs.Fs`, `fs.Object`, `fs.DirInfo` (in `fs/types.go` and `fs/fs.go`) are the contracts every backend implements. Sub-packages:
- `fs/operations` — primitives (`Copy`, `Move`, `MoveDir`, `check`, dedupe, listdirsorted, lsjson)
- `fs/sync``sync.Sync` (one-way) and the pipe-based engine; also drives `bisync`
- `fs/march` — walks two Fs trees in lock step, emitting diffs
- `fs/filter` — include/exclude rules
- `fs/config` — config file, flags, obscured creds, rc config; `configfile`/`configflags`/`configstruct`/`configmap` are sub-packages
- `fs/fshttp` — rclone's `http.Client`/`Transport`; backends **must** use this to inherit `--dump`, `--tpslimit`, `--user-agent`, etc.
- `fs/accounting`, `fs/cache`, `fs/fspath`, `fs/hash`, `fs/log`, `fs/rc` (+ `rcserver`)
- **`backend/`** — one subdirectory per provider (drive, s3, dropbox, sftp, local, …). `backend/all/all.go` imports them all via blank imports; `rclone.go` imports `backend/all`. There are virtual/wrapper backends too: `alias`, `archive`, `cache`, `chunker`, `combine`, `compress`, `crypt`, `hasher`, `union`.
- **`cmd/`** — one subdirectory per CLI subcommand (`copy`, `sync`, `mount`, `serve`, `bisync`, …). `cmd/all/all.go` registers them; `cmd/cmd.go` is the cobra entry point.
- **`lib/`** — cross-cutting libraries backends and fs both use: `rest` (thin net/http wrapper for REST APIs), `pacer` (retry/backoff), `dircache`, `oauthutil`, `encoder` (filename encoding), `readers`, `atexit`, `errcount`, `exitcode`, `multipart`, `plugin`, etc.
- **`vfs/`** — virtual filesystem layer for `mount`/`cmount`/`mountlib`/`nfsmount`.
- **`librclone/`** — embeddable in-memory API for users who want rclone as a library.
- **`fstest/`** — integration test framework: `fstests` (per-backend suite), `mockdir`/`mockobject`, `test_all` (driver that reads `config.yaml`).
- **`cmdtest/`** — end-to-end tests of CLI flags, env vars, exit codes.
`rclone.go` is intentionally tiny: it blank-imports `backend/all`, `cmd/all`, `lib/plugin`, then calls `cmd.Main()`.
## Conventions
**Commit messages** follow `<area>: <summary>` where `<area>` is the directory touched (`drive:`, `fs/sync:`, `mount:`, `completion:`). The changelog is generated from these first lines, so make them user-readable. Long form goes after a blank line, mentioning `Fixes #N` to auto-close.
**New backend layout** (see CONTRIBUTING.md "Writing a new backend"):
- Implement in a single `backend/<name>/<name>.go`**do not** split into `fs.go` and `object.go`. The maintainers explicitly reject this for the >50 existing backends.
- Put API type definitions in `api/types.go`.
- Follow the structure of `backend/box` (directory-based) or `backend/b2` (bucket-based) exactly — same function names, same order, same comments.
- For HTTP backends, use `lib/rest` and the client from `fs/fshttp`. Don't roll your own transport.
- Register in `backend/all/all.go` and add to `fstest/test_all/config.yaml`.
**Backend options** are declared in Go with `Help:` fields that get rendered into docs and `--flag` help. See CONTRIBUTING.md "Writing Documentation" for the rules: first sentence on one line ≤80 chars, ends with period, more detail after a blank `"\n\n"`. The Markdown in `docs/content/<name>.md` between `<!-- autogenerated options start -->…<!-- autogenerated options stop -->` markers is regenerated by `bin/make_backend_docs.py` — never edit those regions by hand.
**Documentation autogeneration.** `MANUAL.md`, `MANUAL.html`, `MANUAL.txt`, `rclone.1`, `docs/content/flags.md`, `docs/content/commands/*.md`, and the autogenerated portions of backend docs are all produced from Go source by Makefile targets (`make doc`, `make commanddocs`, `make backenddocs`, `make rcdocs`). `bin/check_autogenerated_edits.py` runs in CI and rejects PRs that hand-edit those regions. You normally don't run these locally — they run during the release process. The `docs/` site is built with Hugo (`make serve` to preview).
**`go generate` is used** in `lib/transform` and `cmd/bisync` (see the `commanddocs` target). Run those before regenerating command docs.
**Configuration system.** Top-level flags live in `fs/config/configflags`. Backend options are defined per-backend via `Options` structs with `configstruct` tags and surfaced automatically. The `--config` flag points at the rclone.conf used both at runtime and for `Test<Name>:` test remotes.
**Version tagging.** Tags are GPG-signed (`make retag`). `make beta` / `make ci_beta` publish to `beta.rclone.org`. Full release flow is in `RELEASE.md`.
+589
View File
@@ -0,0 +1,589 @@
---
version: alpha
name: Claude-design-analysis
description: A warm-canvas editorial interface for Anthropic's Claude product. The system anchors on a tinted cream canvas with serif display headlines, warm coral CTAs, and dark navy product surfaces (code editor mockups, model showcase cards). Brand voltage comes from the cream/coral pairing — deliberately warm and humanist where most AI brands use cool blue + slate. Type voice runs a slab-serif display ("Copernicus" / Tiempos Headline) for h1/h2 and a humanist sans for body. The signature Anthropic black-radial-spike mark anchors the wordmark.
colors:
primary: "#cc785c"
primary-active: "#a9583e"
primary-disabled: "#e6dfd8"
ink: "#141413"
body: "#3d3d3a"
body-strong: "#252523"
muted: "#6c6a64"
muted-soft: "#8e8b82"
hairline: "#e6dfd8"
hairline-soft: "#ebe6df"
canvas: "#faf9f5"
surface-soft: "#f5f0e8"
surface-card: "#efe9de"
surface-cream-strong: "#e8e0d2"
surface-dark: "#181715"
surface-dark-elevated: "#252320"
surface-dark-soft: "#1f1e1b"
on-primary: "#ffffff"
on-dark: "#faf9f5"
on-dark-soft: "#a09d96"
accent-teal: "#5db8a6"
accent-amber: "#e8a55a"
success: "#5db872"
warning: "#d4a017"
error: "#c64545"
typography:
display-xl:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 64px
fontWeight: 400
lineHeight: 1.05
letterSpacing: -1.5px
display-lg:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 48px
fontWeight: 400
lineHeight: 1.1
letterSpacing: -1px
display-md:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 36px
fontWeight: 400
lineHeight: 1.15
letterSpacing: -0.5px
display-sm:
fontFamily: "Copernicus, Tiempos Headline, serif"
fontSize: 28px
fontWeight: 400
lineHeight: 1.2
letterSpacing: -0.3px
title-lg:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 22px
fontWeight: 500
lineHeight: 1.3
letterSpacing: 0
title-md:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 18px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
title-sm:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 16px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
body-md:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 16px
fontWeight: 400
lineHeight: 1.55
letterSpacing: 0
body-sm:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.55
letterSpacing: 0
caption:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 13px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
caption-uppercase:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 12px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 1.5px
code:
fontFamily: "JetBrains Mono, ui-monospace, monospace"
fontSize: 14px
fontWeight: 400
lineHeight: 1.6
letterSpacing: 0
button:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 14px
fontWeight: 500
lineHeight: 1
letterSpacing: 0
nav-link:
fontFamily: "StyreneB, Inter, sans-serif"
fontSize: 14px
fontWeight: 500
lineHeight: 1.4
letterSpacing: 0
rounded:
xs: 4px
sm: 6px
md: 8px
lg: 12px
xl: 16px
pill: 9999px
full: 9999px
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 16px
lg: 24px
xl: 32px
xxl: 48px
section: 96px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 12px 20px
height: 40px
button-primary-active:
backgroundColor: "{colors.primary-active}"
textColor: "{colors.on-primary}"
rounded: "{rounded.md}"
button-primary-disabled:
backgroundColor: "{colors.primary-disabled}"
textColor: "{colors.muted}"
rounded: "{rounded.md}"
button-secondary:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 12px 20px
height: 40px
button-secondary-on-dark:
backgroundColor: "{colors.surface-dark-elevated}"
textColor: "{colors.on-dark}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: 12px 20px
button-text-link:
backgroundColor: transparent
textColor: "{colors.ink}"
typography: "{typography.button}"
button-icon-circular:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.full}"
size: 36px
text-link:
backgroundColor: transparent
textColor: "{colors.primary}"
typography: "{typography.body-md}"
top-nav:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.nav-link}"
height: 64px
hero-band:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.display-xl}"
padding: 96px
hero-illustration-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
feature-card:
backgroundColor: "{colors.surface-card}"
textColor: "{colors.ink}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
product-mockup-card-dark:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
code-window-card:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.code}"
rounded: "{rounded.lg}"
padding: 24px
model-comparison-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
pricing-tier-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.title-lg}"
rounded: "{rounded.lg}"
padding: 32px
pricing-tier-card-featured:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.title-lg}"
rounded: "{rounded.lg}"
padding: 32px
callout-card-coral:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.title-md}"
rounded: "{rounded.lg}"
padding: 32px
connector-tile:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.title-sm}"
rounded: "{rounded.lg}"
padding: 20px
text-input:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body-md}"
rounded: "{rounded.md}"
padding: 10px 14px
height: 40px
text-input-focused:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.md}"
cookie-consent-card:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.body-sm}"
rounded: "{rounded.lg}"
padding: 24px
category-tab:
backgroundColor: transparent
textColor: "{colors.muted}"
typography: "{typography.nav-link}"
padding: 8px 14px
rounded: "{rounded.md}"
category-tab-active:
backgroundColor: "{colors.surface-card}"
textColor: "{colors.ink}"
typography: "{typography.nav-link}"
rounded: "{rounded.md}"
badge-pill:
backgroundColor: "{colors.surface-card}"
textColor: "{colors.ink}"
typography: "{typography.caption}"
rounded: "{rounded.pill}"
padding: 4px 12px
badge-coral:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.caption-uppercase}"
rounded: "{rounded.pill}"
padding: 4px 12px
cta-band-coral:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.display-sm}"
rounded: "{rounded.lg}"
padding: 64px
cta-band-dark:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark}"
typography: "{typography.display-sm}"
rounded: "{rounded.lg}"
padding: 64px
footer:
backgroundColor: "{colors.surface-dark}"
textColor: "{colors.on-dark-soft}"
typography: "{typography.body-sm}"
padding: 64px
---
## Overview
Claude.com is the warmest, most editorial interface in the AI-product category. The base atmosphere is a **tinted cream canvas** (`{colors.canvas}`#faf9f5) — distinctly warm, deliberately not the cool gray-white that every other AI brand uses. Headlines run a **slab-serif display** ("Copernicus" / Tiempos Headline) at weight 400 with negative letter-spacing, paired with **StyreneB / Inter** body sans. The combination feels like a literary publication, not a SaaS marketing page.
Brand voltage comes from the **cream + coral pairing** — coral (`{colors.primary}`#cc785c) is the signature Anthropic accent, used on every primary CTA, on the brand wordmark, and on full-bleed callout cards. The coral is warm, slightly muted, never cyan/blue — a deliberate counter-positioning against OpenAI's cool slate, Google's saturated blue, and Microsoft's corporate cyan.
The system has three surface modes that alternate page-by-page:
1. **Cream canvas** (`{colors.canvas}`) — default body floor
2. **Light cream cards** (`{colors.surface-card}`) — feature card backgrounds
3. **Dark navy product surfaces** (`{colors.surface-dark}`) — code editor mockups, model showcase cards, pre-footer CTAs, footer itself
The dark surfaces are where Claude shows its product chrome — code blocks, terminal output, model comparison tables, agentic-flow diagrams. The cream-to-dark contrast is the page's pacing rhythm.
**Key Characteristics:**
- Warm cream canvas (`{colors.canvas}`#faf9f5) with dark warm-ink text (`{colors.ink}`#141413). The brand's defining color choice.
- Coral primary CTA (`{colors.primary}`#cc785c). Used scarcely on individual buttons, generously on full-bleed coral callout cards.
- Slab-serif display headlines via Copernicus / Tiempos Headline at weight 400 with negative letter-spacing. Pairs with humanist sans body for a literary editorial voice.
- Dark navy product mockup cards (`{colors.surface-dark}`#181715) carrying code blocks, terminal panels, model comparison data — the brand shows the product chrome at scale rather than abstract marketing illustrations.
- Light cream feature cards (`{colors.surface-card}`#efe9de) — slightly darker than canvas, used for content-driven feature explanations.
- Anthropic radial-spike mark — a small black asterisk-like glyph (4-spoke radial) — appears as the brand wordmark prefix and as a content marker.
- Border radius is hierarchical: `{rounded.md}` (8px) for buttons + inputs, `{rounded.lg}` (12px) for content + product cards, `{rounded.xl}` (16px) for the hero illustration container, `{rounded.pill}` for badges.
- Section rhythm `{spacing.section}` (96px) — modern-SaaS standard. Internal card padding stays generous at `{spacing.xl}` (32px).
## Colors
### Brand & Accent
- **Coral / Primary** (`{colors.primary}`#cc785c): The signature Anthropic warm coral. Used on every primary CTA background, on full-bleed coral callout cards, on the brand wordmark accent. The most-recognized Anthropic color outside of the spike-mark logo.
- **Coral Active** (`{colors.primary-active}`#a9583e): The press / hover-darker variant.
- **Coral Disabled** (`{colors.primary-disabled}`#e6dfd8): A desaturated cream-tinted disabled state.
- **Accent Teal** (`{colors.accent-teal}`#5db8a6): Used sparingly on secondary product surfaces (terminal status indicators, "active connection" dots in connectors page).
- **Accent Amber** (`{colors.accent-amber}`#e8a55a): A small companion warm-tone used on category badges and inline highlights.
### Surface
- **Canvas** (`{colors.canvas}`#faf9f5): The default page floor. Tinted cream — warm, deliberately not pure white.
- **Surface Soft** (`{colors.surface-soft}`#f5f0e8): Section dividers, very-soft band backgrounds.
- **Surface Card** (`{colors.surface-card}`#efe9de): Feature cards, content cards. One step darker than canvas.
- **Surface Cream Strong** (`{colors.surface-cream-strong}`#e8e0d2): A strongest-cream variant used on selected category tabs and emphasized section bands.
- **Surface Dark** (`{colors.surface-dark}`#181715): Code editor mockups, model showcase cards, footer. The dominant dark surface.
- **Surface Dark Elevated** (`{colors.surface-dark-elevated}`#252320): Elevated cards inside dark bands (settings panels in mockups).
- **Surface Dark Soft** (`{colors.surface-dark-soft}`#1f1e1b): Slightly lighter dark, used for code block backgrounds inside larger dark cards.
- **Hairline** (`{colors.hairline}`#e6dfd8): The 1px border tone on cream surfaces. Same hex as `{colors.primary-disabled}` — borders feel like one elevation step rather than ink lines.
- **Hairline Soft** (`{colors.hairline-soft}`#ebe6df): Barely-visible divider used inside the same band.
### Text
- **Ink** (`{colors.ink}`#141413): All headlines and primary text. Warm dark, slightly off-pure-black.
- **Body Strong** (`{colors.body-strong}`#252523): Emphasized paragraphs, lead text.
- **Body** (`{colors.body}`#3d3d3a): Default running-text color.
- **Muted** (`{colors.muted}`#6c6a64): Sub-headings, breadcrumbs, footer-adjacent secondary text.
- **Muted Soft** (`{colors.muted-soft}`#8e8b82): Captions, fine-print, copyright lines.
- **On Primary** (`{colors.on-primary}`#ffffff): Text on coral buttons.
- **On Dark** (`{colors.on-dark}`#faf9f5): Cream-tinted white used on dark surfaces (echoes the canvas tone).
- **On Dark Soft** (`{colors.on-dark-soft}`#a09d96): Footer body text, secondary labels in dark mockups.
### Semantic
- **Success** (`{colors.success}`#5db872): Green status dots, "available" indicators.
- **Warning** (`{colors.warning}`#d4a017): Warning callouts (rare on marketing surfaces).
- **Error** (`{colors.error}`#c64545): Validation errors.
## Typography
### Font Family
The system runs **Copernicus** (or **Tiempos Headline** as substitute) as the slab-serif display face for headlines, and **StyreneB** (or **Inter** as substitute) as the humanist sans for body, navigation, and UI labels. **JetBrains Mono** handles code blocks. The fallback stack walks `Tiempos Headline, Garamond, "Times New Roman", serif` for display and `Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` for body.
The display/body split is editorial:
- Copernicus serif (weight 400, negative tracking) → h1, h2, h3, hero display
- StyreneB sans (weight 400-500) → body, navigation, buttons, captions, labels
- JetBrains Mono → all code blocks and terminal text
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| `{typography.display-xl}` | 64px | 400 | 1.05 | -1.5px | Homepage h1 ("Meet your thinking partner") — Copernicus serif |
| `{typography.display-lg}` | 48px | 400 | 1.1 | -1px | Section heads — Copernicus |
| `{typography.display-md}` | 36px | 400 | 1.15 | -0.5px | Sub-section heads, model names — Copernicus |
| `{typography.display-sm}` | 28px | 400 | 1.2 | -0.3px | Pricing tier names, callout headlines — Copernicus |
| `{typography.title-lg}` | 22px | 500 | 1.3 | 0 | Pricing plan size labels — StyreneB |
| `{typography.title-md}` | 18px | 500 | 1.4 | 0 | Feature card titles, intro paragraphs |
| `{typography.title-sm}` | 16px | 500 | 1.4 | 0 | Connector tile titles, list labels |
| `{typography.body-md}` | 16px | 400 | 1.55 | 0 | Default running-text — StyreneB |
| `{typography.body-sm}` | 14px | 400 | 1.55 | 0 | Footer body, fine-print |
| `{typography.caption}` | 13px | 500 | 1.4 | 0 | Badge labels, captions |
| `{typography.caption-uppercase}` | 12px | 500 | 1.4 | 1.5px | Category tags, "NEW" badges |
| `{typography.code}` | 14px | 400 | 1.6 | 0 | Code blocks — JetBrains Mono |
| `{typography.button}` | 14px | 500 | 1.0 | 0 | Standard button labels |
| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu items |
### Principles
Display sizes use weight 400 (regular), never bold. Negative letter-spacing (-0.3 to -1.5px) is essential — Copernicus without it reads as off-brand. The serif character is what gives Anthropic its literary, considered voice; switching to a sans-serif display would make Claude feel like every other AI tool.
Body type stays at weight 400 for paragraphs, weight 500 for labels and emphasized phrases. The sans body is humanist (StyreneB) — never geometric. Inter is an acceptable substitute because of its similar humanist proportions; Helvetica or Arial would be too neutral and break the warm-editorial feel.
### Note on Font Substitutes
If Copernicus / Tiempos Headline is unavailable, **Cormorant Garamond** at weight 500 with -0.02em letter-spacing is the closest open-source approximation. **EB Garamond** is a fallback. For StyreneB, **Inter** is the closest match — both are humanist sans designed for screen reading. **Söhne** is another close alternative if licensed.
## Layout
### Spacing System
- **Base unit:** 4px.
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 96px.
- **Section padding:** `{spacing.section}` (96px) — modern-SaaS rhythm.
- **Card internal padding:** `{spacing.xl}` (32px) for feature cards, pricing tier cards, model comparison cards; `{spacing.lg}` (24px) for code-window cards and connector tiles.
- **Callout / CTA bands:** `{spacing.xxl}` (48px) inside coral callout cards; 64px inside the larger dark CTA band.
### Grid & Container
- **Max content width:** ~1200px centered.
- **Editorial body:** Single 12-column grid; hero often uses 6/6 split (h1 left, illustration right).
- **Feature card grids:** 3-up at desktop, 2-up at tablet, 1-up at mobile.
- **Connector tile grids:** 4-up or 6-up at desktop, 2-up at tablet, 1-up at mobile.
- **Pricing grid:** 3-up at desktop (Free / Pro / Team / Enterprise often), 1-up at mobile.
### Whitespace Philosophy
The cream canvas + serif display + generous internal padding create an editorial pacing — Claude reads like a long-form magazine column rather than a marketing template. Whitespace between bands stays uniform at 96px; whitespace inside cards is generous (32px), letting type breathe.
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| Flat | No shadow, no border | Body sections, top nav, hero bands |
| Soft hairline | 1px `{colors.hairline}` border | Inputs, sub-nav, occasionally on cards |
| Cream card | `{colors.surface-card}` background — no shadow | Feature cards, content cards |
| Dark surface card | `{colors.surface-dark}` background — no shadow | Code editor mockups, model showcase cards |
| Subtle drop shadow | Faint shadow at low alpha | Hover-elevated states (the system uses `0 1px 3px rgba(20,20,19,0.08)` rarely) |
The elevation philosophy is **color-block first, shadow rare**. Most depth comes from the cream-vs-dark surface contrast. Shadows are minimal. The dark surface mockups have their own internal product chrome (code editor scrollbars, line numbers, syntax highlighting) which adds detail without needing external shadows.
### Decorative Depth
- The Anthropic spike-mark glyph (4-spoke radial asterisk) appears as a small black mark in the brand wordmark and inline as a content marker.
- Code editor mockups carry their own internal depth: syntax-highlighted text in muted blues / oranges / grays, line numbers in `{colors.muted-soft}`, status bars at the bottom in `{colors.surface-dark-elevated}`.
- Some hero illustrations use simple line-art with coral and dark-navy strokes on cream — minimal, hand-drawn-feeling, never photorealistic.
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| `{rounded.xs}` | 4px | Reserved for badge accents and tiny dropdowns |
| `{rounded.sm}` | 6px | Small inline buttons, dropdown items |
| `{rounded.md}` | 8px | Standard CTA buttons, text inputs, category tabs |
| `{rounded.lg}` | 12px | Content cards (feature, pricing, code-window, model-comparison) |
| `{rounded.xl}` | 16px | Hero illustration container, the larger marquee components |
| `{rounded.pill}` | 9999px | Badge pills, "NEW" tags |
| `{rounded.full}` | 9999px / 50% | Avatar substitutes, icon buttons |
### Photography & Illustrations
Claude's hero rarely uses photography. Instead it uses:
- Simple line-art illustrations with coral + dark-navy strokes on the cream canvas
- Code editor mockups (the dominant "hero" treatment on developer-focused pages)
- Terminal output mockups with monospace text on dark
- Model comparison cards (Opus / Sonnet / Haiku) with abstract geometric thumbnails
When photography is used (rare — mostly testimonials), avatars crop to perfect circles at 40px diameter.
## Components
### Top Navigation
**`top-nav`** — Cream nav bar pinned to the top of every page. 64px tall, `{colors.canvas}` background. Carries the Anthropic spike-mark + "Claude" wordmark at left, primary horizontal menu (Product, Solutions, Use Cases, Pricing, Research, Company) center-left, right-side cluster with "Sign in" text-link, "Try Claude" `{component.button-primary}` (coral). Menu items in `{typography.nav-link}` (StyreneB 14px / 500).
### Buttons
**`button-primary`** — The signature coral CTA. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), type `{typography.button}` (StyreneB 14px / 500), padding 12px × 20px, height 40px, rounded `{rounded.md}` (8px). Active state `button-primary-active` darkens to `{colors.primary-active}` (#a9583e).
**`button-secondary`** — Cream button with hairline outline. Background `{colors.canvas}`, text `{colors.ink}`, 1px hairline border, same padding + height + radius as primary.
**`button-secondary-on-dark`** — Used over `{colors.surface-dark}` cards. Background `{colors.surface-dark-elevated}` (#252320), text `{colors.on-dark}`. Stays dark — the system never inverts to a light secondary on dark surfaces.
**`button-text-link`** — Inline text button, no background. Used for "Sign in" in the top nav and inline CTA links.
**`button-icon-circular`** — 36px circular icon button. Background `{colors.canvas}`, hairline border, ink-color icon. Used for carousel arrows, share, "view more".
**`text-link`** — Inline body links in `{colors.primary}` (the coral). Underlined on press; the coral inline link is one of the system's most distinctive small details.
### Cards & Containers
**`hero-band`** — Cream-canvas hero with a 6-6 grid: h1 + sub-headline + button row on the left, hero illustration card or product mockup card on the right. Vertical padding `{spacing.section}` (96px).
**`hero-illustration-card`** — A larger card holding the hero's right-side artifact — sometimes a coral-stroke line illustration on cream background, sometimes a dark code editor mockup. Background `{colors.canvas}` or `{colors.surface-dark}` depending on context, rounded `{rounded.xl}` (16px).
**`feature-card`** — Used in 3-up feature grids. Background `{colors.surface-card}` (#efe9de — slightly darker cream), rounded `{rounded.lg}` (12px), internal padding `{spacing.xl}` (32px). Carries a small icon at top, an `{typography.title-md}` headline, and a body description in `{typography.body-md}`.
**`product-mockup-card-dark`** — Dark navy card showing actual Claude product chrome (chat interface, code editor, agent controls). Background `{colors.surface-dark}`, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries text labels in `{colors.on-dark}` and product UI fragments below.
**`code-window-card`** — A specialized dark card showing a code editor with line numbers, syntax-highlighted code in `{typography.code}` (JetBrains Mono), and sometimes a "Run" button or terminal output panel below. Background `{colors.surface-dark}` with `{colors.surface-dark-soft}` for the inner code block, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). The signature visual element of Claude Code product pages.
**`model-comparison-card`** — Used on the homepage's "Which problem are you up against?" section comparing Opus / Sonnet / Haiku. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, internal padding `{spacing.xl}` (32px). Carries the model name, a short capability blurb, and a `{component.text-link}` to learn more.
**`pricing-tier-card`** — Standard tier card. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding `{spacing.xl}` (32px). Carries the plan name in `{typography.title-lg}` (StyreneB), price in `{typography.display-sm}` (Copernicus serif!), feature checklist in `{typography.body-md}`, and a `{component.button-primary}` at the bottom.
**`pricing-tier-card-featured`** — The featured tier (typically "Pro" or "Team"). Background flips to `{colors.surface-dark}`, text inverts to `{colors.on-dark}`. The dark surface IS the featured-tier signal.
**`callout-card-coral`** — A full-bleed coral card carrying a major call-to-action. Background `{colors.primary}` (#cc785c), text `{colors.on-primary}` (white), rounded `{rounded.lg}`, padding `{spacing.xxl}` (48px). The coral surface IS the voltage; the CTA inside uses an inverted button style (cream/canvas button on coral).
**`connector-tile`** — Used on the connectors page's integration grid. Background `{colors.canvas}` with hairline border, rounded `{rounded.lg}`, padding 20px. Each tile carries a logo at top, a `{typography.title-sm}` connector name, and a short description.
### Inputs & Forms
**`text-input`** — Standard text input. Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, rounded `{rounded.md}` (8px), padding 10px × 14px, height 40px. 1px hairline border in `{colors.hairline}`.
**`text-input-focused`** — Focus state. Border thickens or shifts to `{colors.primary}` (coral) for emphasis. Carries a 3px coral-at-15%-alpha outer ring.
**`cookie-consent-card`** — Bottom-right floating dark cookie banner. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding `{spacing.lg}` (24px). One of the few places dark surface appears at small scale on cream pages.
### Tags / Badges
**`badge-pill`** — Small pill label used for category tags. Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.caption}` (13px / 500), rounded `{rounded.pill}`, padding 4px × 12px.
**`badge-coral`** — Coral-fill badge for "NEW", "BETA", featured highlights. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.caption-uppercase}` (12px / 500 / 1.5px tracking), rounded `{rounded.pill}`, padding 4px × 12px.
### Tab / Filter
**`category-tab`** + **`category-tab-active`** — Used in sub-nav rows on solutions / connectors pages. Inactive: transparent background, `{colors.muted}` text. Active: `{colors.surface-card}` background, `{colors.ink}` text. Padding 8px × 14px, rounded `{rounded.md}`.
### CTA / Footer
**`cta-band-coral`** — A pre-footer "Try Claude" CTA card. Full-width coral fill, white type, rounded `{rounded.lg}`, padding 64px. Carries an h2 in `{typography.display-sm}` (still serif!), a sub-line, and a cream-button CTA.
**`cta-band-dark`** — Alternative pre-footer band on developer-focused pages. Background `{colors.surface-dark}`, text `{colors.on-dark}`, rounded `{rounded.lg}`, padding 64px. Often pairs with a code-window card.
**`footer`** — Dark navy footer that closes every page. Background `{colors.surface-dark}` (#181715), text `{colors.on-dark-soft}`. 4-column link list at desktop covering Product / Company / Resources / Legal. Vertical padding 64px. The Anthropic spike-mark + "Anthropic" wordmark sits at the top in `{colors.on-dark}`. The footer never inverts.
## Do's and Don'ts
### Do
- Anchor every page on the cream canvas. Pure white reads as "any other AI tool"; the warm tint is the brand differentiator.
- Use Copernicus serif for every display headline. Pair with StyreneB sans body. Negative letter-spacing on display sizes is non-negotiable.
- Reserve `{colors.primary}` (coral) for primary CTAs and full-bleed `{component.callout-card-coral}` moments. Don't paint accent moments coral elsewhere.
- Use `{component.product-mockup-card-dark}` and `{component.code-window-card}` to show actual Claude product chrome. Don't paint marketing illustrations of code when you can show real code.
- Pair `{component.feature-card}` (cream) with `{component.product-mockup-card-dark}` (navy) in alternating bands. The cream-to-dark rhythm is the brand's pacing mechanism.
- Use the Anthropic spike-mark glyph as the brand wordmark prefix. Never invert the mark to white-on-dark within the wordmark itself.
- Apply `{spacing.section}` (96px) between major bands.
### Don't
- Don't use cool grays or pure white for canvas. Cream is the brand.
- Don't bold serif display weight. Copernicus at 700 reads as bombastic; the system stays at 400.
- Don't use cool blue or saturated cyan as a brand accent. The coral is the brand voltage.
- Don't put coral everywhere. The coral is scarce on individual elements and generous only on full-bleed coral callout cards.
- Don't use Inter for display headlines. The serif character is the brand voice.
- Don't repeat the same surface mode in two consecutive bands. The pacing alternates: cream → cream-card → dark-mockup → cream → coral-callout → dark-footer.
- Don't add hover state styling beyond what the system already encodes — primary darkens on press; nothing else changes.
## Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Mobile | < 768px | Hamburger nav; hero h1 64→32px; hero-illustration-card stacks below content; feature grids 1-up; connector tiles 2-up; pricing 1-up; footer 4 cols → 1 |
| Tablet | 7681024px | Top nav stays horizontal but tightens; feature cards 2-up; connector tiles 3-up; pricing 2-up |
| Desktop | 10241440px | Full top-nav with all menu items; 3-up feature cards; 4-up or 6-up connector tiles; 3-up pricing tiers |
| Wide | > 1440px | Same as desktop with more outer breathing room; max content width caps at 1200px |
### Touch Targets
- `{component.button-primary}` at minimum 40 × 40px.
- `{component.button-icon-circular}` at exactly 36 × 36 — slightly under WCAG 44 but visually centered.
- `{component.text-input}` height is 40px.
- Connector tile entire card area is tappable; effective tap area >> 44px.
### Collapsing Strategy
- Top nav collapses to hamburger at < 768px; menu opens as a full-screen cream sheet.
- Hero band's 6-6 grid collapses to single-column on mobile — h1 + sub-head + buttons first, then the illustration / mockup card below.
- Feature grids reduce columns rather than scaling cards down.
- Pricing tier cards collapse 4 → 2 → 1; featured-tier dark surface stays visually distinct at every breakpoint.
- Code-window cards retain code legibility at every breakpoint by allowing horizontal scroll within the card rather than wrapping code lines.
### Image Behavior
- Code blocks inside dark mockups stay at fixed font-size; horizontal scroll on mobile rather than wrapping.
- Hero illustrations scale proportionally; line-art strokes thin slightly on mobile.
- Avatar photos in testimonials crop to circles at every breakpoint.
## Iteration Guide
1. Focus on ONE component at a time. Reference its YAML key (`{component.feature-card}`, `{component.code-window-card}`).
2. Variants of an existing component (`-active`, `-disabled`, `-focused`) live as separate entries in `components:`.
3. Use `{token.refs}` everywhere — never inline hex.
4. Never document hover. Default and Active/Pressed states only.
5. Display headlines stay Copernicus serif 400 with negative tracking. Body stays StyreneB / Inter 400. The split is unbreakable.
6. Cream + coral + dark navy is the trinity. Don't introduce a fourth surface tone (no purple cards, no green sections).
7. When in doubt about emphasis: bigger Copernicus serif before bolder weight.
## Known Gaps
- Copernicus and StyreneB are licensed Anthropic typefaces and not available as public web fonts. Substitutes (Tiempos Headline / Cormorant Garamond / EB Garamond for serif; Inter / Söhne for sans) are documented in the typography section.
- The Anthropic radial-spike-mark is a brand glyph rendered as inline SVG; it's not formalized as a system token here. Treat it as a logo asset.
- Animation and transition timings (chat message reveal, code block typewriter effect on the homepage, agentic-flow diagram animations) are not in scope.
- Form validation states beyond `{component.text-input-focused}` are not extracted — error / success states would need a sign-up or feedback flow to confirm.
- The actual Claude product surface (claude.ai chat interface) shares some tokens with the marketing site but adds many product-specific components (chat bubbles, message tools, file upload chips, conversation history sidebar) that are out of scope for this marketing-surface document.
- The "agent" / "computer use" demo cards on certain pages display animated Claude controlling a browser — the static screenshot doesn't fully capture the animation chrome.
+13
View File
@@ -0,0 +1,13 @@
# Frontend image for the rclone webgui.
# Serves the static bundle (./webgui/web/) via nginx and reverse-proxies
# RC API calls + remote file downloads to the rclone rcd container on
# the compose network. Same-origin from the browser's perspective, so
# no CORS headaches.
FROM nginx:1.27-alpine
LABEL org.opencontainers.image.title="rclone-webgui-frontend"
LABEL org.opencontainers.image.description="Static frontend for the rclone webgui, served by nginx with a reverse proxy to rclone rcd."
COPY webgui/web/ /usr/share/nginx/html/
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
+94
View File
@@ -0,0 +1,94 @@
# rclone-webgui
浏览器端的 rclone 图形界面,使用 Anthropic / Claude 设计语言。
`rclone/` 是 [github.com/rclone/rclone](https://github.com/rclone/rclone)
的 git submodule,锁定在上游某个 commit,**不携带我们的任何改动**。
所有 webgui 源码、Docker 编排、文档都在本仓库。
## 项目结构
```
.
├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里)
│ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要)
│ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all
│ └── web/ # 静态前端(nginx 服务的就是这一份)
│ ├── index.html
│ └── assets/
├── docker/ # nginx 反向代理配置
├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交)
├── Dockerfile.webgui # 前端镜像构建(nginx + 静态资源)
├── docker-compose.yml # rclone rcd + gui 双服务编排
├── DESIGN.md # UI 设计系统规范
├── CLAUDE.md # Claude Code 协作指引
└── rclone/ # submodule → github.com/rclone/rclone,纯净不改动
```
## 功能
- **Remotes 管理** — 在浏览器里创建 / 编辑 / 删除 rclone remote,表单从
`/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。
- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete /
rename / download。
- **同步任务** — copy / sync / move 异步任务,1.5 秒轮询进度(速度、
ETA、已传输 / 总量、错误计数),任务元信息(src→dst)持久化到
localStorage,刷新页面不丢。
> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅,
> 引导用户在终端跑 `rclone config` 完成授权。
## 快速开始
```bash
# 1. 拉取子模块
git clone --recurse-submodules <your-fork-url>
# 或在已克隆的仓库里:
git submodule update --init
# 2. 启动堆栈
docker compose up -d --build
# 3. 打开 http://localhost:5580
```
## 端口
| 端口 | 服务 | 说明 |
|---|---|---|
| 5580 | gui (nginx) | 浏览器入口;同源反代 RC API |
| 5572 | rclone rcd | 可直接 curl / `rclone rc` 访问 |
## 自行构建 rclone(可选)
Docker 编排默认使用官方 `rclone/rclone:latest` 镜像,配合 rcd 即可。
如果你想构建一个内置 webgui 命令的 rclone 二进制(`rclone webgui`
能像 `rclone gui` 那样独立运行),可以:
```bash
# 1. 把 webgui 源码软链或拷贝到 rclone 子模块的 cmd/ 下
ln -s ../../webgui rclone/cmd/webgui
# 2. 应用注册补丁
cd rclone && git apply ../webgui/rclone-cmd-all-add-webgui-import.patch
# 3. 构建
make
```
## 升级 rclone 子模块
```bash
cd rclone
git fetch origin
git checkout <new-tag-or-commit>
cd ..
git add rclone
git commit -m "chore: 升级 rclone 至 <new-tag>"
```
## 协议
- 本外层仓库:MIT
- `rclone/` 子模块:遵循上游 [rclone](https://github.com/rclone/rclone)
的 MIT 协议
+4
View File
@@ -0,0 +1,4 @@
# rclone config directory — bind-mounted into the rclone container at
# /config/rclone. Place your rclone.conf here, or run:
# docker compose exec rclone rclone config
# to create remotes interactively.
+65
View File
@@ -0,0 +1,65 @@
# Docker Compose stack for the rclone webgui.
#
# Two services:
# - rclone : official rclone/rclone image running `rcd` with --rc-serve
# and --rc-no-auth. Exposed on host port 5572 so you can
# also drive it directly with curl / rclone rc if needed.
# - gui : nginx serving the embedded frontend bundle on host port
# 5580, reverse-proxying RC API + remote-file downloads
# to the rclone container. Open this in your browser.
#
# rclone config lives in ./config/rclone/rclone.conf (bind-mounted).
# If it doesn't exist yet, create your remotes with:
# docker compose exec rclone rclone config
# or copy an existing rclone.conf into ./config/rclone/ before starting.
#
# Usage:
# docker compose up -d --build # build + start
# open http://localhost:5580
# docker compose logs -f gui rclone # tail logs
# docker compose down # stop
services:
rclone:
image: rclone/rclone:latest
container_name: rclone-rcd
command:
- rcd
- --rc-no-auth
- --rc-addr=:5572
- --rc-serve
- --rc-job-expire-duration=24h
- --rc-job-expire-interval=1m
- -vv
volumes:
# rclone looks for $XDG_CONFIG_HOME/rclone/rclone.conf; the
# official image sets XDG_CONFIG_HOME=/config.
- ./config/rclone:/config/rclone
# Cache dir for tokens, chunk cache, vfs cache, etc.
- rclone-cache:/cache
# Scratch space for any local-FS remotes that point here.
- rclone-data:/data
environment:
- XDG_CONFIG_HOME=/config
- RCLONE_CACHE_DIR=/cache
ports:
- "5572:5572"
expose:
- "5572"
restart: unless-stopped
gui:
build:
context: .
dockerfile: Dockerfile.webgui
image: rclone-webgui-frontend:local
container_name: rclone-webgui
depends_on:
- rclone
ports:
- "5580:80"
restart: unless-stopped
volumes:
rclone-cache:
rclone-data:
+66
View File
@@ -0,0 +1,66 @@
# nginx config for the webgui frontend container.
#
# Two jobs:
# 1. Serve the SPA static bundle (HTML/CSS/JS) with SPA fallback.
# 2. Reverse-proxy RC API calls and remote-file downloads to the
# rclone rcd container on the compose network.
#
# Same-origin from the browser, so the frontend's fetch() calls hit
# this nginx and get forwarded to rclone — no CORS, no basic-auth
# popups, no embedded credentials in URLs.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Uploads can be large; let rclone decide on size limits.
client_max_body_size 0;
# Light gzip for text assets.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
application/javascript
application/json
image/svg+xml;
# --- Static assets ---
# Anything that resolves to a real file is served from the bundle.
# Unknown paths fall through to index.html so client-side hash
# routing (#/remotes, #/browse/...) keeps working.
location / {
try_files $uri $uri/ /index.html;
}
# --- RC API endpoints (POST JSON) ---
# rclone registers endpoints under well-known top-level prefixes.
# Match any of them and forward to rclone.
location ~ ^/(config|operations|sync|job|fs|rc|cache|vfs|subsystem|options|metrics)/ {
proxy_pass http://rclone:5572;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
}
# --- Remote file downloads (GET) ---
# rclone rcd with --rc-serve exposes remotes at /<remote>:<path>.
# The colon in the first path segment is the tell — static asset
# paths never have one.
location ~ ^/([^/]+): {
proxy_pass http://rclone:5572;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Submodule
+1
Submodule rclone added at 59c86b01bb
@@ -0,0 +1,10 @@
diff --git a/cmd/all/all.go b/cmd/all/all.go
index 912cde629..cf3c7f4bd 100644
--- a/cmd/all/all.go
+++ b/cmd/all/all.go
@@ -80,4 +80,5 @@ import (
_ "github.com/rclone/rclone/cmd/touch"
_ "github.com/rclone/rclone/cmd/tree"
_ "github.com/rclone/rclone/cmd/version"
+ _ "github.com/rclone/rclone/cmd/webgui"
)
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="#141413">
<path d="M8 0 L9.2 6.8 L16 8 L9.2 9.2 L8 16 L6.8 9.2 L0 8 L6.8 6.8 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 157 B

+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>Couldnt 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>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);
}
+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(`Couldnt 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>Couldnt 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>Couldnt 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;");
}
+85
View File
@@ -0,0 +1,85 @@
/* Base reset, font loading, and document defaults. */
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400&display=swap");
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background-color: var(--color-canvas);
color: var(--color-body);
font: var(--typo-body-md);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1 0 auto;
width: 100%;
max-width: var(--content-max);
margin: 0 auto;
padding: var(--space-lg) var(--space-xl);
}
h1, h2, h3, h4 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-weight: 400;
}
h1 {
font: var(--typo-display-lg);
letter-spacing: var(--typo-display-lg-tracking);
}
h2 {
font: var(--typo-display-md);
letter-spacing: var(--typo-display-md-tracking);
}
h3 {
font: var(--typo-display-sm);
letter-spacing: var(--typo-display-sm-tracking);
}
p {
margin: 0;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font-family: inherit;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
code,
pre {
font: var(--typo-code);
font-family: var(--font-mono);
}
+758
View File
@@ -0,0 +1,758 @@
/* Component library — all UI primitives map to DESIGN.md tokens. */
/* ---------- Top Navigation ---------- */
.top-nav {
display: flex;
align-items: center;
gap: var(--space-lg);
height: var(--nav-height);
padding: 0 var(--space-xl);
background-color: var(--color-canvas);
border-bottom: 1px solid var(--color-hairline);
}
.top-nav .wordmark {
display: flex;
align-items: center;
gap: var(--space-xs);
font: var(--typo-title-md);
font-weight: 500;
color: var(--color-ink);
}
.top-nav .wordmark .spike {
width: 16px;
height: 16px;
color: var(--color-ink);
}
.top-nav nav {
display: flex;
gap: var(--space-md);
flex: 1;
}
.top-nav nav a {
padding: var(--space-xs) var(--space-sm);
color: var(--color-muted);
font: var(--typo-nav-link);
border-radius: var(--radius-md);
text-decoration: none;
}
.top-nav nav a:hover {
color: var(--color-ink);
background-color: var(--color-surface-soft);
}
.top-nav nav a.active {
color: var(--color-ink);
background-color: var(--color-surface-card);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
height: 40px;
padding: 12px 20px;
font: var(--typo-button);
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
text-decoration: none;
transition: background-color 120ms ease;
user-select: none;
}
.btn-primary {
background-color: var(--color-primary);
color: var(--color-on-primary);
}
.btn-primary:hover {
background-color: var(--color-primary-active);
text-decoration: none;
}
.btn-primary:disabled {
background-color: var(--color-primary-disabled);
color: var(--color-muted);
cursor: not-allowed;
}
.btn-secondary {
background-color: var(--color-canvas);
color: var(--color-ink);
border-color: var(--color-hairline);
}
.btn-secondary:hover {
background-color: var(--color-surface-soft);
text-decoration: none;
}
.btn-danger {
background-color: transparent;
color: var(--color-error);
border-color: var(--color-hairline);
}
.btn-danger:hover {
background-color: var(--color-error);
color: var(--color-on-primary);
border-color: var(--color-error);
}
.btn-icon {
width: 36px;
height: 36px;
padding: 0;
border-radius: var(--radius-full);
background-color: var(--color-canvas);
color: var(--color-ink);
border: 1px solid var(--color-hairline);
font-size: 16px;
}
.btn-icon:hover {
background-color: var(--color-surface-soft);
}
.btn-sm {
height: 32px;
padding: 8px 12px;
font-size: 13px;
}
/* ---------- Cards ---------- */
.card {
background-color: var(--color-surface-card);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
.card-outline {
background-color: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
.card-dark {
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
/* ---------- Connector tiles (remote cards) ---------- */
.connector-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-md);
}
.connector-tile {
background-color: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-lg);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-xs);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.connector-tile:hover {
background-color: var(--color-surface-soft);
border-color: var(--color-surface-cream-strong);
}
.connector-tile .tile-name {
font: var(--typo-title-sm);
color: var(--color-ink);
}
.connector-tile .tile-type {
font: var(--typo-caption);
color: var(--color-muted);
}
/* Tile with action overlay (edit/delete buttons in top-right corner) */
.connector-tile.tile-with-actions {
position: relative;
}
.connector-tile.tile-with-actions .tile-actions {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
display: flex;
gap: var(--space-xxs);
opacity: 0;
transition: opacity 120ms ease;
}
.connector-tile.tile-with-actions:hover .tile-actions {
opacity: 1;
}
.connector-tile.tile-with-actions .tile-actions button {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-hairline);
border-radius: var(--radius-sm);
background-color: var(--color-canvas);
color: var(--color-muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.connector-tile.tile-with-actions .tile-actions button:hover {
background-color: var(--color-surface-card);
color: var(--color-ink);
}
.connector-tile.tile-with-actions .tile-actions button.danger:hover {
background-color: var(--color-error);
color: var(--color-on-primary);
border-color: var(--color-error);
}
/* ---------- Inputs ---------- */
.input,
.select {
width: 100%;
height: 40px;
padding: 10px 14px;
font: var(--typo-body-md);
background-color: var(--color-canvas);
color: var(--color-ink);
border: 1px solid var(--color-hairline);
border-radius: var(--radius-md);
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus,
.select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-15);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field label {
font: var(--typo-caption);
color: var(--color-muted);
}
/* ---------- Badges ---------- */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
font: var(--typo-caption);
border-radius: var(--radius-pill);
background-color: var(--color-surface-card);
color: var(--color-ink);
}
.badge-coral {
background-color: var(--color-primary);
color: var(--color-on-primary);
font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking);
}
.badge-success {
background-color: var(--color-success);
color: var(--color-on-primary);
}
.badge-warning {
background-color: var(--color-warning);
color: var(--color-on-primary);
}
.badge-error {
background-color: var(--color-error);
color: var(--color-on-primary);
}
/* ---------- Breadcrumbs ---------- */
.breadcrumbs {
display: flex;
align-items: center;
gap: var(--space-xxs);
flex-wrap: wrap;
}
.breadcrumbs a,
.breadcrumbs span {
padding: var(--space-xs) calc(var(--space-sm) + 2px);
font: var(--typo-nav-link);
color: var(--color-muted);
border-radius: var(--radius-md);
text-decoration: none;
}
.breadcrumbs a:hover {
color: var(--color-ink);
background-color: var(--color-surface-soft);
}
.breadcrumbs .current {
color: var(--color-ink);
background-color: var(--color-surface-card);
}
.breadcrumbs .sep {
padding: 0 var(--space-xxs);
color: var(--color-muted-soft);
}
/* ---------- Tables ---------- */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: var(--space-sm) var(--space-md);
font: var(--typo-body-sm);
color: var(--color-body);
}
.table thead th {
font: var(--typo-caption);
color: var(--color-muted);
border-bottom: 1px solid var(--color-hairline);
}
.table tbody tr {
border-bottom: 1px solid var(--color-hairline-soft);
}
.table tbody tr:last-child {
border-bottom: none;
}
.table tbody tr:hover {
background-color: var(--color-surface-soft);
}
.table .col-name {
font: var(--typo-body-md);
color: var(--color-ink);
}
.table .col-name a {
color: var(--color-ink);
text-decoration: none;
}
.table .col-name a:hover {
color: var(--color-primary);
text-decoration: underline;
}
.table .col-mono {
font: var(--typo-code);
color: var(--color-muted);
}
.table .col-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.table .row-dir .col-name a {
color: var(--color-primary);
}
/* ---------- Progress bar ---------- */
.progress {
width: 100%;
height: 4px;
background-color: var(--color-surface-card);
border-radius: var(--radius-pill);
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
background-color: var(--color-primary);
transition: width 200ms ease;
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.toolbar .spacer {
flex: 1;
}
/* ---------- Section header ---------- */
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.section-head h2 {
font: var(--typo-display-md);
letter-spacing: var(--typo-display-md-tracking);
}
.section-head .subtitle {
color: var(--color-muted);
font: var(--typo-body-sm);
}
/* ---------- Empty state ---------- */
.empty {
padding: var(--space-section) var(--space-xl);
text-align: center;
color: var(--color-muted);
}
.empty h3 {
margin-bottom: var(--space-sm);
font: var(--typo-display-sm);
letter-spacing: var(--typo-display-sm-tracking);
color: var(--color-ink);
}
/* ---------- Toast ---------- */
.toast-stack {
position: fixed;
bottom: var(--space-lg);
right: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
z-index: 100;
}
.toast {
padding: var(--space-sm) var(--space-lg);
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-lg);
font: var(--typo-body-sm);
max-width: 360px;
box-shadow: var(--shadow-hover);
}
.toast-error {
background-color: var(--color-error);
color: var(--color-on-primary);
}
.toast-success {
background-color: var(--color-success);
color: var(--color-on-primary);
}
/* ---------- Modal ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(20, 20, 19, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-lg);
}
.modal {
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-lg);
padding: var(--space-lg);
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.modal h3 {
font: var(--typo-title-lg);
color: var(--color-on-dark);
margin: 0;
}
.modal label {
color: var(--color-on-dark-soft);
font: var(--typo-caption);
}
.modal .input,
.modal .select {
background-color: var(--color-surface-dark-elevated);
color: var(--color-on-dark);
border-color: var(--color-surface-dark-elevated);
}
.modal .input:focus,
.modal .select:focus {
border-color: var(--color-primary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
}
.modal .btn-secondary {
background-color: var(--color-surface-dark-elevated);
color: var(--color-on-dark);
border-color: transparent;
}
/* ---------- Footer ---------- */
.footer {
background-color: var(--color-surface-dark);
color: var(--color-on-dark-soft);
padding: var(--space-xxl) var(--space-xl);
margin-top: var(--space-section);
flex-shrink: 0;
}
.footer-inner {
max-width: var(--content-max);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: var(--space-xl);
}
.footer h4 {
color: var(--color-on-dark);
font: var(--typo-title-sm);
font-family: var(--font-body);
font-weight: 500;
margin-bottom: var(--space-sm);
}
.footer a {
color: var(--color-on-dark-soft);
font: var(--typo-body-sm);
text-decoration: none;
}
.footer a:hover {
color: var(--color-on-dark);
}
.footer .colophon {
grid-column: 1 / -1;
margin-top: var(--space-lg);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-surface-dark-elevated);
color: var(--color-on-dark-soft);
font: var(--typo-caption);
}
/* ---------- Configure: provider picker + dynamic form ---------- */
.provider-search {
margin-bottom: var(--space-lg);
}
.provider-search input {
max-width: 360px;
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
background-color: var(--color-surface-card);
color: var(--color-body-strong);
font: var(--typo-body-sm);
margin-bottom: var(--space-lg);
}
.banner-warning {
background-color: color-mix(in srgb, var(--color-warning) 18%, var(--color-canvas));
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
}
.banner code {
display: block;
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background-color: var(--color-surface-dark);
color: var(--color-on-dark);
border-radius: var(--radius-sm);
font: var(--typo-code);
white-space: pre-wrap;
user-select: all;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md) var(--space-lg);
}
.form-grid .field-full {
grid-column: 1 / -1;
}
.form-grid .field-bool {
flex-direction: row;
align-items: center;
gap: var(--space-sm);
}
.form-grid .field-bool input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.field .field-help {
font: var(--typo-body-sm);
color: var(--color-muted);
line-height: 1.4;
}
.field .field-required {
color: var(--color-error);
margin-left: 2px;
}
.form-section-title {
grid-column: 1 / -1;
font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking);
color: var(--color-muted-soft);
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-hairline-soft);
}
.form-section-title:first-child {
margin-top: 0;
border-top: none;
padding-top: 0;
}
.advanced-toggle-wrap {
grid-column: 1 / -1;
margin-top: var(--space-sm);
}
.advanced-section {
grid-column: 1 / -1;
display: contents;
}
.advanced-section.hidden {
display: none;
}
.danger-zone {
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-top: var(--space-xl);
background-color: color-mix(in srgb, var(--color-error) 5%, var(--color-canvas));
}
.danger-zone h4 {
font: var(--typo-title-sm);
font-family: var(--font-body);
color: var(--color-error);
margin: 0 0 var(--space-xs);
}
.danger-zone p {
font: var(--typo-body-sm);
color: var(--color-muted);
margin-bottom: var(--space-md);
}
/* ---------- Job cell with action + src→dst ---------- */
.job-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 200px;
}
.job-cell .job-action {
font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking);
color: var(--color-muted);
}
.job-cell .job-paths {
font: var(--typo-body-sm);
color: var(--color-body);
word-break: break-all;
}
.job-cell .job-paths .arrow {
color: var(--color-muted-soft);
margin: 0 var(--space-xxs);
}
/* ---------- Responsive ---------- */
@media (max-width: 1024px) {
.connector-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.footer-inner {
grid-template-columns: 1fr 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
main {
padding: var(--space-md);
}
.connector-grid {
grid-template-columns: 1fr;
}
.footer-inner {
grid-template-columns: 1fr;
}
.top-nav {
padding: 0 var(--space-md);
}
.top-nav nav {
display: none;
}
}
+93
View File
@@ -0,0 +1,93 @@
/* DESIGN.md tokens — Anthropic/Claude design system
Source of truth: every color/size/radius used in the app is defined here. */
:root {
/* --- Brand & Accent --- */
--color-primary: #cc785c;
--color-primary-active: #a9583e;
--color-primary-disabled: #e6dfd8;
--color-primary-15: rgba(204, 120, 92, 0.15);
--color-accent-teal: #5db8a6;
--color-accent-amber: #e8a55a;
/* --- Surfaces --- */
--color-canvas: #faf9f5;
--color-surface-soft: #f5f0e8;
--color-surface-card: #efe9de;
--color-surface-cream-strong: #e8e0d2;
--color-surface-dark: #181715;
--color-surface-dark-elevated: #252320;
--color-surface-dark-soft: #1f1e1b;
--color-hairline: #e6dfd8;
--color-hairline-soft: #ebe6df;
/* --- Text --- */
--color-ink: #141413;
--color-body-strong: #252523;
--color-body: #3d3d3a;
--color-muted: #6c6a64;
--color-muted-soft: #8e8b82;
--color-on-primary: #ffffff;
--color-on-dark: #faf9f5;
--color-on-dark-soft: #a09d96;
/* --- Semantic --- */
--color-success: #5db872;
--color-warning: #d4a017;
--color-error: #c64545;
/* --- Typography families --- */
--font-display: "Cormorant Garamond", "Tiempos Headline", "EB Garamond", Garamond, "Times New Roman", serif;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
/* --- Typography scale (matches DESIGN.md 1:1) --- */
--typo-display-xl: 400 64px/1.05 var(--font-display);
--typo-display-xl-tracking: -1.5px;
--typo-display-lg: 400 48px/1.1 var(--font-display);
--typo-display-lg-tracking: -1px;
--typo-display-md: 400 36px/1.15 var(--font-display);
--typo-display-md-tracking: -0.5px;
--typo-display-sm: 400 28px/1.2 var(--font-display);
--typo-display-sm-tracking: -0.3px;
--typo-title-lg: 500 22px/1.3 var(--font-body);
--typo-title-md: 500 18px/1.4 var(--font-body);
--typo-title-sm: 500 16px/1.4 var(--font-body);
--typo-body-md: 400 16px/1.55 var(--font-body);
--typo-body-sm: 400 14px/1.55 var(--font-body);
--typo-caption: 500 13px/1.4 var(--font-body);
--typo-caption-uppercase: 500 12px/1.4 var(--font-body);
--typo-caption-uppercase-tracking: 1.5px;
--typo-code: 400 14px/1.6 var(--font-mono);
--typo-button: 500 14px/1 var(--font-body);
--typo-nav-link: 500 14px/1.4 var(--font-body);
/* --- Spacing scale --- */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-xxl: 48px;
--space-section: 96px;
/* --- Radii --- */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 9999px;
--radius-full: 9999px;
/* --- Layout --- */
--content-max: 1200px;
--nav-height: 64px;
/* --- Shadows (used sparingly per DESIGN.md elevation philosophy) --- */
--shadow-hover: 0 1px 3px rgba(20, 20, 19, 0.08);
}
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rclone</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles/tokens.css">
<link rel="stylesheet" href="/assets/styles/base.css">
<link rel="stylesheet" href="/assets/styles/components.css">
</head>
<body>
<header class="top-nav">
<a class="wordmark" href="#/remotes">
<svg class="spike" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0 L9.2 6.8 L16 8 L9.2 9.2 L8 16 L6.8 9.2 L0 8 L6.8 6.8 Z"/>
</svg>
<span>rclone</span>
</a>
<nav id="nav-links">
<a href="#/remotes" data-route="remotes">Remotes</a>
<a href="#/configure/new" data-route="configure">Configure</a>
<a href="#/jobs" data-route="jobs">Jobs</a>
</nav>
<a class="btn btn-primary btn-sm" href="#/jobs/new">New Job</a>
</header>
<main id="app">
<div class="empty">
<h3>Loading</h3>
<p>Connecting to rclone RC&hellip;</p>
</div>
</main>
<footer class="footer">
<div class="footer-inner">
<div>
<h4>Product</h4>
<ul>
<li><a href="#/remotes">Remotes</a></li>
<li><a href="#/jobs">Jobs</a></li>
<li><a href="#/jobs/new">New Job</a></li>
</ul>
</div>
<div>
<h4>Documentation</h4>
<ul>
<li><a href="https://rclone.org/docs/" target="_blank" rel="noopener">rclone Docs</a></li>
<li><a href="https://rclone.org/commands/rclone_rcd/" target="_blank" rel="noopener">rcd Command</a></li>
<li><a href="https://rclone.org/rc/" target="_blank" rel="noopener">RC API</a></li>
</ul>
</div>
<div>
<h4>Community</h4>
<ul>
<li><a href="https://forum.rclone.org/" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://github.com/rclone/rclone" target="_blank" rel="noopener">GitHub</a></li>
<li><a href="https://github.com/rclone/rclone/issues" target="_blank" rel="noopener">Issues</a></li>
</ul>
</div>
<div>
<h4>About</h4>
<ul>
<li><a href="https://rclone.org/" target="_blank" rel="noopener">rclone.org</a></li>
<li><a href="https://rclone.org/changelog/" target="_blank" rel="noopener">Changelog</a></li>
<li><a href="https://rclone.org/faq/" target="_blank" rel="noopener">FAQ</a></li>
</ul>
</div>
<p class="colophon">rclone &mdash; rsync for cloud storage.</p>
</div>
</footer>
<div id="toast-stack" class="toast-stack"></div>
<div id="modal-root"></div>
<script type="module" src="/assets/js/app.js"></script>
</body>
</html>
+310
View File
@@ -0,0 +1,310 @@
// Package webgui implements the "rclone webgui" command — an in-process
// web GUI for rclone with the Anthropic design system. It mirrors the
// architecture of cmd/gui/gui.go (two in-process HTTP servers: a static
// GUI server and an RC API server, with the browser opened automatically),
// but serves our own embedded vanilla HTML/CSS/JS frontend instead of
// the upstream React bundle.
package webgui
import (
"context"
_ "embed"
"fmt"
iofs "io/fs"
"net/http"
"net/url"
"os"
"strings"
"sync"
"github.com/go-chi/chi/v5/middleware"
"github.com/rclone/rclone/cmd"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/rc"
"github.com/rclone/rclone/fs/rc/rcserver"
libhttp "github.com/rclone/rclone/lib/http"
"github.com/rclone/rclone/lib/random"
"github.com/rclone/rclone/lib/systemd"
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
)
//go:embed web
var embedFS iofs.FS
var (
guiAddr []string
apiAddr []string
user string
pass string
noAuth bool
noOpenBrowser bool
enableMetrics bool
)
func init() {
cmd.Root.AddCommand(commandDefinition)
f := commandDefinition.Flags()
f.StringArrayVar(&guiAddr, "addr", nil, "IPaddress:Port for the GUI server (default auto-chosen localhost port)")
f.StringArrayVar(&apiAddr, "api-addr", nil, "IPaddress:Port for the RC API server (default auto-chosen localhost port)")
f.StringVar(&user, "user", "", "User name for RC authentication")
f.StringVar(&pass, "pass", "", "Password for RC authentication")
f.BoolVar(&noAuth, "no-auth", false, "Don't require auth for the RC API")
f.BoolVar(&noOpenBrowser, "no-open-browser", false, "Skip opening the browser automatically")
f.BoolVar(&enableMetrics, "enable-metrics", false, "Enable OpenMetrics/Prometheus compatible endpoint at /metrics")
}
var commandDefinition = &cobra.Command{
Use: "webgui [path]",
Short: `Open the web based GUI.`,
Long: `This command starts an embedded web GUI for rclone and opens it in
your default browser.
Two localhost ports are bound: one serves the static GUI, the other is
the rclone RC API server that the GUI talks to. Credentials are
generated automatically unless --no-auth is specified.
rclone webgui
By default ` + "`rclone webgui`" + ` serves the GUI embedded into the rclone
binary at build time. You can override this by passing a path to an
unpacked GUI directory, which is useful for iterating on the frontend
without rebuilding rclone:
rclone webgui ./cmd/webgui/web
Use --no-open-browser to skip opening the browser automatically:
rclone webgui --no-open-browser
Use --addr to bind the GUI to a specific address:
rclone webgui --addr localhost:5580
Use --user and --pass to set specific credentials:
rclone webgui --user admin --pass secret
Use --no-auth to disable authentication entirely (localhost only):
rclone webgui --no-auth
Note: --no-auth enables the RC API's --rc-serve mode, which exposes an
HTTP fileserver on every configured remote. Only run this on a trusted
network.
`,
Annotations: map[string]string{
"versionIntroduced": "v1.75",
"groups": "RC",
},
RunE: func(command *cobra.Command, args []string) error {
cmd.CheckArgs(0, 1, command, args)
ctx := context.Background()
// Resolve the GUI source (embedded subtree or local directory)
// before binding any sockets so errors surface immediately.
var srcPath string
if len(args) == 1 {
srcPath = args[0]
}
srcFS, err := guiSourceFS(srcPath)
if err != nil {
return err
}
// Create the GUI server (binds port eagerly, before Serve)
guiCfg := libhttp.DefaultCfg()
if command.Flags().Changed("addr") {
guiCfg.ListenAddr = guiAddr
} else {
guiCfg.ListenAddr = []string{"localhost:0"}
}
guiServer, err := libhttp.NewServer(ctx, libhttp.WithConfig(guiCfg))
if err != nil {
return fmt.Errorf("failed to create GUI server: %w", err)
}
// Read the GUI origin from the bound address (available before Serve).
guiOrigin := originFromURL(guiServer.URLs()[0])
// Configure the RC API server
opt := rc.Opt // copy global defaults
opt.Enabled = true
opt.WebUI = false
// opt.Serve = true exposes an HTTP fileserver on every configured
// remote so the GUI can download files via GET /<remote>:<path>.
opt.Serve = true
if command.Flags().Changed("api-addr") {
opt.HTTP.ListenAddr = apiAddr
} else {
opt.HTTP.ListenAddr = []string{"localhost:0"}
}
// CORS: allow the GUI origin to make cross-port API requests.
opt.HTTP.AllowOrigin = guiOrigin
// Forward metrics flag to the RC server.
if command.Flags().Changed("enable-metrics") {
opt.EnableMetrics = enableMetrics
}
// Auth
if command.Flags().Changed("user") {
opt.Auth.BasicUser = user
}
if command.Flags().Changed("pass") {
opt.Auth.BasicPass = pass
}
if command.Flags().Changed("no-auth") {
opt.NoAuth = noAuth
}
if !opt.NoAuth {
if opt.Auth.BasicUser == "" {
opt.Auth.BasicUser = "gui"
fs.Infof(nil, "No username specified. Using default username: %s", opt.Auth.BasicUser)
}
if opt.Auth.BasicPass == "" {
randomPass, err := random.Password(128)
if err != nil {
return fmt.Errorf("failed to make password: %w", err)
}
opt.Auth.BasicPass = randomPass
fs.Infof(nil, "No password specified. Using random password: %s", randomPass)
}
}
// Start the RC server
rcServer, err := rcserver.Start(ctx, &opt)
if err != nil || rcServer == nil {
return fmt.Errorf("failed to start RC server: %w", err)
}
// Read the bound RC URL back from rcserver, in case we asked
// libhttp to pick a free port (localhost:0).
rcURL := rcServer.URLs()[0]
// Mount the GUI handler and start serving
spaHandler := guiHandler(srcFS)
guiServer.Router().Use(middleware.Compress(5))
guiServer.Router().Get("/*", spaHandler.ServeHTTP)
guiServer.Router().Head("/*", spaHandler.ServeHTTP)
guiServer.Serve()
guiURL := guiServer.URLs()[0]
guiSource := "embedded bundle"
if srcPath != "" {
guiSource = fmt.Sprintf("from %s", srcPath)
}
fs.Logf(nil, "Serving GUI %s on %s", guiSource, guiURL)
// Build the launch URL: always pass ?url=<rcURL> so the SPA can
// discover the RC base; embed user/pass only when auth is on.
loginURL := buildLoginURL(guiURL, rcURL, opt.Auth.BasicUser, opt.Auth.BasicPass, opt.NoAuth)
fs.Logf(nil, "GUI available at %s", loginURL)
if !noOpenBrowser {
if err := open.Start(loginURL); err != nil {
fs.Errorf(nil, "failed to open GUI in browser: %v", err)
}
}
// Wait for either server to exit, then shut both down and
// join the second goroutine before returning.
defer systemd.Notify()()
var wg sync.WaitGroup
done := make(chan struct{}, 2)
wg.Add(2)
go func() { defer wg.Done(); rcServer.Wait(); done <- struct{}{} }()
go func() { defer wg.Done(); guiServer.Wait(); done <- struct{}{} }()
<-done
_ = rcServer.Shutdown()
_ = guiServer.Shutdown()
wg.Wait()
return nil
},
}
// originFromURL extracts the origin (scheme://host) from a URL string,
// stripping any path or trailing slash.
func originFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return strings.TrimRight(rawURL, "/")
}
return u.Scheme + "://" + u.Host
}
// guiSourceFS opens the GUI bundle at the given path. An empty path
// returns the embedded bundle (the `web/` directory compiled into the
// binary). A non-empty path must be an existing directory whose contents
// are served directly — useful for hot-reload during development.
func guiSourceFS(path string) (iofs.FS, error) {
if path == "" {
sub, err := iofs.Sub(embedFS, "web")
if err != nil {
return nil, fmt.Errorf("failed to read embedded GUI: %w", err)
}
if _, err := iofs.Stat(sub, "index.html"); err != nil {
return nil, fmt.Errorf("embedded GUI has no index.html: %w", err)
}
return sub, nil
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("failed to stat GUI source %q: %w", path, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("GUI source must be a directory: %q", path)
}
if _, err := os.Stat(path + "/index.html"); err != nil {
return nil, fmt.Errorf("GUI source directory has no index.html: %w", err)
}
return os.DirFS(path), nil
}
// guiHandler returns an http.Handler that serves the GUI bundle from
// srcFS with SPA fallback: paths that don't match a real file return
// index.html so client-side hash routing keeps working.
func guiHandler(srcFS iofs.FS) http.Handler {
fileServer := http.FileServer(http.FS(srcFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := iofs.Stat(srcFS, path); err == nil {
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback: serve index.html for unknown paths so that
// client-side routing (e.g. /login) works.
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
})
}
// buildLoginURL constructs the URL the browser should open. The query
// string always carries the RC API base URL so the SPA can find it.
// When auth is enabled, user/pass and a /login hash are added so the
// SPA can present credentials to the cross-port RC server.
func buildLoginURL(guiBaseURL, rcURL, user, pass string, noAuth bool) string {
u, err := url.Parse(guiBaseURL)
if err != nil {
return guiBaseURL
}
q := u.Query()
q.Set("url", rcURL)
if !noAuth {
u.Path = "/login"
q.Set("user", user)
q.Set("pass", pass)
}
// Always land on the remotes view.
if u.Fragment == "" {
u.Fragment = "/remotes"
}
u.RawQuery = q.Encode()
return u.String()
}