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 ? (