From 29821e3cc4b71963ccc9aa471b7742d5562fee65 Mon Sep 17 00:00:00 2001 From: Joe Oghali <12156981+Joe11221@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:03:10 -0500 Subject: [PATCH 1/3] Rework the UI for the first release The dashboard now opens with a verdict on whether backups are healthy and puts the jobs that need attention first. Unscheduled jobs show as "Not scheduled" and no longer count as pending. Job form: Copy/Sync/Move cards, a live destination preview, and extra flags typed as CLI flags. Flags are checked against rclone's options/info. Schedules: cron is validated on save and described in plain English. History: filters, failure rows that work at mobile widths, and a failures view. Theme: status color tokens with WCAG AA contrast in every theme and mode, a visible focus ring, and soft status badges. README rewritten with new screenshots. Added DESIGN.md and PRODUCT.md. --- DESIGN.md | 184 +++++++ PRODUCT.md | 48 ++ README.md | 479 ++++++++-------- app/__init__.py | 11 +- app/rclone_client.py | 4 + app/routes/history.py | 46 +- app/routes/jobs.py | 352 ++++++++---- app/routes/schedules.py | 60 +- app/routes/settings.py | 25 + app/services/cron_text.py | 97 ++++ app/services/extra_flags.py | 194 +++++++ app/services/health.py | 120 +++- app/static/css/style.css | 543 ++++++++++++++++--- app/static/js/app.js | 13 +- app/static/js/dashboard.js | 125 ++++- app/static/js/errors.js | 42 +- app/static/js/jobform.js | 121 +++++ app/static/js/suspects.js | 11 +- app/static/js/theme.js | 2 +- app/templates/auth/login.html | 2 +- app/templates/auth/setup.html | 2 +- app/templates/base.html | 31 +- app/templates/dashboard.html | 415 ++++++-------- app/templates/history/detail.html | 249 ++++----- app/templates/history/list.html | 82 ++- app/templates/jobs/form.html | 349 +++++++----- app/templates/jobs/list.html | 37 +- app/templates/operations/running.html | 78 ++- app/templates/partials/bandwidth_editor.html | 4 +- app/templates/partials/run_errors.html | 21 +- app/templates/remotes/new.html | 11 +- app/templates/schedules/form.html | 51 +- app/templates/schedules/list.html | 37 +- app/templates/settings.html | 53 +- docs/screenshots/dashboard.png | Bin 105774 -> 110408 bytes docs/screenshots/failures.png | Bin 0 -> 65391 bytes docs/screenshots/history.png | Bin 66147 -> 93592 bytes docs/screenshots/job-form.png | Bin 84733 -> 76072 bytes docs/screenshots/running.png | Bin 58007 -> 75583 bytes docs/screenshots/schedules.png | Bin 35632 -> 0 bytes tests/test_cron_text.py | 41 ++ tests/test_health.py | 83 ++- tests/test_job_form.py | 294 ++++++++++ tests/test_remotes.py | 46 ++ tests/test_routes.py | 2 +- tests/test_schedules.py | 62 +++ tests/test_settings_page.py | 56 ++ 47 files changed, 3362 insertions(+), 1121 deletions(-) create mode 100644 DESIGN.md create mode 100644 PRODUCT.md create mode 100644 app/services/cron_text.py create mode 100644 app/services/extra_flags.py create mode 100644 app/static/js/jobform.js create mode 100644 docs/screenshots/failures.png delete mode 100644 docs/screenshots/schedules.png create mode 100644 tests/test_cron_text.py create mode 100644 tests/test_job_form.py create mode 100644 tests/test_settings_page.py diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..79a8b02 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,184 @@ +--- +name: rClone GUI +description: A calm, dense control room for scheduled rclone backups. +colors: + accent-dusk: "#7b97ff" + accent-dusk-contrast: "#0b0f1a" + bg-graphite: "#121316" + surface-graphite: "#1b1d22" + surface-raised: "#252830" + border-graphite: "#343944" + text-primary: "#eceef2" + text-muted: "#a4abb8" + accent-ink: "#3f55d9" + bg-paper: "#f3f4f7" + surface-paper: "#ffffff" + surface-paper-raised: "#eef0f4" + border-paper: "#d5d9e1" + text-ink: "#1c2028" + text-ink-muted: "#566071" + status-ok: "#2fd08a" + status-fail: "#f87171" + status-warn: "#f5b400" + status-info: "#22bedc" +typography: + title: + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif" + fontSize: "1.5rem" + fontWeight: 600 + letterSpacing: "-0.01em" + stat: + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif" + fontSize: "1.35rem" + fontWeight: 600 + lineHeight: 1.2 + body: + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif" + fontSize: "1rem" + fontWeight: 400 + label: + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif" + fontSize: "0.72rem" + fontWeight: 600 + letterSpacing: "0.05em" + mono: + fontFamily: "Cascadia Code, Consolas, Monaco, Courier New, monospace" + fontSize: "0.85rem" +rounded: + badge: "0.4rem" + button: "0.5rem" + card: "0.75rem" + pill: "999px" +spacing: + table-cell-y: "0.7rem" + table-cell-x: "1rem" + card-header: "0.8rem 1rem" +components: + button-primary: + backgroundColor: "{colors.accent-dusk}" + textColor: "{colors.accent-dusk-contrast}" + rounded: "{rounded.button}" + button-secondary: + backgroundColor: "transparent" + textColor: "{colors.text-muted}" + rounded: "{rounded.button}" + card: + backgroundColor: "{colors.surface-graphite}" + textColor: "{colors.text-primary}" + rounded: "{rounded.card}" + input: + backgroundColor: "{colors.surface-raised}" + textColor: "{colors.text-primary}" + rounded: "{rounded.button}" + badge-success: + textColor: "{colors.status-ok}" + rounded: "{rounded.badge}" +--- + +# Design System: rClone GUI + +## Overview + +**Creative North Star: "The Quiet Control Room"** + +This is the canonical **Graphite** theme, the default of six user-selectable themes (Liquid Glass, Keycaps, Terminal, Synthwave and Brutalist are palette and shape variants not covered here). The interface is a calm, dense, glanceable surface for someone who checks in briefly to learn whether last night's backups ran. Neutral cool-grey surfaces recede; a single periwinkle accent marks navigation and primary action, and status colour (green, red, amber, cyan) is the only thing allowed to be loud. + +Density is moderate and tabular. Information is carried by text, tinted badges and progress bars, not ornament. Dark is the default mode, with a matching light mode ("paper") that keeps the same structure. + +**Key Characteristics:** +- Flat tonal layering: surface vs raised surface with 1px borders +- One accent, used for navigation state, primary actions, icons and progress +- Soft status badges: tinted fill, coloured text +- Uppercase small labels for table headers and stat captions +- Built on vendored Bootstrap 5 with theme variables mapped over it + +## Colors + +A cool-grey graphite ramp with one periwinkle accent; status hues stay separate from the accent. + +### Primary +- **Dusk Periwinkle** (#7b97ff): The dark-mode accent. Active nav link (at 14% tint), primary buttons, links, focus ring (20% glow), progress bars, card-header icons, `code`. In light mode it becomes **Ink Indigo** (#3f55d9) for contrast. + +### Neutral +- **Graphite Floor** (#121316): Page background in dark mode. +- **Graphite Surface** (#1b1d22): Navbar, cards, modals. +- **Raised Graphite** (#252830): Form fields, progress track, hover fills, secondary badges. +- **Graphite Rule** (#343944): All borders and dividers. +- **Mist Text** (#eceef2) / **Slate Muted** (#a4abb8): Primary and secondary text. +- Light mode: **Paper** (#f3f4f7) page, **White** (#ffffff) surface, **Paper Raised** (#eef0f4), **Paper Rule** (#d5d9e1), **Ink** (#1c2028) text, **Ink Muted** (#566071). + +### Status +- **Run Green** (#2fd08a) completed, **Fault Red** (#f87171) failed, **Late Amber** (#f5b400) warning, **Signal Cyan** (#22bedc) info. Light mode darkens the text colours (#0a6640, #b42323, #7a5400, #08627a; all six themes define these as `--app-ok`, `--app-fail`, `--app-warn`, `--app-info`). + +### Named Rules +**The One Accent Rule.** Periwinkle is the only non-status hue. Never use it to signal success or failure. + +**The Status Is Loud Rule.** Status colours appear as tinted badges and progress, never as large fills or decorative colour. + +## Typography + +**Font:** the system UI stack (Bootstrap default: system-ui, Segoe UI, Roboto) for everything; **Mono:** Cascadia Code / Consolas for paths, flags, command fragments and file lists. + +**Character:** Plain and native; no webfonts. Hierarchy comes from weight, size and case rather than typeface changes. + +### Hierarchy +- **Title** (600, ~1.5rem, -0.01em): Page headings (h4-level). +- **Stat** (600, 1.35rem, 1.2): Dashboard figures. +- **Body** (400, 1rem): Forms, table cells. +- **Label** (600-700, 0.72-0.78rem, +0.05em, uppercase): Table headers and stat captions. +- **Mono** (0.85rem): File lists, paths, `code`. + +### Named Rules +**The Paths Are Mono Rule.** Anything the user might copy into rclone (paths, flags, remote names) is set in monospace. + +## Layout + +Full-width fluid container (`container-fluid`) under a sticky top navbar that collapses on small screens. Content is stacked Bootstrap cards, tables and a stat-card row on the dashboard. Table cells use 0.7rem 1rem padding; card headers 0.8rem 1rem. Large lists (files, failures) are built to stay readable at thousands of rows. + +## Elevation & Depth + +Flat by default. Depth comes from tonal steps (floor, surface, raised) and 1px borders. The only shadow is a faint card hairline (`box-shadow: 0 1px 2px rgba(0,0,0,0.12)`). Focus is shown by an accent border plus a 0.2rem 20% accent glow. + +### Named Rules +**The Tonal Depth Rule.** Express hierarchy with surface steps and borders, not shadows. Heavy shadows, glass blur and glow belong to other themes. + +## Shapes + +Soft but not bubbly: badges 0.4rem, buttons and inputs 0.5rem, stat icon tiles 0.7rem, cards and modals 0.75rem, dropdowns 0.75rem, progress bars fully round (999px). Borders are 1px in the rule colour. + +## Components + +### Buttons +- **Shape:** 0.5rem radius. +- **Primary:** accent fill, dark contrast text; hover brightens (`filter: brightness(1.1)`). +- **Outline primary:** accent border and text, fills with accent on hover. +- **Outline secondary:** muted text and rule border; hover shifts to raised fill and full text colour. + +### Badges +- Tinted 16% background with coloured text, weight 600, 0.4rem radius; used for run and job status. Secondary badge uses raised surface and muted text. + +### Cards / Containers +- 0.75rem radius, surface background, 1px rule border, hairline shadow; headers weight 600 with an accent icon. + +### Inputs / Fields +- Raised-surface fill, rule border, 0.5rem radius. Focus: accent border plus soft accent glow. Placeholder muted at 70%. + +### Navigation +- Sticky navbar on surface with bottom border. Links are muted with 0.5rem radius; hover gains a raised fill; active link takes the accent colour on a 14% accent tint. A small running-count badge sits beside Running. Theme picker and connection status live at the right. + +### Stat Card +- Icon tile (2.6rem, 0.7rem radius, 14% accent tint) beside an uppercase caption and a stat value. + +## Do's and Don'ts + +### Do: +- **Do** drive every colour from the `--app-*` theme variables so all themes and both modes keep working. +- **Do** keep status in tinted badges and progress bars. +- **Do** set paths, flags and remote names in monospace. +- **Do** keep light mode in step with dark; both are first-class. + +### Don't: +- **Don't** use the accent colour to mean success or failure. +- **Don't** add shadows, blur or glow to Graphite; those are other themes' signatures. +- **Don't** hardcode hex values in components; add a theme variable. +- **Don't** introduce webfonts or a CDN dependency; assets are vendored. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..d4ece34 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,48 @@ +# Product + + + +## Platform + +web + +## Users + +Self-hosters who run rclone backups of their own data: homelab owners deploying with Docker on a home server, and desktop users running the Windows tray app on a PC (the same web UI shown in the app's own window). They check in occasionally and mostly want to know whether each backup ran on time and succeeded. The project is also published as open source for other self-hosters. + +## Product Purpose + +A standalone Flask GUI for creating, scheduling, running and monitoring rclone sync/copy/move jobs without editing command lines or cron files. Success: anything rclone can do for a sync, copy or move is controllable from the job form, and the user is told when a backup is late or failed. + +## Positioning + +- Full rclone control in the job form (excludes, extra flags, per-job bandwidth rules, comparison method, `--order-by` composition including size + mixed), with sensible defaults so simple jobs stay simple. +- Backup health: checks that each backup runs on time and succeeds, with notifications and a heartbeat when one doesn't. +- Dashboard and failure list stay readable with thousands of files in a run. + +## Operating Context + +Jobs have source/destination (local paths or remotes), cron schedules interpreted in the configured `TIMEZONE`, live SSE-driven progress, per-file failures, and run history. All rclone operations go through the rclone RC HTTP API; the app never shells out. Docker deployment binds to `127.0.0.1` by default; login is on by default (first visit sets a password). Runs on a single gunicorn worker. + +## Capabilities and Constraints + +Dashboard, Jobs, Running, Schedules, History, Settings; directory browser modal over local paths and remotes; auto-resume of interrupted runs; run statuses running/completed/failed/stopped/interrupted. Bootstrap 5 and Bootstrap Icons are vendored; no external CDN. The app already offers six user-selectable themes (Graphite, Liquid Glass, Keycaps, Terminal, Synthwave, Brutalist), each in light, dark and system modes. Screenshots in `docs/screenshots` use demo data. + +## Brand Commitments + +Name: "rClone GUI". Icon: `app/static/favicon.svg`. Not the upstream rclone web GUI. + +## Evidence on Hand + +Demo screenshots (dashboard, running, job form, schedules, history) in `docs/screenshots`. No testimonials, user counts or benchmarks exist; do not fabricate them. + +## Product Principles + +1. Functionality first: expose rclone's real options rather than hiding them. +2. Answer "did my backup run?" before anything else. +3. Stay legible at scale: thousands of files, many jobs. +4. Safe by default: login on, localhost binding, explicit opt-outs. + +## Accessibility & Inclusion + +No product-specific standard established. diff --git a/README.md b/README.md index c2482e1..86d3472 100644 --- a/README.md +++ b/README.md @@ -3,138 +3,164 @@ ## Why this exists rclone is an excellent tool for moving data to and from cloud storage, but -running it on a schedule means editing command lines and cron files by hand. -I wanted a web interface for it that I could deploy on my own homelab server, -and I couldn't find a good option for that. - -So I built this one with functionality first. The goal is that anything rclone -can do for a sync, copy, or move, you can control from the job form, without -dropping to a terminal. That means exclude patterns, extra flags, per-job -bandwidth, file comparison method, and transfer ordering are all exposed, with -sensible defaults so simple jobs stay simple. - -Backups are rarely uniform. A single job can hold a few huge files and many -small ones, and the order rclone transfers them in matters: either the big -files finish last, or the small ones sit idle behind them. That's why the ordering options (see [Why size + mixed -ordering?](#why-size--mixed-ordering)) are a first-class part of the job form, -and why the dashboard and failure list are built to stay readable with -thousands of files in a run. - -It runs in Docker on a home server, or as a Windows app for a desktop PC. Both +running it on a schedule means editing command lines and cron files by hand. I +wanted a web interface for it that I could deploy on my own homelab server, and +I couldn't find a good option. + +So I built this one, functionality first. Anything rclone can do for a sync, +copy or move, you can set from the job form without dropping to a terminal: +exclude patterns, extra flags, per-job bandwidth, file comparison method and +transfer ordering. Every option has a default, so a basic job needs a name, a +source and a destination. + +One backup job can hold a few huge files and thousands of small ones, and the +order rclone transfers them in decides whether the big files drag on at the end +or the small ones wait behind them. The job form lets you set that order (see +[Why size + mixed ordering?](#why-size--mixed-ordering)), and the dashboard and +failure list stay readable with thousands of files in a run. + +It runs in Docker on a home server, or as a Windows app on a desktop PC. Both use the same web UI. > [!CAUTION] > Anyone who can log in can browse the mounted filesystem, create jobs, and run -> `sync`/`move` operations against your data (which is mounted **read-write**). +> `sync`/`move` operations against your data, which Docker mounts **read-write**. > Login is on by default: the first visit asks you to choose a password. > -> - By default the port is published on `127.0.0.1` only. +> - Docker publishes the port on `127.0.0.1` only, unless you change it. > - Only set `BIND_ADDRESS=0.0.0.0` on a network you trust. -> - **Never expose it to the internet directly.** For remote access, use a VPN +> - **Never put it on the internet as is.** For remote access, use a VPN > (WireGuard, Tailscale) or a reverse proxy that enforces authentication. > See [Security notes](#security-notes). -> - `AUTH_MODE=none` turns login off. It is an explicit opt-out, not a default. +> - `AUTH_MODE=none` turns login off. You have to set it on purpose. -A Flask web GUI for managing [rclone](https://rclone.org) backup jobs, on a home -server with Docker or on a Windows PC as a [tray app](#windows-app). Create sync/copy/move jobs, schedule them with cron, watch live -transfer progress, and browse run history — from a browser, or from the Windows -app's own window. It also checks that each backup runs on time and succeeds, -and alerts you when one doesn't. +rClone GUI is a Flask web app for managing [rclone](https://rclone.org) backup +jobs, on a home server with Docker or on a Windows PC as a +[tray app](#windows-app). You create sync, copy and move jobs, schedule them, +watch transfers live and look back through run history, in a browser or in the +Windows app's own window. The app also checks that each backup runs on time and +succeeds, and tells you when one doesn't. -This is a standalone app (not the upstream rclone web GUI). It never shells out -to rclone; every operation goes through the **rclone RC HTTP API**. +It is a standalone app, separate from the upstream rclone web GUI. It never +shells out to rclone: every operation goes through the **rclone RC HTTP API**. -![Dashboard](docs/screenshots/dashboard.png) +![Dashboard: one line says whether your backups are on schedule, then each job with its state, last success and next run](docs/screenshots/dashboard.png) - - + + - - + +
Running operationsCreate job formRunning page with live progress, speed and the files in flightJob form with copy, sync and move cards that name the destination
SchedulesRun historyA failed run with its failures grouped by causeRun history with job and status filters
-*(Screenshots use demo data served by a stub rclone.)* +*(The screenshots use demo data from a stub rclone.)* ## Features -- **Jobs** — CRUD for sync/copy/move jobs with source/destination (local - paths or remotes), exclude patterns, extra flags, per-job bandwidth rules, and - `--order-by` composition (field `size`/`name`/`modtime`, direction - `asc`/`desc`/`mixed` with a window %). The default, **size + mixed**, keeps - small and large files moving at the same time — see - [Why size + mixed ordering?](#why-size--mixed-ordering). -- **File comparison method** — per job: default (modtime + size), - `--size-only`, `--checksum`, or `--ignore-existing`. -- **Directory browser** — the Browse button on the job form opens a modal - that walks local paths and remotes via `operations/list`. -- **Schedules** — per-job cron schedules (APScheduler), enable/disable, - interpreted in your configured `TIMEZONE`. A run that comes due while the - server is down starts if it's less than an hour late (Docker); the Windows - app instead starts one catch-up run for everything missed. -- **Live dashboard** — SSE-driven progress bars, speed, bytes, file counts - (new vs unchanged), and ETA. -- **Failures** — per-file errors from rclone are saved while a run is still - going, not just the error count. The Running page shows a live feed of the - newest failures; the run's history page lists them all, grouped by cause, - with a filter (by file or message), paging, and a CSV export. Up to 50,000 - failures are kept per run. -- **Backup health** — the dashboard opens with a summary of every job's state: - `healthy`, `failing`, `overdue`, `warning` (its last run deleted an unusual - number of files), or `pending`. Each job also shows its last success, next - run, failures in a row, and the 7-day success rate. Deadlines come from the - cron schedule, or from a per-job "Must succeed at least every N hours". - `GET /api/health` returns the same data as JSON. - - `failing`: a run has failed or been held since the last success. - - `overdue`: no success by the deadline. For a scheduled job, that is the - first cron time after the last success plus 60 minutes of grace. A running - job is not overdue against its schedule. +- **Dashboard.** The first line answers "did my backups run?", for example + "1 of 4 backups needs attention", followed by what to check and the next + scheduled run. Below it, each job shows its state, the reason, its last + success and next run, and a **Run** button. Problems sort to the top. Live + transfers appear above the list while something is running. +- **Jobs.** Sync, copy and move jobs between local paths and remotes. The form + starts with the basics (name, source, destination, and what the job does) + and keeps the rclone tuning in a collapsed **Advanced rclone options** + section. New jobs default to **Copy**. Each mode spells out what it will do + to the destination you typed, so you see that a sync deletes files at + `gdrive:Backup/Documents` before you save it. +- **Advanced options.** Exclude patterns, per-job bandwidth rules, transfers + and checkers, `--retries`, fast list, the file comparison method (default, + `--size-only`, `--checksum`, `--ignore-existing`) and `--order-by` + (`size`/`name`/`modtime`, `asc`/`desc`/`mixed` with a window %). The default + ordering, **size + mixed**, keeps small and large files moving together. See + [Why size + mixed ordering?](#why-size--mixed-ordering) +- **Extra flags.** Type any other rclone flag the way you would on the command + line, one per line (`--max-backlog 200000`, `--buffer-size=32M`). The app + checks each flag against the options your rclone knows before it saves the + job and names any it rejects: unknown flags, flags the form already sets, + filter flags (use the exclude field), and flags that would wait for input in a + background run. +- **Directory browser.** The folder button next to source and destination walks + local paths and remotes through `operations/list`. +- **Schedules.** Give a job a cron schedule on the job form, or on the + Schedules page. Both show it in plain words ("Every day at 02:00") next to the + cron expression, and the app interprets it in your `TIMEZONE`. If a run comes + due while the server is down, Docker starts it when it is less than an hour + late; the Windows app starts one catch-up run for everything it missed. +- **Running page.** Live progress over SSE: speed, data transferred and + remaining, ETA, files left, new and unchanged files, errors, and the files in + flight. **Stop** asks before it stops a run. +- **Failures.** The app saves the error for each failed file while the run is + still going. The Running page shows the newest failures as + they happen. A run's page puts its failures first, grouped by cause, with a + filter, paging and CSV export, and shows every time in your `TIMEZONE`. Each + run keeps up to 50,000 failures. +- **Backup health.** Each job has one of these states. `GET /api/health` + returns the same data as JSON. + - `failing`: a run failed, or the source check held one, since the last + success. + - `overdue`: no success by the deadline. For a scheduled job, the deadline is + the first cron time after the last success plus 60 minutes of grace. A + running job is not overdue against its schedule. A per-job "Must succeed at + least every N hours" sets the deadline instead. - `warning`: the last successful run deleted more than 100 files and more than ten times the usual number (the median of the ten runs before it). - - `healthy` shows the time of the last success. `pending` means the job has - no successful run yet and no failure to report. -- **Safety checks** — before a sync starts, its source is read. If the source - can't be read or is empty, the run is `held` and rclone is not started. Each - sync also has a delete limit (default 1000 files). A held or delete-limited run - has a **Run anyway** button. See [Keeping backups safe](#keeping-backups-safe). -- **Skipped runs** — if a scheduled run comes due while the previous run of the - same job is still going, it is recorded as `skipped` instead of disappearing. -- **Notifications** — alerts through [Apprise](https://github.com/caronc/apprise) - to ntfy, Discord, Telegram, email, Gotify and 100+ more. You choose which events - notify on the Settings page. Failure messages list the top causes, and a - "Send test notification" button checks the setup. -- **Heartbeat** — pings a dead-man's-switch URL (Healthchecks.io, an Uptime Kuma - push monitor) every N minutes while the app and rclone are working. If either - one dies, the pings stop and your monitor alerts you. -- **Login** — a single password by default. Behind a reverse proxy (Authelia, + - `healthy`: the job succeeded on time. + - `pending`: the job has a schedule or a deadline and hasn't succeeded yet. + - `manual` (shown as "Not scheduled"): no enabled schedule and no deadline, so + the app expects nothing. Its detail line still says what happened last, for + example "Last run was stopped 9 days ago · never succeeded". +- **Safety checks.** Before a sync starts, the app reads its source. If the + source is unreadable or empty, the app holds the run (`held`) and rclone + never starts. + Each sync also has a delete limit (default 1000 files). A held or + delete-limited run has a **Run anyway** button. See + [Keeping backups safe](#keeping-backups-safe). +- **Skipped runs.** If a schedule fires while the job's previous run is still + going, the app records a `skipped` run instead of dropping it. +- **Notifications.** Alerts go through [Apprise](https://github.com/caronc/apprise) + to ntfy, Discord, Telegram, email, Gotify and 100+ more. You choose the events + on the Settings page. Failure messages list the top causes. **Send test + notification** sends to the URLs in the box without saving the rest of the + page. +- **Heartbeat.** The app pings a dead-man's-switch URL (Healthchecks.io, an + Uptime Kuma push monitor) every N minutes while it and rclone are working. If + either one dies, the pings stop and your monitor alerts you. +- **Login.** A single password by default. Behind a reverse proxy (Authelia, Authentik, oauth2-proxy), the app can trust the proxy's login instead. See [Security notes](#security-notes). -- **History** — every run is recorded with final stats and status - (`completed`/`failed`/`stopped`/`interrupted`/`held`/`skipped`). Each run also - records its deletes, checks, and the exact settings it used. -- **Auto-resume** — per-job toggle. Orphaned `running` records are - re-dispatched on container restart; failed runs retry after a delay up to - `max_retries` (default 3). Each retry is a new history record linked via - `retry_of_run_id`. User-stopped runs are never auto-resumed. A manual - Resume button is available on failed/interrupted runs. -- **Settings** — configured remotes list, guided setup for all of rclone's - storage types (with browser sign-in for Google Drive, OneDrive, Dropbox, ...) - and global bandwidth limits. -- **Bandwidth limits** — any number of weekly rules, each with days, a time - window (overnight windows work), and a speed in KiB/s, MiB/s, GiB/s or - Mbit/s. Set them per job (job edit page) or globally (Settings), where a rule - can apply to everything, to all remotes — including remotes added later — or - to specific remotes. A job counts as using a remote if its source or - destination is on it. Where rules overlap, the lowest limit wins. rclone has a - single speed limiter shared by all its transfers, so the app sets it as runs - start and stop and as windows open and close; jobs running at the same time - share the lowest limit that applies to any of them. Times use `TIMEZONE`. -- **Themes** — eight colour themes (dark and light) from the **Theme** menu in - the navigation bar. The choice is stored in your browser, so each browser - picks its own. +- **History.** Every run keeps its final stats, status + (`completed`/`failed`/`stopped`/`interrupted`/`held`/`skipped`), deletes, + checks and the exact settings it used. Filter by job and status, 50 runs per + page; the filtered URL is shareable. +- **Auto-resume.** A per-job toggle. On restart, the app re-dispatches runs + still marked `running`. Failed runs retry after a delay, up to + `max_retries` (default 3), and each retry becomes a new history record linked + through `retry_of_run_id`. A run you stopped never resumes on its own. Failed + and interrupted runs have a manual **Resume** button. +- **Remotes.** Settings lists your rclone remotes, adds new ones with guided + setup for every storage type rclone supports (including browser sign-in for + Google Drive, OneDrive and Dropbox), and deletes ones you no longer use. + Deleting a remote leaves the files on it alone. +- **Bandwidth limits.** Any number of weekly rules, each with days, a time + window (overnight windows work) and a speed in KiB/s, MiB/s, GiB/s or Mbit/s. + Set them per job, or for the whole app in Settings, where a rule can cover + everything, all remotes (including ones you add later) or specific remotes. A job uses a + remote if its source or destination is on it. Where rules overlap, the lowest + limit wins. rclone has one speed limiter shared by all its transfers, so the + app updates it as runs start and stop and as windows open and close. Jobs + running at the same time share the lowest limit that applies to any of them. + Rules use `TIMEZONE`. +- **Themes.** Six themes (Graphite, Liquid Glass, Keycaps, Terminal, Synthwave, + Brutalist), each in light, dark or system mode, from the **Theme** menu. Each + browser keeps its own choice. +- **Accessibility.** Text passes WCAG AA contrast in all six themes, light and + dark. Icon buttons carry screen-reader names that include the job, keyboard + focus stays visible, and screen readers hear when a run starts or finishes. ## Keeping backups safe @@ -146,47 +172,46 @@ backup. Two checks stop that: - **Source check.** Before a sync starts, the app lists the top level of the - source. If that fails or comes back empty, the run is **held**. rclone is not - started, and the run page says why. -- **Delete limit.** A sync refuses to delete more than a set number of files in - one run (default 1000, set per job; `-1` turns it off). It is passed to rclone - as `--max-delete`. A run stopped this way is marked failed, with the reason. + source. If that fails or comes back empty, the app **holds** the run: rclone + never starts, and the run page says why. +- **Delete limit.** A sync stops after deleting a set number of files in one run + (default 1000, set per job; `-1` turns it off). The app passes it to rclone as + `--max-delete` and marks a run that hits it as failed, with the reason. -Neither kind of run is retried automatically, even when auto-resume is on. Check -the run before you run the job again. +The app never retries either kind of run on its own, even with auto-resume on. +Check the run before you start the job again. -Copy and move jobs are not checked, because they can't delete files at the +Copy and move jobs skip these checks, because they can't delete files at the destination. **Run anyway** (on a held or delete-limited run) starts the job once with both -checks off. It asks for confirmation first. Use it only when you know the -deletions are expected. +checks off, after you confirm. Use it when you expect the deletions. ## Why size + mixed ordering? -If a backup contains both huge files (videos, disk images) and thousands of -tiny ones (photos, documents, source code), the order rclone transfers them in -matters a lot: +Say a backup holds both huge files (videos, disk images) and thousands of tiny +ones (photos, documents, source code). The order rclone transfers them in +changes how the run goes: | Order | What happens | |-------|--------------| | Default (directory order) | Arbitrary. Big files can start late and become a long tail at the end. | -| Largest first (`size,desc`) | Every worker is busy on huge files, so the small files sit idle in the queue — you see few "new files" for hours, and per-file overhead (API calls, round trips) goes unused. | -| Smallest first (`size,asc`) | Thousands of small files finish quickly, but the huge files only start at the end, so the job's final stretch is a few slow, bandwidth-bound transfers. | -| **Mixed (`size,mixed,50`)** | Half of the transfer workers take the **smallest** remaining file, the other half take the **largest**. Small files keep flowing while the big ones are also being processed. | +| Largest first (`size,desc`) | Every worker is busy on huge files, so the small files wait in the queue. You see few "new files" for hours, and the per-file overhead (API calls, round trips) that could overlap with the big transfers goes unused. | +| Smallest first (`size,asc`) | Thousands of small files finish fast, but the huge files start at the end, so the run finishes on a few slow, bandwidth-bound transfers. | +| **Mixed (`size,mixed,50`)** | Half of the transfer workers take the **smallest** remaining file and the other half take the **largest**. Small files keep flowing while the big ones transfer. | The mixed window is the percentage of workers that go smallest-first (default `50`); the rest go largest-first, and each group keeps its direction for the -whole run. For example, with `Transfers = 4` and `size,mixed,50`, two workers -chew through small files while two work on large ones. Use a lower percentage -(say `25`) if you want most workers on the big files, or a higher one if you -want small files to finish sooner. +whole run. With `Transfers = 4` and `size,mixed,50`, two workers chew through +small files while two work on large ones. Use a lower percentage (say `25`) to +put most workers on the big files, or a higher one to finish the small files +sooner. -Set it per job under **Order By Field / Order Direction / Mixed Window (%)** in -the job form; it is passed to rclone as `--order-by size,mixed,50`. Because the -split is across transfer workers, it works best with several parallel -transfers, and rclone's own docs note that ordering is more accurate with a -larger `--max-backlog`. +Set it per job under **Advanced rclone options**, in **Order By Field**, **Order +Direction** and **Mixed Window (%)**. The app passes it to rclone as +`--order-by size,mixed,50`. The split works across transfer workers, so it +works best with several parallel transfers, and rclone's docs note that a larger +`--max-backlog` makes the ordering more accurate. ## Windows app @@ -195,35 +220,35 @@ and includes rclone. Closing the window leaves it in the system tray so schedule syncs keep running; choose **Quit** from the tray icon's menu to exit. 1. Download `rClone-GUI-Setup.exe` from the - [releases page](https://github.com/Joe11221/rClone-GUI/releases). No admin - rights are needed. The installer is unsigned, so SmartScreen warns on first + [releases page](https://github.com/Joe11221/rClone-GUI/releases). You don't + need admin rights. The installer is unsigned, so SmartScreen warns on first run: choose **More info**, then **Run anyway**. 2. Leave **Start rClone GUI when I log in** ticked if you want schedules to keep running. 3. Right-click the tray icon and choose **Configure remotes…** to add cloud - remotes. Existing rclone remotes are picked up automatically. + remotes. The app picks up the rclone remotes you already have. -If the PC was asleep or off when a schedule came due, the run starts once when -the app is next running, however many were missed. The PC stays awake during a -transfer, but the app can't wake a sleeping PC, and schedules only run while you -are logged in. +If the PC was asleep or off when a schedule came due, the app starts the job +once when it next runs, however many runs it missed. The PC stays awake during +a transfer, but the app can't wake a sleeping PC, and schedules only run while +you are logged in. -The Windows app has no login by default. It listens on loopback only and is -meant for one user. Other accounts on the same PC can still reach that port, so +The Windows app has no login by default. It listens on loopback only and +expects one user. Other accounts on the same PC can still reach that port, so on a PC shared with people you don't trust, turn login on. Open `%LOCALAPPDATA%\rClone GUI\settings.json`, add `"auth_mode": "password"`, quit the -app from the tray, and start it again. The window will then ask you to choose a +app from the tray, and start it again. The window then asks you to choose a password. -Data is kept in `%LOCALAPPDATA%\rClone GUI` and is left in place on uninstall. -Password-encrypted rclone configs aren't supported yet. +The app keeps its data in `%LOCALAPPDATA%\rClone GUI` and leaves it in place on +uninstall. It doesn't support password-encrypted rclone configs yet. See [docs/standalone-windows.md](docs/standalone-windows.md) for the design and build steps. ## Quickstart (Docker) -Requires Docker with the Compose plugin, and an existing rclone config +You need Docker with the Compose plugin, and an existing rclone config (`rclone config`) if you want to use cloud remotes. ```bash @@ -234,25 +259,25 @@ docker compose up -d --build Then open `http://127.0.0.1:${PORT}` (default `8080`). The first visit asks you to choose a password, so do that before anyone else can reach the port. The -container auto-restarts (`unless-stopped`). +container restarts unless you stop it (`unless-stopped`). -`SECRET_KEY` is optional. If it is empty, the app generates one on first start -and keeps it in `./data`, so logins survive restarts. Placeholder values such as -`change-me` are rejected. +`SECRET_KEY` is optional. If you leave it empty, the app generates one on first +start and keeps it in `./data`, so logins survive restarts. The app refuses +placeholder values such as `change-me`. ### Configuration (`.env`) | Key | Default | Description | |-----|---------|-------------| -| `SECRET_KEY` | generated | Signs login sessions. Leave empty to have one generated and saved in `./data`, or set a long random value (`python3 -c "import secrets; print(secrets.token_hex(32))"`). Placeholder values are refused. | +| `SECRET_KEY` | generated | Signs login sessions. Leave empty to have one generated and saved in `./data`, or set a long random value (`python3 -c "import secrets; print(secrets.token_hex(32))"`). The app refuses placeholder values. | | `AUTH_MODE` | `password` | `password`: one password, chosen on first visit. `proxy`: trust the login from a reverse proxy (see [Security notes](#security-notes)). `none`: no login at all. | | `AUTH_PROXY_HEADER` | `Remote-User` | Header the reverse proxy sets to the logged-in user. Only used when `AUTH_MODE=proxy`. | -| `SECURE_COOKIES` | `false` | Set to `true` when the app is served over https, so the login cookie is never sent over plain http. | -| `PUBLIC_URL` | — | Base address for links in notifications, e.g. `http://nas.lan:8080`. The value saved on the Settings page takes precedence. Passed through by `docker-compose.yml`. | +| `SECURE_COOKIES` | `false` | Set to `true` when you serve the app over https, so the browser never sends the login cookie over plain http. | +| `PUBLIC_URL` | (none) | Base address for links in notifications, e.g. `http://nas.lan:8080`. The value saved on the Settings page takes precedence. `docker-compose.yml` passes it through. | | `PORT` | `8080` | Host port for the web GUI | -| `BIND_ADDRESS` | `127.0.0.1` | Host address the port is published on. `0.0.0.0` exposes it to your whole network — see the warning above. | +| `BIND_ADDRESS` | `127.0.0.1` | Host address Docker publishes the port on. `0.0.0.0` exposes it to your whole network; see the warning above. | | `TIMEZONE` | `UTC` | IANA timezone (e.g. `America/New_York`) used to display times **and** to interpret cron schedules | -| `RCLONE_CONFIG` | `~/.config/rclone` | Host path to the rclone config directory (remotes are defined here) | +| `RCLONE_CONFIG` | `~/.config/rclone` | Host path to the rclone config directory (where your remotes live) | | `DATA_PATH` | `/mnt/storage` | Host data path, mounted at `/mnt/` inside the container | | `ALLOWED_HOSTS` | any | Comma-separated `host:port` values the app answers to, e.g. `nas.lan:9020`. Setting it blocks DNS rebinding attacks | @@ -267,33 +292,36 @@ DATA_PATH=/mnt ### Volumes -- `./data` -> `/app/instance` — SQLite database (gitignored) -- `${RCLONE_CONFIG}` -> `/root/.config/rclone` — rclone remotes/config. This - contains your cloud credentials; keep it out of version control. -- `${DATA_PATH}` -> `/mnt/` (**read-write**) — the host data hierarchy. - Note: this gives the container write access to everything under - `DATA_PATH`; `move` operations to local destinations rely on this. Point it - at the narrowest directory that works for you. +- `./data` -> `/app/instance`: SQLite database (gitignored) +- `${RCLONE_CONFIG}` -> `/root/.config/rclone`: rclone remotes and config. It + holds your cloud credentials; keep it out of version control. +- `${DATA_PATH}` -> `/mnt/` (**read-write**): the host data hierarchy. The + container can write to everything under `DATA_PATH`, and `move` jobs to local + destinations need that. Point it at the narrowest directory that works for + you. ### Upgrading -This release adds login and safety checks, so two things change on upgrade: +If you ran an earlier build from this repository: -- After upgrading, the first visit asks you to choose a password. To keep the - old no-login behaviour, set `AUTH_MODE=none` in `.env` and restart. -- Existing sync jobs get a delete limit of 1000 files. If a job legitimately - deletes more than that in one run, raise the limit on the job form, or set it - to `-1` for no limit. A run that hits the limit is marked failed. **Run +- The first visit asks you to choose a password. To keep the old no-login + behaviour, set `AUTH_MODE=none` in `.env` and restart. +- Existing sync jobs get a delete limit of 1000 files. If a job needs to delete + more than that in one run, raise the limit on the job form, or set it to `-1` + for no limit. The app marks a run that hits the limit as failed, and **Run anyway** starts it once without the limit. +- Jobs with no enabled schedule and no "Must succeed" deadline now show as + `manual` ("Not scheduled") instead of `pending`. `/api/health` has a new + `manual` count. +- Existing jobs keep their sync type. Only new jobs default to Copy. -The database schema is created and upgraded by the app itself on startup -(`db.create_all()` plus `_ensure_columns()` in `app/__init__.py`), so there is -no separate migration step. Just `docker compose up -d --build`. +The app creates and upgrades its database schema on startup +(`db.create_all()` plus `_ensure_columns()` in `app/__init__.py`), so you don't +run a separate migration step. `docker compose up -d --build` is enough. -If you previously ran a version where `TIMEZONE` was not set in -`docker-compose.yml`, note that cron schedules used the container's clock -(UTC) while times were displayed in `America/New_York`. Schedules now run in -`TIMEZONE`, so set it explicitly and double-check your cron expressions. +If you ran a version without `TIMEZONE` in `docker-compose.yml`, cron schedules +used the container's clock (UTC) while the UI displayed `America/New_York`. +Schedules now run in `TIMEZONE`, so set it and check your cron expressions. ## Architecture @@ -303,64 +331,65 @@ Browser <--HTTP/SSE--> Gunicorn (gevent, 1 worker) :8080 <--RC HTTP--> rclone rc SQLite (./data) ``` -Both `rclone rcd` and Gunicorn run in a single container, started by -`entrypoint.sh`. The rclone RC API is bound to -`127.0.0.1` inside the container and protected by a password generated on every -start, so only the Flask app can reach it. (rclone only allows adding remotes -through an authenticated API.) +`entrypoint.sh` starts both `rclone rcd` and Gunicorn in a single container. +rclone's RC API listens on `127.0.0.1` inside the container behind a password +generated on every start, so only the Flask app can reach it. (rclone only +allows adding remotes through an authenticated API.) Key design rules: -- **Nothing runs unless the user created it.** The app only tracks runs in +- **The app shows only the runs it started.** It tracks runs in `JobRunner._active_runs`, and the SSE endpoint polls rclone per stats group - (`core/stats?group=job_{run_id}`), never the global stats, so unrelated - rclone activity never shows up. + (`core/stats?group=job_{run_id}`), never the global stats, so other rclone + activity stays out of the UI. - **All rclone operations are async** (`_async: true`) and return a `jobid`; the app never blocks on multi-hour transfers. - **Each run gets a stats group** `job_{run_id}` for per-job metrics. SSE and the dashboard depend on this naming. - **Gevent is mandatory** (SSE connections hold greenlets). `wsgi.py` calls `gevent.monkey.patch_all()` first; don't use stdlib `threading`/`queue`. -- **Gunicorn runs with `workers = 1`** — the in-memory `_active_runs` dict and +- **Gunicorn runs with `workers = 1`.** The in-memory `_active_runs` dict and the APScheduler monitor must live in a single process (see `gunicorn.conf.py`). ### How a run works 1. Start -> `POST /ops/start/` -> `JobRunner.start_job()` -2. A `RunHistory` row is created (status `running`). For a sync, the source - is checked first and the run is `held` if the check fails. Then rclone is - called with `_async=true` and `_group=job_{run_id}` +2. The runner creates a `RunHistory` row (status `running`). For a sync, it + checks the source first and holds the run if the check fails. Then it calls + rclone with `_async=true` and `_group=job_{run_id}` 3. The SSE endpoint polls `core/stats?group=job_{run_id}` (every 2s while active, 5s when idle) 4. A 10-second monitor copies new per-file failures into the DB while a run is active, then finalizes completed runs and writes their final stats -5. Auto-resume (if enabled) re-dispatches orphaned/failed runs +5. Auto-resume (if enabled) re-dispatches orphaned and failed runs ### Project structure ``` app/ -├── __init__.py # App factory, startup checks, _ensure_columns() schema upgrades +├── __init__.py # App factory, dashboard view, startup checks, _ensure_columns() schema upgrades ├── auth.py # Login (password / proxy / none), sessions, lockout, secret key ├── config.py # Env-based config (SECRET_KEY, AUTH_MODE, TIMEZONE, RCLONE_RC_URL, ...) ├── extensions.py # SQLAlchemy instance -├── models.py # Job, Schedule, RunHistory (SQLite) +├── models.py # Job, Schedule, RunHistory, RunError, BandwidthRule, AppSetting (SQLite) ├── rclone_client.py # HTTP wrapper for rclone RC API ├── routes/ │ ├── health.py # GET /api/health: backup health as JSON -│ ├── jobs.py # CRUD for job definitions + directory browse API +│ ├── jobs.py # Job CRUD, inline schedule, directory browse API │ ├── operations.py # Start/stop/resume/status of running syncs -│ ├── schedules.py # Cron schedule CRUD + enable/disable -│ ├── history.py # Past run list + detail view +│ ├── schedules.py # Cron schedule CRUD, enable/disable, cron_text filter +│ ├── history.py # Run list (filters, paging) and run detail │ ├── run_errors.py # Per-run failure list (JSON, paged), summary, CSV export -│ ├── sse.py # SSE endpoint — polls rclone directly (no bg thread) +│ ├── sse.py # SSE endpoint; polls rclone directly (no background thread) │ └── settings.py # Remotes, bandwidth rules, notification and heartbeat settings ├── services/ │ ├── app_settings.py # Key/value settings edited in the UI (stored in SQLite) │ ├── bandwidth.py # Weekly bandwidth rules and the single rclone speed limit +│ ├── cron_text.py # Cron expressions in plain words ("Every day at 02:00") │ ├── events.py # In-process event bus: run finished/held/skipped, health changed -│ ├── health.py # Backup health rules: per-job state, deadlines, 7-day success rate +│ ├── extra_flags.py # Command-line flags <-> rclone _config keys, checked against options/info +│ ├── health.py # Backup health rules, dashboard verdict, 7-day success rate │ ├── job_runner.py # Core orchestration: start/stop jobs, safety checks, finalize runs │ ├── notifier.py # Notifications (Apprise) and the heartbeat ping │ ├── remotes.py # Creating rclone remotes from the GUI, including OAuth sign-in @@ -369,15 +398,16 @@ app/ │ └── suspects.py # Suggests folders to exclude while a run is in progress ├── security.py # Cross-site POST and ALLOWED_HOSTS checks ├── templates/ # Jinja2, Bootstrap 5 (themed via data-theme) -└── static/ # CSS, vendored Bootstrap, vanilla JS (SSE, errors, themes) +└── static/ # CSS, vendored Bootstrap, vanilla JS (SSE, job form, failures, themes) desktop/ # Windows app: WebView2 window, tray icon, rclone supervisor packaging/ # PyInstaller spec, Inno Setup script, build.ps1 tests/ # pytest suite (no rclone needed) ``` Tech stack: Flask 3.1, SQLAlchemy (SQLite), APScheduler 3.10, Apprise -(notifications), Gunicorn + gevent, Bootstrap 5 (vendored, no CDN) and vanilla JS (no build tools). The -Windows app adds pywebview, pystray, Pillow and PyInstaller. +(notifications), Gunicorn + gevent, Bootstrap 5 (vendored, no CDN) and vanilla +JS (no build tools). The Windows app adds pywebview, pystray, Pillow and +PyInstaller. ## Security notes @@ -388,28 +418,29 @@ Windows app adds pywebview, pystray, Pillow and PyInstaller. out every other browser. - `proxy`: the app trusts the user named in the `AUTH_PROXY_HEADER` header (default `Remote-User`), which a reverse proxy such as Authelia, Authentik or - oauth2-proxy sets after it has logged the user in. Requests without the - header get `401`. The app's port must then be reachable only by that proxy, - or anyone can send the header. + oauth2-proxy sets after it logs the user in. Requests without the header get + `401`. Only that proxy may reach the app's port; anyone else could send the + header. - `none`: no login. The web UI shows a warning banner. - **Lockouts are per client address.** Behind a reverse proxy in `password` mode, every client appears to come from the proxy's address, so one person's failed attempts lock everyone out for 15 minutes. -- **Cross-site requests are rejected** (`app/security.py`). A browser POST that - another website sent is refused, based on the `Sec-Fetch-Site` or `Origin` - header. Requests without those headers (scripts, `curl`) are not covered by - that check, so they still need a login. -- The directory browser (`/jobs/api/browse`) and job `extra_flags` let any - user of the app list arbitrary paths and set arbitrary rclone options. -- The data path is mounted read-write, and mounted rclone config contains your +- **The app rejects cross-site requests** (`app/security.py`). It refuses a + browser POST that another website sent, based on the `Sec-Fetch-Site` or + `Origin` header. Requests without those headers (scripts, `curl`) skip that + check, so they still need a login. +- Any user of the app can list arbitrary paths through the directory browser + (`/jobs/api/browse`) and set rclone options through a job's extra flags. The + app accepts only options your rclone reports, but those include powerful ones. +- Docker mounts the data path read-write, and the mounted rclone config holds your remote credentials. -- The published port defaults to loopback; widening it is an explicit opt-in - (`BIND_ADDRESS`). -- The rclone RC API is bound to loopback inside the container only, behind a +- The published port defaults to loopback; you widen it on purpose with + `BIND_ADDRESS`. +- The rclone RC API listens on loopback inside the container only, behind a per-start password. -Found a security problem? Please open an issue (or contact the maintainer -privately for anything sensitive). +Found a security problem? Open an issue, or contact the maintainer in private +for anything sensitive. ## Development @@ -419,24 +450,28 @@ pip install -r requirements-dev.txt pytest ``` -The tests use an in-memory SQLite database and a fake rclone client, and do -not start any background scheduler threads (`BACKGROUND_TASKS_ENABLED = False`). -CI runs the test suite and a Docker build on every push and pull request. +The tests use an in-memory SQLite database and a fake rclone client, and start +no background scheduler threads (`BACKGROUND_TASKS_ENABLED = False`). CI runs +the test suite and a Docker build on every push and pull request. Gotchas: -- `exclude_patterns`/`extra_flags` are JSON strings in SQLite; use - `job.get_excludes()` / `job.get_extra_flags()`. -- Runs record the settings they were dispatched with (`config_snapshot`), - shown under "Settings used for this run" on the run page. Runs from before - this release have no snapshot, so for those the job's *current* settings are - the only reference. -- Move operations may not resume cleanly after an interruption (partial file - relocation); a warning is shown in the job form. +- SQLite stores `exclude_patterns` and `extra_flags` as JSON strings; use + `job.get_excludes()` / `job.get_extra_flags()`. `extra_flags` holds rclone + `_config` keys (`{"MaxBacklog": 200000}`); `app/services/extra_flags.py` + converts them to and from the command-line form the job form shows. +- Each run records the settings it started with (`config_snapshot`), shown under + "Settings used for this run" on the run page. Runs from older builds have no + snapshot, so for those the job's *current* settings are the only reference. +- A move can leave files half relocated if something interrupts it, so resuming it + may not give a clean result; the job form warns when you combine move with auto-resume. - New columns on existing tables need an `ALTER TABLE` entry in - `_ensure_columns()` (`create_all()` won't add them). + `_ensure_columns()`, because `create_all()` won't add them. - APScheduler jobs run outside a request, so DB access needs `app.app_context()`. +- Untrusted text (file names, error messages, remote names) goes into the page + with `textContent`, never `innerHTML`; `tests/test_frontend_safety.py` + enforces this for the live views. ## License diff --git a/app/__init__.py b/app/__init__.py index dd29180..56083a2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -260,7 +260,7 @@ def dashboard(): jobs = Job.query.order_by(Job.name).all() active_runs = RunHistory.query.filter_by(status="running").all() recent_runs = ( - RunHistory.query.order_by(RunHistory.started_at.desc()).limit(10).all() + RunHistory.query.order_by(RunHistory.started_at.desc()).limit(8).all() ) rclone_connected = False try: @@ -268,14 +268,17 @@ def dashboard(): rclone_connected = True except Exception: pass - from app.services.health import current_report + from app.services import health as health_service - health_report, _ = current_report( - app.config["TIMEZONE"], app.config["HEALTH_GRACE_MINUTES"] + now = datetime.utcnow() + health_report, _ = health_service.current_report( + app.config["TIMEZONE"], app.config["HEALTH_GRACE_MINUTES"], now ) return render_template( "dashboard.html", health=health_report, + verdict=health_service.verdict(health_report, now, tz), + rows=health_service.dashboard_rows(health_report, now, tz), jobs=jobs, active_runs=active_runs, recent_runs=recent_runs, diff --git a/app/rclone_client.py b/app/rclone_client.py index f04f3d3..94bbec2 100644 --- a/app/rclone_client.py +++ b/app/rclone_client.py @@ -143,6 +143,10 @@ def update_remote(self, name, state, result): def delete_remote(self, name): return self._call("config/delete", {"name": name}) + def options_info(self): + """Every option rclone knows, grouped by block (e.g. "main").""" + return self._call("options/info") + # --- File operations --- def list_files(self, fs, remote="", recurse=False): diff --git a/app/routes/history.py b/app/routes/history.py index 841b9fb..afb6060 100644 --- a/app/routes/history.py +++ b/app/routes/history.py @@ -1,18 +1,54 @@ -from flask import Blueprint, render_template +from flask import Blueprint, render_template, request -from app.models import RunError, RunHistory +from app.models import Job, RunError, RunHistory history_bp = Blueprint("history", __name__) +PER_PAGE = 50 +STATUSES = [ + "running", "completed", "failed", "stopped", "interrupted", "held", "skipped", +] + @history_bp.route("/") def list_history(): + jobs = Job.query.order_by(Job.name).all() + job_id = request.args.get("job", type=int) + if job_id not in {j.id for j in jobs}: + job_id = None + status = request.args.get("status", "") + if status not in STATUSES: + status = "" + page = max(1, request.args.get("page", 1, type=int) or 1) + + query = RunHistory.query + if job_id: + query = query.filter(RunHistory.job_id == job_id) + if status: + query = query.filter(RunHistory.status == status) + total = query.count() + pages = max(1, -(-total // PER_PAGE)) + page = min(page, pages) + + # Newest start first; id breaks ties so paging stays stable. runs = ( - RunHistory.query.order_by(RunHistory.started_at.desc()) - .limit(100) + query.order_by(RunHistory.started_at.desc(), RunHistory.id.desc()) + .offset((page - 1) * PER_PAGE) + .limit(PER_PAGE) .all() ) - return render_template("history/list.html", runs=runs) + return render_template( + "history/list.html", + runs=runs, + jobs=jobs, + statuses=STATUSES, + job_id=job_id, + status=status, + page=page, + pages=pages, + total=total, + per_page=PER_PAGE, + ) @history_bp.route("/") diff --git a/app/routes/jobs.py b/app/routes/jobs.py index 6eba275..0aefff8 100644 --- a/app/routes/jobs.py +++ b/app/routes/jobs.py @@ -1,6 +1,7 @@ import json import os import re +from datetime import datetime from flask import ( Blueprint, @@ -13,9 +14,11 @@ url_for, ) +from apscheduler.triggers.cron import CronTrigger + from app.extensions import db -from app.models import Job -from app.services import bandwidth +from app.models import Job, Schedule +from app.services import bandwidth, extra_flags jobs_bp = Blueprint("jobs", __name__) @@ -105,6 +108,231 @@ def _parse_bandwidth_rules(form): return bandwidth.parse_rules(raw, allow_remote_scope=False) +# Form fields that live inside the collapsed "Advanced rclone options" section, +# so a validation error on one of them opens it. +ADVANCED_FIELDS = {"extra_flags"} + +_TEXT_FIELDS = ( + "name", "source", "destination", "sync_type", "transfers", "checkers", + "retries", "order_by_field", "order_by_direction", "order_by_mixed_window", + "compare_method", "max_delete", "max_age_hours", "max_retries", + "retry_delay_seconds", "excludes", "extra_flags", "schedule_cron", +) + + +def _form_values(job=None, form=None): + """What each field on the job form shows: the submitted text after a + validation error, else the saved job, else the defaults for a new job.""" + if form is not None: + return { + **{k: form.get(k, "") for k in _TEXT_FIELDS}, + "fast_list": "fast_list" in form, + "auto_resume": "auto_resume" in form, + "schedule_enabled": "schedule_enabled" in form, + } + schedule = job.schedule if job else None + return { + "name": job.name if job else "", + "source": job.source if job else "", + "destination": job.destination if job else "", + # Copy never deletes anything, so it is the safe thing to start from. + "sync_type": job.sync_type if job else "copy", + "transfers": job.transfers if job else 4, + "checkers": job.checkers if job else 8, + "retries": job.retries if job else 3, + "fast_list": bool(job.fast_list) if job else True, + "order_by_field": (job.order_by_field if job else None) or "size", + "order_by_direction": (job.order_by_direction if job else None) or "mixed", + "order_by_mixed_window": ( + job.order_by_mixed_window + if job and job.order_by_mixed_window is not None else 50 + ), + "compare_method": (job.compare_method if job else None) or "default", + "max_delete": job.max_delete if job and job.max_delete is not None else 1000, + "max_age_hours": job.max_age_hours if job and job.max_age_hours else "", + "auto_resume": bool(job.auto_resume) if job else False, + "max_retries": job.max_retries if job else 3, + "retry_delay_seconds": job.retry_delay_seconds if job else 60, + "excludes": "\n".join(job.get_excludes()) if job else "", + "extra_flags": _saved_flags_text(job), + "schedule_cron": schedule.cron_expression if schedule else "", + "schedule_enabled": schedule.enabled if schedule else True, + } + + +def _saved_flags_text(job): + """The job's saved extra options as flag lines.""" + saved = job.get_extra_flags() if job else {} + if not saved: + return "" + try: + options = extra_flags.load_options(current_app.config["RCLONE_CLIENT"]) + except extra_flags.OptionsUnavailable: + options = None + return extra_flags.format_flags(saved, options) + + +def _available_remotes(): + try: + rclone = current_app.config["RCLONE_CLIENT"] + return rclone.list_remotes().get("remotes", []) + except Exception: + return [] + + +def _render_form(job, values, errors=None, rules=None, status=200): + if rules is None: + rules = ( + [bandwidth.rule_to_dict(r) for r in job.bandwidth_rules] if job else [] + ) + errors = errors or {} + timezone = current_app.config["SCHEDULER_SERVICE"].timezone + html = render_template( + "jobs/form.html", + job=job, + f=values, + errors=errors, + advanced_open=bool(ADVANCED_FIELDS & set(errors)), + remotes=_available_remotes(), + bandwidth_rules=rules, + schedule_tz=str(timezone) if timezone else "the server's local time", + **_browser_context(), + ) + return html, status + + +def _parse_extra_flags(form): + """(config, errors); config is None to leave the saved flags alone. Fails + closed: flags are only stored once rclone has confirmed it knows them.""" + if "extra_flags" not in form: + return None, [] + text = form["extra_flags"] + if not text.strip(): + return {}, [] + try: + options = extra_flags.load_options(current_app.config["RCLONE_CLIENT"]) + except extra_flags.OptionsUnavailable: + return None, [ + "rclone isn't reachable, so these options can't be checked and " + "weren't saved. Try again once rclone is running, or clear the field." + ] + return extra_flags.parse(text, options) + + +def _parse_schedule(form): + """(cron text, error); cron is None to leave the schedule alone and "" + to remove it.""" + if "schedule_cron" not in form: + return None, None + cron = " ".join(form["schedule_cron"].split()) + if not cron: + return "", None + try: + CronTrigger.from_crontab( + cron, timezone=current_app.config["SCHEDULER_SERVICE"].timezone + ) + except ValueError: + return cron, ( + "Not a valid cron expression. Use five fields: minute hour " + "day-of-month month day-of-week, for example 0 2 * * *." + ) + return cron, None + + +def _apply_schedule(job, cron, enabled): + """Create, update or remove the job's schedule the way the Schedules page + does, so the APScheduler entry follows. The job is already committed.""" + if cron is None: + return + svc = current_app.config["SCHEDULER_SERVICE"] + sched = job.schedule + if not cron: + if sched: + svc.remove_schedule(sched.id) + db.session.delete(sched) + db.session.commit() + return + if sched is None: + sched = Schedule(job_id=job.id, cron_expression=cron, enabled=enabled) + db.session.add(sched) + elif sched.cron_expression != cron or sched.enabled != enabled: + sched.cron_expression = cron + sched.enabled = enabled + # Changing a schedule shouldn't replay what came due under the old one. + sched.catch_up_after = datetime.utcnow() + db.session.commit() + if sched.enabled: + svc.add_schedule(sched) + else: + svc.remove_schedule(sched.id) + + +def _save_job(job): + """Validate the posted form and write it to ``job`` (a new, unsaved Job on + create). Returns a response when the form needs fixing, else None.""" + form = request.form + excludes = [p.strip() for p in form.get("excludes", "").split("\n") if p.strip()] + order_by_field, order_by_direction, order_by_mixed_window = _parse_order_by(form) + try: + rules = _parse_bandwidth_rules(form) + except ValueError as exc: + flash(str(exc), "danger") + if job.id is None: + return redirect(url_for("jobs.create_job")) + return redirect(url_for("jobs.edit_job", job_id=job.id)) + + flags, flag_errors = _parse_extra_flags(form) + cron, cron_error = _parse_schedule(form) + errors = {} + if flag_errors: + errors["extra_flags"] = flag_errors + if cron_error: + errors["schedule_cron"] = cron_error + if errors: + try: + editor_rules = json.loads(form.get("bandwidth_rules") or "") + except ValueError: + editor_rules = None + return _render_form( + job if job.id is not None else None, + _form_values(form=form), + errors, + rules=editor_rules, + status=400, + ) + + is_new = job.id is None + job.name = form["name"] + job.source = form["source"] + job.destination = form["destination"] + job.sync_type = form["sync_type"] + job.transfers = int(form.get("transfers", 4)) + job.checkers = int(form.get("checkers", 8)) + job.fast_list = "fast_list" in form + job.exclude_patterns = json.dumps(excludes) + job.retries = int(form.get("retries", 3)) + job.order_by_field = order_by_field + job.order_by_direction = order_by_direction + job.order_by_mixed_window = order_by_mixed_window + job.compare_method = _parse_compare_method(form) + job.max_delete = _parse_max_delete(form) + job.max_age_hours = _parse_max_age_hours(form) + job.auto_resume = "auto_resume" in form + job.max_retries = int(form.get("max_retries", 3)) + job.retry_delay_seconds = int(form.get("retry_delay_seconds", 60)) + if flags is not None: + job.extra_flags = json.dumps(flags) + if rules is not None: + bandwidth.replace_rules(rules, job=job) + if is_new: + db.session.add(job) + db.session.commit() + _apply_schedule(job, cron, "schedule_enabled" in form) + if not is_new: + current_app.config["BANDWIDTH"].reconcile() + return None + + @jobs_bp.route("/") def list_jobs(): jobs = Job.query.order_by(Job.created_at.desc()).all() @@ -114,123 +342,25 @@ def list_jobs(): @jobs_bp.route("/create", methods=["GET", "POST"]) def create_job(): if request.method == "POST": - excludes = [ - p.strip() - for p in request.form.get("excludes", "").split("\n") - if p.strip() - ] - order_by_field, order_by_direction, order_by_mixed_window = ( - _parse_order_by(request.form) - ) - try: - rules = _parse_bandwidth_rules(request.form) - except ValueError as exc: - flash(str(exc), "danger") - return redirect(url_for("jobs.create_job")) - job = Job( - name=request.form["name"], - source=request.form["source"], - destination=request.form["destination"], - sync_type=request.form["sync_type"], - transfers=int(request.form.get("transfers", 4)), - checkers=int(request.form.get("checkers", 8)), - fast_list="fast_list" in request.form, - exclude_patterns=json.dumps(excludes), - retries=int(request.form.get("retries", 3)), - order_by_field=order_by_field, - order_by_direction=order_by_direction, - order_by_mixed_window=order_by_mixed_window, - compare_method=_parse_compare_method(request.form), - max_delete=_parse_max_delete(request.form), - max_age_hours=_parse_max_age_hours(request.form), - auto_resume="auto_resume" in request.form, - max_retries=int(request.form.get("max_retries", 3)), - retry_delay_seconds=int( - request.form.get("retry_delay_seconds", 60) - ), - ) - if rules: - bandwidth.replace_rules(rules, job=job) - db.session.add(job) - db.session.commit() + job = Job() + response = _save_job(job) + if response is not None: + return response flash(f"Job '{job.name}' created.", "success") return redirect(url_for("jobs.list_jobs")) - - # Try to get available remotes for the form - remotes = [] - try: - rclone = current_app.config["RCLONE_CLIENT"] - result = rclone.list_remotes() - remotes = result.get("remotes", []) - except Exception: - pass - return render_template( - "jobs/form.html", - job=None, - remotes=remotes, - bandwidth_rules=[], - **_browser_context(), - ) + return _render_form(None, _form_values()) @jobs_bp.route("//edit", methods=["GET", "POST"]) def edit_job(job_id): job = Job.query.get_or_404(job_id) if request.method == "POST": - excludes = [ - p.strip() - for p in request.form.get("excludes", "").split("\n") - if p.strip() - ] - order_by_field, order_by_direction, order_by_mixed_window = ( - _parse_order_by(request.form) - ) - try: - rules = _parse_bandwidth_rules(request.form) - except ValueError as exc: - flash(str(exc), "danger") - return redirect(url_for("jobs.edit_job", job_id=job.id)) - job.name = request.form["name"] - job.source = request.form["source"] - job.destination = request.form["destination"] - job.sync_type = request.form["sync_type"] - job.transfers = int(request.form.get("transfers", 4)) - job.checkers = int(request.form.get("checkers", 8)) - job.fast_list = "fast_list" in request.form - job.exclude_patterns = json.dumps(excludes) - job.retries = int(request.form.get("retries", 3)) - job.order_by_field = order_by_field - job.order_by_direction = order_by_direction - job.order_by_mixed_window = order_by_mixed_window - job.compare_method = _parse_compare_method(request.form) - job.max_delete = _parse_max_delete(request.form) - job.max_age_hours = _parse_max_age_hours(request.form) - job.auto_resume = "auto_resume" in request.form - job.max_retries = int(request.form.get("max_retries", 3)) - job.retry_delay_seconds = int( - request.form.get("retry_delay_seconds", 60) - ) - if rules is not None: - bandwidth.replace_rules(rules, job=job) - db.session.commit() - current_app.config["BANDWIDTH"].reconcile() + response = _save_job(job) + if response is not None: + return response flash(f"Job '{job.name}' updated.", "success") return redirect(url_for("jobs.list_jobs")) - - remotes = [] - try: - rclone = current_app.config["RCLONE_CLIENT"] - result = rclone.list_remotes() - remotes = result.get("remotes", []) - except Exception: - pass - return render_template( - "jobs/form.html", - job=job, - remotes=remotes, - bandwidth_rules=[bandwidth.rule_to_dict(r) for r in job.bandwidth_rules], - **_browser_context(), - ) + return _render_form(job, _form_values(job)) @jobs_bp.route("/api/browse") @@ -261,6 +391,10 @@ def browse_dirs(): def delete_job(job_id): job = Job.query.get_or_404(job_id) name = job.name + # Deleting the row doesn't touch the in-memory scheduler; without this the + # cron entry would keep firing for a job that no longer exists. + if job.schedule: + current_app.config["SCHEDULER_SERVICE"].remove_schedule(job.schedule.id) db.session.delete(job) db.session.commit() flash(f"Job '{name}' deleted.", "success") diff --git a/app/routes/schedules.py b/app/routes/schedules.py index 24e2c53..fdad214 100644 --- a/app/routes/schedules.py +++ b/app/routes/schedules.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from flask import ( Blueprint, @@ -10,12 +10,21 @@ url_for, ) +from apscheduler.triggers.cron import CronTrigger + from app.extensions import db from app.models import Job, Schedule +from app.services.cron_text import describe_cron schedules_bp = Blueprint("schedules", __name__) +@schedules_bp.app_template_filter("cron_text") +def cron_text_filter(expr): + """Cron expression in plain words, for any template.""" + return describe_cron(expr) + + @schedules_bp.route("/") def list_schedules(): schedules = ( @@ -24,10 +33,36 @@ def list_schedules(): scheduler_svc = current_app.config["SCHEDULER_SERVICE"] # Attach next run times for sched in schedules: - sched.next_run = scheduler_svc.get_next_run(sched.id) + nxt = scheduler_svc.get_next_run(sched.id) + # local_time expects naive UTC; APScheduler hands back an aware datetime. + sched.next_run = ( + nxt.astimezone(timezone.utc).replace(tzinfo=None) if nxt else None + ) return render_template("schedules/list.html", schedules=schedules) +@schedules_bp.route("/describe") +def describe(): + """Plain-words reading of ?expr=, for the live preview on the form.""" + expr = (request.args.get("expr") or "").strip()[:100] + text = describe_cron(expr) + return {"text": text, "described": bool(expr) and text != expr} + + +def _cron_error(cron): + """Why the scheduler would refuse this expression, or None.""" + try: + CronTrigger.from_crontab( + cron, timezone=current_app.config["SCHEDULER_SERVICE"].timezone + ) + except ValueError: + return ( + "Not a valid cron expression. Use five fields: minute hour " + "day-of-month month day-of-week, for example 0 2 * * *." + ) + return None + + @schedules_bp.route("/create", methods=["GET", "POST"]) def create_schedule(): if request.method == "POST": @@ -38,9 +73,18 @@ def create_schedule(): flash("This job already has a schedule. Edit or delete it first.", "warning") return redirect(url_for("schedules.list_schedules")) + cron = " ".join(request.form["cron_expression"].split()) + error = _cron_error(cron) + if error: + jobs = Job.query.order_by(Job.name).all() + return render_template( + "schedules/form.html", schedule=None, jobs=jobs, + cron_value=cron, cron_error=error, selected_job_id=job_id, + ), 400 + sched = Schedule( job_id=job_id, - cron_expression=request.form["cron_expression"], + cron_expression=cron, enabled="enabled" in request.form, ) db.session.add(sched) @@ -61,7 +105,15 @@ def create_schedule(): def edit_schedule(schedule_id): sched = Schedule.query.get_or_404(schedule_id) if request.method == "POST": - sched.cron_expression = request.form["cron_expression"] + cron = " ".join(request.form["cron_expression"].split()) + error = _cron_error(cron) + if error: + jobs = Job.query.order_by(Job.name).all() + return render_template( + "schedules/form.html", schedule=sched, jobs=jobs, + cron_value=cron, cron_error=error, + ), 400 + sched.cron_expression = cron sched.enabled = "enabled" in request.form sched.catch_up_after = datetime.utcnow() db.session.commit() diff --git a/app/routes/settings.py b/app/routes/settings.py index d849ac2..d341fae 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -315,3 +315,28 @@ def remote_finish(job_id): def remote_cancel(job_id): _sign_ins.cancel(job_id) return jsonify(ok=True) + + +# --- Deleting remotes --- + + +@settings_bp.route("/remotes/delete", methods=["POST"]) +def delete_remote(): + name = str(request.form.get("name") or "").strip() + rclone = current_app.config["RCLONE_CLIENT"] + try: + existing = rclone.list_remotes().get("remotes", []) + except Exception as exc: + flash(f"Could not reach rclone: {exc}", "danger") + return redirect(url_for("settings.index")) + # Only delete what rclone knows about; a stale or forged name does nothing. + if name not in existing: + flash(f"No remote named '{name}'.", "warning") + return redirect(url_for("settings.index")) + try: + rclone.delete_remote(name) + except Exception as exc: + flash(f"Could not delete remote '{name}': {exc}", "danger") + else: + flash(f"Remote '{name}' deleted.", "success") + return redirect(url_for("settings.index")) diff --git a/app/services/cron_text.py b/app/services/cron_text.py new file mode 100644 index 0000000..b805469 --- /dev/null +++ b/app/services/cron_text.py @@ -0,0 +1,97 @@ +"""Plain-English descriptions of 5-field cron expressions.""" + +import re + +_DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] +_DAY_ABBR = {n[:3].lower(): i for i, n in enumerate(_DAYS)} +_NUM = re.compile(r"^\d+$") + + +def _ordinal(n): + if 10 <= n % 100 <= 20: + suffix = "th" + else: + suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + return f"{n}{suffix}" + + +def _day_index(token): + token = token.lower() + if _NUM.match(token): + n = int(token) + return 0 if n == 7 else n if 0 <= n <= 6 else None + return _DAY_ABBR.get(token[:3]) if token[:3] in _DAY_ABBR else None + + +def _describe_days(field): + """'1-5' -> 'Weekdays'-style text; None when not confidently describable.""" + if field.lower() in ("1-5", "mon-fri"): + return "Weekdays" + if field in ("0,6", "6,0", "6,7", "0,7"): + return "Weekends" + parts = field.split(",") + days = [] + for p in parts: + idx = _day_index(p) + if idx is None: + return None + days.append(_DAYS[idx]) + if len(days) == 1: + return f"Every {days[0]}" + return "Every " + ", ".join(days[:-1]) + " and " + days[-1] + + +def _step(field, limit): + """'*/N' -> N when N divides the range sensibly, else None.""" + m = re.match(r"^\*/(\d+)$", field) + if not m: + return None + n = int(m.group(1)) + return n if 1 <= n < limit else None + + +def describe_cron(expr): + """Describe a 5-field cron expression, or return it unchanged if unsure.""" + if not isinstance(expr, str): + return expr or "" + raw = expr.strip() + fields = raw.split() + if len(fields) != 5: + return raw + minute, hour, dom, month, dow = fields + + def is_num(f, hi): + return bool(_NUM.match(f)) and int(f) <= hi + + if month != "*": + return raw + + # Every N minutes / every minute + if hour == "*" and dom == "*" and dow == "*": + if minute == "*": + return "Every minute" + n = _step(minute, 60) + if n: + return "Every minute" if n == 1 else f"Every {n} minutes" + if is_num(minute, 59): + return f"Every hour at :{int(minute):02d}" + return raw + + # Every N hours at a fixed minute + n_hours = _step(hour, 24) + if n_hours and is_num(minute, 59) and dom == "*" and dow == "*": + when = "on the hour" if int(minute) == 0 else f"at :{int(minute):02d}" + return f"Every {n_hours} hours, {when}" if n_hours > 1 else f"Every hour {when}" + + if not (is_num(minute, 59) and is_num(hour, 23)): + return raw + time = f"{int(hour):02d}:{int(minute):02d}" + + if dom == "*" and dow == "*": + return f"Every day at {time}" + if dom == "*": + days = _describe_days(dow) + return f"{days} at {time}" if days else raw + if dow == "*" and is_num(dom, 31) and 1 <= int(dom) <= 31: + return f"On the {_ordinal(int(dom))} of every month at {time}" + return raw diff --git a/app/services/extra_flags.py b/app/services/extra_flags.py new file mode 100644 index 0000000..6401ffe --- /dev/null +++ b/app/services/extra_flags.py @@ -0,0 +1,194 @@ +"""Extra rclone options on a job, typed as familiar command-line flags. + +``Job.extra_flags`` holds rclone ``_config`` keys (``{"MaxBacklog": 200000}``). +Users know the flag names (``--max-backlog 200000``), so the form takes those +and this module converts both ways. rclone silently ignores ``_config`` keys it +doesn't know, so every flag is checked against the daemon's ``options/info`` +before anything is stored: a typo must be an error, not a no-op. +""" + +import re + +# The job form already has a field for each of these; a second value here would +# silently fight it (extra flags are merged last). +CONTROLLED = { + "transfers": "Transfers", + "checkers": "Checkers", + "retries": "Retries (rclone --retries)", + "bwlimit": "Bandwidth limits", + "bwlimit_file": "Bandwidth limits", + "order_by": "Order by", + "fast_list": "Fast list", + "size_only": "File comparison method", + "checksum": "File comparison method", + "ignore_existing": "File comparison method", + "max_delete": "Delete limit", +} + +# Filters are held in the Exclude patterns field. +FILTER_FLAGS = { + "exclude", "exclude_from", "include", "include_from", "filter", + "filter_from", "files_from", "files_from_raw", "files_from0", + "min_size", "max_size", "min_age", "max_age", "exclude_if_present", + "delete_excluded", "ignore_case", "hash_filter", +} + +# Would wait for a person, or write progress to a terminal, in a background run. +NOT_FOR_JOBS = { + "interactive", "ask_password", "auto_confirm", "progress", + "progress_terminal_title", "no_console", "dry_run", +} + +# Value types that fit on one line. rclone parses the string forms of +# Duration and SizeSuffix itself; the others we convert. +SIMPLE_TYPES = {"bool", "int", "int64", "float64", "string", "Duration", "SizeSuffix"} + +_SIZE = re.compile(r"^(off|-?\d+(\.\d+)?([bBkKmMgGtTpP]([iI]?[bB]?)?)?)$") +_DURATION = re.compile(r"^(off|-?(\d+(\.\d+)?(ns|us|µs|ms|s|m|h|d|w|M|y)?)+)$") + + +class OptionsUnavailable(Exception): + """rclone's option list couldn't be fetched, so flags can't be checked.""" + + +def load_options(client): + """rclone's main options as {Name: option}, fetched once per client.""" + cached = getattr(client, "_main_options", None) + if cached is not None: + return cached + try: + info = client.options_info() + options = {o["Name"]: o for o in info["main"]} + except Exception as exc: + raise OptionsUnavailable(str(exc)) from exc + try: + client._main_options = options + except AttributeError: + pass + return options + + +def _flag(name): + return "--" + name.replace("_", "-") + + +def _convert(option, text): + """The typed value for ``text``, or raise ValueError with a short reason.""" + kind = option["Type"] + if kind == "bool": + lowered = text.lower() + if lowered not in ("true", "false"): + raise ValueError("takes no value, or =true / =false") + return lowered == "true" + if text == "": + raise ValueError("needs a value") + if kind in ("int", "int64"): + try: + return int(text) + except ValueError: + raise ValueError("needs a whole number") from None + if kind == "float64": + try: + return float(text) + except ValueError: + raise ValueError("needs a number") from None + if kind == "SizeSuffix": + if not _SIZE.match(text): + raise ValueError("needs a size such as 32M, 1G or off") + return text + if kind == "Duration": + if not _DURATION.match(text): + raise ValueError("needs a duration such as 30s, 5m or 1h30m") + return text + return text + + +def parse(text, options): + """Parse flags typed one per line. + + Returns (config, errors): ``config`` maps rclone _config keys to typed + values and ``errors`` is a list of messages that each name the flag; store + nothing unless it is empty. Blank lines and lines starting with # are + ignored. + """ + config = {} + errors = [] + seen = set() + for number, raw in enumerate((text or "").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + prefix = f"Line {number}: " + if not line.startswith("--") or line.startswith("---"): + errors.append( + f"{prefix}{line.split()[0]} is not a long flag. " + "Start each line with -- (for example --buffer-size 32M)." + ) + continue + head, equals, value = line[2:].partition("=") + if not equals: + head, _, value = line[2:].partition(" ") + name = head.strip().replace("-", "_") + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + value = value[1:-1] + shown = _flag(name) + + if not name or not re.fullmatch(r"[a-z0-9_]+", name): + errors.append(f"{prefix}{line.split()[0]} is not a valid flag name.") + continue + if name in CONTROLLED: + errors.append( + f"{prefix}{shown} is set by the {CONTROLLED[name]} field above. " + "Change it there." + ) + continue + if name in FILTER_FLAGS: + errors.append( + f"{prefix}{shown} is a filter flag. Use Exclude patterns instead." + ) + continue + if name in NOT_FOR_JOBS: + errors.append(f"{prefix}{shown} does not make sense in a background job.") + continue + option = options.get(name) + if option is None: + errors.append(f"{prefix}{shown} is not an rclone option this app can set.") + continue + if option["Type"] not in SIMPLE_TYPES: + errors.append( + f"{prefix}{shown} takes a value this form can't carry " + f"({option['Type']}). Leave it out." + ) + continue + if name in seen: + errors.append(f"{prefix}{shown} appears more than once.") + continue + seen.add(name) + if option["Type"] == "bool" and not value: + value = "true" + try: + config[option["FieldName"]] = _convert(option, value) + except ValueError as exc: + errors.append(f"{prefix}{shown} {exc}.") + return config, errors + + +def format_flags(config, options=None): + """Saved _config keys as flag lines, in key order. + + ``options`` may be None when rclone is unreachable; names are then guessed + from the key (MaxBacklog -> --max-backlog), which is right for most. + """ + names = {o["FieldName"]: o["Name"] for o in (options or {}).values()} + lines = [] + for key, value in (config or {}).items(): + name = names.get(key) or re.sub(r"(? 3: + detail += f" and {len(problems) - 3} more" + detail += "." + elif report["healthy"] == total: + level = "ok" + headline = ( + "The backup is healthy" + if total == 1 + else f"All {total} backups are healthy" + ) + detail = "" + else: + level = "neutral" + headline = ( + f"{report['healthy']} of {total} " + f"{plural(total, 'backup is', 'backups are')} healthy" + ) + detail = "No problems found. The others are manual or waiting for a first run." + upcoming = [e for e in jobs if e.next_run] + if upcoming: + nxt = min(upcoming, key=lambda e: e.next_run) + line = f"Next: {nxt.name} {when_text(nxt.next_run, now, tz)}" + detail = f"{detail} {line}" if detail else line + if report["recent_runs"]: + line = ( + f"{report['recent_ok']} of {report['recent_runs']} " + f"{plural(report['recent_runs'], 'run', 'runs')} succeeded this week" + ) + detail = f"{detail} · {line}" if detail else line + return {"level": level, "headline": headline, "detail": detail} + + +def dashboard_rows(report, now, tz): + """Job entries with display text for the dashboard rows.""" + rows = [] + for e in report["jobs"]: + rows.append( + { + "entry": e, + # The Last success column already says it for healthy jobs. + "reason": "" if e.state == "healthy" else e.detail, + "last_success_text": time_ago(e.last_success, now) + if e.last_success + else "never", + "last_success_title": _local_text(e.last_success, tz) + if e.last_success + else "", + "next_text": when_text(e.next_run, now, tz) if e.next_run else "", + "next_title": _local_text(e.next_run, tz) if e.next_run else "", + } + ) + return rows + + def load_runs(jobs, now): """Only the runs the rules need, per job id: failures since the last success, the last 11 completed runs, running runs, the last 7 days and the diff --git a/app/static/css/style.css b/app/static/css/style.css index c355d5f..6fab257 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -4,6 +4,35 @@ styles then read those variables, and the theme sections at the end of this file add each theme's own shapes and effects. */ +/* Status tokens. Text colours, tuned per theme and mode so each clears 4.5:1 on + its surfaces (tinted badge fill included). --app-on-status is the text on a + solid status fill. Dark first, then light; themes below override as needed. */ +:root, +[data-theme] { + --app-ok: #2fd08a; + --app-ok-rgb: 47, 208, 138; + --app-fail: #f87171; + --app-fail-rgb: 248, 113, 113; + --app-warn: #f5b400; + --app-warn-rgb: 245, 180, 0; + --app-info: #22bedc; + --app-info-rgb: 34, 190, 220; + --app-on-status: #0b0f1a; + --app-focus: var(--app-accent); +} + +[data-bs-theme="light"] { + --app-ok: #0a6640; + --app-ok-rgb: 10, 102, 64; + --app-fail: #b42323; + --app-fail-rgb: 180, 35, 35; + --app-warn: #7a5400; + --app-warn-rgb: 122, 84, 0; + --app-info: #08627a; + --app-info-rgb: 8, 98, 122; + --app-on-status: #ffffff; +} + /* Graphite: the plain default. */ :root, [data-theme="graphite"] { @@ -87,8 +116,8 @@ --app-border: #2b2d42; --app-text: #2b2d42; --app-muted: #5d5973; - --app-accent: #d81b6a; - --app-accent-rgb: 216, 27, 106; + --app-accent: #c9145f; + --app-accent-rgb: 201, 20, 95; --app-accent-contrast: #ffffff; --divider: rgba(43, 45, 66, 0.3); --dots: rgba(43, 45, 66, 0.12); @@ -109,6 +138,14 @@ --divider: rgba(255, 255, 255, 0.2); --dots: rgba(255, 255, 255, 0.07); --row-hover: rgba(255, 226, 138, 0.12); + --app-ok: #5be0a6; + --app-ok-rgb: 91, 224, 166; + --app-fail: #ff9393; + --app-fail-rgb: 255, 147, 147; + --app-warn: #ffd34d; + --app-warn-rgb: 255, 211, 77; + --app-info: #6fd8f0; + --app-info-rgb: 111, 216, 240; } /* Terminal */ @@ -131,6 +168,14 @@ --t-sec: #86d3a0; --scan: rgba(0, 0, 0, 0.16); --glow: 0 0 4px rgba(57, 255, 122, 0.22); + --app-ok: var(--t-ok); + --app-ok-rgb: 57, 255, 122; + --app-fail: var(--t-bad); + --app-fail-rgb: 255, 107, 122; + --app-warn: var(--t-warn); + --app-warn-rgb: 255, 211, 77; + --app-info: var(--t-info); + --app-info-rgb: 108, 242, 255; } [data-theme="terminal"][data-bs-theme="light"] { @@ -152,6 +197,14 @@ --t-sec: #455f4c; --scan: rgba(0, 0, 0, 0.035); --glow: none; + --app-ok: var(--t-ok); + --app-ok-rgb: 11, 122, 50; + --app-fail: var(--t-bad); + --app-fail-rgb: 179, 18, 42; + --app-warn: var(--t-warn); + --app-warn-rgb: 138, 90, 0; + --app-info: var(--t-info); + --app-info-rgb: 11, 111, 128; } /* Synthwave: neon night by default, pastel vaporwave sunset in light mode. */ @@ -220,6 +273,8 @@ --code-bg: #ffefa8; --code-fg: #b0002f; --link: #0033ff; + --link-rgb: 0, 51, 255; + --app-focus: #111111; --dots: rgba(17, 17, 17, 0.16); } @@ -237,6 +292,8 @@ --code-bg: #3a3410; --code-fg: #ffd600; --link: #8fb0ff; + --link-rgb: 143, 176, 255; + --app-focus: #f4f1e8; --dots: rgba(255, 255, 255, 0.07); } @@ -259,6 +316,26 @@ --bs-primary-rgb: var(--app-accent-rgb); --bs-code-color: var(--app-accent); --bs-focus-ring-color: rgba(var(--app-accent-rgb), 0.25); + --bs-success: var(--app-ok); + --bs-success-rgb: var(--app-ok-rgb); + --bs-success-text-emphasis: var(--app-ok); + --bs-success-bg-subtle: rgba(var(--app-ok-rgb), 0.14); + --bs-success-border-subtle: rgba(var(--app-ok-rgb), 0.4); + --bs-danger: var(--app-fail); + --bs-danger-rgb: var(--app-fail-rgb); + --bs-danger-text-emphasis: var(--app-fail); + --bs-danger-bg-subtle: rgba(var(--app-fail-rgb), 0.14); + --bs-danger-border-subtle: rgba(var(--app-fail-rgb), 0.4); + --bs-warning: var(--app-warn); + --bs-warning-rgb: var(--app-warn-rgb); + --bs-warning-text-emphasis: var(--app-warn); + --bs-warning-bg-subtle: rgba(var(--app-warn-rgb), 0.14); + --bs-warning-border-subtle: rgba(var(--app-warn-rgb), 0.4); + --bs-info: var(--app-info); + --bs-info-rgb: var(--app-info-rgb); + --bs-info-text-emphasis: var(--app-info); + --bs-info-bg-subtle: rgba(var(--app-info-rgb), 0.14); + --bs-info-border-subtle: rgba(var(--app-info-rgb), 0.4); } body { @@ -267,11 +344,29 @@ body { min-height: 100vh; } -h4 { +:is(h4, h1.h4) { font-weight: 600; letter-spacing: -0.01em; } +/* ---------- Keyboard focus ---------- + One visible ring for every interactive element in every theme. Form fields keep + their own border-plus-glow focus style. */ + +:is(a, button, .btn, .nav-link, .dropdown-item, .form-check-input, .btn-close, summary, [tabindex]):focus-visible { + outline: 2px solid var(--app-focus); + outline-offset: 2px; +} + +.dropdown-item:focus-visible { + outline-offset: -2px; +} + +/* The tray app's window focuses the first link whenever it reopens. */ +html[data-desktop] .navbar-brand:focus-visible { + outline: none; +} + /* ---------- Navbar ---------- */ .navbar { @@ -314,13 +409,8 @@ h4 { color: var(--app-muted); } -/* The tray app's window focuses the first link whenever it reopens. */ -.navbar-brand:focus-visible { - outline: none; -} - #running-badge { - font-size: 0.65rem; + font-size: 0.7rem; vertical-align: top; } @@ -475,12 +565,97 @@ select optgroup { padding: 0.35em 0.6em; } -.badge.bg-success { background-color: rgba(25, 195, 125, 0.16) !important; color: #2fd08a !important; } -.badge.bg-danger { background-color: rgba(239, 68, 68, 0.16) !important; color: #f87171 !important; } -.badge.bg-primary { background-color: rgba(var(--app-accent-rgb), 0.16) !important; color: var(--app-accent) !important; } -.badge.bg-warning { background-color: rgba(245, 180, 0, 0.16) !important; color: #f5b400 !important; } -.badge.bg-info { background-color: rgba(34, 190, 220, 0.16) !important; color: #22bedc !important; } -.badge.bg-secondary { background-color: var(--app-surface-2) !important; color: var(--app-muted) !important; } +/* Soft status badges: tinted background, coloured text. text-bg-* is the same + look, so a stray Bootstrap solid badge can't bring back white-on-pale text. */ +.badge { + font-weight: 600; + border-radius: 0.4rem; + padding: 0.35em 0.6em; + /* Never below ~11px, even inside .small or table cells. */ + font-size: max(0.75em, 0.72rem); +} + +.badge:is(.bg-success, .text-bg-success) { background-color: rgba(var(--app-ok-rgb), 0.16) !important; color: var(--app-ok) !important; } +.badge:is(.bg-danger, .text-bg-danger) { background-color: rgba(var(--app-fail-rgb), 0.16) !important; color: var(--app-fail) !important; } +.badge:is(.bg-primary, .text-bg-primary) { background-color: rgba(var(--app-accent-rgb), 0.16) !important; color: var(--app-accent) !important; } +.badge:is(.bg-warning, .text-bg-warning) { background-color: rgba(var(--app-warn-rgb), 0.16) !important; color: var(--app-warn) !important; } +.badge:is(.bg-info, .text-bg-info) { background-color: rgba(var(--app-info-rgb), 0.16) !important; color: var(--app-info) !important; } +.badge:is(.bg-secondary, .text-bg-secondary) { background-color: var(--app-surface-2) !important; color: var(--app-muted) !important; } + +/* Status buttons: Bootstrap's stock green and red miss 4.5:1 on dark surfaces. */ +.btn-success, +.btn-danger { + --bs-btn-color: var(--app-on-status); + --bs-btn-hover-color: var(--app-on-status); + --bs-btn-active-color: var(--app-on-status); + --bs-btn-disabled-color: var(--app-on-status); +} + +.btn-success { + --bs-btn-bg: var(--app-ok); + --bs-btn-border-color: var(--app-ok); + --bs-btn-hover-bg: var(--app-ok); + --bs-btn-hover-border-color: var(--app-ok); + --bs-btn-active-bg: var(--app-ok); + --bs-btn-active-border-color: var(--app-ok); + --bs-btn-disabled-bg: var(--app-ok); + --bs-btn-disabled-border-color: var(--app-ok); +} + +.btn-danger { + --bs-btn-bg: var(--app-fail); + --bs-btn-border-color: var(--app-fail); + --bs-btn-hover-bg: var(--app-fail); + --bs-btn-hover-border-color: var(--app-fail); + --bs-btn-active-bg: var(--app-fail); + --bs-btn-active-border-color: var(--app-fail); + --bs-btn-disabled-bg: var(--app-fail); + --bs-btn-disabled-border-color: var(--app-fail); +} + +.btn-success:hover, +.btn-danger:hover { + filter: brightness(1.08); +} + +.btn-outline-success { + --bs-btn-color: var(--app-ok); + --bs-btn-border-color: var(--app-ok); + --bs-btn-hover-bg: var(--app-ok); + --bs-btn-hover-border-color: var(--app-ok); + --bs-btn-hover-color: var(--app-on-status); + --bs-btn-active-bg: var(--app-ok); + --bs-btn-active-border-color: var(--app-ok); + --bs-btn-active-color: var(--app-on-status); + --bs-btn-disabled-color: var(--app-ok); + --bs-btn-disabled-border-color: var(--app-ok); +} + +.btn-outline-danger { + --bs-btn-color: var(--app-fail); + --bs-btn-border-color: var(--app-fail); + --bs-btn-hover-bg: var(--app-fail); + --bs-btn-hover-border-color: var(--app-fail); + --bs-btn-hover-color: var(--app-on-status); + --bs-btn-active-bg: var(--app-fail); + --bs-btn-active-border-color: var(--app-fail); + --bs-btn-active-color: var(--app-on-status); + --bs-btn-disabled-color: var(--app-fail); + --bs-btn-disabled-border-color: var(--app-fail); +} + +.btn-outline-warning { + --bs-btn-color: var(--app-warn); + --bs-btn-border-color: var(--app-warn); + --bs-btn-hover-bg: var(--app-warn); + --bs-btn-hover-border-color: var(--app-warn); + --bs-btn-hover-color: var(--app-on-status); + --bs-btn-active-bg: var(--app-warn); + --bs-btn-active-border-color: var(--app-warn); + --bs-btn-active-color: var(--app-on-status); + --bs-btn-disabled-color: var(--app-warn); + --bs-btn-disabled-border-color: var(--app-warn); +} /* ---------- Dashboard ---------- */ @@ -610,11 +785,6 @@ code { align-items: center; } -/* The tray app's window focuses the first link whenever it reopens. */ -.navbar-brand:focus-visible { - outline: none; -} - .dropdown-item { display: flex; align-items: center; @@ -638,27 +808,11 @@ code { body::before, body::after, .progress-bar, - h4::after { + :is(h4, h1.h4)::after { animation: none !important; } } -/* Light-mode status badges need darker text than the dark-mode tints. */ -[data-bs-theme="light"] .badge.bg-success { color: #0c6e45 !important; } -[data-bs-theme="light"] .badge.bg-danger { color: #b42323 !important; } -[data-bs-theme="light"] .badge.bg-warning { color: #8a6100 !important; } -[data-bs-theme="light"] .badge.bg-info { color: #0a6f86 !important; } - -[data-bs-theme="light"] .btn-outline-warning { - --bs-btn-color: #7a5600; - --bs-btn-border-color: #b88200; - --bs-btn-hover-bg: #ffc107; - --bs-btn-hover-border-color: #b88200; - --bs-btn-hover-color: #1a1400; - --bs-btn-active-bg: #ffc107; - --bs-btn-active-color: #1a1400; -} - /* ---------- Liquid Glass ---------- Soft orbs drift behind translucent panes. Each pane blurs what is behind it, catches a bright edge along the top, and carries a faint diagonal sheen. */ @@ -719,8 +873,6 @@ code { [data-theme="glass"] :is(.form-control, .form-select, .input-group-text) { background-color: var(--app-surface-2); - -webkit-backdrop-filter: blur(8px); - backdrop-filter: blur(8px); box-shadow: inset 0 1px 0 var(--glass-highlight); } @@ -740,8 +892,6 @@ code { } [data-theme="glass"] :is(.btn-outline-secondary, .btn-outline-primary, .btn-outline-danger, .btn-outline-success, .btn-outline-warning) { - -webkit-backdrop-filter: blur(6px); - backdrop-filter: blur(6px); box-shadow: inset 0 1px 0 var(--glass-highlight); } @@ -754,6 +904,21 @@ code { box-shadow: 0 0 14px rgba(var(--app-accent-rgb), 0.6); } +/* Blur is costly and some people turn transparency off: swap in solid surfaces. */ +@media (prefers-reduced-transparency: reduce) { + [data-theme="glass"], + [data-theme="synthwave"] { + --app-surface: var(--app-menu-bg); + --glass-menu-bg: var(--app-menu-bg); + } + + [data-theme="glass"] :is(.card, .navbar, .modal-content, .dropdown-menu), + [data-theme="synthwave"] :is(.card, .navbar, .modal-content, .dropdown-menu) { + -webkit-backdrop-filter: none; + backdrop-filter: none; + } +} + /* ---------- Keycaps ---------- Playful and chunky: every key is its own candy colour, sits on a hard shadow, and presses down when you click it. */ @@ -770,7 +935,7 @@ code { font-weight: 500; } -[data-theme="keycaps"] :is(h4, h5, .stat-value, .navbar-brand) { +[data-theme="keycaps"] :is(h4, h1.h4, h5, h2.h5, h3.h5, .stat-value, .navbar-brand) { font-weight: 800; letter-spacing: -0.02em; } @@ -939,12 +1104,12 @@ code { box-shadow: 0 2px 0 var(--ink); } -[data-theme="keycaps"] .badge.bg-success { background-color: #a8ecc8 !important; color: #1c2b3a !important; } -[data-theme="keycaps"] .badge.bg-danger { background-color: #ffb3b3 !important; color: #1c2b3a !important; } -[data-theme="keycaps"] .badge.bg-primary { background-color: #ffc2dc !important; color: #1c2b3a !important; } -[data-theme="keycaps"] .badge.bg-warning { background-color: #ffe28a !important; color: #1c2b3a !important; } -[data-theme="keycaps"] .badge.bg-info { background-color: #b9dcff !important; color: #1c2b3a !important; } -[data-theme="keycaps"] .badge.bg-secondary { background-color: #e6e1f0 !important; color: #1c2b3a !important; } +[data-theme="keycaps"] .badge:is(.bg-success, .text-bg-success) { background-color: #a8ecc8 !important; color: #1c2b3a !important; } +[data-theme="keycaps"] .badge:is(.bg-danger, .text-bg-danger) { background-color: #ffb3b3 !important; color: #1c2b3a !important; } +[data-theme="keycaps"] .badge:is(.bg-primary, .text-bg-primary) { background-color: #ffc2dc !important; color: #1c2b3a !important; } +[data-theme="keycaps"] .badge:is(.bg-warning, .text-bg-warning) { background-color: #ffe28a !important; color: #1c2b3a !important; } +[data-theme="keycaps"] .badge:is(.bg-info, .text-bg-info) { background-color: #b9dcff !important; color: #1c2b3a !important; } +[data-theme="keycaps"] .badge:is(.bg-secondary, .text-bg-secondary) { background-color: #e6e1f0 !important; color: #1c2b3a !important; } [data-theme="keycaps"] .progress { height: 1.5rem !important; @@ -1041,7 +1206,7 @@ code { display: none; } -[data-theme="terminal"] h4::after { +[data-theme="terminal"] :is(h4, h1.h4)::after { content: "\2588"; margin-left: 0.4rem; color: var(--app-accent); @@ -1123,12 +1288,12 @@ code { letter-spacing: 0.05em; } -[data-theme="terminal"] .badge.bg-success { color: var(--t-ok) !important; } -[data-theme="terminal"] .badge.bg-danger { color: var(--t-bad) !important; } -[data-theme="terminal"] .badge.bg-primary { color: var(--t-prim) !important; } -[data-theme="terminal"] .badge.bg-warning { color: var(--t-warn) !important; } -[data-theme="terminal"] .badge.bg-info { color: var(--t-info) !important; } -[data-theme="terminal"] .badge.bg-secondary { color: var(--t-sec) !important; } +[data-theme="terminal"] .badge:is(.bg-success, .text-bg-success) { color: var(--t-ok) !important; } +[data-theme="terminal"] .badge:is(.bg-danger, .text-bg-danger) { color: var(--t-bad) !important; } +[data-theme="terminal"] .badge:is(.bg-primary, .text-bg-primary) { color: var(--t-prim) !important; } +[data-theme="terminal"] .badge:is(.bg-warning, .text-bg-warning) { color: var(--t-warn) !important; } +[data-theme="terminal"] .badge:is(.bg-info, .text-bg-info) { color: var(--t-info) !important; } +[data-theme="terminal"] .badge:is(.bg-secondary, .text-bg-secondary) { color: var(--t-sec) !important; } [data-theme="terminal"] .table > thead th { color: var(--app-accent); @@ -1234,7 +1399,7 @@ code { box-shadow: 0 6px 26px rgba(255, 46, 151, 0.26); } -[data-theme="synthwave"] :is(h4, .navbar-brand) { +[data-theme="synthwave"] :is(h4, h1.h4, .navbar-brand) { text-transform: uppercase; letter-spacing: 0.08em; text-shadow: var(--neon-glow); @@ -1326,6 +1491,8 @@ code { --bs-body-font-family: "Arial Narrow", "Helvetica Neue", Arial, sans-serif; --bs-link-color: var(--link); --bs-link-hover-color: var(--link); + --bs-link-color-rgb: var(--link-rgb); + --bs-link-hover-color-rgb: var(--link-rgb); --bs-code-color: var(--code-fg); --hard: 5px 5px 0 var(--ink); } @@ -1341,7 +1508,7 @@ code { border-radius: 0; } -[data-theme="brutalist"] :is(h4, h5, .stat-value, .navbar-brand) { +[data-theme="brutalist"] :is(h4, h1.h4, h5, h2.h5, h3.h5, .stat-value, .navbar-brand) { font-weight: 900; text-transform: uppercase; letter-spacing: 0.02em; @@ -1357,6 +1524,7 @@ code { [data-theme="brutalist"] code { background: var(--code-bg); + color: var(--code-fg); padding: 0.05em 0.3em; border: 1px solid var(--ink); } @@ -1403,6 +1571,20 @@ code { border-bottom: 3px solid var(--ink); } +/* Text on the yellow header uses the on-colour, never the muted grey. */ +[data-theme="brutalist"] .card-header { + --bs-secondary-color: color-mix(in srgb, var(--on-color) 80%, transparent); +} + +[data-theme="brutalist"] .card-header :is(a:not(.btn), .text-primary) { + color: var(--on-color); +} + +/* Yellow is a fill, never text: accent-coloured text falls back to the link blue. */ +[data-theme="brutalist"] .text-primary { + color: var(--link) !important; +} + [data-theme="brutalist"] .card-header i, [data-theme="brutalist"] .stat-icon { color: var(--on-color); @@ -1488,12 +1670,12 @@ code { box-shadow: 2px 2px 0 var(--ink); } -[data-theme="brutalist"] .badge.bg-success { background-color: #3ddc84 !important; color: var(--on-color) !important; } -[data-theme="brutalist"] .badge.bg-danger { background-color: #ff4d4d !important; color: var(--on-color) !important; } -[data-theme="brutalist"] .badge.bg-primary { background-color: #7aa7ff !important; color: var(--on-color) !important; } -[data-theme="brutalist"] .badge.bg-warning { background-color: var(--app-accent) !important; color: var(--on-color) !important; } -[data-theme="brutalist"] .badge.bg-info { background-color: #6fe3ff !important; color: var(--on-color) !important; } -[data-theme="brutalist"] .badge.bg-secondary { background-color: #dcdcdc !important; color: var(--on-color) !important; } +[data-theme="brutalist"] .badge:is(.bg-success, .text-bg-success) { background-color: #3ddc84 !important; color: var(--on-color) !important; } +[data-theme="brutalist"] .badge:is(.bg-danger, .text-bg-danger) { background-color: #ff4d4d !important; color: var(--on-color) !important; } +[data-theme="brutalist"] .badge:is(.bg-primary, .text-bg-primary) { background-color: #7aa7ff !important; color: var(--on-color) !important; } +[data-theme="brutalist"] .badge:is(.bg-warning, .text-bg-warning) { background-color: var(--app-accent) !important; color: var(--on-color) !important; } +[data-theme="brutalist"] .badge:is(.bg-info, .text-bg-info) { background-color: #6fe3ff !important; color: var(--on-color) !important; } +[data-theme="brutalist"] .badge:is(.bg-secondary, .text-bg-secondary) { background-color: #dcdcdc !important; color: var(--on-color) !important; } [data-theme="brutalist"] .progress { height: 1.6rem !important; @@ -1689,3 +1871,238 @@ code { text-overflow: ellipsis; box-shadow: inset -1px 0 0 var(--app-bg); } + +/* ---------- History / failure rows ---------- */ +.run-errors .re-toggle-cell { + width: 1.75rem; +} + +.run-errors .re-toggle { + color: var(--app-muted); + line-height: 1; +} + +/* ---------- Dashboard (verdict-first) ---------- */ +.verdict-title { + font-size: 1.65rem; + line-height: 1.25; + text-wrap: balance; +} +.verdict-detail { + color: var(--app-muted); + max-width: 90ch; + text-wrap: pretty; +} +.section-label { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--app-muted); +} + +.job-rows { + background: var(--app-surface); + border: 1px solid var(--app-border); + border-radius: 0.75rem; + overflow: hidden; +} +.job-row { + display: grid; + grid-template-columns: 9.5rem minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.5rem 1rem; + padding: 0.8rem 1rem; +} +.job-row + .job-row { + border-top: 1px solid var(--app-border); +} +.job-row-main { + min-width: 0; +} +.job-row-reason { + font-size: 0.9rem; +} +.job-row-path { + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.job-row-times { + display: flex; + gap: 1.5rem; +} +.job-row-times dt { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--app-muted); +} +.job-row-times dd { + margin: 0; + font-size: 0.9rem; + white-space: nowrap; +} + +@media (max-width: 991.98px) { + .job-row { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "status run" + "main main" + "times times"; + } + .job-row-status { grid-area: status; } + .job-row-run { grid-area: run; } + .job-row-main { grid-area: main; } + .job-row-times { grid-area: times; } +} + +/* Running page: group the eight figures into Data and Files */ +.run-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 0.75rem 2rem; +} +.run-stats-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr)); + gap: 0.5rem 1rem; + margin: 0; +} +.run-stats-group dt { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--app-muted); +} +.run-stats-group dd { + margin: 0; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* ---------- Job form ---------- */ +.jf-types legend { + float: none; + width: auto; + margin-bottom: 0.5rem; +} + +.jf-type-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +@media (max-width: 767.98px) { + .jf-type-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.jf-type { + display: flex; + gap: 0.6rem; + align-items: flex-start; + padding: 0.75rem; + border: 1px solid var(--app-border); + border-radius: 0.5rem; + background: var(--app-surface-2); + cursor: pointer; +} + +.jf-type:hover { + border-color: var(--app-muted); +} + +.jf-type:has(input:checked) { + border-color: var(--app-accent); + background: rgba(var(--app-accent-rgb), 0.12); + box-shadow: inset 0 0 0 1px var(--app-accent); +} + +.jf-type .form-check-input { + flex: none; + margin-top: 0.2rem; +} + +.jf-type-body { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.jf-type-name { + font-weight: 600; +} + +.jf-type-note { + color: var(--app-muted); + font-size: 0.85rem; + overflow-wrap: anywhere; +} + +.jf-advanced > summary { + cursor: pointer; + list-style: none; +} + +.jf-advanced > summary::-webkit-details-marker { + display: none; +} + +.jf-advanced > summary:focus-visible { + outline: 2px solid var(--app-accent); + outline-offset: -2px; +} + +.jf-advanced .jf-chevron { + transition: transform 0.15s ease-out; +} + +.jf-advanced[open] .jf-chevron { + transform: rotate(90deg); +} + +.jf-advanced:not([open]) > summary { + border-bottom: 0; +} + +@media (prefers-reduced-motion: reduce) { + .jf-advanced .jf-chevron { + transition: none; + } +} + +/* Before a destination is typed the consequence copy says "the destination" in plain text. */ +code.jf-dest-placeholder { + font-family: inherit; + font-size: inherit; + color: inherit; +} + +/* Narrow screens: a failure row becomes a small stack (toggle and time, then the + full path, then the message) so the path isn't squeezed to one letter per line. */ +@media (max-width: 575.98px) { + .run-errors .re-row { + display: grid; + grid-template-columns: auto 1fr; + border-bottom: 1px solid var(--app-border); + } + .run-errors .re-row > td { + display: block; + border: 0; + padding: 0.3rem 0.5rem; + } + .run-errors .re-row > .re-path, + .run-errors .re-row > .re-msg { + grid-column: 1 / -1; + width: auto; + max-width: none; + } +} diff --git a/app/static/js/app.js b/app/static/js/app.js index 438caf0..79dfcb3 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -20,6 +20,7 @@ document.addEventListener('submit', event => { event.preventDefault(); const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('confirmModal')); + document.getElementById('confirm-title').textContent = form.dataset.confirmTitle || 'Are you sure?'; document.getElementById('confirm-message').textContent = message; document.getElementById('confirm-ok').textContent = form.dataset.confirmLabel || 'Delete'; document.getElementById('confirm-ok').onclick = () => { @@ -52,21 +53,21 @@ function formatETA(seconds) { return s + 's'; } +// Says "Live", not "Connected": Settings has its own rclone connection status. function updateConnectionStatus(connected) { const el = document.getElementById('connection-status'); if (!el) return; - if (connected) { - el.innerHTML = ' Connected'; - } else { - el.innerHTML = ' Disconnected'; - } + const dot = document.createElement('i'); + dot.className = 'bi bi-circle-fill ' + (connected ? 'text-success' : 'text-danger'); + dot.setAttribute('aria-hidden', 'true'); + el.replaceChildren(dot, ' ', connected ? 'Live' : 'Reconnecting...'); } function updateRunningBadge(count) { const badge = document.getElementById('running-badge'); if (!badge) return; if (count > 0) { - badge.textContent = count; + document.getElementById('running-count').textContent = count; badge.classList.remove('d-none'); } else { badge.classList.add('d-none'); diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 6e2eaca..b0ba66f 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -3,6 +3,75 @@ // Track last known stats per run to avoid flickering back to zero const lastKnownStats = {}; +// Run ids from the previous SSE message; null until the first one arrives. +let knownRunIds = null; +let refreshTimer = null; + +function announce(message) { + let region = document.getElementById('live-status'); + if (!region) { + region = document.createElement('div'); + region.id = 'live-status'; + region.className = 'visually-hidden'; + region.setAttribute('role', 'status'); + region.setAttribute('aria-live', 'polite'); + document.body.appendChild(region); + } + region.textContent = message; +} + +function setProgress(bar, pct) { + bar.style.width = pct + '%'; + bar.textContent = pct + '%'; + bar.setAttribute('aria-valuenow', pct); +} + +// Re-fetch this page and swap in the server-rendered sections, so a run that +// starts or finishes while the page is open shows up without a manual reload. +async function refreshSections() { + try { + const response = await fetch(location.href, { headers: { Accept: 'text/html' } }); + if (!response.ok) return null; + const doc = new DOMParser().parseFromString(await response.text(), 'text/html'); + document.querySelectorAll('[data-refresh]').forEach(el => { + const fresh = doc.querySelector('[data-refresh="' + el.dataset.refresh + '"]'); + if (fresh) el.replaceWith(document.importNode(fresh, true)); + }); + return doc; + } catch (e) { + return null; + } +} + +function runNameFor(runId) { + const item = document.querySelector('.transfer-item[data-run-id="' + runId + '"]'); + return item && item.dataset.jobName ? item.dataset.jobName : 'A backup'; +} + +// Compare the runs rclone reports with the previous message and react to changes. +function handleRunSetChange(currentIds) { + if (knownRunIds === null) { + knownRunIds = currentIds; + return; + } + const started = [...currentIds].filter(id => !knownRunIds.has(id)); + const finished = [...knownRunIds].filter(id => !currentIds.has(id)); + knownRunIds = currentIds; + if (!started.length && !finished.length) return; + + finished.forEach(id => announce(runNameFor(id) + ' finished.')); + const onDashboard = !!document.querySelector('[data-refresh]'); + clearTimeout(refreshTimer); + refreshTimer = setTimeout(async () => { + if (onDashboard) { + await refreshSections(); + started.forEach(id => announce(runNameFor(id) + ' started.')); + } else { + location.reload(); // the running page has per-run scripts bound to its cards + } + }, finished.length ? 1500 : 500); // let the run record settle before re-reading it +} + window.addEventListener('rclone-stats', function(e) { const data = e.detail; const runs = data.runs || {}; @@ -19,6 +88,11 @@ window.addEventListener('rclone-stats', function(e) { // Track which run IDs are still active so we can clean up lastKnownStats const activeRunIds = new Set(Object.keys(runs)); + handleRunSetChange(activeRunIds); + + // The Active transfers section exists only while something runs + const activeSection = document.getElementById('active-section'); + if (activeSection) activeSection.classList.toggle('d-none', !(data.active_count > 0)); // Update each transfer item with its own per-run stats const transferItems = document.querySelectorAll('.transfer-item'); @@ -109,10 +183,7 @@ window.addEventListener('rclone-stats', function(e) { const displayPct = Math.max(overallPct, prev.pct || 0); const progressBar = item.querySelector('.progress-bar'); - if (progressBar) { - progressBar.style.width = displayPct + '%'; - progressBar.textContent = displayPct + '%'; - } + if (progressBar) setProgress(progressBar, displayPct); } else if (stats.transferring && stats.transferring.length > 0) { // Fallback: if totalBytes not available, use average of in-flight files let avgPct = 0; @@ -120,10 +191,7 @@ window.addEventListener('rclone-stats', function(e) { avgPct = Math.round(avgPct / stats.transferring.length); const progressBar = item.querySelector('.progress-bar'); - if (progressBar && avgPct > 0) { - progressBar.style.width = avgPct + '%'; - progressBar.textContent = avgPct + '%'; - } + if (progressBar && avgPct > 0) setProgress(progressBar, avgPct); // Don't update progress bar when avgPct is 0 — keep last shown value } @@ -133,23 +201,34 @@ window.addEventListener('rclone-stats', function(e) { const fileList = item.querySelector('.file-list'); if (currentFiles && fileList) { currentFiles.style.display = 'block'; - fileList.innerHTML = ''; + // Reconcile rows by file name so the list doesn't flash on every tick. + const rows = new Map(); + fileList.querySelectorAll('.file-item').forEach(row => rows.set(row.dataset.file, row)); + const seen = new Set(); stats.transferring.forEach(t => { - const div = document.createElement('div'); - div.className = 'file-item d-flex justify-content-between'; - const pct = t.percentage || 0; - // File names come from the filesystem and are untrusted: - // always use textContent, never innerHTML. - const nameSpan = document.createElement('span'); - nameSpan.className = 'text-truncate me-2'; - nameSpan.style.maxWidth = '70%'; - nameSpan.textContent = t.name; - const statSpan = document.createElement('span'); - statSpan.className = 'text-nowrap'; - statSpan.textContent = pct + '% ' + formatSpeed(t.speed); - div.append(nameSpan, statSpan); - fileList.appendChild(div); + seen.add(t.name); + let div = rows.get(t.name); + let statSpan; + if (!div) { + div = document.createElement('div'); + div.className = 'file-item d-flex justify-content-between'; + div.dataset.file = t.name; + // File names come from the filesystem and are untrusted: + // always use textContent, never innerHTML. + const nameSpan = document.createElement('span'); + nameSpan.className = 'text-truncate me-2'; + nameSpan.style.maxWidth = '70%'; + nameSpan.textContent = t.name; + statSpan = document.createElement('span'); + statSpan.className = 'text-nowrap file-stat'; + div.append(nameSpan, statSpan); + fileList.appendChild(div); + } else { + statSpan = div.querySelector('.file-stat'); + } + statSpan.textContent = (t.percentage || 0) + '% ' + formatSpeed(t.speed); }); + rows.forEach((row, name) => { if (!seen.has(name)) row.remove(); }); } } // Don't hide current-files when transferring is empty — keep showing last state diff --git a/app/static/js/errors.js b/app/static/js/errors.js index 4bfdbaa..a4643b0 100644 --- a/app/static/js/errors.js +++ b/app/static/js/errors.js @@ -13,6 +13,17 @@ function makeEl(tag, className, text) { return node; } +// Failure times use the configured TIMEZONE in 24h, matching the server's +// local_time filter, rather than whatever locale the browser happens to use. +function makeTimeFormatter(timeZone) { + const options = { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }; + try { + return new Intl.DateTimeFormat('en-GB', { ...options, timeZone }); + } catch (e) { + return new Intl.DateTimeFormat('en-GB', options); + } +} + class RunErrors { constructor(root) { this.root = root; @@ -20,6 +31,7 @@ class RunErrors { this.live = root.dataset.live === '1'; this.maxRows = parseInt(root.dataset.maxRows, 10) || 0; // 0 = no limit this.pageSize = parseInt(root.dataset.pageSize, 10) || 100; + this.timeFormat = makeTimeFormatter(root.dataset.tz || undefined); this.category = ''; this.query = ''; this.lastId = 0; // newest failure shown, for the live feed @@ -203,14 +215,36 @@ class RunErrors { tr.dataset.id = error.id; const time = error.occurred_at ? new Date(error.occurred_at) : null; const when = makeEl('td', 'text-muted text-nowrap small', - time ? time.toLocaleTimeString() : ''); + time ? this.timeFormat.format(time) : ''); const path = makeEl('td', 'font-monospace small re-path text-break', error.path || '(no file)'); path.title = error.path || ''; const message = makeEl('td', 'small re-msg', error.message); - tr.append(when, path, message); - // Messages can be long; click a row to read all of it. - tr.addEventListener('click', () => tr.classList.toggle('expanded')); + message.id = `re-msg-${this.runId}-${error.id}`; + + // Messages can be long; the button is the keyboard route to read all of + // it, and clicking anywhere on the row does the same for the mouse. + const toggleCell = makeEl('td', 're-toggle-cell'); + const toggle = makeEl('button', 'btn btn-sm btn-link re-toggle p-0'); + toggle.type = 'button'; + toggle.setAttribute('aria-expanded', 'false'); + toggle.setAttribute('aria-controls', message.id); + toggle.setAttribute('aria-label', 'Show full message for ' + (error.path || 'failure')); + const icon = makeEl('i', 'bi bi-chevron-right'); + icon.setAttribute('aria-hidden', 'true'); + toggle.append(icon); + toggleCell.append(toggle); + + const flip = () => { + const open = tr.classList.toggle('expanded'); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + toggle.setAttribute('aria-label', + (open ? 'Hide full message for ' : 'Show full message for ') + (error.path || 'failure')); + icon.className = open ? 'bi bi-chevron-down' : 'bi bi-chevron-right'; + }; + toggle.addEventListener('click', event => { event.stopPropagation(); flip(); }); + tr.addEventListener('click', flip); + tr.append(toggleCell, when, path, message); return tr; } } diff --git a/app/static/js/jobform.js b/app/static/js/jobform.js new file mode 100644 index 0000000..eeacf18 --- /dev/null +++ b/app/static/js/jobform.js @@ -0,0 +1,121 @@ +// Behaviour of the job form: what each type does to the typed destination, +// the safety and warning panels, the schedule preview and the Advanced summary. +(function () { + const form = document.getElementById('job-form'); + if (!form) return; + const $ = id => document.getElementById(id); + + // --- Type cards: name the real destination, show what applies --- + + const destination = $('destination'); + const typeInputs = [...form.querySelectorAll('input[name="sync_type"]')]; + const autoResume = $('auto_resume'); + + const selectedType = () => (typeInputs.find(i => i.checked) || {}).value; + + function refreshType() { + const typed = destination.value.trim(); + form.querySelectorAll('[data-dest-name]').forEach(el => { + el.textContent = typed || 'the destination'; + // Only a real path is set as code. + el.classList.toggle('jf-dest-placeholder', !typed); + }); + // The delete limit is only applied to syncs. + $('safety-group').hidden = selectedType() !== 'sync'; + const risky = selectedType() === 'move' && autoResume.checked; + form.querySelectorAll('[data-move-resume-warning]').forEach(el => { el.hidden = !risky; }); + } + + destination.addEventListener('input', refreshType); + typeInputs.forEach(i => i.addEventListener('change', refreshType)); + + // --- Auto-resume options --- + + autoResume.addEventListener('change', () => { + $('resume-options').hidden = !autoResume.checked; + refreshType(); + }); + + // --- Order by: the window only matters for "mixed" --- + + const direction = $('order_by_direction'); + function refreshOrder() { + $('order-by-window-row').hidden = direction.value !== 'mixed'; + refreshAdvanced(); + } + direction.addEventListener('change', refreshOrder); + + // --- Quick-select remote links --- + + form.querySelectorAll('a[data-remote]').forEach(link => { + link.addEventListener('click', event => { + event.preventDefault(); + browseRemote('destination', link.dataset.remote); + }); + }); + + // --- Schedule: presets and a plain-words reading of the cron text --- + + const cron = $('schedule_cron'); + const preview = $('cron-preview'); + let previewTimer = null; + let previewToken = 0; + + function refreshPreview() { + const expr = cron.value.trim().replace(/\s+/g, ' '); + if (!expr) { preview.textContent = ''; return; } + const token = ++previewToken; + fetch('/schedules/describe?expr=' + encodeURIComponent(expr)) + .then(r => r.json()) + .then(data => { + if (token !== previewToken) return; // a newer keystroke won + preview.textContent = data.described ? data.text : ''; + }) + .catch(() => { if (token === previewToken) preview.textContent = ''; }); + } + + cron.addEventListener('input', () => { + clearTimeout(previewTimer); + previewTimer = setTimeout(refreshPreview, 250); + }); + form.querySelectorAll('.cron-preset').forEach(button => { + button.addEventListener('click', () => { + cron.value = button.dataset.cron; + refreshPreview(); + }); + }); + + // --- Advanced: how many things differ from the defaults --- + + const advanced = $('advanced'); + const count = $('advanced-count'); + const bwRules = advanced.querySelector('.bw-rules'); + + function refreshAdvanced() { + let changed = 0; + advanced.querySelectorAll('[data-default]').forEach(el => { + if (el.closest('[hidden]')) return; + if (el.value.trim() !== el.dataset.default) changed += 1; + }); + advanced.querySelectorAll('[data-default-checked]').forEach(el => { + if (el.checked !== (el.dataset.defaultChecked === '1')) changed += 1; + }); + if (bwRules && bwRules.children.length) changed += 1; + count.hidden = changed === 0; + count.textContent = changed + ' changed from defaults'; + } + + advanced.addEventListener('input', refreshAdvanced); + advanced.addEventListener('change', refreshAdvanced); + if (bwRules) new MutationObserver(refreshAdvanced).observe(bwRules, { childList: true }); + + // A browser can't focus an invalid field inside a closed
, and + // would silently refuse to submit. Open it so the message is visible. + form.addEventListener('invalid', event => { + const section = event.target.closest('details'); + if (section && !section.open) section.open = true; + }, true); + + refreshType(); + refreshOrder(); +})(); diff --git a/app/static/js/suspects.js b/app/static/js/suspects.js index 84156d6..cbf0bc8 100644 --- a/app/static/js/suspects.js +++ b/app/static/js/suspects.js @@ -25,12 +25,18 @@ function suspectRow(runId, excluded, suspect) { const button = document.createElement('button'); button.type = 'button'; - button.className = 'btn btn-sm btn-outline-warning text-nowrap'; - button.innerHTML = ' Exclude'; + button.className = 'btn btn-sm btn-outline-secondary text-nowrap'; + const icon = document.createElement('i'); + icon.className = 'bi bi-slash-circle'; + icon.setAttribute('aria-hidden', 'true'); + button.append(icon, ' Exclude'); + // Several rows say just "Exclude"; name the folder for screen readers. + button.setAttribute('aria-label', 'Exclude ' + suspect.path + ' from the next run'); const markExcluded = () => { button.disabled = true; button.className = 'btn btn-sm btn-outline-secondary text-nowrap'; button.textContent = 'Excluded next run'; + button.setAttribute('aria-label', suspect.path + ' will be excluded from the next run'); }; if (excluded.has(suspect.pattern)) markExcluded(); button.addEventListener('click', () => { @@ -48,6 +54,7 @@ function suspectRow(runId, excluded, suspect) { .catch(() => { button.disabled = false; button.textContent = 'Failed, retry'; + button.setAttribute('aria-label', 'Exclude failed for ' + suspect.path + ', retry'); }); }); diff --git a/app/static/js/theme.js b/app/static/js/theme.js index 6992ab5..4121404 100644 --- a/app/static/js/theme.js +++ b/app/static/js/theme.js @@ -4,7 +4,7 @@ const THEMES = { graphite: { label: 'Graphite', dark: ['#121316', '#7b97ff'], light: ['#f3f4f7', '#3f55d9'] }, glass: { label: 'Liquid Glass', dark: ['#4f46e5', '#8be0ff'], light: ['#bfdbfe', '#1f5fe0'] }, - keycaps: { label: 'Keycaps', dark: ['#1c1d3d', '#ff7ab8'], light: ['#fdf0d5', '#d81b6a'] }, + keycaps: { label: 'Keycaps', dark: ['#1c1d3d', '#ff7ab8'], light: ['#fdf0d5', '#c9145f'] }, terminal: { label: 'Terminal', dark: ['#030a05', '#39ff7a'], light: ['#eef2e6', '#0b7a32'] }, synthwave: { label: 'Synthwave', dark: ['#2a0a4a', '#ff2e97'], light: ['#ffd1f0', '#c2006b'] }, brutalist: { label: 'Brutalist', dark: ['#151515', '#ffd600'], light: ['#f3efe4', '#ffd600'] }, diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index dd18dea..0bd0762 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -5,7 +5,7 @@
-
Log in
+

Log in

{% if error %}
{{ error }}
{% endif %}
diff --git a/app/templates/auth/setup.html b/app/templates/auth/setup.html index 354a994..213eeea 100644 --- a/app/templates/auth/setup.html +++ b/app/templates/auth/setup.html @@ -5,7 +5,7 @@
-
Welcome - set a password
+

Welcome - set a password

Choose the password that protects this app. You will need it to log in from now on. diff --git a/app/templates/base.html b/app/templates/base.html index f0c3f52..f660d16 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,5 @@ - + @@ -25,44 +25,52 @@ rClone GUI -

- - Connecting... + {# "Live" means this browser's updates from this app, not the rclone connection on Settings. #} + + Connecting... {% if show_logout %} @@ -114,7 +124,7 @@ {% for category, message in messages %} {% endfor %} {% endif %} @@ -123,12 +133,17 @@ {% block content %}{% endblock %}
-
{% endif %} {% if run.job %} -
-
Job Configuration
+
+ Current job settings (may differ from this run)
- - - - - - - - -
Source{{ run.job.source }}
Destination{{ run.job.destination }}
Sync Type{{ run.job.sync_type }}
Transfers{{ run.job.transfers }}
Checkers{{ run.job.checkers }}
Fast List{{ 'Yes' if run.job.fast_list else 'No' }}
BW Limits{{ run.job.bandwidth_rules | length or 'None' }}{{ ' rule(s)' if run.job.bandwidth_rules }}
+
+
Source
+
{{ run.job.source }}
+
Destination
+
{{ run.job.destination }}
+
Sync type
+
{{ run.job.sync_type }}
+
Transfers
+
{{ run.job.transfers }}
+
Checkers
+
{{ run.job.checkers }}
+
Fast list
+
{{ 'Yes' if run.job.fast_list else 'No' }}
+
Bandwidth limits
+
{% if run.job.bandwidth_rules %}{{ run.job.bandwidth_rules | length }} rule(s){% else %}None{% endif %}
+
-
+ {% endif %} diff --git a/app/templates/history/list.html b/app/templates/history/list.html index d8ccc03..a719cfb 100644 --- a/app/templates/history/list.html +++ b/app/templates/history/list.html @@ -2,7 +2,42 @@ {% block title %}History - rClone GUI{% endblock %} {% block content %} -

Run History

+

Run History

+ +
+
+ + +
+
+ + +
+
+ + {% if job_id or status %} + Reset filters + {% endif %} +
+
+ +{% if job_id or status %} +

+ {{ total }} run{{ '' if total == 1 else 's' }} + {%- if job_id %} for {% for j in jobs if j.id == job_id %}{{ j.name }}{% endfor %}{% endif %} + {%- if status %} with status {{ status }}{% endif %}. +

+{% endif %} {% if runs %}
@@ -18,13 +53,16 @@

Run History

Files Errors Trigger - + Actions {% for run in runs %} - {{ run.job.name if run.job else 'Deleted Job' }} + + {{ run.job.name if run.job else 'Deleted Job' }} +
Run #{{ run.id }}
+ {% if run.status == 'running' %} Running @@ -66,19 +104,16 @@

Run History

{{ run.files_transferred or '--' }} {% if run.errors %} - {{ run.errors }} + {{ run.errors }} {% else %} 0 {% endif %} {{ run.triggered_by }} - - - - +
{% if run.status in ['failed', 'interrupted'] and run.job and not run.guard %}
-
@@ -87,21 +122,44 @@

Run History

-
{% endif %} - +
{% endfor %}
+ + {% else %}
- + + {% if job_id or status %} +

No runs match these filters.

+ Reset filters + {% else %}

No run history yet.

+ {% endif %}
{% endif %} {% endblock %} diff --git a/app/templates/jobs/form.html b/app/templates/jobs/form.html index c5853fa..76987b3 100644 --- a/app/templates/jobs/form.html +++ b/app/templates/jobs/form.html @@ -1,22 +1,34 @@ {% extends "base.html" %} {% block title %}{{ 'Edit' if job else 'Create' }} Job - rClone GUI{% endblock %} +{% macro field_errors(key) %} + {% if errors.get(key) %} + {% set msgs = errors[key] if errors[key] is not string else [errors[key]] %} + + {% endif %} +{% endmacro %} + {% block content %} +{% set dest_label = f.destination or 'the destination' %}
-

+

{{ 'Edit' if job else 'Create' }} Job -

+ -
+
-
Basic Settings
+

Basics

@@ -24,27 +36,29 @@

-
Local path or remote:path. You can also type a new path.
- +
-
@@ -52,58 +66,171 @@

Quick-select remote: {% for r in remotes %} - {{ r }}{{ ', ' if not loop.last }} + {{ r }}{{ ', ' if not loop.last }} {% endfor %}
{% endif %}

+
+ What should this job do? +
+ + + +
+
+ +
+ + Move with auto-resume: a move that was interrupted partway may not resume correctly. + Files already moved are gone from the source. +
+ +
+ Safety +
+
+ + +
Stops the sync once it has deleted this many files from {{ dest_label }}. Guards against a missing or empty source. -1 means no limit.
+
+
+
+
+
+ +
+

Schedule

+
- - + + + {{ field_errors('schedule_cron') }} +
+ {% if f.schedule_cron and not errors.get('schedule_cron') %}{{ f.schedule_cron | cron_text }}{% endif %} +
+
+ Leave empty to run this job only when you start it. Times are in {{ schedule_tz }}. +
+
+
+ + + + +
+
+ +
-
Performance
+

When to worry

+
+ + +
Leave blank to expect a successful run after every scheduled time. Set it for jobs without a schedule, or to allow more slack.
+
+ +
+
+ + +
+
Also picks up jobs that were interrupted by an app restart.
+
+
+
+ + +
How many times to start the job again after a failed or interrupted run.
+
+
+ + +
0 starts the next attempt straight away.
+
+
+
+ + Move operations may not resume correctly if files were partially moved. +
+
+
+ +
+ + +

Advanced rclone options

+ +
+
+

Performance

-
Parallel file transfers
+ value="{{ f.transfers }}" min="1" max="64" data-default="4"> +
Files copied in parallel
-
Parallel file checkers
+ value="{{ f.checkers }}" min="1" max="64" data-default="8"> +
Files compared in parallel
- + -
Retries on error
+ value="{{ f.retries }}" min="0" max="99" data-default="3" aria-describedby="retries_help"> +
--retries: repeat a pass that had errors before the run counts as failed.
+ data-default-checked="1" {{ 'checked' if f.fast_list }}>
+

Order and comparison

- + + +
First value in --order-by.
- + + +
Second value in --order-by.
- @@ -142,33 +269,17 @@

- + + + +
How rclone decides whether a file needs to be transferred.
-
- - -
Stop a sync after deleting this many files at the destination. Protects against a missing or empty source. -1 = no limit.
-
-
- - -
Leave blank to expect a successful run after every scheduled time. Set it for jobs without a schedule, or to allow more slack.
-
-

-
-
-
Bandwidth Limits
-
+

Bandwidth limits

Cap this job's speed on chosen days and times. Leave empty for no job-specific limit. Limits from Settings also apply, and the lowest one wins. @@ -176,60 +287,37 @@

{% with bw_rules=bandwidth_rules, bw_scoped=false, bw_remotes=[] %} {% include "partials/bandwidth_editor.html" %} {% endwith %} -

-
-
-
Filters
-
+

Filters

- +
One pattern per line. Uses rclone's filter syntax.
-
-
-
-
Auto-Resume
-
-
-
- - -
-
When enabled, failed runs will be retried automatically. Also resumes jobs interrupted by an app restart.
-
- +
Cancel -
@@ -238,14 +326,14 @@

-