# Changelog All notable changes to the Vault plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project uses date-based versioning (`YYYY.MM.PATCH`). ## [Unreleased] ## [v2026.10.02] - 2026-10-17 ### Fixed - Leaving the Logs page freezes the UI (console renders every log line) (#507) ## [v2026.10.01] - 2026-10-16 ### Added - **Memory statistics and a memory limit (#500):** Settings → General has a new **Memory** card. It shows the daemon's heap in use, total Go memory, resident and peak RSS, the share of CPU spent in garbage collection, GC cycles, goroutines, and the effective memory limit. The same figures are in `GET /api/v1/settings/runtime` and the diagnostics bundle. The new **Memory limit (MiB)** setting (`memory_limit_mb`, default 0 = off) applies a soft limit immediately, with no restart. A `GOMEMLIMIT` environment variable still takes precedence. The card lists S3 jobs whose upload buffers (part size × 5 × parallel uploads) could exceed the limit, the run log warns when such an upload starts, and the S3 form shows the per-stream buffer size under **Part size**. - **Real-time progress (#456):** the dashboard and restore wizard show the current item's phase and the work actually done, such as "Uploading 84.2 GB / 225 GB — 37%", with the transfer rate, an estimated time left, and a "No progress for Xm" hint when an operation goes quiet. Remote uploads, verification read-back, and restore downloads report real byte counts across all of an item's files, instead of 0% for hours followed by a jump to 99%. Folder backups first scan the source ("Scanning… 12,340 files") and then report archiving and chunking against its size; incremental runs count unchanged files as done. Deduplicated restores report against the files being restored, VM backups report libvirt's byte counters, VM restores report disk copies, ZFS reports send and receive bytes, and container volumes show live bytes and files. A phase whose total is unknown shows its counters without a percentage. `GET /runner/status`, the `backup_progress` / `restore_progress` WebSocket events, and the MCP `get_runner_status` tool carry the new fields. - **Troubleshooting detail in run logs (#455):** backup and restore run logs now start with a "Run details" line (job, run, destination and storage type, restore point), time each stage of each item ("Phase upload completed for appdata in 4m12s"), and name the stage that failed. Retried storage requests, resumed streams, and restarted download parts each get a redacted warning line, capped at 20 per run. Item and summary lines report bytes stored, uploaded (including retries), read back, and restored, plus retry counts. Verification lines include bytes read and the time taken. ### Changed - **Lower memory churn on the data plane (#500):** deduplicated backups no longer allocate an 8 MiB chunking buffer for every file. A backup of 40,000 small files on Unraid went from 4,877 garbage collections (about half of its CPU) to 16, and ran about 3× faster. Folder and container archives reuse pooled copy buffers instead of allocating 32 KiB per file. Deduplicated backups seal chunks straight into the pack buffer and no longer copy each pack (up to 24 MiB, more for large chunks) before uploading it. Chunk reads reuse a pooled buffer. VM disk, NVRAM and database-dump copies to local paths use the kernel's `copy_file_range` again, which allows block cloning on ZFS 2.2 and later. Progress reporting, cancellation and stall detection are unchanged. - **Test coverage (#458):** backend statement coverage rises from about 79% to about 93%, and web library line coverage from 65% to 98%. The new tests cover the libvirt VM, ZFS, FUSE mount, container, runner, storage (SFTP, SMB, WebDAV), API, CLI and database paths. Narrow, unexported test seams were added where code talked to libvirt, ZFS, FUSE, SMB or the kernel directly; production behaviour is unchanged. - **Selective restore size (#455):** restoring a few files from a backup item reports the bytes actually restored instead of the whole item's recorded size, in History, the run log, and the restore summary. Restore summaries also name the job and destination. - **mDNS advertisement control (#468):** new **Network Discovery (mDNS)** setting (`MDNS_ADVERTISE` in `vault.cfg`) and `--mdns` flag on `vault daemon` and `vault replica` turn LAN discovery off without changing the bind address. Both default to on. - Ansible deploys install the real `plugin/rc.vault` instead of an outdated inline copy, so live verification runs the same service script the plugin package ships (#468). - **Verification cost warning (#457):** the job form now warns when **Verify backups after writing** is on and the destination is remote: under the toggle, beside the destination in the express form, and on the review step. After every backup, Vault reads all the data that backup references back from that destination, including chunks reused from earlier backups, so a large backup can mean significant bandwidth and a long wait near 100%. S3 and S3-compatible destinations also note possible request, retrieval, or egress charges. The warning suggests scheduled verification for large backups. - Formatting hooks no longer fail on a clean checkout: files merged without pre-commit were reformatted (no content changes), workflow YAML keeps the organization's single-quote style through `.prettierrc.json`, and a new CI **Format** job runs the same pinned formatting hooks on every pull request. - Pull requests get path labels and at most one type label, `bug` or `enhancement`, from the shared tinkerologie PR labeler. Dependabot pull requests whose largest update is a patch or minor version merge automatically once the checks and commit statuses on their head commit have finished without a failure (a Codecov check that is still pending is not waited for; a Codecov failure blocks); majors, and pull requests with open review threads, stay for review. - Dependabot also updates the Playwright end-to-end test dependencies (root `package.json`) and the Docker base images in the root `Dockerfile`. - Release workflows hardened (least-privilege job permissions, no expressions in shell scripts, no persisted checkout credentials, no dependency caches); scheduled workflows run on Australia/Brisbane time. - Workflow layout tidied after the release hardening (no behaviour change): `permissions:` blocks moved back to the top of their workflow or job in `docs.yml`; blank-line layout restored in `ci.yml`, `codeql.yml`, `docker.yml`, `release.yml`; the `id-token: write` comment in `ci.yml` names the Codecov OIDC upload. - Releases push the updated PLG hash to `main` with a token from the Tinkerologie-Release GitHub App, the only actor allowed to bypass the pull-request rule on `main`, so the Unraid plugin manager and Community Applications see each new release. - Tinkerologie-Bot governance report updated to v1.4.3: an older run can no longer post a stale report after a newer run passed. Workflow formatting tidied. - Repository standardised to the tinkerologie layout (STANDARDS.md in tinkerologie/.github): workflows renamed to the standard set (CI, Validate, Release, PR Governance, ...), every action pinned to a commit SHA, shared Dependabot, `.editorconfig`, `.gitattributes` and `CODEOWNERS` files, and organization-wide labels and community files. - Tinkerologie-Bot's comment is now a readable "Pull request checklist": each problem is a numbered heading with plain-English guidance and its items listed underneath, and report updates for a pull request are serialised so an older run can never overwrite a newer one. - Contribution governance: issues without a `bug` or `enhancement` label are labeled automatically when their type can be determined (issues labeled `question`, `duplicate`, `invalid`, `wontfix`, or `no-type` are skipped), so CodeRabbit can plan them. Pull requests that conflict with the default branch are labeled, and their authors are notified in a comment. Stale issues and pull requests are labeled after 14 days without activity and closed 30 days later. PR governance now re-validates a human-authored, non-draft PR when a bot such as CodeRabbit edits its description, instead of skipping the check and reporting a pass, only maintainers with write access may use `None` instead of linking an issue, and a link to an issue that does not exist now fails the check. Codecov patch coverage must now reach 90%. - Tinkerologie-Bot now posts one comment on pull requests that fail PR governance, listing every problem to fix, and updates it once they are fixed. PR governance runs from the default branch's workflow (`pull_request_target`), so a pull request cannot change its own check. - Tinkerologie-Bot governance report updated to v1.3.3: the report is capped below GitHub's comment size limit, and an older run that finishes late can no longer overwrite a newer report. - Updated repository, plugin template, documentation and docs-site URLs to `tinkerologie` organization (Go module path unchanged; GitHub redirects keep it working) - Configured Codecov GitHub Actions workflow for tokenless coverage uploads ### Fixed - **UI froze when leaving the Logs page (#507):** the log console put every loaded line on the page, about 8,600 rows, or 78,000 DOM elements, on a server with a long history. Moving from Logs to any other section stalled for up to several seconds, especially in Chrome with an accessibility tool attached. The console now shows the newest 200 lines and adds more as you scroll down, keeping at most 600 on the page. **Jump to oldest** shows the oldest 200 lines, and scrolling up from there adds newer ones. Returning to the newest lines trims back to 200. Leaving Logs now takes about as long as leaving any other page (10–20 ms instead of 55–145 ms, or seconds). - **Flaky adaptive re-check test (#506):** `TestAdaptivePostponeRecheckA458` now waits for the re-checked run to finish finalizing before its temporary directory is removed. It had failed CI on `main` with "directory not empty" whenever the database backup was still writing. - **WebDAV uploads failing with "423 Locked" during parallel uploads:** gowebdav re-creates every parent directory before each upload. When two uploads into the same backup folder overlapped, a server that locks a directory while creating it (such as Go's `x/net/webdav`) answered 423, and one upload failed even though the folder existed. An MKCOL answered 423 is now re-sent briefly (which also works for Digest-authenticated servers), and one that still returns 423 for a directory that exists is treated as "already exists", like the 403 case from #242. A 423 for a missing directory is still retried. - **Unrealistic transfer rates for short operations (#455):** a backup or restore that finishes in under a second no longer reports rates like "10000 GB/s". Run logs, Discord notifications, History and the Activity timeline only show a rate for operations of at least one second, and divide by precise elapsed time rather than whole seconds. Run logs and the web UI show sub-second durations as `<1s` instead of `0s` and keep a fractional second under a minute (`2.4s`); run log data includes `duration_ms`. - **mDNS answers leaked to other interfaces (#498):** with Vault bound to one LAN address, a host on a different local network (a second NIC, or a Docker or libvirt bridge) that asked for `_vault._tcp` still got that address back, even though Vault only announced it on the bound interface. Query answers now come only from the interfaces Vault advertises on: the bound interface for a specific address, or every interface with a routable address for a wildcard bind (`0.0.0.0`), the same set that announcements already used. Loopback binds still advertise nothing. Vault carries the fix as a small patch to `brutella/dnssd` under `third_party/dnssd` until upstream releases it. - **Unraid host renamed after binding Vault to a LAN address (#468):** a non-loopback bind no longer makes `avahi-daemon` report a host name conflict and rename the server (for example `TheVault.local` to `TheVault-2.local`). Vault now advertises `_vault._tcp` under its own host name, `vault-.local`, answers address lookups for that name, and publishes on each interface only the addresses the API listens on there (the mDNS responder moved from `grandcat/zeroconf` to `brutella/dnssd`). The service name and TXT records are unchanged, so Home Assistant discovery keeps working. - Deployment and verification remove temporary SSH password files when the server is unreachable, while keeping connection errors visible. - **Ansible no longer prints the Unraid password (#495):** the deploy and verify roles passed it on the command line (`sshpass -p`), so a failed upload or SSH tunnel printed it in the task output, and it showed in the local process list while a deploy ran. `sshpass` now reads it from a private temporary file that is deleted when the run ends, and error messages stay visible. - **Logs page unresponsiveness and freeze when loading older entries (#454):** Fixed microtask starvation caused by concurrent older-page requests during initial mount and background loading loops (`loadAll` and search). Active older-load requests now share the in-flight Promise across callers, older requests are bound to the store context sequence to discard stale responses, and background loading loops terminate when no progress is made. Also added a store `dispose()` method, called from the synchronous `onMount` cleanup in `Logs.svelte`, to abort background activity on page navigation. Closes #454. ## [v2026.10.00] - 2026-10-07 ### Added - **Deduplicated backups can be recovered with the backup password (#451):** Each dedup destination's master key was sealed only with the server's `vault.key`, so losing that file made every dedup backup unreadable. Dedup backups now also keep a copy of the master key sealed with the backup password, written by the first dedup backup that runs with a password set. After a flash-drive loss or reinstall, the Recovery wizard uses the backup password to unlock dedup destinations and re-seal them with the new server's key; the previous `_vault/repo.json` is kept as a `.bak` copy. Restore, browse, mounts and `vault recover` also open a destination with the password when the key does not match. Settings → Security → **Server key** downloads `vault.key` for an off-server copy, and the emergency kit mentions it. Closes #451. - **Recover files without a Vault server, including on Windows (#313):** The new `vault recover` command reads backups straight from a storage destination and extracts files into a local folder. It needs no Vault database and runs on Linux, macOS and Windows. A Windows build, `vault-windows-amd64.exe`, is attached to each release. `vault recover list` shows the backups, `contents` lists an item's files, and `extract` recovers whole items or chosen paths. Deduplicated backups, classic backups, incremental and differential chains, and age-encrypted backups are supported. Deduplicated backups need the original server's `vault.key`, or the backup passphrase if one was set when they ran (#451); encrypted classic backups need the backup passphrase. The passphrase is read from a file or the `VAULT_PASSPHRASE` environment variable. Containers are recovered as their volume files plus the Unraid template and configuration, and VMs as their disk images and definition. Storage is opened read-only and stored checksums are verified. On Windows, names Windows cannot store and names that differ only by case are renamed, and every rename is listed in a report written next to the recovered files. NFS exports must be mounted first. See the Disaster Recovery guide. Closes #313. - **Multi-job item restores in restore wizard (#438):** The restore wizard now supports restoring selected items across different backup jobs in a single restore flow. In Step 2, restore points are aggregated across all relevant jobs with visible source job badges, item coverage counts, missing item explanations, and legacy membership indicators. Users can choose one restore point per source job, view a real-time coverage plan, and use one-click recovery actions to remove uncovered items. In Step 3, restore units are reviewed with scoped destination overrides, passphrases, clean destination controls, and per-unit pre-flight checks, followed by sequential execution and live multi-unit progress tracking. Closes #438. - **Headless Playwright browser regression test suite:** Added an end-to-end browser testing suite with GitHub Actions CI workflow (`playwright.yml`) and deterministic REST/WebSocket mocks (`tests/mocks/vault-api.ts`), validating desktop shell navigation and multi-job restore wizard flows headless without requiring a live Unraid host. - **Expanded Playwright browser test coverage across core UI workflows:** Added automated end-to-end browser specifications for Backup Jobs (creation wizard, item pickers, schedule/retention, run now, delete), Storage Management (destination creation, test connection, remote browser drawer, refresh, delete), Settings (tab navigation, dark/light theme, Discord webhook tests, diagnostics zip export), Activity Logs (search and level filters, wrap/details toggle, log purge), History (operation metrics, search, run log inspector), and Disaster Recovery (5-step cold recovery wizard). ### Changed - **Uninstalling keeps `vault.key` (#451):** Removing the plugin no longer deletes the server key, so a reinstall opens the same deduplicated destinations. Delete `/boot/config/plugins/vault/` by hand to remove every trace of Vault. ### Fixed - **Clear message when a dedup destination needs a different `vault.key` (#451):** Browsing, restoring, mounting and `vault dedup` now explain that the destination was sealed with another server's key and how to recover, instead of a generic error. Browsing returns HTTP 424 with code `dedup_key_mismatch`. A backup that runs on a mis-keyed destination still completes, with a warning in its run log. - **Replication no longer bounces jobs between reciprocal peers (#440):** When two Vault servers are configured as each other's replication source, a sync now imports only the jobs the remote server created itself (`source_id` 0) and skips jobs it had replicated from elsewhere. Previously each sync re-imported the other side's copies, creating ever-deeper `[A] [B] …` job names. Only the pulling server needs the update. Nested jobs created before this fix are not removed automatically; delete them manually, or delete and re-create the affected replication source. The Replication page now describes replication as pulling from the remote server. Closes #440. - **Partial folder restores keep directory owner and permissions (#442):** Restoring selected files from a folder backup now applies each containing directory's recorded owner, group and mode, instead of creating it as `root:root 0755`. Previously SMB users could not rename, move or delete files in the restored directory. Applies to deduplicated and classic backups. Unselected sibling files are still not restored. Closes #442. - **FUSE backup mounts moved out of `/mnt` root (#446):** Read-only backup mounts now live under Unraid's `/mnt/addons/vault-fuse/mount-`, or under a temporary directory when `/mnt/addons` does not exist, and each mount's directory is created only when that mount starts. If a mount directory cannot be created under `/mnt/addons/vault-fuse`, Vault retries once under the temporary directory (unless `fuse_mount_base_dir` is configured). This clears the Fix Common Problems warning "Invalid folder vault-fuse contained within /mnt". On startup Vault removes the old `/mnt/vault-fuse` directory and leftover empty session directories, never deleting non-empty content. Uninstall removes both mount roots only when empty, and pool discovery never treats `addons` or `vault-fuse` as storage pools. `/mnt/user/.vault` is Vault's database snapshot directory (`/.vault`, shown under `/mnt/user` because Unraid exposes top-level pool folders as shares); it is intentional and can be moved under **Settings → General → Database Location**. Closes #446. - **Partial-restore file browser no longer times out on slow remote storage (#449):** Listing a large restore point on B2/S3 can take longer than the normal 10–15 second request limits, so the file browser failed with "vault daemon unavailable". Only the restore-point contents request now gets longer limits: 120 seconds in the daemon, 125 seconds in the Unraid plugin proxy and 130 seconds in the browser. All other requests keep their current limits. A listing that runs past 115 seconds now gets a clear timeout error from the daemon instead of a dropped connection. The proxy now reports a timed-out request as HTTP 504 "vault daemon request timed out" instead of "vault daemon unavailable", and stops waiting after 5 seconds when the daemon cannot be reached. When the file list fails to load because of a timeout, network error or server error, the picker now shows "Could not load the file list" with a Retry button, and no longer says a whole-archive extract will run. While a file list is loading or has failed to load, Start Restore is disabled until the list loads or you choose **Restore whole item**, so a timeout can no longer turn an intended partial restore into a full one. The whole-item fallback note is shown only when the restore point cannot be browsed. Closes #449. ### Security - **The backup password now also protects deduplicated backups (#451):** Anyone with the backup password and access to the backup storage can read deduplicated destinations, as with `vault.key`. Use a strong backup password. ## [v2026.09.01] - 2026-09-28 ### Added - **FUSE read-only mounts for backups (#312):** Operators can now mount deduplicated backup snapshots as standard read-only filesystems on the host to inspect archive contents, compare versions, or copy out specific files using standard Linux shell tools and file managers. Backups can be mounted directly from Step 3 of the Restore Wizard via "Mount as Filesystem" or through the `vault mount` CLI subcommand. Active mounts are tracked and managed via SQLite and exposed through REST API routes (`GET /api/v1/mounts`, `GET /api/v1/mounts/{id}`, `POST /api/v1/mounts/{id}/unmount`, `POST /api/v1/jobs/{id}/restore-points/{rpid}/mount`), WebSocket event broadcasting (`mount.started`, `mount.active`, `mount.unmounted`, `mount.failed`), and an "Active Backup Mounts" card on the Restore page with path copying and one-click unmounting. Inactive mounts are automatically reclaimed by an idle timeout sweeper. Closes #312. - **Direct file downloads from storage browser modal (#311):** Operators can now download individual files directly from the Storage browser dialog. Files streamed from destinations include an appropriate `Content-Disposition: attachment; filename="..."` header, and the browser UI offers per-file download actions with in-flight loading indicators and toast feedback. Closes #311. - **Restore progress, live run logs, and completion status on restore page (#316):** Surfaced real-time restore progress and results directly on the Restore page. Step 3 of the Restore Wizard now features an overall progress bar with active item, item type, elapsed time, and phase indicators driven by WebSocket runner events. Once the restore finishes, an outcome banner details item success/failure counts and total restored bytes with a quick reset action, and an embedded live restore log panel displays formatted, level-coded log messages streamed from the daemon. Additionally, global toast notifications alert operators when a restore operation completes or fails. Closes #316. - **Future jobs queue visibility and cancellation (#302):** Concurrently triggered backup, restore, and cleanup operations are now tracked with unique queue identifiers, operation kinds (`backup`, `restore`, `delete`), and statuses (`queued`, `running`). An "Active & Queued Operations" card is now surfaced at the top of the History tab with real-time WebSocket synchronization. Operators can cancel queued backup and restore operations directly from the queue before execution begins via `POST /api/v1/queue/{id}/cancel` and the web UI. Closes #302. - **Auto-add newly created containers to container backup jobs (#324):** Jobs configured to protect Docker containers can now enable "Auto-include newly created containers". During preflight reconciliation, newly deployed containers on the host are automatically discovered, configured with `appdata_only=true` (safely excluding bulk media mounts by default), and added to the job's item list. Containers tagged with the `vault.exclude` label are respected and ignored. Additionally, containers missing past the configurable absence grace period are automatically pruned from the job. Closes #324. - **Appdata path prompt and default exclusion of non-appdata volumes (#317):** When configuring container backup jobs, Vault now prompts for the host's appdata root paths (defaulting to `/mnt/user/appdata` and `/mnt/cache/appdata`). Container bind mounts outside the configured appdata directories (such as bulk `/mnt/user/media` storage) are automatically excluded by default to avoid unintended multi-terabyte archives, while Docker named volumes and appdata mounts remain protected. Users can inspect excluded mounts and toggle them back in at any time. Closes #317. - **User override for auto-skipped shared-data mounts (#307):** Added an explicit toggle in the container job editor allowing users to include bind mounts that Vault automatically skips as shared or media data, accompanied by clear warnings regarding backup duration and storage consumption. Closes #307. - **Periodic full backups alongside differential and incremental chains (#322):** Jobs using differential or incremental backup strategies can now configure an independent periodic full backup schedule (e.g. `@weekly` or custom cron). When due, the runner forces a full base archive and establishes a fresh chain root, preventing unbounded chain lengths and bounding restore replay depth. Closes #322. - **Custom cron expression schedule picker (#309):** Added a "Custom" schedule mode to the Schedule Picker across job creation, job editing, and periodic full backup configuration. The field validates standard 5-part cron syntax with real-time cadence descriptions and prevents invalid schedules from being saved. Closes #309. - **Post-backup chunk verification for deduplicated jobs (#382):** Deduplicated backup jobs with `verify_backup` enabled now immediately read back and hash all written chunks upon session completion. Verification status is recorded in restore point metadata and surfaced in the job run results, guarding against silent backend write corruption before staging files are cleared. Closes #382. ### Changed - **Go toolchain and dependency updates (#437):** Updated Go from 1.26.5 to 1.27.1 across module configuration, CI workflows, contributor docs, and the Docker builder image (`golang:1.27-alpine`). Adopted Go 1.27 standard library features (`uuid`, `strings.CutLast`) and `go fix` modernizers (`range n`, `slices.Contains`, `strings.SplitSeq`/`FieldsSeq`, `sync.WaitGroup.Go`, `atomic.Int64`/`Bool`, `errors.AsType`, `testing.T.Context`, `min`/`max`, `strings.Cut`/`CutPrefix`), replacing the direct `github.com/google/uuid` dependency with the standard library `uuid` package, and updated Go and web npm dependencies to their latest releases. Closes #437. - **Unflatten file contents and modernize selection in restore UI (#323):** Step 3 of the Restore Wizard now presents archive contents as an expandable, collapsible hierarchical tree rather than a single flat list. Directories lazily render their child nodes when expanded, maintaining fast DOM performance even with backups containing thousands of files. When contents load, all files and folders are selected by default, inverting the checkbox semantics so that checking an item explicitly includes it and unchecking explicitly excludes it. Folder checkboxes provide tri-state indication (`checked`, `indeterminate`, `unchecked`), searching automatically expands matching branches and highlights relevant items, and "Select all", "Deselect all", "Expand all", and "Collapse all" controls offer convenient batch operations. Ambiguous "Clear to restore everything" messaging has been removed, and selecting 0 files blocks execution with an informative warning to safeguard against accidental whole-archive restores. Closes #323. - **Restore wizard page 2 timeline, size, and confirmation UX improvements (#332):** In Step 2 of the Restore Wizard, clarified backup sizes by displaying explicit "Restore:" and "Backup:" labels instead of the ambiguous "selected" suffix, updated item counts to use type-specific nouns (e.g. "containers", "VMs", "flash drives") when the selection is homogeneous, simplified daily group headers to display aggregate sizes directly, added intermediate date labels to the activity density graph, added an introductory overview paragraph explaining the version list, and upgraded restore point deletion to an expanding two-click "Delete?" confirmation badge with descriptive accessibility labels. Closes #332. - **Restore wizard page 1 UI and item selection improvements (#327):** Reorganized Step 1 of the Restore Wizard with alphabetical item sorting by default, quick search filtering across labels and job names, distinct filter chips and dedicated icon badge for Unraid Flash Drive backups, compact card layouts with uppercase type badges, and a sticky bottom floating action bar with active selection counts so operators can proceed immediately without scrolling long item lists. Closes #327. - **Storage scan and import support encrypted manifests (#325):** Scanning a storage destination now automatically decrypts deduplicated manifests using the local server key and identifies age-encrypted manifests. When age-encrypted manifests are detected, the Import modal displays an encryption notice with a passphrase field and unlock button, allowing users to decrypt metadata and import backups securely. Closes #325. - **Stage classic folder backups beside the selected local destination (#366):** Classic folder backups targeting local storage now stage temporary archives directly in a `.vault-stage` directory on the target destination rather than defaulting to the first available cache pool. This eliminates out-of-space (`ENOSPC`) failures when backing up shares larger than cache pool free capacity. The staging folder is automatically excluded from recursive source walks. Closes #366. - **Dropped job-run-ID prefix from backup run folders (#319):** Backup run directories in classic storage destinations no longer include the internal auto-incrementing job run ID in their folder names, standardizing on `_` (e.g. `2026-09-15_143000_full`). Historical restore points formatted with the previous `__` convention remain fully discoverable and restorable without manual migration. Closes #319. ### Security - **Harden restore paths across VM, container, folder, and plugin restores against path injection:** Validates restore destinations and file paths against traversal sequences (`../`, `..\`) at the HTTP boundary in `RestorePointPreflight` and `Restore`. In the restore engines (VM disk/NVRAM restores, container volume restores, directory extractions, folder restores, and plugin restores), replaces direct directory creation with `mkdirRestored` enforcing symlink rejection and traversal validation, enforces atomic descriptor-relative directory traversal with `openNoFollow` on file creations, validates relative symlink targets within extraction boundaries while preventing writes through escaping links, and wraps modification time updates in `applyModTimeFromTime` guarded by `restorePathSafe`. This addresses all 24 open CodeQL alerts (alerts 23, 24, 65, 77–97, `go/path-injection`). - **Sanitize and validate target paths before setting modification times on restored files:** `FolderHandler.RestoreChunked` now validates and normalizes destination directories through `normalizeRestorePath` upon entry, validates that all restored directory and file paths remain strictly contained within the destination root and free of path traversal sequences, and wraps modification time updates in `applyModTime` guarded by `restorePathSafe`. This addresses CodeQL alert #74 (`go/path-injection`). - **Encrypt manifest.json at rest (#325):** Backup run manifests (`manifest.json`) stored on destinations now have their metadata encrypted at rest. For deduplicated destinations, manifests are encrypted with AES-256-GCM using the destination's master key. For age-encrypted jobs, manifests are encrypted with age using the job's passphrase. This prevents unauthorized inspection of job configuration, protected item lists, container names, and paths on untrusted remote storage. Existing unencrypted manifests remain fully discoverable and readable without migration. Closes #325. ### Fixed - **Container volume archives correlate by mount source host path (#352):** Classic container volume archives are now named `volume_.tar` keyed stably to the mount source host path rather than the fragile position index in `inspect.Mounts`. This prevents silent data corruption and cross-volume file unioning during differential restores when Docker alters the inspection mount order following container recreation or Unraid template updates. Existing archives named under the legacy positional convention remain restorable. Closes #352. - **Container chain restores no longer resurrect deleted files (#345):** Classic container differential and incremental chain restores now perform a post-restore prune pass against each volume's destination, matching the folder restore behavior from #231. Using the newest restore point's authoritative effective listing and earlier tar index sidecars, files deleted from a container volume after the base full backup are pruned from disk rather than resurrected by the chain overlay. Closes #345. - **Symlinked container volume sources follow symlinks before stale-mtime detection (#353):** Container mount sources that resolve through symlinks (such as `/mnt/user/appdata` links pointing to `/mnt/cache/appdata`) now evaluate their target before checking timestamps against parent effective listings, ensuring new files with older mtimes are reliably detected and archived in differential runs. Closes #353. - **Plugin backups now honor incremental and differential backup types (#351):** Plugin items in classic and deduplicated jobs now respect the job's backup type. Differential and incremental runs evaluate `changed_since` cutoffs and parent effective listings instead of always taking full uncompressed archives, reducing backup sizes and elapsed time for systems with large plugin configurations. Closes #351. - **Unreadable files during archiving are skipped with a warning instead of failing the job (#393):** Bad blocks or unreadable files (such as flash drive corruption or permission errors) encountered during tar archiving are recorded as skipped files and reported in the job run log as non-fatal warnings, allowing the remainder of the backup to complete. Skipped paths are excluded from effective listing sidecars so subsequent runs retry them. Closes #393. - **Restore clean destination replaces target tree instead of merging (#321):** When "Clean destination directory" is enabled, restores delete existing files in the target directory prior to archive extraction, matching the user-facing contract. The option is safely disabled for partial restores where only a subset of files is selected, preventing accidental deletion of unselected files. Closes #321. - **Container template synchronization on volume remapping (#336):** Restoring a container to an alternate destination path now synchronizes the container's Unraid `template.xml` in addition to the live Docker container, preventing Unraid's Docker GUI from reverting the container's path mappings back to the original location during template edits. The restore wizard now requires explicit confirmation before executing a volume remapping. Closes #336. - **Deduplicated container backups capture and restore Unraid template.xml (#379):** The deduplicated container backup path now saves the Unraid container template under the synthetic key `__template` and restores it alongside container recreation, achieving parity with the classic container engine. Closes #379. - **Deduplicated backups capture single-file container bind mounts (#380):** Single-file bind mounts in container configurations are now correctly chunked and restored under `__volfile__` manifest keys, preserving file permissions, modes, and timestamps. Socket and named pipe bind mounts (such as `/var/run/docker.sock`) are cleanly skipped with descriptive skip reasons in the restore point metadata. Closes #380. - **Classic plugin restores honor custom restore destination (#381):** The classic plugin restore path now extracts files to the user-specified restore destination override rather than overwriting live plugin configurations under `/boot/config/plugins/`. Closes #381. - **Containers with zero bind mounts back up cleanly (#305):** Containers configured without bind mounts now produce valid configuration and template restore points without failing or displaying confusing "no mounts detected" warnings in the job editor. Closes #305. - **Ansible deployment SSH connection multiplexing (#410):** The Ansible deploy role now multiplexes delegated `scp` uploads over a single authenticated SSH control master connection, preventing Unraid `sshd` rate limiting and connection drops during plugin deployment. Closes #410. - **One absolute symlink aborted the whole restore, so a full restore of images like MySQL always failed:** The classic extractor rejected any archived symlink whose target was absolute, and did so with a hard error that stopped the entire extraction — a restore of a container whose volume held even one such link failed outright with `unsafe symlink in archive`. The rejection treated an absolute target as an escape attempt, but a volume archive is a snapshot of the _container's_ filesystem: `/var/lib/mysql/x` inside a volume mounted at `/var/lib/mysql` points back into that same volume once the container runs again, and MySQL's data directory ships exactly these links. Absolute targets are now recreated verbatim, preserving what the container expects. Writing _through_ such a link is still refused: every archive entry already resolves its real parent chain on disk before extraction, so an entry addressed via a symlink that leaves the destination is rejected there, and nothing is ever created outside the restore directory. Relative targets are validated exactly as before, including chained `..` escapes through previously-extracted links — but a link that fails validation is now skipped with a log line rather than aborting the run, since refusing to create it is the entire safety property and killing the restore over one entry only adds data loss. Symlinks are also archived with their owner now — they were written with a hand-built tar header that carried no uid or gid, which would have restored every link as root-owned. - **Restores gave every file back to root, and a container restored into a new folder could not start:** Neither restore path put back the ownership or the permissions a backup captured. The daemon runs as root, so a restored file was created root-owned, and a mode was applied only when the extractor happened to create the file — restoring over an existing tree kept the old permissions, and the mount's own root directory, which no tar archive or dedup manifest describes, was invented as root-owned `0750`. On Unraid, where appdata belongs to `nobody:users`, that is enough to stop a container from reading its own configuration: a MariaDB container restored into a fresh destination died on `Can't read dir of '/etc/mysql/conf.d/' (Errcode: 13 "Permission denied")` while the restore itself reported success. Both the classic and the deduplicated path now restore the numeric owner and the recorded mode for every file, directory, and symlink, and both record the volume root's own mode and ownership at backup time — in `volumes.json` for classic backups and on the `__vol__` manifest entry for deduplicated ones — so a volume restored into a directory that did not exist yet comes back as the directory it was backed up from. Directory modes are applied after the files inside them are written, which also fixes restoring a read-only directory: its contents used to fail with a permission error because the directory was created read-only before its children were extracted. Ownership is best-effort and never fails a restore — an unprivileged daemon, or a destination filesystem with no ownership model, logs one warning and restores the data regardless — and a backup taken before this metadata existed restores exactly as it did before. - **Container restore ignored the file picker and restored every volume (#275):** Selecting a handful of files from a container backup restored the container's volumes in full — every file in every mount, overwriting whatever was on disk. The picker's selection was carried as far as the restore request and then dropped: the classic path untarred each volume archive whole, and the deduplicated path handed each volume's sub-manifest to the folder restorer with no filter. Both paths now translate the selection — container-internal absolute paths such as `/config/settings.yml` — into per-volume relative paths, extract only those files, and skip entirely any volume the selection never names, so an unselected mount is left untouched rather than restored and then partially overwritten. Selecting a mount point itself still restores that whole volume, a selected directory brings any mount nested beneath it along, a file inside a nested mount is taken from the deepest mount that holds it rather than from its parent, and a restore with no selection is unchanged. Making this work required fixing the classic listing it depends on. Browsing a non-deduplicated container listed the item directory and returned the _first_ readable `*.index.json` it found, so a multi-volume container showed one arbitrary volume's files under bare volume-relative names, with no indication of which mount they belonged to and nothing to map a selection back onto. The classic listing now reads the backup's `volumes.json` and merges every backed-up volume's index sidecar into a single listing in container-internal absolute paths — the same vocabulary the deduplicated listing already used, so the picker and the extraction filter cannot drift into different spellings of the same file. Single-file bind mounts list as their mount destination, a volume whose sidecar is missing or corrupt is omitted rather than failing the whole request, and a container backed up before `volumes.json` existed still falls back to the previous single-archive listing. Closes #275. - **History and the dashboard showed every configured container as backed up, even when nothing had changed (#326):** A differential or incremental run always wrote the container's `config.json`, so an item whose volumes, image, and template were all unchanged still appeared in the run's per-item results with a green tick — indistinguishable from a container that had actually been re-copied. The engine now reports when a run captured no changed content (the classic path tracks whether it wrote a volume archive, image, template, or database dump; the deduplicated path compares the manifest it just wrote against its parent, ignoring the always-rewritten `__inspect` blob), and the run log records it per item. History and the activity timeline render those items with a neutral dash and an `unchanged` badge rather than a success tick, and the run's item counter reads `3/3 containers (2 unchanged)`. The item's status stays `ok` and it remains restorable from the restore point through its chain — nothing about what is backed up or restorable changes, only what the history claims happened. Closes #326. - **The restore file picker displayed "No files match" when an item had no files at all:** With the container picker no longer emitting engine metadata, a container whose volumes were every one of them excluded, anonymous, or virtual now correctly lists nothing — but the picker rendered the empty-filter message, telling the user their filter matched nothing when there was nothing to filter. It now says the backup captured no restorable files, and for containers explains that restoring still recreates the container from its saved configuration and image. - **Database-dump containers leaked two more metadata entries into the restore picker:** A container item with `database_dump` enabled stores its logical dump under the synthetic manifest keys `__dbdump__` and `__dbdump_replay__`. These were not on the synthetic-key list, so the picker listed `__dbdump__` as a file and `__dbdump_replay__` — a zero-value marker — as a spurious `0 B` entry. Both are now recognised as engine metadata and dropped, completing the #333 fix for database containers. - **Container restore file picker showed wrong sizes and excluded volumes (#333):** Browsing a deduplicated container restore point listed engine metadata as if it were files, showed volumes the job had excluded, and rendered sizes as `-1 B`, `0 B`, or `NaN`. A container's dedup manifest holds only synthetic keys — `__inspect` and `__image_meta` are internal metadata, and each `__vol__` is a pointer whose real file entries live in a nested per-volume sub-manifest — but the picker emitted those top-level keys verbatim, so pointer and sentinel sizes were displayed as file sizes. The picker now drops synthetic keys, drops skipped and excluded volumes without dereferencing them, and expands each backed-up volume from its sub-manifest with container-internal absolute paths (for example `/config/settings.yml`). The synthetic-key rules are applied only to container items, so a folder or plugin that happens to hold a path named `__inspect` or `__vol__/...` is listed verbatim. Closes #333. - **An item that backed up nothing was recorded as having no size at all:** The backup metadata writer skipped any item whose size was 0, so "backed up nothing" was indistinguishable from "size never reported". Restoring only such an item then hit the legacy fallback and reported the whole restore point's size. Zero is now written explicitly; only a missing or non-numeric size counts as unknown. - **Restore runs reported the whole backup's size instead of what was restored (#334):** Restoring a subset of a backup — one container out of twenty — showed the entire restore point's size as the "restored amount" on the history and dashboard pages. The run size, completion broadcast, activity entry, and run summary now sum the per-item sizes recorded at backup time, and each item's restore log entry carries its own size. Restore points that record no per-item sizes — those written before this metadata existed — fall back to the restore point total and log that they did, rather than reporting zero; a run in which no item succeeded stays at zero. The history item counter is now type-aware, reading `3/3 containers` instead of `3/3 items`, with mixed-type runs keeping the generic noun. Closes #334. ## [v2026.09.00] - 2026-09-01 ### Added - **Backup and restore progress overlay now shows the active item being processed (#368):** The "Backup in progress" and "Restore in progress" overlay on the Dashboard previously showed only the completed/total item count and elapsed duration. It now displays the name and type of the container, VM, folder, or plugin currently being processed (for example `Backing up: nextcloud (container)`), updating in real time across backup phases, deferred uploads, and reconnect resyncs. Closes #368. - **Display real plugin names and filter hidden files (#329):** Plugin discovery now extracts readable plugin names from `.plg` DOCTYPE entity declarations and root XML attributes, rendering human-friendly display names across the item picker and job configuration while keeping internal identifiers intact. AppleDouble files (`._*`) and other hidden files are now automatically filtered out from discovered plugin items. Closes #329. - **Restore points now show the base full backup for chained restores (#318):** Differential and incremental restore points in the restore timeline and wizard now display the date and size of the root full backup they depend on, and the `Chain ×N` badge includes an explanatory tooltip describing chain length and replay behavior. Closes #318. - **Configurable log level (#346):** Added a global log level setting (`debug`, `info`, `warn`, `error`) configurable via the web Settings page and the settings API (`log_level`). Daemon logging routes through a centralized `slog` handler with dynamic level adjustments applied at runtime without restarting the daemon. Closes #346. - **Codecov configuration and dual-stack test coverage:** Added repository-root `codecov.yml` defining dual-stack coverage flags (`backend` and `frontend`), subsystem component dashboards, and an 80% coverage target on PR patches. Added `@vitest/coverage-v8` frontend coverage reporting with LCOV output, unified CI web test runs, and updated Go coverage targets with cross-package atomic instrumentation. - **Replication targets now show what the last sync actually did.** A target that connected but had nothing new to copy previously just read "Synced", which looked the same as a sync that transferred data — so replication could appear to "connect but not do much". Each target now shows an explicit **Up to date** state when a sync found nothing new, a per-sync summary (jobs synced, new restore points, bytes transferred, and any failures), and the time replication last fully succeeded. These counters are stored with each run, so they are visible on page load and after a refresh, not only in the moment a sync finishes. Closes #287. - **Run logs (#328):** backups and restores now write a real streaming log — run start, per-item start/success/failure with sizes and durations, phase changes (staging, deferred upload, stop-all), stalls, and an end-of-run summary (items, duration, percent successful). In the Logs tab, any backup or restore entry now expands to show its run's log in a scrollable box that appends in real time while the run is live. Entries persist with the run and are cleaned up with run history, backed by a defensive 10,000-line-per-run cap alongside the existing activity-log cap. Storage health checks now record per-destination outcomes in the activity log. New API: `GET /api/v1/runs/{runId}/logs?after=`; new MCP tool: `get_run_logs`; new WebSocket event: `run_log`. Closes #328. ### Security - **Dependency refresh across the Go and web stacks.** Updated all Go modules to their latest compatible releases (AWS SDK v2 S3/config/credentials/STS, `go-libvirt`, `gzfs`, OpenTelemetry 1.46.0, `miekg/dns`, `modernc` SQLite support libraries) and the web dependencies (`svelte` 5.57.0, `eslint` 10.9.1). `go mod tidy` also dropped four modules that were no longer required. `gosec` reports 0 issues, `govulncheck` finds 0 called vulnerabilities, `npm audit` finds 0, and the full backend and frontend suites, linters, and production build pass unchanged. - **Cleared vulnerability advisory in build-time dependency.** Bumped `golang.org/x/mod` to `v0.40.0` (and `golang.org/x/tools` to `v0.49.0`) to clear CVE-2026-56864 in `sumdb`. The package is a build-time dependency, is not reachable from Vault's own code, and there is no runtime behaviour change. Verification via `make security-check` (gosec, govulncheck, and `go mod verify`) is clean with 0 vulnerabilities and all modules verified. Closes #367. - Updated Go and web dependencies to their latest compatible releases and resolved a high-severity advisory in the web `nanoid` dependency. gosec, govulncheck, and the Go linter pass clean, and the MCP integration remains on the go-sdk v1.7.0 API. ### Changed - **The Logs console now shows the newest entry first.** Logs were listed oldest-first, so the entry you almost always want — the one that just happened — was at the very bottom, and reading it meant scrolling through the entire history first. The console is now inverted: the newest line is at the top, scrolling down reaches further back, "Jump to present" returns to the top, and the "End of logs" marker sits at the bottom where the oldest entry is. - **Log lines are colour-coded by level and far less cluttered.** Every row used to render in the same two tones, so an error and a routine info line looked alike, and each row spent a third of its width repeating the same date. Errors, warnings, info, debug and success now each have their own left-border colour, level pill and row tint — always paired with a text label, so the console stays readable in the monochrome themes and for anyone who cannot distinguish the hues. Rows now show only a time, with a sticky day separator naming the date and counting that day's entries; the full timestamp is still in the tooltip. Runs of identical consecutive lines fold into a single row with an `×N` count, and the `key=value` metadata tail is hidden behind a new **Details** toggle. Both **Collapse** and **Details** can be switched off to get every line back verbatim, and copying a folded row still yields every line it stands for. - **Contributor setup docs now include the local build, test, lint, and deploy workflow.** The repo-root contributor guide now documents the required toolchain, bootstrap steps, local development loop, daemon build/run path, frontend dev flow, and the safe boundaries around `make deploy`/`make verify` so new contributors can get working without digging through the README or the codebase. This keeps the contributor guidance in the canonical repo-root location while leaving the README focused on project overview and installation. - **The `vault mcp` command has been removed.** It started a second process that wrote directly to the running daemon's database from outside it, so anything it changed bypassed the scheduler, the flash-storage flush, and the live UI updates — which is what made assistant-created jobs silently never run. AI assistants should connect to the daemon's `/mcp` endpoint instead, which exposes the same tools with all of those protections in place. - **A scheduled deep verify no longer piles onto a destination that a backup is still writing to.** Deep verification streams and re-hashes every stored item, so running it against the same storage destination as an in-progress backup saturated the shared disk and network — the backup slowed to a crawl and looked frozen, with nothing in the logs to explain it. Vault now defers a scheduled deep verify while a backup is running on the same destination and records why in the activity log, letting the deep verify run at its next scheduled tick instead. Quick verification (existence and size checks only) is light enough to keep running alongside a backup, and an on-demand verify you trigger yourself is never deferred. Closes #290. - **Anomaly alert summaries are now concise and name the job or destination they refer to.** The duration, size, reliability, and capacity anomaly summaries previously opened with a generic "This backup" or "Storage …" and folded raw figures into the message itself. Each now leads with the affected job or destination name and states the change as a plain percentage (for example `"media-backup" duration increased by 233%.`), while the exact per-day rate, byte counts, and growth factor stay in the anomaly's investigation details. The direction wording always matches the numbers — a high-side rule that fires on a slightly-smaller-than-usual run now reads "decreased by" instead of contradicting its own figures. Closes #315. ### Fixed - **Anomaly pills on Jobs and History now separate warning and critical counts with hover tooltips.** Jobs and backup runs with open anomalies previously collapsed all issues into a single pill displaying the worst severity, masking when a job had both warnings and critical errors, and rendered with an ambiguous circle dot with no explanation of what the pill meant. The Jobs and History pages now render distinct pills for each active severity level (critical, warning, info) with a clear warning-triangle icon, wrapped in interactive tooltips explaining the anomaly breakdown and how to inspect or resolve them. Closes #330. - **Folder backup jobs now display the full folder path.** When configuring folder backup jobs or viewing configured folder targets, only the folder basename was shown (for example `appdata` instead of `/mnt/user/appdata`), which obscured vital path context and made it impossible to distinguish between folders sharing the same name. The full path was already stored in item settings and IDs but was not rendered by the interface. Folder items across the Item Picker, exclusions, Dashboard protection widget, Restore Wizard, and History views now show their full path with hover tooltips, while discovered presets retain their friendly names. Closes #335. - **Restoring a renamed or newly added item no longer fails when an older chain step predates the item.** When restoring an item from a backup chain where older restore points were created prior to the item being introduced or renamed, staging failed outright with a `no such file or directory` error because the item's subdirectory did not exist in those older chain steps. Vault now treats not-found errors (`fs.ErrNotExist`) during item directory listing as missing restore data for that step, logging a warning and gracefully skipping to replay subsequent chain steps that contain the item data. Normalisation of missing directory errors to `fs.ErrNotExist` was also extended to SFTP and SMB storage adapters. Closes #355. - **The Restore wizard restore button now recovers if the completion notice is missed.** When a restore completed while the WebSocket was disconnected or the tab was in the background, the completion message was lost and the local `restoring` flag stayed set, leaving the button grayed out on "Restoring…" even after the user notification had fired. The wizard now reconciles against the `/runner/status` endpoint on mount and handles `runner_status_snapshot` events on WebSocket reconnect, re-enabling the restore button once no matching restore is active. Closes #356. - **Classic (tar) folder and container restores now preserve original file and directory mtimes.** Restoring from classic archives previously wrote files and directories with the restore-time timestamp instead of their archive mtime, causing a fidelity gap with the deduplicated restore path and making subsequent differential or incremental backup runs treat unchanged files as modified. All classic restore callers now restore the archive mtime on regular files, hardlinks, and directories. Closes #354. - **Clicking Run Now updates the job row instantly, with no page flash.** Triggering a job used to blank the Jobs table into loading skeletons and swap the row's Run Now button only after a WebSocket round trip — a visible "refresh" in place of a simple click response. Background refreshes now keep the existing table on screen and update the keyed rows in place, and the clicked row flips to its Cancel control the moment the API call succeeds, with the real run details arriving over WebSocket moments later. A background watchdog reconciles the running state against the runner status on a short interval, so a dropped WebSocket event — even both the start and completion of a long-running job — can never leave a stuck Cancel button. Complements the cancel-button behaviour from #235/#238. - **The Jobs page Cancel button now clears as soon as the run ends, without waiting for the next refresh.** Whether a job showed Cancel or Run Now was derived from the live progress state, which kept the finished run listed as active until the next poll replaced it — so a completed job could sit with a Cancel button that did nothing. The running flag is now cleared the moment the run-completion event arrives (guarded against stale or out-of-order completion events), and the running/queued decision lives in one tested helper shared by the page. Complements the cancel-button behaviour from #235/#238. - **Newly added items to an existing backup job now perform a complete capture on differential and incremental runs.** When a container, VM, or folder was added to a pre-existing job that had already produced restore points, differential and incremental runs inherited the parent restore point's timestamp as the item's `changed_since` boundary. For containers, Docker images created before the job's last run were omitted from the archive (`image.tar.zst` missing); for folders, files whose modified timestamps predated the parent restore point were skipped. Differential and incremental backups now check whether the specific item was captured in the parent restore point, taking a full capture when an item is newly introduced to the job and skipping uncaptured historical steps during restore. Closes #310. - **The Recovery page no longer lists a container, VM, or folder once per job that covers it.** When the same item was protected by more than one backup job, the disaster-recovery plan appended it once per job, so the restore steps showed duplicate entries, double-counted sizes, and repeated warnings. The plan now merges items across jobs by type and name: an item counts as protected if any job has a restore point for it, the displayed backup date, size, and storage come from the job with the newest restore point, folder presets are preserved, and each item is counted and warned about exactly once. Closes #314. - **Backup jobs created or changed through an AI assistant now actually run.** The MCP tools wrote jobs straight to the database without telling the scheduler, and the scheduler only reads jobs when the daemon starts. A job created through an assistant was saved, appeared in the UI with its schedule, and then silently never fired — until the next daemon restart. Changing a schedule and deleting a job were stale in the same way, so a deleted job could keep firing. All three now reload the scheduler, flush to flash, and notify the UI exactly as the web console does. - **The MCP tools now validate input instead of saving whatever they are given.** `create_job` and `update_job` accepted any value for compression, encryption, container mode, VM mode and notification settings, and accepted schedules the cron parser cannot read and storage destinations that do not exist. Bad values were stored verbatim and only failed much later inside the backup engine, far from the action that caused them. Both tools now apply the same checks the web console applies, and report the same messages. - **Deleting a job through an AI assistant no longer interrupts a restore in progress.** `delete_job` removed the job's rows immediately, with none of the protections the web console has: it did not refuse while a restore for that job was mid-write (which both corrupts the restore and erases its records), and it did not stop a running backup first, leaving the backup writing to storage after its job was gone. It now behaves exactly like deleting from the web console, and takes an optional `delete_files` — defaulting to off — to also erase the job's backup data. - **A job created through an AI assistant now gets the same defaults as one created in the web console.** Each entry point carried its own copy of the default backup chain and retention policy, and they disagreed, so two identical-looking jobs could keep a different number of restore points for a different number of days. This was invisible until the retention sweep removed the wrong backups. - **Incremental and differential backups now include new files even when those files have older mtimes than the parent restore point.** Files copied in with preserved timestamps (for example via `cp -a`) were previously skipped as "unchanged" in both classic and deduplicated paths, which could silently drop data from later restore points. Vault now uses parent effective listings to detect these new paths, falls back to a safe full archive when parent listing data is unavailable, and preserves unchanged base files when restoring classic container chains. Closes #320. - **A container's database dump is no longer lost when the restore does not reload it.** When a restore left the SQL dump in place — because the restored volume was the newer, consistent copy, the container was not running, or the database never became ready — the dump only ever existed in a temporary staging directory that Vault cleaned up when the restore finished, so there was nothing left for an operator to find. Vault now copies the un-reloaded dump to a durable, discoverable location and reports the exact path in the restore progress and logs: the custom restore destination when one is set (`/-database.sql`), otherwise beside the container's first restored volume (for example `/mnt/user/appdata/-database.sql`). The file name is prefixed with the container name so restores that share a parent directory do not collide, and the container backup guide now documents where the dump lands for in-place and custom restores. Closes #289. - **The web console no longer gets stuck on an endless "connecting" screen after a plugin upgrade until you clear site data.** The page that the Unraid shortcut opens (`app.php`) served the app shell with no cache headers, so a browser — Brave especially — could keep a copy of it from before an upgrade. That stale shell still pointed at the previous JavaScript bundles, which the upgrade had replaced with new content-hashed filenames, so the browser fetched bundles that no longer existed and the app never finished loading. The shell is now sent with the same "never cache" policy the daemon already applies to its own `index.html`, so every visit loads the shell that matches the installed version. Closes #288. - **The Dashboard "Largest backups" tile no longer shrinks after incremental backups run.** It was computed from only the last handful of runs, so once a job's large full backup dropped out of that short window the tile fell back to the size of the most recent — much smaller — incremental. It now reads the largest backup per job from the latest 200 history records, so the full backup remains represented well beyond the short recent-runs window. Closes #286. - **A custom restore location is now honoured when restoring a plugin from a deduplicated destination.** The deduplicated plugin restore only looked at the restore directory passed to it directly and ignored the `restore_destination` setting as a fallback, unlike the container restore path which already checks both. In current use both are set to the same value so there was no visible effect, but the two independent paths could have drifted apart in future. The plugin restore now falls back to the setting when no directory is passed, keeping the two in step. Closes #274. - **A plugin backed up to a deduplicated destination now keeps its `.plg` installer file.** The deduplicated plugin backup only stored the plugin's config directory, leaving out the `.plg` installer at `/boot/config/plugins/.plg`. A restore from such a backup — for example onto a fresh Unraid install — brought the configuration back but not the installer, so Unraid might not recognise the plugin on the next boot. The `.plg` is now stored alongside the config in the same restore point and written back on restore, matching what the non-deduplicated plugin backup already did. Backups taken before this change simply have no installer to restore, exactly as before. Closes #273. - **Restoring selected files from a plugin backup on a deduplicated destination no longer restores the whole plugin.** When a plugin backup lived on a dedup destination and you picked specific files to restore, the file-picker selection was dropped on the way through the deduplicated restore path and the entire plugin config directory was written back instead. The selection now carries through to the folder restore that reconstructs the files, so only the chosen files are restored — matching how the same picker already worked for non-dedup plugin restores. Closes #271. - **Backup sizes now read the same everywhere they are shown.** Four operator-facing places still formatted sizes with their own math instead of the shared byte contract, so the same backup could read differently depending on where you looked: the Storage page's saved-backups list stopped at TB with no clamp (a petabyte showed as `1024.0 TB`), the Recover Vault wizard always printed raw kilobytes (`1048576 KB` for a gigabyte), the packaged plugin History page always printed megabytes (`1073741824.0 MB` for a petabyte), and the transfer-speed label capped at GB/s and disagreed with the app near unit boundaries. All four now go through the same `formatBytes` contract the app and notifications already use, so a size or speed reads identically across the console, the wizard, the plugin pages, and Discord/webhook messages. Closes #270. ## [v2026.08.00] - 2026-08-02 ### Added - **Database containers can now be backed up as a proper database dump, not just its files.** PostgreSQL, MySQL, and MariaDB containers get an optional _"Also save a … dump"_ tick in the job editor. Vault exports the databases with the server running and stores the dump alongside the usual file backup — copying a database's files while it is writing can capture a torn state that will not load, whereas a dump is always consistent and can be reloaded into a different server version. The dump covers the whole server including users and permissions, so a restore does not come back missing the accounts the application signs in with. It uses the credentials already set on the container, so there is nothing to configure and no second copy of a password for you to look after. If the container exposes no usable root password, the dump is scoped to the database named in its environment instead, since an application user cannot read the whole server. A container that is stopped or stuck restarting is reported plainly rather than silently skipped. Restoring puts the folders back and leaves the dump alongside them for you to reload by hand or into a different server version. It is deliberately not replayed over the restored folders: the dump is taken while the server is still running, the folders are copied after it stops, so the folders are the newer copy — and a reload would drop anything written while the dump was still being taken. Where the folders were read from a running server, and so may be mid-write, the dump is the trustworthy copy and is reloaded automatically; if that reload fails the restore fails with it, rather than handing back a database that looks restored and is not. Off unless you tick it. Closes #259. - **Exclude the same paths everywhere, without editing every job.** Settings → Backup Exclusions takes a list of paths that every folder and container backup skips. They are added to whatever a job already excludes rather than replacing it, so a job can still exclude more of its own. Useful for the paths that are never worth backing up anywhere — caches, sockets, scratch directories. Closes #257. - **Containers can declare their own exclusions with a Docker label.** Setting `vault.exclude=/config/cache,/tmp` on a container tells Vault to skip those paths when backing it up. The exclusions travel with the container, so a template or compose file carries its own answer and adding the container to a job needs no further setup. The paths are matched exactly like ones typed into the job wizard, and the job editor shows which mounts a label is skipping. A label can exclude paths but not the whole container — a catch-all pattern is refused, since the label is written by whoever built the image rather than by the person who owns the backup. On by default; it can be turned off in Settings → Backup Exclusions. Closes #258. - **Duration trend chart on the History page.** The trend chart now has a metric selector — **Backup size** or **Job duration** — so duration drift can be watched over time the same way size drift already can. Duration is averaged per bucket and drawn with a slower/faster trend indicator. Closes #331. ### Fixed - **A container backup now fails instead of quietly saving nothing when every one of its folders is excluded.** With a broad exclusion rule — from the global list or a container's own label — it was possible for every backup-eligible folder to be skipped, leaving a backup that reported success but held only the container's configuration. It now stops with an error naming where to look, matching how virtual-machine backups already refuse to run when a VM has no disks they can handle. This applies to deduplicated destinations as well as ordinary ones: the check originally guarded only the ordinary path, so a dedup destination could still record a restore point holding no folder data at all — one that retention might later keep in place of a complete one. The check also runs before the container is stopped, so a backup that cannot produce anything no longer costs it any downtime. - **Large backups to a dedup destination no longer slow to a crawl and appear frozen.** Every time a backup finished a 24 MiB pack, Vault listed the entire `_vault/index/` directory on the destination to work out the next index-blob number. That directory gains one entry per pack, so the cost grew with every pack written: a 500 GB folder flushes roughly 21,000 packs and so issued 21,000 listings of a directory holding up to 21,000 entries — hundreds of millions of directory entries pulled across the network in a single run. Progress crawled, got steadily worse the longer a backup ran, and on a remote destination (WebDAV/SFTP/S3) eventually degenerated into connection and DNS timeouts that failed the run. Vault now reads that directory once per backup and counts on from there, so index bookkeeping costs the same on the last pack as on the first. Existing destinations are picked up as-is — no re-upload, repair, or reconfiguration is needed. Closes #256. - **A slow or stalled browser can no longer hold up a running backup.** Live progress events were handed to the WebSocket layer with a blocking send, so once the outbound queue filled — a browser tab left open that had stopped reading, or a flaky connection — the backup goroutine itself waited on it. Progress updates are now discarded rather than queued when a connection cannot keep up, since the next update carries the same information. Events the interface cannot recover by waiting — run completion, item failures, queue changes — are still delivered reliably, so a busy backup never leaves a finished job showing as still running. - **Backup runs no longer log a spurious "malformed settings JSON" warning.** A job item saved without any per-item settings stored an empty value rather than an empty set, so every run that checked it reported the item as corrupt in the log. Nothing was actually wrong and backups were unaffected, but the warning pointed at a healthy item. Items now always store a valid value, and existing ones are read correctly without the warning. Genuinely corrupt settings are still reported. - **A VM that ignores the request to shut down no longer fails the backup.** Cold VM backups ask the guest to shut down gracefully and wait for it. If the guest accepted the request but never acted on it — no ACPI handler installed, sitting at a login prompt, or an application blocking shutdown — the wait timed out and the backup gave up with _"waiting for domain … to shut off: timed out with state running"_, even though Vault already had a forced-stop step ready for exactly this situation. It now falls through to that forced stop, so a stubborn guest is powered off and the backup proceeds. A backup you cancel yourself is still never turned into a forced power-off. The same fix applies when a restore has to stop an existing VM before replacing it. Reported in #255. - **"No space left on device" now explains itself on an Unraid user share.** A backup writing to a path under `/mnt/user` could fail with a bare out-of-space error while the array showed terabytes free, which reads like a permissions fault or a Vault bug. It is neither: a user share spreads its contents across disks, but any single file is written to one of them, so a large VM disk image fails as soon as the disk it landed on is full — no matter what the array total says. The error now names the directory that filled and, for a user share, explains this and points at the settings that govern it (the share's Minimum Free Space and Split Level), or suggests targeting a specific disk or pool such as `/mnt/cache` instead. Reported in #255. - **A dedup backup can no longer report success while leaving data that a later repair would discard.** After writing a pack of deduplicated data, Vault recorded it in its local database before writing the matching record to the destination. If the second write failed, the backup still completed successfully, but the destination held data with no record of what was in it — so `vault dedup repair`, the tool for rebuilding the local database from the destination, would quietly drop it and reveal the backup had been unrestorable all along. The destination record is now written first, so a failure fails the backup immediately and visibly instead of surfacing later as missing data. - **A backup can no longer be built on top of data that space reclamation has already marked for deletion.** When reclaiming space, Vault records its intent to remove a pack before deleting it, so an interrupted run can be finished later. If the deletion itself failed — a destination that is reachable but refusing deletes, for instance — the pack's contents stayed listed as available, so the next backup of the same files would reuse them instead of storing its own copy. That backup then depended on data already marked for removal, and repairing the index afterwards would drop it, leaving the backup unrestorable. Contents of a pack marked for deletion are now excluded from reuse the moment that mark is recorded, while the record itself is kept so the next reclamation retries the delete. The affected backup simply stores its own copy instead. Existing destinations pick this up automatically on upgrade. - **Repairing a dedup index can no longer lose chunks that a surviving pack still holds.** `vault dedup repair` rebuilds the index by replaying the bookkeeping records stored alongside your backups, and it applied deletion records as it went. Because a chunk is recorded once and space reclamation copies chunks into a replacement pack before retiring the old one, replaying the retired pack's record last left the chunk attributed to it — and applying the deletion then removed the chunk outright, even though the replacement pack still held the data. Restores after a repair could then fail with missing chunks. The rebuild now determines which packs were retired before it replays anything, so the result no longer depends on record ordering. This only affected repair and disaster recovery; normal backups and restores were never at risk. - **The interface now recovers on its own after losing contact with the daemon.** A browser that stopped keeping up — a background tab, a flaky link — was disconnected by the daemon, and any update in flight at that moment was lost. Since a job's completion notice is what clears the progress display, losing that one message left a finished backup shown as still running until the page was reloaded. On reconnecting, the console now re-reads the current run state and reconciles what it missed, so the display corrects itself. The daemon also states plainly that it dropped a connection for falling behind, instead of closing it in a way indistinguishable from a network failure. - **Per-file progress updates are now rate-limited to roughly one per second.** Chunked backups and restores sent a WebSocket event for every single file — 46,000 events for a large music library — each one costing CPU on the server and work in the browser, for updates far faster than anyone can read. Backup, restore, and the restore download phase now report at a steady cadence instead. On-screen progress is unchanged in practice, and the internal stall detector still sees every file and every byte, so a genuinely hung backup is still detected just as quickly. - **Restoring a differential or incremental container from a dedup destination no longer fails with "no such file or directory".** Only the full backup would restore; every later point in the chain stopped with an error naming a directory that does not exist. Dedup backups keep their data in the chunk store rather than in a folder per container, but container restores were being sent down the older path that looks for that folder — a shortcut for restoring a single point directly was in place but sat behind a check that only folders ever reached. Container and plugin backups write a complete record of the item on every run, exactly as folders do, so restoring the chosen point on its own reproduces it correctly. Restores from non-dedup destinations are unchanged. Closes #266. - **Saving a job in Batch container mode no longer fails with "invalid container_mode".** Choosing Batch in the job editor and clicking Save returned an error and refused to save; Sequential saved normally. The job wizard, the backup runner and the rest of the codebase all call this mode `stop_all`, but the API's list of accepted values had been written as `all_at_once` — a name nothing else used — so the one value the interface could actually send was the one value the API rejected. Batch mode was therefore impossible to select on any job. The list now matches the rest of the stack. Any job still holding the old value — it could only have been written by the MCP interface, an import or a replication sync, none of which check this field — is converted once, on upgrade, so every such job changes together rather than only the ones that happen to get edited. Those jobs had been running sequentially despite the name, and will now genuinely stop all their containers together. Closes #261. - **A cold VM backup no longer fails outright and leaves the guest powered off when the guest is slow to stop.** Vault asked the guest to shut down and then waited for it, but treated "shutting down" — a passing state on the way to being off, while the guest is still running — as though the guest had already stopped. When a poll happened to land in that window, the backup pressed straight on to start its temporary session and libvirt refused with _"domain is already running"_, so the backup failed while the guest carried on shutting down and stayed off. Because it depended on the timing of a single poll it struck only sometimes, which made it look like a random failure rather than a bug. Vault now waits for the guest to be genuinely off. Reported in #265. - **A cold VM backup can no longer leave the guest powered off and the job stuck on "running" for hours.** After a cold backup stopped a VM and copied its disks, the temporary paused session had to be torn down before the guest was started again. If that teardown failed, Vault returned immediately and never issued the start — so the VM stayed down, the run never finished, the job showed "running" indefinitely, and only restarting the daemon cleared it. The guest is now brought back up whether or not the teardown succeeded, and is resumed rather than restarted when the session is still paused, since a paused guest is just as unavailable as a stopped one. Both failures are reported instead of one hiding the other. - **A backup that stops responding is now reported as stalled instead of sitting silently on "running".** Several problems combined to make a wedged run invisible. The stall watchdog stopped running the moment it cancelled a stalled job, so from that point nothing was written to the log at all — the operator saw a job stuck on "running" with a dead log and no way to tell a working backup from a jammed daemon. Underneath, calls to the virtualisation service could wait forever: they have no time limit of their own, so if libvirt accepted a request and never answered, no amount of cancelling could free the backup, and even opening the connection could hang before anything was watching it. A run that stops making progress is now marked as stalled, with the reason, and that is published to the interface — so a stuck run can be told apart from a healthy one rather than both reading as "running". The run keeps its real status until it genuinely finishes, so nothing is reported as failed that has not actually failed. The watchdog also keeps reporting, at widening intervals, for as long as a cancelled run refuses to finish. Connecting to the virtualisation service is now time-limited, and a cancelled run still stuck on it after a grace period has its connection dropped so the stuck call fails and the run can finish. Dropping it can no longer strand a virtual machine: the shutdown-and-restart step disconnects that safety net first and reconnects if it has already fired, so the guest is still brought back up. Reported in #265. - **Differential container backups no longer stop and restart containers that have nothing to back up.** Every running container in the job was stopped and started again on each differential run, even when none of its folders had changed since the last full backup — pure downtime for no data. Vault now checks the folders first and leaves the container running when there is nothing new, applying the same exclusions as the backup itself so an excluded host-wide mount is never scanned. Full backups, containers marked not to be stopped, and containers that are already stopped all behave exactly as before. - **Restoring a container to a chosen location no longer merges two of its folders into one.** Where a restore sends data somewhere other than its original home, each folder lands in a directory named after the last part of its original path. Two folders whose paths differ only higher up — `/mnt/user/app/config` and `/mnt/cache/other/config`, say — therefore both claimed the same directory: their contents were interleaved, files with matching names overwrote one another, and the rebuilt container was pointed at that one directory for both. The clash is now detected before anything is written and the restore stops, naming both folders, rather than reporting success over merged data. Restoring to the original locations was never affected. - **A restore no longer reports success when reloading a database dump failed part-way.** The failure was written to the log and the restore finished normally, even though reloading a dump replaces the existing tables as it goes — so a reload that ran out of time, hit an error, or filled the disk left the database holding only part of its data while the restore said it had completed. Such a failure now fails the restore and says the database should not be relied on. - **The daemon can no longer hang indefinitely while disconnecting from the virtualisation service.** Closing a libvirt connection asked politely first and waited without limit for the answer, which never came if libvirtd had accepted the connection and then stopped responding — the exact situation the connection was meant to be able to break. Because the idle-check that runs before an adaptive backup holds the lock that serialises runs, one such hang stopped every later backup from starting. The polite close is now given five seconds before the connection is broken outright. - **Restoring a container from a dedup destination to a custom location no longer writes volume data to the original paths while pointing the recreated container at the empty custom directory.** The chunked restore path discarded the destination parameter and always wrote files back to the original bind sources, then recreated the container with mounts rewritten to the custom destination — so the running container was destroyed and replaced with one pointing at empty directories, while the data sat orphaned at the original paths. Volume data now lands under the custom destination (matching both the classic restore path and the container's rewritten binds), restoring parity between the two code paths. The classic restore path's volume-target computation is also extracted into a shared helper so the two can never drift apart again. - **The Dashboard no longer contradicts itself on a fresh install.** The Protected tile reported "All items covered" in green beside a red 0-of-N bar while every item was still waiting for its first backup — the caption counted only items missing from every job, and an item queued but never yet backed up is in neither group. It now reads how many are pending. The Health tile likewise announced "Attention needed — recent backups have not completed" on a server where nothing had failed and nothing had yet finished; a score of zero with no runs at all now reads "No backups completed yet". ### Security - **Cleared a high-severity advisory in a build-time dependency and updated a bundled library.** `brace-expansion` in the web UI's dependency tree is updated to a patched release (denial of service via unbounded expansion), and `go.opentelemetry.io/otel` moves to 1.44.0 for an unbounded-header-length advisory. Neither is reachable from Vault's own code and no runtime behaviour changes. A full vulnerability scan of the Go module graph and the web dependency tree reports nothing else outstanding. ### Changed - **The 8-Bit and 16-Bit themes now use fonts bundled with the plugin instead of downloading them from Google Fonts.** Selecting a retro theme previously injected a stylesheet link to `fonts.googleapis.com`, so on a server without internet access — or behind a firewall or DNS filter that blocks Google — the pixel fonts silently fell back to Courier New and the theme lost its character. `Press Start 2P` and `VT323` (both SIL OFL 1.1) now ship inside the web bundle and are served by the daemon itself, so every theme renders identically on an offline Unraid box and Vault makes no third-party network requests. The fonts are still fetched only when a retro theme is actually selected — the Default and 1-Bit themes download nothing. - **Backup sizes in notifications now use sensible units instead of always megabytes.** A completed-backup notification divided the total by a megabyte and printed it whatever the size, so a 2 TB backup arrived as "2097152.0 MB" — a number nobody can read at a glance. Sizes now pick their own unit, from bytes up to petabytes, in both the Unraid notification and the size and transfer speed in Discord messages. The code that turns a byte count into text had also been copied several times over and the copies had drifted apart, so the same 1.5 KB could read as "2 KB" in one place and "1.5 KB" in another. Notifications, backup progress, anomaly summaries, the main web console and the older plugin pages bundled with a release now share a single version of it. Some screens still have their own — the storage and recovery pages, the legacy history page, and the restore space check, which deliberately reports in KiB — and are unchanged here. ## [v2026.07.10] - 2026-07-24 ### Fixed - **Cancelling a differential backup now takes effect immediately, even during the pre-scan of unchanged data.** The `changed_since` pre-scan (`pathChangedSince`) walked a volume or VM disk tree to completion before honouring a cancel request, so cancelling a differential backup of a large, mostly-unchanged path could hang until the whole tree had been read. The scan now checks for cancellation between files — matching the rest of the backup engine — so container (classic and dedup/chunked) and VM differential backups stop promptly. As part of making cancellation prompt: a cancelled container backup now always restarts the container it stopped (the restart no longer rides on the cancelled request context, which previously could leave a running container stopped), and an operator **Cancel** is no longer occasionally misclassified as a stall and scheduled for an automatic retry. Closes #251. - **Differential backups of containers that bind-mount the host root no longer scan the entire host filesystem before skipping it.** On the classic (tar) path the `changed_since` pre-scan ran before the mount-exclusion check, so a differential backup of a container mounting `/` (Telegraf, Netdata, Glances, cAdvisor, node-exporter) walked all of `/` only to then discard it — the same runaway walk originally fixed in #70. The exclusion check now runs first, matching the dedup/chunked path. - **Container backups to dedup/chunked destinations now stop the container first for a consistent snapshot.** The chunked/dedup backup path read container volumes live without stopping the container, unlike the classic tar path which stops running containers (unless **Don't stop** is set). In-flight writes during the copy could produce an inconsistent or corrupt dedup backup. The chunked path now mirrors the classic behaviour: it honours the `no_stop` setting, stops a running container before reading its volumes, and guarantees the container is restarted afterwards — even if the backup fails partway through. - **Differential backups to dedup/chunked destinations now skip unchanged data at the filesystem level.** The chunked/dedup backup path did not honour the `changed_since` reference that the classic tar path already used, so an incremental run re-read and re-processed files (and whole container volumes) that had not changed since the last backup. Files and volumes older than the reference are now skipped in the chunked walk — matching the differential behaviour of the tar path — while directories are still recorded. A malformed or absent `changed_since` safely falls back to a full backup. Closes #249. - **The web UI no longer hangs forever on the loading spinner in Brave (and other privacy-hardened browsers).** Brave's default Shields can make `localStorage` and `matchMedia` throw a `SecurityError` — most often when the console is embedded in the Unraid page as an iframe — which aborted theme setup during boot before the app ever became interactive, so the tab loaded endlessly ("Tab is not responding"). Every storage/media-query access during startup is now guarded and degrades to sensible in-memory defaults, and a boot watchdog guarantees the interface always becomes usable even if a startup step stalls. Closes #250. ### Changed - **A connection banner now appears when the live link to the Vault daemon is lost.** Instead of a silently frozen page, a banner explains that data may be out of date and offers a one-click reload, so a persistent connection problem is visible and recoverable rather than invisible. - **The live-update WebSocket now reconnects with exponential backoff and jitter** (capped at 30s) instead of a fixed 3-second loop, so an unreachable daemon no longer churns the network and many clients reconnecting after a restart don't stampede in lockstep. ### Security - **Direct LAN access to the daemon now answers browser Private Network Access preflights.** Chrome and Brave (130+/Edge 143+) block requests from a more-public origin to a private LAN/loopback address unless the daemon echoes `Access-Control-Allow-Private-Network: true` on the preflight. The daemon now returns this header alongside its existing CORS response, without changing the origin allow-list, so the Unraid-embedded and LAN-IP access paths keep working across browsers. - **Cleared three high-severity advisories in build-time dependencies.** `brace-expansion` (reachable through ESLint) is updated to a patched release in the web UI's dependency tree. The repository root also carried a second, unused npm project whose only declared dependency was the `shadcn` CLI — it was referenced nowhere, excluded from every build, yet still accumulated advisories (`brace-expansion`, `js-yaml`) that had to be patched. It has been removed rather than patched again. None of these packages ship in the daemon or the web UI, so no runtime behaviour changes. ## [v2026.07.09] - 2026-07-21 ### Added - **Changing the database location now reports whether the database actually moved.** Saving a new location previously confirmed only "Snapshot path updated", which said nothing about the data itself. Settings now reports the migration — for example _"Database migrated to /mnt/garbage/vault.db — 5 files removed from /mnt/cache/.vault/vault.db"_ — and if the move could not be completed it says so as an error, naming the reason, so it is clear the database is still readable at its previous location. ### Fixed - **The directory browser no longer opens empty when the field holds a file path.** The Database Location field stores a file (`vault.db`), which the browse API rejects, so opening the browser from it silently failed and showed a blank dialog with no way to navigate. The browser now falls back to the containing directory — and to the top-level listing if that also fails — so it always opens somewhere usable. ### Changed - **Browsing for a Database Location or Temporary Work Area no longer applies the change on its own.** Confirming a folder in the directory browser saved and applied it immediately, while typing the same path required clicking **Apply** — so the two ways of setting the field behaved differently, and picking a folder could move the database before you had a chance to review it. Browsing now only fills the field; **Apply** commits it in both cases. - **Changing the database location now moves the database instead of copying it.** Pointing Vault at a custom location (or reverting to the default) previously wrote a fresh copy at the new path but left the old one behind, so a stale `vault.db` — plus its rotated copies — stayed on the previous drive indefinitely. Because startup picks the _freshest_ valid database across all known locations, that leftover was not merely untidy: if the new drive was ever slow to mount (a ZFS pool importing after the cache pool, for example), the old copy could be selected and silently undo the move. The previous location is now retired once the new one is confirmed written, so nothing is left to go stale. Only Vault's own files are removed — a custom location that is a share or pool root keeps everything else in it untouched, and the old copy is always left in place if the new one did not write successfully. ## [v2026.07.08] - 2026-07-21 ### Fixed - **The web UI no longer keeps running the old version after a plugin upgrade.** `index.html` was served with no cache headers at all, so browsers applied their own heuristic caching and could keep loading the _previous_ JavaScript bundle against the freshly-upgraded API until the user manually hard-refreshed. `index.html` is now explicitly never cached, while the content-hashed assets under `/assets/` are cached permanently (they can never change identity), so upgrades take effect on the next normal page load. - **Deleting a storage destination that jobs still use no longer leaves those jobs silently broken.** Confirming the deletion previously removed the destination but left every dependent job **enabled** and pointing at an id that no longer existed — the Jobs page showed the destination as "Unknown" and the scheduler kept starting runs that could never write anywhere. Dependent jobs are now disabled and their destination cleared, and the response reports how many were affected so the UI can tell you what to re-point. - **Creating or editing a job with a storage destination that doesn't exist now explains the problem** instead of failing with a generic "internal server error" (HTTP 500). The invalid reference is reported as a normal validation error naming the offending id. - **Job settings are validated before they are saved.** Unrecognised values for backup type, compression, encryption, container/VM mode, verification mode and notification trigger — plus negative retention counts, out-of-range retry overrides and excessively long job names — were previously accepted and stored, only surfacing later as a confusing failure mid-backup. They are now rejected with a message naming the field and its allowed values. (Upload concurrency continues to be clamped rather than rejected, as before.) - **"Test Connection" against an unreachable host now reports the failure instead of returning nothing.** When a remote host silently dropped packets, the connectivity probe and the server's response deadline expired at the same instant, so the error could never be sent and the browser saw an empty reply after a 15-second wait. Probes now finish early enough for the error to be delivered — a blackholed replication target reports a clear timeout error in about 10 seconds. - **Dashboard protection counts no longer double-count items that belong to more than one job.** A VM or container covered by two jobs was counted once per job, inflating the totals; and when only one of those jobs had produced a restore point the same item was reported as both "protected" and "pending" at once. Items are now counted once, and an item backed up by any job counts as protected. - **Size anomaly messages no longer contradict their own numbers.** A run marginally smaller than usual could be described as "This backup **grew** to 2.7 GB, about **<1×** its usual 2.8 GB". The wording now follows the actual direction of the change. - **Restore item-type filters are labelled properly** — the ZFS filter read "zfss" and virtual machines read "vms"; they now read "ZFS Datasets" and "VMs". - **Jobs, Restore, and History stay responsive with production-scale data.** The pages now hydrate job items, anomaly baselines, and run history through bounded bulk queries instead of issuing hundreds of browser requests; bulk queries are cancellation-aware and indexed for deterministic newest-first history. - **Replica mode no longer requests daemon-only discovery APIs or exposes daemon-only pages through direct links.** Jobs, Restore, Recovery, and Recover Vault now redirect to Dashboard on a replica, matching the hidden navigation and read-only API contract. - **Custom folder jobs can no longer bypass the server's allowed-root policy by typing or crafting a path.** Create and update validate `settings.path` and legacy path fields against the same `/mnt`, `/boot`, and discovered-ZFS boundary used by the browser, while retaining overlap protection against backing up into the destination itself. - **Recovery readiness now counts configured items consistently when discovery is unavailable.** Duplicate items are counted once, custom folders remain tracked, Flash Drive follows its own setting, and the page no longer shows a false empty state beside a non-zero protected count. - **Historical anomaly messages with the old “grew … `<1×`” contradiction now render correctly** in both Anomalies and Activity Log without rewriting stored audit history. - **Very large VM backups to Hetzner Storage Box (WebDAV) no longer fail on the final metadata write** (closes #242). Hetzner answers a WebDAV "create directory" request for a directory that already exists with `403` instead of the standard `405`, which made the very last, tiny file of a multi-hour VM upload fail and discard the entire finished backup. Vault now recognises this reply (and any other non-standard status) by verifying that the directory actually exists, and treats it as success. Long VM copies also log a progress milestone every 5 minutes (percent, bytes, elapsed time), so a failure hours into a large backup is finally diagnosable from the diagnostics bundle. - **Folder backups of cache-only shares no longer produce empty archives.** On Unraid a cache-only share appears under `/mnt/user/` as a symlink to `/mnt/cache/`, and the backup walk did not follow it — it saw a symlink rather than a directory, never descended, and wrote an archive containing nothing. Both the classic tar and dedup paths now resolve the source symlink before walking, so the share's real contents are captured. The recorded restore location still uses the path you configured (e.g. `/mnt/user/myshare`), not the internal cache target, so restores land where you expect. - **Configuration — including the Temporary Work Area path — no longer reverts to an old state after a server reboot** (closes #241). Two root causes fixed: (1) on boot Vault now restores the **freshest** valid database copy by comparing modification times across all persistence locations — including the USB-direct database written when a boot ran before the cache pool was mounted, which the old fixed-order chain never even considered, so newer settings were silently overwritten by a stale cache snapshot; (2) the plugin now installs Unraid event hooks: `stopping_svcs` stops the daemon gracefully on reboot/array-stop so the final database flush gets the chance to complete before the OS kills processes, and `started` starts the daemon after the array is up if the boot-time start raced the pool mount. ## [v2026.07.07] - 2026-07-19 ### Security - **Storage adapters now contain symlinks to the destination root.** A symlink planted under a local destination can no longer redirect reads, listings, or deletes outside the configured base path (resolved-to-resolved comparison, so destinations that legitimately live behind symlinks like `/mnt/user` fuse paths keep working). SFTP destinations get the same server-side check (via `realpath`) on the browse/download surface. S3 has no symlinks; SMB/NFS client libraries offer no resolution primitive and follow their server's own symlink policy. ### Fixed - **Very large VM backups (100 GB+) no longer fail or destabilise the daemon** (closes #239). The VM engine's fixed 2-hour wall-clock limit is replaced by a progress-resetting 30-minute idle timeout, so a huge disk that is still copying can run for as long as it needs — only a genuinely stalled job is aborted, and Vault now attempts (with retries and verification) to abort the in-flight libvirt job on stall or cancellation so the hypervisor stops writing into the staging area. Full VM backups check staging free space up front and fail fast with an actionable message instead of dying mid-copy, and background worker panics (uploads, downloads, health checks, async cleanups) are contained as failed runs instead of crashing the whole daemon. - **Restoring an incremental/differential folder backup no longer resurrects deleted or excluded files** (closes #231). Folder backups now record an authoritative point-in-time file listing alongside each restore point; after replaying the chain, the restore prunes files that earlier chain steps wrote but that no longer existed (or were excluded) at the selected point. Dedup folder restore points restore directly from their complete manifest — no chain replay at all, which also makes them faster. The restore wizard's file picker reflects the same rules, so it only offers files a restore will actually produce. Backups made before this version have no listing and keep the old overlay behavior. - **Running and queued backups can now actually be cancelled from the UI** (closes #235, closes #238). The documented Cancel flow never had a button: the Jobs page now shows a Cancel control on the running/queued job's row, and the Dashboard's "Backup in progress" tile has a Cancel button with live "Cancelling…" state. Cancelling a job that is **queued** behind another run (e.g. Run Now on a scheduled job while a backup is active) removes it from the queue before it starts — previously such runs could not be stopped at all. Deleting a running job now cancels its run first instead of leaving the backup goroutine running against deleted records. ### Added - **Adaptive backups — jobs can wait for their workloads to go idle** (closes #240). A new per-job "Adaptive Backup" toggle checks each container's CPU/network activity (Docker stats), each VM's CPU activity (libvirt), and each folder's recent writes before a scheduled run starts. Busy workloads (Plex mid-stream, a download still writing) postpone the backup with a clear reason in History (new `postponed` status), re-checking every few minutes until idle — or until a configurable max-postpone window elapses, when the backup runs anyway so busy systems still get protected. Manual Run Now always runs immediately. Idle thresholds and windows are configurable in the global settings; probe failures never block a backup (fail-open). - **Local destinations can now be throttled too** (#237 follow-up). The per-destination bandwidth cap — previously hidden for local storage — now appears as a "Disk write speed limit" on local destinations, capping how fast backups write to disk so apps streaming from the same array (Plex, Jellyfin) keep their I/O headroom while a backup runs. The cap is **write-only**: restores and verification always read at full speed. Note the unit is megabits (8 Mbps = 1 MB/s). The cap applies to the copy-to-destination phase; keep staging on a different disk (cache, the default) for full effect. - **Adaptive upload throttling — Vault now yields bandwidth to Plex, Jellyfin and friends** (closes #237). An opt-in "Upload Throttling" section in Settings lets you enter your link's upload capacity; Vault then continuously measures non-Vault traffic on the network interface and slows its own uploads to network destinations so streaming keeps its bandwidth, speeding back up when the link goes quiet. A configurable minimum rate keeps backups progressing, per-destination bandwidth caps still apply (stricter limit wins), and changes take effect within seconds without a restart. - **Browse the files on a storage destination like an FTP client** (closes #236). Each destination card on the Storage page now has a Browse button that opens a read-only file browser — drill into folders with breadcrumb navigation and see file sizes and modification times for whatever Vault sees on the destination (local, SFTP, SMB, NFS, WebDAV, S3). Available in replica mode too since it never writes anything. ### Changed - **The Replication and Storage Logging toggles in Settings now apply immediately** — flipping the switch saves right away (rolling back with an error toast if the save fails), so the separate "Save Replication Settings" and "Save" buttons are gone. - **The Full Form "sensible defaults" note in the job editor is now a prominent info callout** instead of small dim text, so it's clear why Advanced Options are not shown in Full Form mode (closes #232). - **Documentation is now linked directly from the sidebar** — a new "Documentation" entry in the System section opens the Vault manual in a new tab (closes #233). - **Backup type docs and job editor now explain first-run behavior**: selecting Incremental or Differential on a new job automatically performs a Full backup on the first run, so no manual initial Full is needed. Clarified in the manual, the API reference, the `backup_type_chain` field description, and the job editor's helper text (closes #234). ## [v2026.07.06] - 2026-07-18 ### Fixed - **Restoring an incremental or differential backup no longer sends duplicate "Restore completed" notifications.** The runner notified once per chain step (base full + each increment), so a single folder restore could produce several identical Unraid notifications — some arriving while the restore was still running. One notification is now sent per restored item, after the whole chain has been replayed. - **The restore wizard's file picker now works on incremental and differential restore points.** Browsing a restore point only listed the files captured by that specific increment — often nothing, showing an empty "No files match" picker even though a restore would recover the full folder. The picker now merges the file lists of the entire backup chain (base full plus increments, newest version of each file winning), matching what a restore actually produces. ## [v2026.07.05] - 2026-07-18 ### Fixed - **Folder backups now honor the `exclude_paths` setting** (#230). The runner never passed a folder item's configured exclusions to the backup engine, so exclusion patterns (e.g. `*.log` or a `logs` directory) were silently ignored and excluded files still ended up in folder backups. Exclusions now apply to both classic tar and dedup/chunked folder backups, matching the container behavior. Note: exclusions added to an existing incremental/differential chain take full effect from the next **full** backup (#231). Thanks to **@0x1917** for the fix and regression tests! ## [v2026.07.04] - 2026-07-16 ### Fixed - **Bind address and port changes now actually restart the daemon** (Settings → Vault → Apply). The plugin package shipped `apply.sh` without execute permission, so Unraid's settings-save hook failed with a silent "Permission denied" and the daemon kept running on its previous address. Selecting a NIC address (e.g. `192.168.20.21`) then made the web UI report **Vault daemon unavailable** while Start insisted it was **already running**, because the daemon was still bound to `127.0.0.1`. The package now ships `apply.sh` executable and the installer re-applies the permission on every install/upgrade, healing existing installs. ## [v2026.07.03] - 2026-07-14 ### Added - **Recover Vault wizard — guided disaster recovery after a server rebuild** (closes #220). A fresh install now offers a clear choice — **Set up Vault** or **Recover from a backup** — and the new five-step wizard walks you through reconnecting your backup storage, entering your backup password, restoring the Vault database (jobs, storage, history, settings), and fixing folder paths that changed on the rebuilt server. Also reachable any time from Recovery → **Recover Vault**. - **Database restore now works with encrypted backups.** `POST /api/v1/storage/{id}/restore-db` restores the encrypted `vault.db.latest.age` snapshots Vault writes by default (previously only a plaintext `vault.db` could be restored), verifies your backup password before touching anything (`verify_only`), re-seals the password with the new server's key so scheduled DB backups stay encrypted after recovery, and reloads the database in-process — **no daemon restart needed** after a restore. - `GET /api/v1/storage/{id}/db-backups` lists the database snapshots available on a destination (newest first, with the latest pointer flagged). - **Path check after recovery**: `GET /api/v1/recovery/path-audit` flags folder and local-storage paths from the restored config that don't exist on this server, and `POST /api/v1/recovery/path-remap` updates them in place — the exact "my array and shares are different now" pain from #220. - **Disaster Recovery guide** in the docs — including why hand-copying `vault.db` to the flash drive doesn't work and what to do instead. ### Fixed - **Vault's own database now reliably runs in WAL mode with a 30-second lock timeout.** The SQLite driver was silently ignoring the connection options that were meant to set these, so freshly created databases ran in rollback-journal mode with no busy timeout — slower under concurrent access and more prone to "database is locked" errors. All connection settings are now applied in a form the driver understands, and Vault logs a warning if WAL is unavailable on the filesystem. ### Security - Snapshot rotation and database-backup temp-file cleanup now refuse to delete any path that isn't absolute and traversal-free — hardening flagged by CodeQL; no user-visible behavior change. ### Changed - **Dashboard is now uncluttered on a fresh install.** Until you've configured storage _and_ your first backup job, the dashboard shows just the Getting Started guide plus the four at-a-glance KPI tiles (Health, Protected, Next Run, Last Backup). The heavier panels — Backup Jobs, Protection status, Recent Activity, 3-2-1 rule, and the storage/analytics tiles — stay hidden until there's real data to fill them, so they no longer render empty. They appear automatically once your first job exists. - **Documentation screenshots refreshed** to match the current Vault dashboard, storage, job wizard, recovery, responsive, and theme-aware UI. - **License changed from MIT to AGPL-3.0.** Vault is now distributed under the GNU Affero General Public License v3.0 to keep derivative and network-served modifications open source. ## [v2026.07.02] - 2026-07-09 ### Added - **VM backup jobs now detect raw vs qcow2 disks and only offer supported backup types** (closes #163). Vault reads each VM's disk format at discovery time and exposes it (`disk_format`, `supports_incremental`) on `GET /api/v1/vms`. Because libvirt checkpoint-based incremental/differential backups require qcow2 disks, the job wizard now hides those options — and falls back to Full — when a selected VM uses raw or mixed disks, with an inline note naming the affected VM. This surfaces up front the constraint the engine previously enforced silently by downgrading raw-disk VMs to full backups. - **Pre/post-backup scripts now receive job context as environment variables** (part of #187). The job editor has long promised `VAULT_JOB_NAME` and `VAULT_STATUS` to hook scripts, but they were never actually set. Vault now exports `VAULT_JOB_NAME`, `VAULT_STATUS` (`starting` for pre-scripts, the run's final status for post-scripts), `VAULT_JOB_ID`, and `VAULT_RUN_ID` into every pre/post-backup script's environment — enabling, for example, an Immich `pg_dump` pre-script that targets the right job. - **Container exclusion presets can now carry advisory notes and warnings** (part of #187). `GET /api/v1/presets/exclusions` returns optional `notes`/`warnings` for apps that need a caveat, and the job wizard shows them inline beneath the container's exclusions. - **Docker named volumes are now backed up, not silently skipped.** Vault previously only backed up bind mounts, so containers that store their data in named `docker volume`s (many compose stacks — the single most-reported gap for the old appdata.backup plugin) were archived empty. Vault now backs up named volume mounts too — a volume's data lives at a real host path (`/var/lib/docker/volumes//_data`), handled exactly like a bind mount — across the classic, dedup and restore paths, and reattaches them on restore via the container's bind spec. The job wizard's mount editor lists them tagged "named volume" so you can include or exclude each one. Anonymous volumes (a random 64-hex id) are deliberately skipped because they can't be reliably reattached on restore; tmpfs/npipe and other non-data mounts are skipped as before. - **Compression level is now configurable per job** (fastest / default / better / best). The job wizard exposes a level next to the compression algorithm, mapped to the zstd/gzip encoder levels, so you can trade backup time for archive size. The default level is unchanged, so existing jobs and their archives are byte-for-byte identical. - **Jobs that would back up into their own source are now rejected up front.** Saving a job whose local destination path sits inside (or contains) one of its folder/flash source trees now returns a clear error instead of silently archiving each run into the next run's source — the old plugin's "backing itself up" footgun. Remote destinations are unaffected. - **Sparse files that could stall a backup are now flagged.** Vault logs a warning when a large file's logical size vastly exceeds its on-disk size (e.g. a Meilisearch/LMDB store reporting terabytes but occupying kilobytes), so a slow backup reading the full logical extent is no longer a silent mystery — the exact trap behind the old plugin's multi-hour stalls. - **Containers that route their network through another container are flagged.** When a container uses `network_mode: container:` (a Gluetun/VPN-sidecar setup), Vault now logs a note reminding you to include the provider in the same job and order it to start first, so the dependent doesn't lose its network when restarted after backup. - **The Command Palette (⌘K) now finds backup items and restore points, not just pages** (closes #210). Typing a container, VM, or folder name surfaces it under an **Items** group and jumps straight to its restore view in one action; each job's most recent restore point is listed under **Restore Points**. Results are grouped (Pages / Items / Restore Points / Jobs / Storage) and loaded once when the palette opens, so filtering stays instant. - **Storage destinations can now be tested before saving** (part of #206). The add/edit modal has a prominent **Test connection** button that validates the current form — including S3 credentials and endpoints — against the backend before you commit the destination, with clear pending / success / failure states. Backed by a new `POST /api/v1/storage/test` endpoint that builds an ephemeral adapter from the posted config. - **Two more Dashboard tiles — Retention & cleanup and Replication status** (Dashboard polish). _Retention & cleanup_ shows how many restore points a job's long-term-retention policy would prune next, out of the total, plus when the next prune runs; _Replication status_ rolls up offsite sync state across replication sources (synced count and most recent last-synced time, red when any source failed). Both draw from data already on the page, appear in the Customize catalog, and hide automatically when no job has a retention policy / no replication source is configured. - **Redesigned, customizable Dashboard** (Dashboard "5A" + E13). The Dashboard now leads with a status hero — **Health**, **Protected** (X/Y + bar), **Next run** countdown, and **Last backup** result — so "am I protected right now?" is answered above the fold, followed by a live **Backup in progress** / **Recent activity** / **Backup jobs** / **Protection status** grid. Every tile is drawn from data already on the page (no new endpoints). Info tiles are click-through — Next run → Jobs, Last backup / Health / Success rate → History, Storage → Storage, and so on. A **Customize** mode lets you add, remove, and reorder tiles (drag on desktop, ↑/↓ buttons for keyboard and touch) from a catalog that also includes Storage (combined & per-target), Recovery readiness, Needs attention, Success rate, Anomalies, Quick actions, **Backup size trend**, **Backup calendar** heatmap, **Dedup & compression** savings, **Storage forecast** ("full in ~N days"), and **Largest backups** — each drawn from data already available (history trend, dedup stats, capacity trajectory) and loaded lazily only when the tile is added; Tiles are **resizable** in Customize mode (narrower/wider steps across the 12-col grid), size to their content, and **compact vertically** — each tile packs upward into the space a shorter neighbour would otherwise leave beside a taller one, so the grid stays tidy with no holes between tiles (a fixed-grid-with-compaction model, as used by Grafana/Datadog/gridstack, rather than Pinterest-style masonry which reorders unpredictably when the layout or screen size changes). The layout and per-tile widths persist per-browser (`vault:dash:layout:v1`) and **Reset** restores the default. Every tile now follows one compact, data-dense layout — an icon-chip header with an uppercase label, a large tabular-figure value, and a micro-visual (progress/usage bar, sparkline, dedup ratio, or the contribution-style calendar heatmap) — for a consistent, professional ops-dashboard look in light, dark, and retro themes. On phones the small KPI tiles sit two-up (single column only below 360px). The 12-column grid reflows to 2-up tiles and full-width panels on tablets and phones (single column on very small screens), and works in light, dark, and all three retro themes. The Create Job wizard now offers a **Switch to full form** toggle that renders every primary field on one scrollable page for users who create jobs often; advanced settings keep their defaults and stay available in the step-by-step view. ### Fixed - **Dashboard polish — accessibility and correctness.** Added an app-wide `prefers-reduced-motion` guard so motion-sensitive users no longer get the live-progress pulse, progress-bar shimmer, run spinners, or the long health-ring/bar transitions (only a handful of named animations were previously covered). Compact KPI tiles (Health, Protected, Next run, Success rate, Needs attention, and the like) can no longer be stretched to full width in Customize mode — a per-tile width cap keeps a single figure from becoming an awkward full-width band, and any width persisted beyond the cap is clamped on load; wide panels (Jobs, Activity, Calendar, Size trend, Largest) still reach full width. On phones the narrower/wider resize arrows are hidden in Customize mode — the grid is forced to 1–2 columns there, so tapping them changed nothing — while reorder (↑/↓) and remove (×) still work. Essential KPI captions moved from the faint `text-dim` to `text-muted` for WCAG-AA contrast in light mode, and the Health tile gained a "View backup history" tooltip clarifying where it navigates. - **Replica (read-only) instances no longer show write actions they can't perform.** In replica mode the Dashboard (run-all, quick actions, per-item restore, 3-2-1 dismiss), Storage (add/edit/delete/import/DB-backup/cleanup/breaker), Replication (add/edit/delete/sync), Anomalies (ack/dismiss), History (purge), and the destructive/secret Settings controls (API key, encryption, snapshot path) are now hidden behind a "Read-only replica" notice, instead of surfacing buttons that only fail against the server's read-only guard. - **Duplicating a backup job no longer opens an unsaveable form.** Duplicate now carries the original job's backup type(s) across (it previously copied the items but not the type selection), so the wizard doesn't strand you on an invalid first step. Inline job rename also no longer fires twice when you press Enter and then blur. - **Recovery readiness copy now matches the ring, and the Restore banner tells the truth about where data lands.** The readiness summary follows the ring's colour bands (red under 50%, amber 50–99%, green only at 100%), an empty setup reads "No items to protect yet" instead of a false 100%, and the unprotected list now includes folders/flash so it matches the count. The restore overwrite banner shows a concrete path only when it's actually known (custom destination, or a folder/flash item) and says "its original location" for containers/VMs rather than a hard-coded `/mnt/user/appdata/...` that could be wrong. - **Assorted UI robustness fixes.** `partial`/`pending` backup runs now render amber consistently (Dashboard, History) instead of red/grey; the storage connection test can't show a stale "Connection OK" if you edit the form mid-test; the Settings "Database" jump link is hidden when that section isn't shown; the Dashboard protection bar turns green only when every item is truly protected; API requests now time out after 15s and report expired auth clearly; and Storage/History surface a load error instead of a falsely-empty "all clear" when their initial fetch fails. - **Express-mode job creation no longer submits invalid forms via Enter key.** The Jobs page form submit handler now applies the same `canSaveExpress` gate as the disabled Save buttons, so pressing Enter in an input cannot bypass required item/storage/name validation. - **The Dashboard "Next run" now shows the real countdown again.** The `/jobs/next-runs` (and `/jobs/{id}/next-run`) endpoints returned empty because the API router — and its job handler — is built in `NewServer`, before the daemon wires the scheduler's next-run resolver, so the handler captured a nil resolver and every job reported "not scheduled". The resolver is now propagated to the already-registered handler when it's set, so scheduled jobs report their next run correctly. - **Backups no longer fail when the Recycle Bin plugin is installed** (closes #204). The Unraid Recycle Bin plugin creates a `/mnt/RecycleBin` view directory (symlinks/subfolders, not a real pool) that it rebuilds periodically. Vault's staging-pool discovery treated it as a normal pool and staged backups there, so a docker or flash backup could fail mid-run with `open /mnt/RecycleBin/.vault-stage/…: no such file or directory` when the plugin regenerated the view. `/mnt/RecycleBin` (and any dot-prefixed `/mnt` entry such as a stray `.Recycle.Bin`) is now excluded from staging-pool candidates. - **Flash and folder backups now honour path exclusions** (closes #204). The classic tar backup path used for flash/folder items on non-dedup destinations ignored `exclude_paths` entirely, and the job wizard offered no way to set them. Folder items (including the Flash Drive preset) now have an "Exclude paths" editor in the wizard's advanced step, and the engine applies those exclusions on the classic path just as it already did for deduplicated backups — so you can, for example, exclude a share's `.Recycle.Bin` folder from a flash backup. - **The Recycle Bin plugin's trash folder is auto-excluded from the Flash Drive backup when detected** (closes #204). Vault now detects the installed Recycle Bin plugin (via its `/boot/config/plugins/recycle.bin.plg` marker) and, when present, pre-fills `.Recycle.Bin` as a recommended exclusion for the Flash Drive backup in the job wizard — so deleted files held in the trash aren't backed up. The pre-filled exclusion is shown in the editor and can be changed or removed. ### Changed - **Rate limiting on the encryption-verify and API-key endpoints now uses an explicit client-IP key.** Migrated off `httprate`'s `LimitByIP`, which a recent dependency bump deprecated (tripping the lint/build gate), to `LimitBy` with a documented key function. Behaviour is unchanged — requests are still bucketed by the connecting TCP peer address — the trust model is just stated explicitly now. - **Anomaly messages are now plain-English sentences** (closes #192). Every detector now describes what happened versus what was expected in friendly language — e.g. _"This backup took 4m 23s, about 1.2× its usual 3m 40s"_ instead of _"backup duration anomaly: 4m 23s (1.2x median)"_ — across the dashboard, the anomalies page, notifications, the API, and MCP. Notifications also replace the raw JSON blob (`{"z_score":11.06,...}`) with a short context line such as _"Based on the last 10 samples"_, while the precise statistics remain available in the structured `details` field for programmatic consumers. - **Tdarr container backups now drop stray log files and are covered by tests** (closes #188). The Tdarr exclusion preset already skipped the transcode cache (`/temp`) and log directory (`/app/logs`); it now also excludes stray `*.log` files, while deliberately keeping `/app/server` (the DB2 database, plugins and samples) and `/app/configs` so Tdarr settings and statistics restore cleanly. Both the combined `tdarr` image and the `tdarr_node` image are recognised, now with regression tests to keep detection working. - **Contrast** — new on-light accent token fixes amber-on-white text below WCAG AA 4.5:1 for small links and captions (Dashboard, Jobs, History); dark and retro themes unchanged (closes #205). - **Filter clarity** — paired filter groups on History (Status / Kind) and Logs (Category / Level) now carry muted micro-labels so two amber "active" controls no longer read as one (closes #205). - **Recovery readiness ring** — ring is now red below 50%, amber mid, and green only at 100%, matching the Dashboard protection bar (closes #205). - **Anomaly "Expected"** tooltip explains that marking Expected teaches the baseline (future similar runs won't be flagged), versus Dismiss which only clears it once (closes #205). - **Restore overwrite banner** names the concrete target path (e.g. `/mnt/user/appdata/plex`, a folder's own path, or the chosen custom destination). Restore deep-links (`?type=&name=`) from the Dashboard and command palette now preselect the item (closes #205). - **Sidebar** groups its items under **Protect / Recover / System** headers for faster wayfinding; replica mode and feature-flag hiding are preserved (closes #209). - **Dashboard Protection Status** collapses to a one-line summary at 100% coverage (choice persisted) so Recent Activity and Jobs move up; stays expanded when anything is unprotected (closes #211). - **Storage add flow** replaces the type dropdown with an icon card picker and adds S3 provider presets (AWS, Backblaze B2, Cloudflare R2, Wasabi, MinIO, IDrive E2, MEGA) that prefill endpoint/region (closes #206). - **Job wizard** consolidated to **4 clear steps** — What, Where & when, How, Name & review — with no fields removed and the express "full form" path intact (closes #207). - **Settings › General** gains a sticky **Jump to** bar so everyday toggles are reachable without long scrolling (closes #208). - **Immich container backups are now correct for both image variants** (closes #187). The exclusion preset previously only covered the imagegenius fork's `/photos` layout; it now also covers the official `immich-app` image's `/data` layout, excluding only regeneratable thumbnails and re-encoded video while always keeping the original `upload`/`library`/`profile` assets and Immich's built-in `backups/` database dumps. The wizard now also surfaces a warning that Immich's PostgreSQL database runs in a separate container and must be captured via Immich's built-in database backup, a dedicated Postgres backup job, or a `pg_dump` pre-script — see the Backup Jobs guide in the documentation for the recommended setup. ## [2026.07.01] - 2026-07-04 ### Added - **Documentation link in Settings → Reference** (closes #189). A new Documentation card at the top of the Reference tab links directly to the online manual at [tinkerologie.github.io/vault](https://tinkerologie.github.io/vault/), so users can reach the guides and settings reference without leaving the plugin. - **Optional "Backups" quick link in the Unraid top navigation** (closes #162). Enable it under Settings → Utilities → Vault → Quick Access to get a one-click **Backups** entry in the Unraid top bar that opens the Vault web UI embedded in the Unraid interface. Off by default. Unlike the community add-on that inspired it, this uses Unraid's supported plugin menu extension point — no core files are patched and no background watchdog is needed; the embedded view also refreshes its session token automatically when you return to a long-idle tab. ### Fixed - **Anomaly baselines no longer report a 100% failure rate for every job** (closes #181). The baseline refresher counted a run as failed unless its status was `success` — a value the runner never writes (successes are `completed`) — so every job's stored failure rate was a meaningless constant 1.0. Failures are now counted the same way the reliability detector counts them (`failed` status or failed items), producing real rates. - **Database snapshots are now written atomically** (closes #182). The pool snapshot and USB safety-net copy were written directly over the previous good file, so a crash, power loss, or full disk mid-save corrupted the very copy the daemon needs for disaster recovery. Snapshots are now written to a temp file and renamed into place only after completing successfully — and if the primary snapshot is ever unreadable at startup, the daemon now also tries the rotated snapshot copies (newest first) before falling back to the USB copy or a fresh database. - **Post-backup health checks show container names again after a container was recreated** (closes #178). In stop-all mode, the health check after restarting containers looked names up by the stored container ID — but when a container had been recreated (update, compose recreate) the runner works with the re-resolved ID, so the check reported the raw 64-character ID instead of the name. Names now resolve through the re-resolved IDs too. - **Filename validation now rejects single backslashes** (closes #179). The path-separator guard in restore-name validation checked for a double backslash due to an escaping mistake, letting a single `\` through this specific check (harmless on Linux, but the guard didn't do what it said). It now rejects both `/` and `\`. - **Replication sync results are now honest about failures** (closes #177). A sync whose jobs all failed to copy still recorded "success" and announced a completed sync — a false safety signal for an off-site-copy feature. Sync status is now `success` only when every job synced, `partial` when some failed, and `failed` when nothing synced, with the failure count shown on the Replication page (which previously showed failed syncs as "Pending"). - **Replicated restore-point metadata can no longer be silently corrupted** (closes #176). The replication origin marker was spliced into restore-point metadata by string concatenation, so a source name containing quotes/backslashes — or metadata like `{ }` — produced invalid JSON, silently stripping checksums and dedup manifest references from every replicated restore point. The metadata is now rebuilt through the JSON encoder and is always valid. - **Restoring over an existing installation no longer fails on pre-existing symlinks** (closes #175). Regular files are overwritten during restore, but symlink and hardlink entries were created without replacing what was already on disk — so restoring a container/folder backup onto a live appdata tree aborted with "file exists" at the first pre-existing link (version symlinks, certificate links, node_modules/.bin). Links are now replaced like files; directories are never silently removed. - **Deduplicated backups no longer fail outright on a single unreadable file** (closes #174). Classic backups skip inaccessible files with a log line, but the deduplicated (chunked) path aborted the entire item on the first permission-denied or vanished file — so the same job succeeded on a classic destination and hard-failed on a dedup one. The chunked walk now skips-and-logs identically, reporting the number of skipped paths in the log and progress message. - **Automatic database backups are now pruned instead of accumulating forever** (closes #184). After every successful run Vault copies its own database to the destination's `_vault/` directory, but old copies were never removed — one server had accumulated 136 copies (477 MB) in six weeks, and on quota-limited remotes this growth eventually crowds out real backups. Vault now keeps the newest 14 timestamped copies per destination (plus the `vault.db.latest` recovery pointer, which is always kept) and deletes older ones after each write. - **Deleting the last dedup job on a destination no longer destroys the daemon's database backups stored there** (closes #183). The dedup chunk repository and the automatic `vault.db` recovery copies share the `_vault` directory on every destination; the cleanup that removes an orphaned dedup repo deleted the entire directory — including every database backup. Cleanup now removes only the dedup-owned paths (`repo.json`, `packs/`, `index/`) and leaves the database copies untouched. - **Replication syncs no longer leak storage connections** (closes #173). Each replication sync opened the local storage destination but never closed it; for SFTP destinations that leaked up to four SSH connections per scheduled sync (and orphaned NFS mounts), accumulating until daemon restart. The adapter is now released when the sync completes. - **Daemon shutdown now waits for in-flight restores** (closes #172). A planned restart (plugin update, `rc.vault restart`) drained active backups but was blind to restores: shutdown could kill a restore mid-write, leaving half-restored appdata or VM disks. Restores now participate in the same drain protocol as backups — shutdown waits for them, and a draining daemon refuses to start new ones. - **Cancelling a VM backup or restore now takes effect immediately** (closes #171). VM operations ignored cancellation entirely: pressing Cancel (or the stall watchdog firing) had no effect until the libvirt backup job finished or its 2-hour timeout expired, and the hypervisor kept writing disk data into a staging directory the runner had already torn down. Cancellation is now honoured within seconds across every VM stage — the in-flight libvirt backup job is aborted (`DomainAbortJob`), multi-GB disk/NVRAM copies stop at the next 1 MiB chunk, and all shutdown/startup/readiness waits exit early — while VM teardown (restarting a cold-backed-up VM, tearing down the paused backup session) still always completes so a cancelled run never leaves a VM in the wrong power state. - **Backups of imported jobs no longer crash when a container's stored ID is missing** (closes #170). Jobs imported from existing backups (native manifests without per-item IDs, or appdata.backup imports) store containers with an empty ID; when such a job ran while a container with that name existed, the ID-change log line sliced the empty ID and panicked, failing the run with an opaque "Internal error (panic)" on every attempt. ID truncation is now bounds-checked everywhere. - **SMB capacity probes no longer leak a connection on every check** (closes #169). The scheduled capacity probe (and the Storage page's refresh button) opened an SMB session for the space query but never logged it off, leaking one authenticated session plus its TCP connection per probe until the NAS started refusing new SMB connections. The probe now releases the session like every other SMB operation. - **Empty directories left behind by deleted backups are now actually cleaned up** (closes #168). Deleting restore points (retention, manual deletion) removed the files but the sweep that prunes the now-empty parent directories never ran: the storage middleware chain didn't pass the directory-removal capability through to the underlying adapter, so the sweep silently did nothing on every destination. Local, SFTP, SMB, and NFS destinations now prune emptied parent directories again; object stores (S3) and WebDAV, which have no safe "remove if empty" primitive, are skipped cleanly. - **ZFS incremental and differential chains now record and use the correct parent snapshot** (closes #180). The snapshot created by each ZFS backup was never persisted into the restore point, so chained runs silently fell back to sending against whatever vault snapshot happened to be newest on the dataset — a "differential" was really an incremental against the previous run, and snapshot cleanup then destroyed the last full's snapshot (the chain anchor). The result: differential restore chains could not be received (`zfs recv: most recent snapshot does not match incremental source`). Each backup now records its snapshot per item in the restore point (`zfs_snapshots`), the next chain run resolves its `-i` parent from there (falling back to a full backup with a clear warning when the parent is missing or was deleted — including for restore points created before this fix), and cleanup preserves the chain-anchor snapshot alongside the newest one. - **Automatic retries now actually fire for pre-flight failures and stall-cancelled runs** (closes #167). When a run was skipped because its destination failed the pre-flight check (DNS blip, unmounted share), or cancelled by the no-progress stall watchdog, Vault scheduled a retry — but the retry watcher only ever dispatched retries for runs whose status was `failed`, so those scheduled retries sat in the database forever and never ran. The watcher now claims due retries for `failed`, `skipped`, and `cancelled` runs alike, matching the outcomes retries are scheduled for. - **Truncated archives can no longer be reported as successful backups** (closes #166). When finalising a container-volume, folder, or plugin archive, errors from the tar footer flush, the gzip/zstd trailer, and the final write to disk were silently discarded — so a disk that filled up during the last flush, or a file that shrank while being archived, produced a corrupt/truncated archive that the run reported (and even checksum-verified) as success, discovered only at restore time. All three finalisation steps now fail the backup with a clear error (`closing tar writer: …`, `finalising compression: …`, `closing archive file: …`). - **Deduplication garbage collection can no longer run while a backup is writing to the same destination** (closes #165). Starting a GC (Storage → Run GC, or the automatic reclaim after deleting a dedup job) while a dedup backup was in flight could delete the chunk packs that backup had just written — its restore point only becomes visible to GC at the very end of the run — silently corrupting the new backup so it would fail at restore time. GC now waits for the active backup/restore to finish before it scans (and backups queue behind a running GC), refuses to start during daemon shutdown, and shutdown in turn waits for an in-progress GC to complete. - **S3 backup verification no longer aborts (and deletes the upload) on large archives** (closes #164). S3 `Read`/`ReadRange` bound the streamed response body to the same 5-minute deadline used for quick metadata calls, so verifying any archive that takes longer than 5 minutes to stream back failed with `context deadline exceeded` — and the runner then removed the files it had just successfully uploaded, leaving the run failed and the destination empty. Streamed S3 reads now carry no wall-clock deadline (matching SFTP/WebDAV/local): closing the reader releases the connection, waiting for a dead endpoint's response headers is still bounded (5 minutes), and genuinely hung transfers are cancelled by the stall watchdog. Deep verify and restores from S3 destinations benefit the same way. ## [2026.07.00] - 2026-07-02 ### Fixed - **VM backups now honour the job's compression setting.** Container, folder, and plugin backups compress their archives in the engine, but VM backups stage raw artifacts (disk images, `domain.xml`, `vm_meta.json`, NVRAM) and the upload layer never applied a codec — so jobs configured for gzip or Zstandard still landed full-size, uncompressed `qcow2`/`img` files on the destination. VM uploads are now compressed in transit with the configured codec (stored as `.gz`/`.zst`, compressed before encryption) and transparently decompressed on restore; existing uncompressed backups keep restoring unchanged. A 10 GiB Fedora test disk dropped from 2.7 GB to 1.1 GB on the destination with zstd. - **VM backups no longer fail with `backup job ended unexpectedly: DomainJobNone`** (closes #160). When a libvirt push-backup job finished, Vault asked libvirt for the completed-job record to decide success or failure. On hosts where that record is unavailable at poll time (libvirt reports "no completed job" as a normal reply, not an error — e.g. another tool consumed the record or libvirtd reloaded), Vault misread the reply as a failure even though every byte had been copied, so backups that ran to completion (44+ minutes of real work) were reported as failed on every attempt. Vault now treats a missing completion record as "no verdict" and confirms success from the backup artifacts on disk (logging that it did so); a job that vanishes without producing its files still fails, now with an error naming the missing files instead of looping for 2 hours. - **`restoring domain state: Failed to terminate process … with SIGKILL: Device or resource busy` after VM backups** (#160). After pushing tens of GB through the page cache (typical on fuse-backed Unraid shares), the temporary paused backup session's qemu process can sit in uninterruptible IO, so libvirt's single kill attempt timed out, failed the whole backup, and could leave a cold-backed-up VM powered off. Teardown of the backup session (and the forced stop before a cold backup) now retries the destroy for up to 5 minutes until the flush completes, then restarts the VM as before. - **qcow2 disks in `.img` files are now backed up and restored in their real format** (#160). Disk format was inferred from the file extension, but Unraid names most vdisks `vdisk1.img` regardless of format. A qcow2-formatted `.img` (like the Windows VM in the report) was written out as a raw image and restored as raw while the domain XML still said qcow2 — an unbootable restore. The format now comes from the domain XML's `` (extension only as fallback), which also makes qcow2-in-`.img` VMs eligible for incremental backups for the first time. - **VM restore-chain assembly no longer assumes every chain layer ends in `.qcow2`.** Restoring an incremental chain resolves each step's disk files from the `vm_meta.json` the backup recorded (with the old filename scan as fallback for pre-existing backups), so chains whose full backup is a `.img` file flatten correctly; a chain whose disks cannot be resolved now fails loudly instead of silently restoring only the newest increment. `qemu-img` calls also honour restore cancellation, and layer/metadata filenames are validated before use. - **A VM whose disks are all skipped (e.g. block-passthrough only) now fails its backup with a clear error** instead of "succeeding" with no disk data. The per-VM disk inventory (eligible disks, formats, and skipped disks with the reason) plus the exact backup job submitted to libvirt are now logged, so future reports like #160 carry the root cause in `vault.log`. - **Restore wizard no longer shows `no tar index sidecar found for this item` for VMs.** The per-file restore picker only applies to tar-based backups (containers, folders, plugins); for VM and ZFS items the wizard showed a red sidecar-lookup error that looked like a broken restore point. Those items now show a plain "Restored in full" row, and the disabled **Start Restore** button explains that the pre-flight checks must be run first. ## [2026.06.07] - 2026-06-25 ### Changed - **Modals no longer close when you click the background** (closes #148). Clicking the dimmed backdrop behind a dialog – or releasing a text selection whose drag ended outside the dialog – used to dismiss the modal and discard any in-progress work, such as a half-filled backup job. Backdrop click-to-close has been removed from every modal: the job and storage wizards, confirmation dialogs, the command palette, and the path and script browsers. Modals are still dismissed the deliberate ways – the close (×) button, Cancel, or the Escape key – all unchanged. ### Fixed - **Creating a backup job now validates its input instead of saving a broken job or returning a 500.** `POST`/`PUT /api/v1/jobs` previously performed no validation: a request with no name (or a whitespace-only name) was saved as an unusable job, and a request with an invalid cron expression was saved as a job that silently never ran. The job create/update endpoints now reject an empty name with `400 name is required` and an unparsable schedule with `400 invalid schedule: …` (an empty schedule is still allowed — that means a manual-only job), matching the validation the storage and replication endpoints already had. Last-day-of-month schedules (`0 2 L * *`) and `@`-descriptors remain valid. - **Duplicate names now return a clear 409 instead of a generic 500.** Creating or renaming a job, storage destination, or replication source to a name that already exists tripped the underlying `UNIQUE` constraint and surfaced as `500 internal server error`. These now return `409 Conflict` with an actionable message (e.g. _"a job with this name already exists"_) so the UI and API clients can show the real reason. - **Container appdata on Unassigned Devices is no longer auto-excluded from backups** (closes #152). Bind mounts under `/mnt/disks/` (the Unassigned Devices mount point) were wrongly flagged as a "direct disk volume" and shown greyed-out in the job wizard's **Container Mounts & Exclusions** step, so appdata kept on an Unassigned Devices SSD (e.g. a container's `/config` at `/mnt/disks//appdata/`) could not be included in a backup. The direct-disk rule now matches only real array disks (`/mnt/disk1`, `/mnt/disk2`, …) and no longer catches the plural `/mnt/disks/`, so those mounts are backed up by default and are tickable like any other path. ## [2026.06.06] - 2026-06-22 ### Added - **Richer Discord notifications: bot identity and role pings** (#146). The **Settings → Notifications → Discord** card now lets you override the **Bot Username** and **Bot Avatar URL** the webhook posts under, and ping a **Mention Role ID** on alerts with a **Mention On** control (Never / Failures only / All backups). Pings are deliberately safe — only the role you specify is mentioned (`allowed_mentions` is locked down, so a stray mention can never `@everyone`), the role ID is validated as a numeric snowflake before use, and the **Test** button now reflects your configured bot name/avatar. By default nothing pings (Mention On = Never), so existing setups are unchanged. ### Fixed - **You can now choose to be notified on successful backups, not just failures** (closes #146). The per-job notification preference (`notify_on`) was already honoured by the backend and submitted by the job form, but the job wizard never rendered a control to change it, so it was stuck at its `failure`-only default and there was no way to opt into success notifications. The job wizard's **Step 5 → Advanced** now has a dedicated **Notifications** section (split out from Scripts for clarity) with a **Notify On** dropdown — **All backups (success & failure)** / **Failures only** / **Never** — and a tooltip noting that the global notifications toggle in Settings must be on and that Discord is configured separately. No backend, database, or API changes were required. ## [2026.06.05] - 2026-06-20 ### Added - **Configurable history retention.** A new **Settings → General → History Retention** control (30 days / 90 days / 6 months / 1 year / 2 years / Keep everything; default 1 year) caps how long backup/restore run history is kept, so the database doesn't grow without bound. The purge (which runs during the daemon's maintenance pass) is deliberately safe: it only removes run-history rows that are both older than the chosen period **and** no longer back a restore point – recoverable backups are governed solely by each job's own backup retention and are never deleted by this setting. - **Selectable backup-size trend window.** The History page's "Backup Size Trend" chart now has a period selector – **7d / 30d / 90d / 6m / 1y** (default 30d) – instead of a fixed last-30-runs view. Long ranges are aggregated server-side into readable buckets (per run for 7d, per day for 30d/90d, per week for 6m/1y) and the bars are stacked by backup type (containers / VMs / folders / flash). Backed by a new `GET /api/v1/history/trend` endpoint. - **Capacity trend and runway forecast on storage destinations.** Each destination's capacity block now shows a sparkline of usage over the last 90 days (the samples Vault already collects) plus a plain-language runway estimate – "~N days until full at current growth", "Usage stable or shrinking", or "Not enough history yet" – computed from a linear fit of recent samples. The trend and runway colour-shift to amber/red as the destination nears full. Quota-less providers (S3, generic WebDAV) show the usage trend without a percentage or runway. - **Dedup savings bar.** Deduplicated destinations now show a visual savings bar beside the existing ratio text: how much of the logical data is physically stored versus how much deduplication avoided writing (e.g. "Stored 29% · Saved 70.6 GB"). - **Search, filter, and sort your backup jobs.** The Jobs page now has a toolbar above the list: a **search** box (matches name and description), a **status** filter (All / Enabled / Disabled), a **storage** filter (any destination), and a **sort** control (Name A–Z, Next run, Recently created). A "Showing X of Y" counter and a **Clear filters** link appear when a filter is active, and the existing **Select all** plus bulk actions (enable / disable / run / delete) now operate on the _filtered_ set – so you can, for example, filter to Disabled and bulk-enable just those. All client-side over the already-loaded list, so there is no extra loading. Useful once you have more than a handful of jobs. - **Pre-flight checks before a restore.** The restore wizard's final step now runs a quick go/no-go check before you commit: **Storage reachable** (the destination answers), **Backup present on storage** (the restore point's data is actually there), **Decryptable** (the supplied passphrase matches, for age-encrypted jobs – skipped otherwise), and **Free space** (the staging/destination volume has room for the restore point). **Start Restore** stays disabled until the checks pass, so a doomed restore fails in a second instead of part-way through. Changing the passphrase or destination re-arms the checks ("Inputs changed since the last check"); when checks fail you can still **Restore anyway** behind a confirm. Backed by a new `POST /api/v1/jobs/{id}/restore-points/{rpid}/preflight` endpoint. - **Restore points now show as a visual timeline.** The restore wizard's version-selection step replaces the flat card list with a day-grouped timeline plus a density overview (one bar per day, sized by backup volume) so you can see at a glance what you can recover and from when. Each point shows its time, type (full/incremental/differential dot), size, and the trust signals Vault already computes – **Recommended** (newest), **Verified**, and chain health (broken / chain depth / retained). Calm, keyboard-navigable, and responsive. - **The 3-2-1 Backup Rule panel is now goal-aware.** Instead of always scoring against the full rule (which left a deliberately simple setup permanently red), you pick a backup goal that matches your intent and the panel scores against that: **Local only** (at least one copy), **Local + offsite** (a local copy plus a remote/replicated copy), or **Full 3-2-1** (3 copies, 2 media types, 1 offsite). It turns green when the chosen goal is met and only suggests the steps relevant to that goal. The goal is a segmented control in the panel; when unset it auto-selects the highest tier your current setup already meets. Persisted in a `backup_rule_goal` UI setting (frontend-only). Works alongside the show/hide toggle. - **The 3-2-1 Backup Rule panel can now be hidden.** The Dashboard's 3-2-1 compliance panel is useful guidance, but for an intentionally simple setup (e.g. a single server backing up locally) it stays permanently red, which reads as a failure rather than a choice. A new **×** on the panel hides it instantly, and a **Settings → Dashboard → Show 3-2-1 Backup Rule** toggle re-enables it. It remains shown by default, so the best-practice nudge is still there for anyone who wants it. No scoring changes. - **Per-mount include/exclude toggles for container backups** (closes #143). The job wizard's container section (now **Container Mounts & Exclusions**) lists every bind mount of each selected container with a checkbox – uncheck a mount to keep its data out of the backup (e.g. a media or downloads share mapped into Sonarr/Radarr/Plex). This replaces the slow, error-prone workflow of hand-typing each container path per container. Mounts Vault already auto-skips (device/virtual paths and known shared-data shares) are shown disabled with the reason, so it's clear what will and won't be backed up. A new `GET /api/v1/containers/{name}/mounts` endpoint exposes the mount list with each mount's auto-skip verdict. The free-text exclusion box is preserved below as **Additional path exclusions** for subpaths and glob patterns (e.g. `/config/Cache`, `*.log`); a checkbox and an exact whole-mount text exclusion stay in sync, so pre-existing exclusions (such as a `/rootfs` entry on Telegraf/Glances) correctly show their mount as unchecked. ### Fixed - **The History "Purge" button now warns it deletes restore points.** Purging run history runs a cascade that also removes the restore points those runs produced – the recoverable backups Vault tracks – leaving their files orphaned on storage. The confirmation dialog previously said only "delete all job run history records"; it now spells out that restore points go too and that this cannot be undone, so it isn't triggered by surprise. - **Adding an item to a job no longer claims it's already backed up.** When you added a container or VM to an existing job, it immediately showed as **protected** on the Dashboard and appeared in the Restore tab with the job's whole backup history (e.g. "backed up today, 5 restore points") — even though that backup had not run yet, and trying to restore it returned **"internal server error"**. The cause: protection and restore-point listing keyed off _job membership_ rather than what each backup actually captured. Vault already records exactly which items each restore point contains, so it now uses that: such an item shows as **"Pending first backup"** (a distinct amber state, not green-protected) until a backup really captures it; the Restore tab only lists restore points that contain the selected item; and browsing or restoring an item a restore point never captured now returns a clear message instead of a 500. The health ring's protection figure is likewise based on real per-item membership. _(Items backed up before this metadata existed are treated as before.)_ - **The Dashboard's Backup Health card no longer contradicts itself.** The ring (operational health: backup success plus protection of the items you've put in jobs) could read a green **100%** while its own subtitle said "Attention needed – 12 items unprotected" – because the subtitle was secretly using a different metric (whole-server coverage, e.g. 8/20). The subtitle now describes the same thing the ring does ("All backups healthy"), and overall coverage is shown as a calm, non-alarming hint ("12 items not in any backup job · see Protection Status") that points at the Protection Status panel, which owns that metric. A deliberately partial setup (backing up only some containers) now stays green instead of looking broken. - **Consistent save buttons across Settings.** Every primary action button now behaves the same while working: it shows an inline spinner next to its label (previously only some did – the encryption, API-key, retry-policy, compaction, and staging/snapshot "Apply" buttons just swapped text or did nothing visible). Also fixed seven Settings buttons that used an undefined `hover` colour and so had **no hover effect at all**, normalised two others that used a different hover shade, and standardised the busy-label punctuation (`Saving…` rather than a mix of `Saving...` and `Saving…`) and font weight. - **Storage destination cards no longer look uneven.** Within a row, the cards already stretched to equal height, but each card's footer (timestamp · job count · health · Test) floated at a different vertical position, leaving a large empty gap under shorter cards. The footer is now pinned to the bottom of every card so the rows line up. - **Disabling anomaly detection now hides it on backup jobs too.** With anomaly detection turned off in Settings, the **Learning baseline** badge (and the per-job anomaly badge and the job editor's anomaly-sensitivity override) still appeared on backup jobs. The Jobs page now gates all anomaly UI on the global toggle, matching the Dashboard and the Anomalies page, so disabling anomaly detection hides it everywhere until it is re-enabled. - **Selecting a specific NIC bind address now works end-to-end** (closes #139). Cycling the **Bind Address** to a detected interface IP (e.g. `192.168.20.21`) left the daemon showing **STOPPED** and made the Vault UI unreachable – only `127.0.0.1` and `0.0.0.0` worked. Two causes are fixed: (1) `rc.vault` validated the address with a bare `ip` command, but the service/php-fpm execution context often lacks `/usr/sbin` in `PATH` (the same gap fixed for `apply.sh` in #136), so a valid NIC address silently failed validation and was reset to `127.0.0.1` _in memory_ – the daemon bound loopback while the UI health probe targeted the NIC IP. `rc.vault` now resolves the `ip` binary to an absolute path and, when `ip` is unavailable, accepts the configured address rather than rejecting it. (2) Once bound to a NIC IP, every request from the co-located Unraid PHP proxy arrived from a non-loopback source and was rejected with **401 "valid API key required"** when an API key was configured – breaking the _entire_ proxied UI, not just the status check. The daemon now exempts requests originating from the host itself (loopback **or** any local interface address), so the local proxy is trusted on any bind address while genuine remote LAN clients still require a key. - **The service status no longer shows a false STOPPED right after applying a configuration change.** A restart that takes the hybrid database-restore path can need ~15–20 s to bind and serve health; the post-Apply status poll gave up too early and reported STOPPED until a manual refresh. The poll window now extends to ~36 s. - **Deleting jobs no longer makes the daemon appear to hang** (issue #143). After any configuration change (job/storage/settings CRUD) the daemon flushes its database to the USB flash drive; this flush ran **synchronously on the request**, so deleting several jobs in a row serialised multiple multi-second flash writes and the UI froze (often mistaken for a crash). The flush is now scheduled off the request path and **coalesced**, so a burst of deletes triggers a single flush and each delete returns immediately. Shutdown durability is unchanged – a synchronous flush still runs before the daemon exits. - **"Delete all files" cleanup no longer fails when a backup directory is already gone** (issue #143). Removing a job or restore point with _delete files_ could report **"failed – files may remain on storage"** purely because a directory it tried to list no longer existed (e.g. a WebDAV `PROPFIND` returning `404` for an already-removed or never-written path). A missing directory now counts as already-clean instead of aborting the whole cleanup. - **Deleting a job on a deduplicated destination now reclaims its storage** (issue #143). Dedup backups share a single content-addressed repository (`_vault/`) across every job on a destination, so deleting one job previously left its now-orphaned chunks behind. Deleting a job now runs garbage collection to free the chunks no longer referenced by any surviving job, and when the **last** dedup job on a destination is removed the entire `_vault` repository (and its index) is deleted outright. - **WebDAV backups no longer fail with a transient `423 Locked` while creating folders.** Some WebDAV servers (notably Koofr) briefly lock a directory during `MKCOL` – especially right after a failed upload's partial files are cleaned up – so the very next `MkdirAll` returned `423` and failed the whole item (e.g. _"writing config.json to storage: … MkdirAll …/telegraf/: 423"_). Directory creation now retries transient WebDAV errors (`423`, `409`, `429`, `5xx`, network blips) with the same backoff already used for chunk uploads, so a momentary lock clears and the backup proceeds. Verified end-to-end against a live Koofr destination. ### Changed - **Dashboard VMs card reads "Virtual Machines" instead of "libvirt".** The stat card's subtitle showed the implementation name (`libvirt`); it now uses the friendlier "Virtual Machines" label. - **Item picker empty states follow Unraid's service naming.** When adding items to a job and the relevant service is off, the message now reads "Docker service not available" / "Virtual Machine service not available" instead of "Docker is not available" / "libvirt is not available". - **Configuration form on the Unraid plugin settings page is now left-aligned.** The **Daemon Port** and **Bind Address** labels and fields previously sat in an indented, right-aligned column (Unraid's default `dl/dt` styling); they now sit flush left in a clean label/field layout, matching the Unraid Management Agent plugin's Configuration page. - **Em dashes replaced with en dashes throughout the UI.** Swept the Svelte/JS web UI, the Unraid plugin settings page (including the Bind Address options), and these release notes (which render in the in-app View Changelog modal) to use en dashes instead of em dashes for a cleaner, more consistent look. - **Bind Address dropdown is grouped and no longer stretches across the screen.** The list is organised into labelled sections – _Local only (recommended)_, _All interfaces_, and _Network interfaces_ (detected NICs shown as ` – `) – and the control is given a fixed, compact width so the Configuration form stays left-aligned instead of spanning the full width of wide displays. - **Applying a configuration change reports its result inline instead of as raw script output near the footer.** Previously the Apply form streamed `rc.vault`'s raw output (e.g. "Stopping Vault daemon…") into Unraid's progress area just above the **Array Started** system-status bar. That output is now suppressed and replaced with a co-located "Applying configuration – restarting daemon…" note next to the form that resolves to the live RUNNING/STOPPED status – keeping the daemon's feedback in-component rather than in Unraid's reserved system-status area, consistent with Unraid plugin conventions and the official Unraid API plugin. - **The Dashboard's Protection Status panel grows to fit its contents** (issue #143). Each category column (Containers, VMs, Folders, Flash Drive) was capped at a fixed height with an inner scrollbar, so servers with many containers had to scroll within a small box. The columns now size to the number of items, showing every container/VM at a glance without a nested scrollbar. ### Removed - **The API Endpoints list was removed from Settings → Reference.** It enumerated every REST route in the UI, which added clutter without real value for operators (no comparable backup tool surfaces its API inside the app), so it has been dropped. The Reference tab keeps its Compression Guide and Backup Type Guide. Only the in-UI list was removed – the REST endpoints themselves are unaffected, and integrations that call `/api/v1/...` continue to work. ## [2026.06.04] - 2026-06-13 ### Added - **Network auto-discovery via mDNS/zeroconf** (closes #137). The daemon now advertises itself on the local network as a `_vault._tcp` service (with `version` and `tls` TXT records), so integrations such as the Home Assistant Vault integration can auto-discover a running Vault instance instead of requiring a manually entered address. Discovery is automatically **disabled** when the daemon is bound to a loopback address (the local-only default) – a `127.0.0.1`/`::1` bind is never advertised – and enabled for all-interfaces (`:24085`, `0.0.0.0`, `::`) or specific-NIC binds. Pure-Go and Avahi-compatible; advertisement failures are non-fatal and never block backups. - **Hide unused services for a cleaner interface.** Anomaly Detection and Replication can now be toggled off from **Settings**. Disabling a feature removes it from the sidebar (and the Dashboard, for Anomaly Detection) and halts its backend work – anomaly evaluation already stops, and scheduled replication syncs are now paused while disabled. Replication defaults to visible only when replication sources are already configured, so existing setups are unaffected and fresh installs stay uncluttered. Replication is always shown on replica instances (it is core there). Re-enabling a feature restores its page and resumes its backend work immediately, without a daemon restart. ### Fixed - **Backup jobs now fail when all of their targets are missing** (closes #138). When every container/VM/folder/plugin/dataset configured for a job no longer exists on the server, the run previously reported **completed** with zero items backed up, masking the fact that nothing was protected. The runner now records a **failed** run with a clear error listing the missing targets (and counts them as failed items) so the failure is visible in the UI, notifications, and the reliability detector. Because a missing target is a configuration problem rather than a transient/destination fault, this does not schedule a retry or trip the destination circuit breaker. A safety net also fails any run that ends with zero items processed despite items being configured. - **The Dashboard's Recent Activity now shows the real failure reason instead of "expand for details"** (follow-up to #138). The failure-reason helper only surfaced plain-text log lines containing the words "error"/"fail", so messages that contained neither (such as the all-targets-missing message) fell through to a generic "Backup failed – expand for details" – misleading on the Dashboard, which has no expand affordance. It now surfaces the first meaningful line of any plain-text log, and the remaining placeholder reads "see Logs for details". The Recent Activity failure line also now **wraps** (up to three lines, full text on hover) instead of truncating to a single cut-off line. - **Settings "Save" buttons no longer balloon and show "Loading…" while saving.** The Anomaly Detection, Storage Logging, and Discord Save buttons (and the Discord Test / diagnostics buttons) embedded the full-section loading spinner – which carries large padding and a "Loading…" label – directly inside the button. They now use a small inline spinner that keeps the button's shape and colour. ## [2026.06.03] - 2026-06-12 ### Changed - **S3 key sanitization is no longer silent.** When a job or item name contains characters that must be replaced for S3-compatible gateway signing (e.g. spaces – `QA S3 verify` is stored as `QA_S3_verify`), the daemon now logs the original → stored mapping once per session, so operators browsing the bucket can connect what they see to their job names instead of discovering the rename by accident. ### Fixed - **The Unraid Plugins page now shows "Vault" with a proper description.** The plugin list previously showed a lowercase **vault** with no description because Unraid's plugin manager renders `/usr/local/emhttp/plugins//README.md` for the title and description, falling back to the raw lowercase plugin name when that file is missing. The plugin package (and the dev deploy) now ship that README – title-cased name plus a one-paragraph summary – matching how other plugins present themselves. - **The Unraid Plugins page now shows "Vault" with a proper description.** The plugin list previously showed a lowercase **vault** with no description because Unraid's plugin manager renders `/usr/local/emhttp/plugins//README.md` for the title and description, falling back to the raw lowercase plugin name when that file is missing. The plugin package (and the dev deploy) now ship that README – title-cased name plus a one-paragraph summary – matching how other plugins present themselves. - **Importing backups now skips manifests written by a newer Vault version with a clear log message.** Previously a manifest with an unrecognised (future) schema version was silently parsed as a legacy manifest, which could import a backup with wrong or missing settings. Scan and import now check the manifest version and skip too-new manifests with an explicit "upgrade Vault to import this backup" log line; current and legacy manifests import unchanged. - **Bind Address dropdown now lists detected NIC addresses** (closes #136). The per-interface addresses (e.g. `192.168.20.21 (br0)`) never appeared because PHP silently casts the numeric-string `'4'`/`'6'` array keys in `vault_get_local_ips()` to integers, so the strict family comparison never matched and every detected address was filtered out. The keys are now non-numeric (`v4`/`v6`), and the `ip` binary is resolved to an absolute path (`/usr/sbin/ip`, fallback `/sbin/ip`) since the webgui's php-fpm PATH may not include `/usr/sbin`. The loopback and wildcard options (`127.0.0.1`, `::1`, `0.0.0.0`, `::`) are always present regardless of detection. The Apply-time bind-address validation in `apply.sh` uses the same absolute-path resolution and no longer resets a valid NIC address to `127.0.0.1` when the `ip` binary cannot be found. - **Activity Log and Replication pages no longer flicker every few seconds** (closes #135). When the UI runs through the Unraid plugin proxy it refreshes by polling (5 s on Logs, 10 s on Replication), and every poll swapped the whole list out for a loading spinner and back – a visible jitter/flicker on each tick. Background refreshes now update the list in place; the spinner only shows on first load and explicit user actions (filter change, Refresh). A failed background poll also keeps the current list on screen instead of flashing an error panel, and on Replication it no longer raises an error toast every tick. - **Restoring a chunked WebDAV backup no longer fails with a 404.** Restore downloads stream every object through ranged reads (the resumable reader introduced with resilient restores), but the WebDAV adapter's ranged read did not understand chunked (sidecar-manifest) uploads – and a chunked upload has no object at its logical path at all – so restoring any WebDAV backup larger than the chunk size (default 50 MiB) failed immediately. Ranged reads now detect the chunk manifest and serve the requested byte window from only the chunks that overlap it, restoring both the sequential resumable path and the parallel ranged-download path for chunked objects. Plain objects (including dedup pack files) are unaffected and keep their single-request fast path. - **Background backup-file cleanup now reports its outcome** (follow-up to #111). Deleting a job or restore point with its backup files returns immediately while the remote sweep runs in the background, but a failed sweep was only visible in the daemon log. A failure is now recorded in the Activity Log (with the storage error) so it is durably visible, and the Jobs page shows a toast when the sweep for a deleted job completes or fails. - **Timed-out finalization steps are now cancelled instead of leaking** (follow-up to #112). When a post-backup finalization step (manifest write, database backup, retention sweep) exceeded its hard timeout, the runner moved on – correct – but the hung step's goroutine kept running forever, holding its storage connection open. Each step now receives a context that is cancelled on timeout: in-flight storage I/O is aborted by closing the step's adapter, and the retention and database-backup sweeps stop between items, so the abandoned goroutine exits promptly. A cancelled retention sweep simply resumes on the job's next run. - **Dedup backups can no longer be falsely cancelled by the stall watchdog during long pack uploads** (follow-up to #110). The dedup (chunked) backup path only refreshed the no-progress watchdog once per completed file, so a single very large file uploading packs to a slow remote for over two hours could be cancelled mid-transfer. Pack uploads now heartbeat the watchdog on every chunk of bytes written, matching the classic upload path. ## [2026.06.02] - 2026-06-10 ### Fixed - **Copy API key works on plain-HTTP (non-secure) origins** (closes #129). The **Copy** button under Settings → API Key failed with `Failed to copy – clipboard access denied` in Safari and Chrome because the modern Clipboard API is only available in secure (HTTPS) contexts, and the Unraid web GUI is typically served over plain HTTP on the LAN. Copying now falls back to the legacy `execCommand('copy')` path via a shared clipboard helper when the Clipboard API is unavailable or denied. The Activity Log's per-entry copy button uses the same fallback. - **Changing the Bind Address (or Port) now reliably restarts the daemon when Apply is clicked** (closes #130). The plugin's `apply.sh` only restarted the daemon when the PID file was present and valid – a stale or missing PID file silently skipped the restart, leaving the daemon on the old address until a manual restart. It now falls back to `pidof` (the same process lookup `rc.vault` uses), prints an explicit `Restarting Vault daemon...` line in the Apply progress window, and probes the post-restart health check against the actual configured bind address (previously hardcoded to `127.0.0.1`, which could not confirm a daemon bound to a specific NIC address). ### Changed - **VM and folder backup progress now shows human-readable sizes** (closes #133). Live progress messages such as `backup in progress: 6453198848/34359738368 bytes` now read `backup in progress: 6 GB/32 GB`. The same formatting applies to VM disk restore, NVRAM copy, stale block-job wait, and the folder-backup manifest completion message. - **Anomaly detail values are now human-friendly** (closes #134). Anomaly details stored and shown in notifications, the API, and the UI previously carried full float precision (e.g. `Z Score: -16.76413455138884`, `Growth Factor: 0.5766537578335602`). New anomalies are rounded at the source (z-score and growth factor to 2 decimals, capacity ETA to 1 decimal, slope/intercept to whole bytes, free-space percentage to 1 decimal), and the Activity Log formats these fields at display time so entries recorded before this change read cleanly too. ## [2026.06.01] - 2026-06-08 ### Changed - **Renamed "Grandfather-Father-Son (GFS)" retention to "Long-Term Retention (LTR)"** across the UI, code, and docs (closes #125). LTR is the industry-standard enterprise term for keeping weekly/monthly/yearly recovery points (as used by Veeam, Druva, and others) and reads far more clearly than the legacy tape-rotation name. This is a **terminology-only** change – no database, API, or behaviour changes: the `keep_latest`/`keep_daily`/`keep_weekly`/`keep_monthly`/`keep_yearly` job fields and the `GET /jobs/{id}/retention-preview` endpoint are unchanged. - **Collapsible API reference** (closes #126). The API Endpoints list under Settings → Reference is now split into collapsible sections (Health & Realtime, Jobs, Storage Destinations, Deduplication, and so on), each showing its endpoint count and collapsed by default, so the reference stays tidy and easy to scan as the API grows. - **Clearer help tooltips.** Rewrote the tooltips for the more technical storage and settings options – WebDAV/S3 transfer tuning (chunk/part size, stall and request timeouts, path-style, bandwidth), dedup compaction threshold, database mode, and scheduled/backup verification – into plain language with a consistent _what it does → why it matters → recommended default_ structure. No behaviour change. ### Fixed - **Changing the daemon port no longer silently breaks the web UI** (closes #124). Previously an invalid or already-in-use port was written straight to `vault.cfg` (the port field's `1024-65535` limit was enforced only client-side), the daemon failed to bind and exited, and the UI became unreachable with no feedback. The plugin's `apply.sh` now validates the port server-side – rejecting non-numeric or out-of-range values and resetting to the default `24085` – then verifies the daemon actually comes back up after the restart and prints a clear diagnostic (with the tail of the log) if it does not. `rc.vault` now re-checks the process for a few seconds after launch and dumps the last log lines on a failed start instead of leaving a stale PID file. The Settings page polls the daemon's real status after an Apply and shows a visible error (instead of only a transient progress frame) when the chosen port can't be bound. ## [2026.06.00] - 2026-06-07 ### Fixed - **Large backups no longer fail with `dedup: pack size … exceeds safety bound` when an item contains a very large number of files (#118).** Backup manifests (the per-item index of every file and its chunks) were stored as a single JSON blob; for a directory with an enormous number of files – e.g. a Plex appdata `/tmp` tree – that blob grew past the packer's 96 MiB safety bound and the whole backup aborted. Oversized manifests are now split through the same content-defined chunker used for file data and stored as ordered segments behind a small envelope, so no single chunk approaches the bound regardless of file count. The split is transparent on restore, existing (v1) single-blob manifests still read unchanged, and because near-identical manifests now mostly re-reference existing segments, manifest storage also deduplicates across snapshots. A clearer early error also replaces the opaque packer failure if any oversized chunk is ever stored. - **Anomaly values are now human-readable, and the "Raw details" toggle works.** Anomaly summaries and the detail panel showed raw numbers – e.g. `backup size anomaly: 4258918530 bytes`, `Observed: 4259532913`, `Deviation: -27.28833643436231` – and clicking **Raw details** did nothing. Values now adapt to friendly units (bytes → KB/MB/GB, seconds → `5m 26s`, ETA → days) on both the summary line and the Observed/Expected fields, and the deviation z-score is rounded to two decimals. The **Raw details** disclosure was a `
` nested inside the row's `