Skip to content

feat(ui): toasts with undo for destructive actions (WI-1721) - #248

Merged
thedancingdeveloper merged 1 commit into
mainfrom
wi-1721-toasts-undo
Oct 20, 2026
Merged

thedancingdeveloper merged 1 commit into
mainfrom
wi-1721-toasts-undo

Conversation

@thedancingdeveloper

Copy link
Copy Markdown
Collaborator

WI-1721: toasts with undo for destructive actions (including a backend soft-delete). Written by a DeepSeek worker.

🤖 Generated with Claude Code

Destructive history deletes now go through a 10 s server-side undo window
instead of vanishing, and every UI message goes through one ToastService so
the app speaks with a single voice.

Backend
- Migration v20 adds `history.trash` / `history.trash_deadline` and mirrors
  both into the list-metadata table, which keeps the history page query off
  the NZB blobs. Every read path, the duplicate detector and retention all
  filter on the meta row.
- `history_trash`, `history_restore` and `history_purge_expired`, with a 30 s
  purge sweep next to the disk guard. Trashed rows keep their work
  directories until the window closes.
- `DELETE /api/history/{id}?trash=true` returns the trashed entries and their
  deadline; `POST /api/history/restore` restores per id and reports the ones
  whose window has closed. `POST /api/history/bulk` accepts `trash` and
  returns the same envelope.
- The default delete is still a hard delete, so SABnzbd/*arr clients and
  `clear all` keep their existing semantics.

Frontend
- New `ToastService` (success/info/error/undo) replaces direct MatSnackBar
  use across the app, so durations and dismiss affordances are consistent.
- History delete, bulk delete and clear-by-status offer Undo; the toast never
  outlives the server's deadline.
- e2e locator fixes for queue.spec.ts bulk count and downloads-mock upload:
  both matched the always-mounted sr-only live region as well as the visible
  element. New journey 5.5a covers delete-then-undo.
@thedancingdeveloper

Copy link
Copy Markdown
Collaborator Author

Review of 968a58c: no blockers (CI pending), with 4 should-fix data-safety items. GitHub rust and e2e were still pending; frontend passes.

Checks run locally:

  • cargo test -p nzb-core --lib passes 207/207, every nzb-web suite passes (lib 269/269), and --test history_bulk_api passes 8/8.
  • cargo clippy --workspace --all-targets -D warnings and fmt --check are clean.
  • ng test passes 285/285 and lint is clean.
  • No Cargo.lock changes.

Data safety: what's right

  • API compatibility. DELETE /api/history/{id} without ?trash=true is still the old hard delete returning {status: true}. SABnzbd and *arr clients and "clear all" are unchanged. POST /api/history/bulk only gains an optional trashed field (skip_serializing_if), and /api/history/restore is additive.
  • Soft delete is invisible everywhere. Migration v20 adds trash and trash_deadline, and mirrors trash into history_list_meta, so list queries don't walk the blob overflow chain. Every read filters trashed rows: list, count, categories, summaries (and so the SABnzbd compat API), history_get (and so retry and detail), history_get_listed, and the duplicate check.
  • Retention skips trashed rows, so it can't consume an open undo window.
  • The undo is lossless. The row keeps its NZB and retry blobs. While it's trashed, its work directories count as referenced, so the orphan sweep can't take them.
  • Restore is per-id, honours trash_deadline >= now, and reports late or unknown ids as failures. The UI turns those into "N could no longer be undone".
  • Eventual hard delete. history_purge_expired runs from the periodic loop every 30 ticks. It deletes expired rows, then drops orphan ledger rows, and it's deadline-based, so it also catches up after a restart. The RFC 3339 string comparison is safe here: everything is +00:00, and + sorts before . and the digits.
  • The toast is clamped to the server's purge_after. Clock skew can only shorten it (1 s floor), never stretch it past the window.

Should fix

  1. A large clear can hand out an undo that's already expired. In h_history_bulk, deadline = now + 10s is computed before the per-id loop. Each id is a history_get, which loads its blobs, plus its own history_trash transaction, so clearing a few thousand failed entries can take longer than 10 s. The response then offers an Undo whose window has already closed, and the concurrent 30 s purge can hard-delete the earliest rows mid-request. The user did confirm a clear-by-status, so no data is lost beyond intent, but the undo promise is broken. Either trash the whole status set in one UPDATE … WHERE with the deadline taken after the commit, or compute the deadline after the loop and re-stamp the trashed rows with it.
  2. Any later toast silently cancels the Undo. MatSnackBar shows one snackbar at a time, so a second delete, a poll error or any other toast inside the 10 s closes the first Undo for good. Bulk delete also no longer asks for confirmation ("Undo is the confirmation"), so two quick deletions leave the first one unrecoverable from the UI. Merge pending undos into one toast ("Deleted 5 entries · Undo"), or keep the confirmation dialog for bulk delete.
  3. Purging a trashed Failed entry doesn't reclaim its retry-checkpoint directory. QueueManager::history_purge_expired calls history_work_dirs(&db, id, status) after db.history_purge_expired has deleted the rows. For a Failed entry that function reads db.history_get_retry_data(id), which now returns nothing, so only incomplete/<id> is reclaimed and the checkpoint directory is left for the orphan sweep (if it scans that location). history_remove gets the order right. Collect the directories inside the DB call before the DELETE, and add a test with a failed entry that has a retry checkpoint.
  4. The v20 trigger broadens v19's scoping. v18/v19 deliberately defined history_list_meta_update as AFTER UPDATE OF <list columns>, so blob writes (nzb_data, retry_data, job_logs) don't rewrite the meta row. v20 recreates it as a bare AFTER UPDATE ON history, which is correct but fires on every history update. Use AFTER UPDATE OF id, download_time_secs, server_stats, failure_code, post_processing, delete_archives, par2_volumes_fetched, par2_volumes_available, trash.

Nits

@thedancingdeveloper
thedancingdeveloper added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 0b9f013 Oct 20, 2026
11 of 12 checks passed
@thedancingdeveloper
thedancingdeveloper deleted the wi-1721-toasts-undo branch October 20, 2026 16:38
thedancingdeveloper added a commit that referenced this pull request Oct 20, 2026
… toast service

Two re-review blockers on #249.

A manual Resume inside a window did not survive a real restart: the window
lists were only loaded when `globally_paused` was true, so after a resume the
first tick read the still-open occurrence as newly opened and paused again.
The lists (`open_windows`, `triggered_windows`, `resumed_windows`,
`user_paused_over`) are now always restored; only the live holds are gated on
`was_paused`.

The restart tests built their "restarted" manager by cloning the `Arc`, so the
in-memory holds and an in-memory database were shared and no persisted state
was ever read. They now use a file-backed database and a fresh
`QueueManager`, which is what makes the blocker reproducible; the
manual-resume test fails on the previous restore logic and passes now.

The schedule settings form still called `this.snack`, which #248 replaced with
`this.toast` (`ToastService`). The bundle failed on the merged tree; the three
calls now go through `this.toast` and the spec asserts on it.

Also guards `expire_user_hold` against pausing an already-paused queue, as
`apply_schedule_hold` does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant