From 364bf23163edb25604b3db47142198d64dcf6ef8 Mon Sep 17 00:00:00 2001
From: Nico <47644445+nicotsx@users.noreply.github.com>
Date: Sat, 11 Jul 2026 10:46:11 +0200
Subject: [PATCH] feat(restore): allow cancellation from UI (#1052)
* feat(restore): allow cancellation from UI
* fix: agent cancellation failure report
---
.../api-client/@tanstack/react-query.gen.ts | 38 ++---
app/client/api-client/index.ts | 10 +-
app/client/api-client/sdk.gen.ts | 12 +-
app/client/api-client/types.gen.ts | 56 ++++---
.../components/__test__/restore-form.test.tsx | 59 ++++++-
app/client/components/restore-form.tsx | 45 +++++-
.../modules/repositories/restore-tasks.ts | 37 +++--
.../routes/repository-details.tsx | 17 +-
.../__tests__/agents-manager.events.test.ts | 53 ++++++-
app/server/modules/agents/agents-manager.ts | 38 +++--
.../modules/agents/helpers/runtime-state.ts | 1 -
.../__tests__/repositories.service.test.ts | 4 +-
.../repositories/commands/doctor-command.ts | 7 +-
.../modules/repositories/commands/index.ts | 5 +-
.../repositories/commands/restore-command.ts | 149 ++++++++++++++++++
.../repositories/repositories.controller.ts | 9 --
.../modules/repositories/repositories.dto.ts | 26 ---
.../repositories/repositories.service.ts | 137 ++--------------
.../modules/repositories/restore-executor.ts | 145 -----------------
.../tasks/__tests__/tasks.controller.test.ts | 41 +++++
.../tasks/__tests__/tasks.lifecycle.test.ts | 1 +
app/server/modules/tasks/tasks.controller.ts | 20 ++-
app/server/modules/tasks/tasks.dto.ts | 25 +++
app/server/modules/tasks/tasks.lifecycle.ts | 9 +-
24 files changed, 513 insertions(+), 431 deletions(-)
create mode 100644 app/server/modules/repositories/commands/restore-command.ts
delete mode 100644 app/server/modules/repositories/restore-executor.ts
diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts
index 225c2d7d..ed766775 100644
--- a/app/client/api-client/@tanstack/react-query.gen.ts
+++ b/app/client/api-client/@tanstack/react-query.gen.ts
@@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
-import { browseFilesystem, cancelDoctor, createApiKey, createBackupSchedule, createDesktopSession, createNotificationDestination, createRepository, createVolume, deleteApiKey, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getApiKeys, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPasswordLoginStatus, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getTask, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listTasks, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setPasswordLoginStatus, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
-import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateApiKeyData, CreateApiKeyResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateDesktopSessionData, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteApiKeyData, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetApiKeysData, GetApiKeysResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetTaskData, GetTaskResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListTasksData, ListTasksResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetPasswordLoginStatusData, SetPasswordLoginStatusResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
+import { browseFilesystem, cancelTask, createApiKey, createBackupSchedule, createDesktopSession, createNotificationDestination, createRepository, createVolume, deleteApiKey, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getApiKeys, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPasswordLoginStatus, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getTask, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listTasks, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setPasswordLoginStatus, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
+import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelTaskData, CancelTaskResponse, CreateApiKeyData, CreateApiKeyResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateDesktopSessionData, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteApiKeyData, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetApiKeysData, GetApiKeysResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetTaskData, GetTaskResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListTasksData, ListTasksResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetPasswordLoginStatusData, SetPasswordLoginStatusResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey = [
Pick & {
@@ -891,23 +891,6 @@ export const restoreSnapshotMutation = (options?: Partial>): UseMutationOptions> => {
- const mutationOptions: UseMutationOptions> = {
- mutationFn: async (fnOptions) => {
- const { data } = await cancelDoctor({
- ...options,
- ...fnOptions,
- throwOnError: true
- });
- return data;
- }
- };
- return mutationOptions;
-};
-
/**
* Start an asynchronous doctor operation on a repository to fix common issues (unlock, check, repair index).
*/
@@ -1554,6 +1537,23 @@ export const getTaskOptions = (options: Options) => queryOptions>): UseMutationOptions> => {
+ const mutationOptions: UseMutationOptions> = {
+ mutationFn: async (fnOptions) => {
+ const { data } = await cancelTask({
+ ...options,
+ ...fnOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
/**
* Create an authenticated desktop session
*/
diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts
index fede5ac8..a1fa37a6 100644
--- a/app/client/api-client/index.ts
+++ b/app/client/api-client/index.ts
@@ -3,7 +3,7 @@
export {
browseFilesystem,
- cancelDoctor,
+ cancelTask,
createApiKey,
createBackupSchedule,
createDesktopSession,
@@ -94,10 +94,10 @@ export type {
BrowseFilesystemData,
BrowseFilesystemResponse,
BrowseFilesystemResponses,
- CancelDoctorData,
- CancelDoctorErrors,
- CancelDoctorResponse,
- CancelDoctorResponses,
+ CancelTaskData,
+ CancelTaskErrors,
+ CancelTaskResponse,
+ CancelTaskResponses,
ClientOptions,
CreateApiKeyData,
CreateApiKeyErrors,
diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts
index 4b86739f..ef29e169 100644
--- a/app/client/api-client/sdk.gen.ts
+++ b/app/client/api-client/sdk.gen.ts
@@ -3,7 +3,7 @@
import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, TDataShape } from './client';
import { client } from './client.gen';
-import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteApiKeyData, DeleteApiKeyErrors, DeleteApiKeyResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetApiKeysData, GetApiKeysResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetTaskData, GetTaskResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListTasksData, ListTasksResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetPasswordLoginStatusData, SetPasswordLoginStatusResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, StreamTaskEventsData, StreamTaskEventsResponse, StreamTaskEventsResponses, StreamTasksEventsData, StreamTasksEventsResponse, StreamTasksEventsResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
+import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelTaskData, CancelTaskErrors, CancelTaskResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteApiKeyData, DeleteApiKeyErrors, DeleteApiKeyResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetApiKeysData, GetApiKeysResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetTaskData, GetTaskResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListTasksData, ListTasksResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetPasswordLoginStatusData, SetPasswordLoginStatusResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, StreamTaskEventsData, StreamTaskEventsResponse, StreamTaskEventsResponses, StreamTasksEventsData, StreamTasksEventsResponse, StreamTasksEventsResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options = Options2 & {
/**
@@ -316,11 +316,6 @@ export const restoreSnapshot = (options: O
}
});
-/**
- * Cancel a running doctor operation on a repository
- */
-export const cancelDoctor = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}/doctor', ...options });
-
/**
* Start an asynchronous doctor operation on a repository to fix common issues (unlock, check, repair index).
*/
@@ -612,6 +607,11 @@ export const streamTaskEvents = (options:
*/
export const getTask = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/tasks/{taskId}', ...options });
+/**
+ * Request cancellation of a running task
+ */
+export const cancelTask = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/tasks/{taskId}/cancel', ...options });
+
/**
* Create an authenticated desktop session
*/
diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts
index ba69e0b3..d125efe9 100644
--- a/app/client/api-client/types.gen.ts
+++ b/app/client/api-client/types.gen.ts
@@ -2445,35 +2445,6 @@ export type RestoreSnapshotResponses = {
export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses];
-export type CancelDoctorData = {
- body?: never;
- path: {
- shortId: string;
- };
- query?: never;
- url: '/api/v1/repositories/{shortId}/doctor';
-};
-
-export type CancelDoctorErrors = {
- /**
- * No doctor operation is currently running
- */
- 409: unknown;
-};
-
-export type CancelDoctorResponses = {
- /**
- * Doctor operation cancelled
- */
- 200: {
- status: 'cancelled';
- } | {
- status: 'reset';
- };
-};
-
-export type CancelDoctorResponse = CancelDoctorResponses[keyof CancelDoctorResponses];
-
export type StartDoctorData = {
body?: never;
path: {
@@ -5911,6 +5882,33 @@ export type GetTaskResponses = {
export type GetTaskResponse = GetTaskResponses[keyof GetTaskResponses];
+export type CancelTaskData = {
+ body?: never;
+ path: {
+ taskId: string;
+ };
+ query?: never;
+ url: '/api/v1/tasks/{taskId}/cancel';
+};
+
+export type CancelTaskErrors = {
+ /**
+ * Task is not cancellable or is no longer running
+ */
+ 409: unknown;
+};
+
+export type CancelTaskResponses = {
+ /**
+ * Task cancellation requested
+ */
+ 202: {
+ status: 'cancelling';
+ };
+};
+
+export type CancelTaskResponse = CancelTaskResponses[keyof CancelTaskResponses];
+
export type CreateDesktopSessionData = {
body: {
dateFormat: 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY/MM/DD';
diff --git a/app/client/components/__test__/restore-form.test.tsx b/app/client/components/__test__/restore-form.test.tsx
index 12c19404..817ac686 100644
--- a/app/client/components/__test__/restore-form.test.tsx
+++ b/app/client/components/__test__/restore-form.test.tsx
@@ -153,8 +153,8 @@ describe("RestoreForm", () => {
test("recovers the active restore from the prefetched exact filtered collection", async () => {
const taskStream = await renderRestoreFormWithPrefetchedTask(createRestoreTask());
- const restoreButton = await screen.findByRole("button", { name: "Restoring..." });
- expect(restoreButton.hasAttribute("disabled")).toBe(true);
+ const restoreButton = await screen.findByRole("button", { name: "Cancel restore" });
+ expect(restoreButton.hasAttribute("disabled")).toBe(false);
expect(screen.getByText("Restore in progress")).toBeTruthy();
expect(taskStream?.url).toBe(
"/api/v1/tasks/events?kind=restore&resourceType=repository&resourceId=repo-1&operationKey=snap-1",
@@ -162,6 +162,23 @@ describe("RestoreForm", () => {
expect(MockEventSource.instances).toHaveLength(1);
});
+ test("cancels the exact active restore task", async () => {
+ let cancelledRestoreId: string | undefined;
+ server.use(
+ http.post("/api/v1/tasks/:taskId/cancel", ({ params }) => {
+ cancelledRestoreId = String(params.taskId);
+ return HttpResponse.json({ status: "cancelling" }, { status: 202 });
+ }),
+ );
+ await renderRestoreFormWithPrefetchedTask(createRestoreTask({ id: "restore-task-123" }));
+
+ await userEvent.click(screen.getByRole("button", { name: "Cancel restore" }));
+
+ await waitFor(() => {
+ expect(cancelledRestoreId).toBe("restore-task-123");
+ });
+ });
+
test("renders canonical task progress from the exact filtered stream", async () => {
const activeRestore = createRestoreTask();
const taskStream = await renderRestoreFormWithPrefetchedTask(activeRestore);
@@ -211,7 +228,7 @@ describe("RestoreForm", () => {
expect(queryClient.getMutationCache().getAll()[0]?.state.status).toBe("success");
});
- expect(screen.getByRole("button", { name: "Restoring..." }).hasAttribute("disabled")).toBe(true);
+ expect(screen.getByRole("button", { name: "Cancel restore" }).hasAttribute("disabled")).toBe(false);
expect(screen.queryByRole("button", { name: "Restore All" })).toBeNull();
await waitFor(() => {
@@ -225,7 +242,37 @@ describe("RestoreForm", () => {
.find((eventSource) => eventSource.url === "/api/v1/tasks/task-restore/events")
?.emit(taskChangedEventName, createRestoreTask());
- expect(await screen.findByRole("button", { name: "Restoring..." })).toBeTruthy();
+ expect(await screen.findByRole("button", { name: "Cancel restore" })).toBeTruthy();
+ });
+
+ test("uses the newest task update across the exact and filtered streams", async () => {
+ server.use(
+ snapshotFilesHandler,
+ http.post("/api/v1/repositories/:shortId/restore", () =>
+ HttpResponse.json({ restoreId: "task-restore", status: "started" }, { status: 202 }),
+ ),
+ );
+ renderRestoreForm();
+
+ await userEvent.click(screen.getByRole("button", { name: "Restore All" }));
+ await waitFor(() => {
+ expect(MockEventSource.instances).toHaveLength(2);
+ });
+
+ const exactStream = MockEventSource.instances.find(
+ (eventSource) => eventSource.url === "/api/v1/tasks/task-restore/events",
+ );
+ const filteredStream = MockEventSource.instances.find((eventSource) =>
+ eventSource.url.includes("/tasks/events?"),
+ );
+ exactStream?.emit(taskChangedEventName, createRestoreTask({ updatedAt: 1711411200001 }));
+ filteredStream?.emit(
+ taskChangedEventName,
+ createRestoreTask({ status: "succeeded", updatedAt: 1711411200002 }),
+ );
+
+ expect(await screen.findByText("Restore completed")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Cancel restore" })).toBeNull();
});
test.each([
@@ -254,8 +301,8 @@ describe("RestoreForm", () => {
status: "cancelled" as const,
result: null,
error: null,
- title: "Restore failed",
- description: "Snapshot snap-1 could not be restored.",
+ title: "Restore cancelled",
+ description: "Restore of snapshot snap-1 was cancelled.",
},
{
status: "stale" as const,
diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx
index 7583bba4..fee8a1df 100644
--- a/app/client/components/restore-form.tsx
+++ b/app/client/components/restore-form.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
-import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
+import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw, Square } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
@@ -20,7 +20,7 @@ import {
import { FolderSelector } from "~/client/components/folder-selector";
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
import { RestoreProgress } from "~/client/components/restore-progress";
-import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
+import { cancelTaskMutation, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useRestoreTask, type RestoreTask } from "~/client/modules/repositories/restore-tasks";
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic";
import { isPathWithin } from "@zerobyte/core/utils";
@@ -92,9 +92,17 @@ export function RestoreForm({
restoreProgress,
finishedRestoreTask,
clearFinishedRestoreTask,
+ activeRestoreTaskId,
isRestoreRunning: isRestoreTaskRunning,
} = useRestoreTask(repository.shortId, snapshotId, restoreStart?.restoreId, initialActiveTask);
+ const cancelRestore = useMutation({
+ ...cancelTaskMutation(),
+ onError: (error) => {
+ handleRepositoryError("Failed to cancel restore", error, repository.shortId);
+ },
+ });
+
const handleRestore = useCallback(() => {
const excludeXattrValues = excludeXattr
.split(",")
@@ -211,10 +219,25 @@ export function RestoreForm({
-
+ {activeRestoreTaskId ? (
+
+ ) : (
+
+ )}
@@ -372,12 +395,18 @@ export function RestoreForm({
- {finishedRestoreTask?.status === "succeeded" ? "Restore completed" : "Restore failed"}
+ {finishedRestoreTask?.status === "succeeded"
+ ? "Restore completed"
+ : finishedRestoreTask?.status === "cancelled"
+ ? "Restore cancelled"
+ : "Restore failed"}
{finishedRestoreTask?.status === "succeeded"
? `Snapshot ${snapshotId} was restored successfully.`
- : finishedRestoreTask?.error || `Snapshot ${snapshotId} could not be restored.`}
+ : finishedRestoreTask?.status === "cancelled"
+ ? finishedRestoreTask.error || `Restore of snapshot ${snapshotId} was cancelled.`
+ : finishedRestoreTask?.error || `Snapshot ${snapshotId} could not be restored.`}
diff --git a/app/client/modules/repositories/restore-tasks.ts b/app/client/modules/repositories/restore-tasks.ts
index 6a0a9d13..ccb980e4 100644
--- a/app/client/modules/repositories/restore-tasks.ts
+++ b/app/client/modules/repositories/restore-tasks.ts
@@ -33,34 +33,45 @@ export const getActiveRestoreTask = (tasks: ListTasksResponse): RestoreTask | nu
return task as RestoreTask;
};
+const latestTask = (tasks: Array) =>
+ tasks.reduce((latest, task) => {
+ if (!task) return latest;
+ if (!latest || task.updatedAt > latest.updatedAt) return task;
+ if (task.updatedAt === latest.updatedAt && task.id >= latest.id) return task;
+ return latest;
+ }, null);
+
export const useRestoreTask = (
repositoryId: string,
snapshotId: string,
startedTaskId?: string,
initialActiveTask?: RestoreTask | null,
) => {
- const [retainedFinishedTask, setRetainedFinishedTask] = useState(null);
+ const [lastFinishedTask, setLastFinishedTask] = useState(null);
const filter = restoreTasksFilter(repositoryId, snapshotId);
const { data: activeRestoreTasks } = useActiveTasks(filter, {
initialTasks: initialActiveTask ? [initialActiveTask] : undefined,
- onTaskFinished: setRetainedFinishedTask,
+ onTaskFinished: setLastFinishedTask,
});
- const { task: exactStartedTask } = useTask(startedTaskId);
- const activeRestoreTask = activeRestoreTasks?.[0] ?? null;
- const restoreTask = exactStartedTask ?? activeRestoreTask ?? retainedFinishedTask;
- const taskIsActive = restoreTask ? isTaskActive(restoreTask) : false;
- const finishedRestoreTask = restoreTask && !taskIsActive ? restoreTask : null;
+ const { task: startedTask } = useTask(startedTaskId);
+ const restoreTask = latestTask([startedTask, activeRestoreTasks?.[0] ?? null, lastFinishedTask]);
+ const activeRestoreTaskId =
+ restoreTask && isTaskActive(restoreTask)
+ ? restoreTask.id
+ : restoreTask === null && startedTaskId !== undefined
+ ? startedTaskId
+ : null;
+ const taskIsActive = restoreTask !== null && activeRestoreTaskId === restoreTask.id;
const clearFinishedRestoreTask = useCallback(() => {
- setRetainedFinishedTask(null);
+ setLastFinishedTask(null);
}, []);
- const restoreProgress = taskIsActive ? (restoreTask?.progress?.progress ?? null) : null;
-
return {
- restoreProgress,
- finishedRestoreTask,
+ restoreProgress: taskIsActive ? (restoreTask?.progress?.progress ?? null) : null,
+ finishedRestoreTask: restoreTask && !taskIsActive ? restoreTask : null,
clearFinishedRestoreTask,
- isRestoreRunning: (startedTaskId !== undefined && exactStartedTask === null) || taskIsActive,
+ activeRestoreTaskId,
+ isRestoreRunning: activeRestoreTaskId !== null,
};
};
diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx
index f7ebd9fd..d2cc50e7 100644
--- a/app/client/modules/repositories/routes/repository-details.tsx
+++ b/app/client/modules/repositories/routes/repository-details.tsx
@@ -4,7 +4,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import { ChevronDown, Database, Pencil, Square, Stethoscope, Trash2, Unlock } from "lucide-react";
import {
- cancelDoctorMutation,
+ cancelTaskMutation,
deleteRepositoryMutation,
getRepositoryOptions,
startDoctorMutation,
@@ -59,7 +59,7 @@ export default function RepositoryDetailsPage({
const { data: repository } = useSuspenseQuery({
...getRepositoryOptions({ path: { shortId: repositoryId } }),
});
- const { isDoctorRunning: hasActiveDoctorTask } = useRepositoryDoctorTask(repositoryId);
+ const { activeDoctorTask } = useRepositoryDoctorTask(repositoryId);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -89,12 +89,7 @@ export default function RepositoryDetailsPage({
});
const cancelDoctor = useMutation({
- ...cancelDoctorMutation(),
- onSuccess: (result) => {
- if (result.status === "reset") {
- toast.info("Doctor state reset");
- }
- },
+ ...cancelTaskMutation(),
onError: (error) => {
toast.error("Failed to cancel doctor", {
description: parseError(error)?.message,
@@ -111,7 +106,7 @@ export default function RepositoryDetailsPage({
deleteRepo.mutate({ path: { shortId: repository.shortId } });
};
- const isDoctorRunning = hasActiveDoctorTask || startDoctor.isPending;
+ const isDoctorRunning = activeDoctorTask !== null || startDoctor.isPending;
const displayStatus = isDoctorRunning ? "doctor" : repository.status;
return (
@@ -149,12 +144,12 @@ export default function RepositoryDetailsPage({
- {isDoctorRunning ? (
+ {activeDoctorTask ? (