diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index c751274c..a12a8929 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -5,6 +5,7 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from "@tanst import { client } from "../client.gen"; import { browseFilesystem, + cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, @@ -15,7 +16,6 @@ import { deleteSnapshot, deleteSnapshots, deleteVolume, - doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, @@ -62,6 +62,8 @@ import { import type { BrowseFilesystemData, BrowseFilesystemResponse, + CancelDoctorData, + CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, @@ -82,8 +84,6 @@ import type { DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, - DoctorRepositoryData, - DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, @@ -719,14 +719,33 @@ export const restoreSnapshotMutation = ( }; /** - * Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors. + * Cancel a running doctor operation on a repository */ -export const doctorRepositoryMutation = ( - options?: Partial>, -): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { +export const cancelDoctorMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { mutationFn: async (fnOptions) => { - const { data } = await doctorRepository({ + 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). The operation runs in the background and sends results via SSE events. + */ +export const startDoctorMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await startDoctor({ ...options, ...fnOptions, throwOnError: true, diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index db3c3de8..8fdb5504 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -2,6 +2,7 @@ export { browseFilesystem, + cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, @@ -12,7 +13,6 @@ export { deleteSnapshot, deleteSnapshots, deleteVolume, - doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, @@ -60,6 +60,10 @@ export type { BrowseFilesystemData, BrowseFilesystemResponse, BrowseFilesystemResponses, + CancelDoctorData, + CancelDoctorErrors, + CancelDoctorResponse, + CancelDoctorResponses, ClientOptions, CreateBackupScheduleData, CreateBackupScheduleResponse, @@ -92,9 +96,6 @@ export type { DeleteVolumeData, DeleteVolumeResponse, DeleteVolumeResponses, - DoctorRepositoryData, - DoctorRepositoryResponse, - DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponse, DownloadResticPasswordResponses, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 566dc80c..4d14cecd 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -5,6 +5,9 @@ import { client } from "./client.gen"; import type { BrowseFilesystemData, BrowseFilesystemResponses, + CancelDoctorData, + CancelDoctorErrors, + CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, @@ -26,8 +29,6 @@ import type { DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, - DoctorRepositoryData, - DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, @@ -422,12 +423,19 @@ export const restoreSnapshot = ( }); /** - * Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors. + * Cancel a running doctor operation on a repository */ -export const doctorRepository = ( - options: Options, -) => - (options.client ?? client).post({ +export const cancelDoctor = (options: Options) => + (options.client ?? client).delete({ + url: "/api/v1/repositories/{id}/doctor", + ...options, + }); + +/** + * Start an asynchronous doctor operation on a repository to fix common issues (unlock, check, repair index). The operation runs in the background and sends results via SSE events. + */ +export const startDoctor = (options: Options) => + (options.client ?? client).post({ url: "/api/v1/repositories/{id}/doctor", ...options, }); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 7143ffb6..51edfd79 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -912,12 +912,22 @@ export type ListRepositoriesResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }>; @@ -1340,12 +1350,22 @@ export type GetRepositoryResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; @@ -1550,12 +1570,22 @@ export type UpdateRepositoryResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; @@ -1731,7 +1761,7 @@ export type RestoreSnapshotResponses = { export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses]; -export type DoctorRepositoryData = { +export type CancelDoctorData = { body?: never; path: { id: string; @@ -1740,22 +1770,51 @@ export type DoctorRepositoryData = { url: "/api/v1/repositories/{id}/doctor"; }; -export type DoctorRepositoryResponses = { +export type CancelDoctorErrors = { /** - * Doctor operation completed + * No doctor operation is currently running + */ + 409: unknown; +}; + +export type CancelDoctorResponses = { + /** + * Doctor operation cancelled */ 200: { - steps: Array<{ - error: string | null; - output: string | null; - step: string; - success: boolean; - }>; - success: boolean; + message: string; }; }; -export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses]; +export type CancelDoctorResponse = CancelDoctorResponses[keyof CancelDoctorResponses]; + +export type StartDoctorData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/api/v1/repositories/{id}/doctor"; +}; + +export type StartDoctorErrors = { + /** + * Doctor operation already in progress + */ + 409: unknown; +}; + +export type StartDoctorResponses = { + /** + * Doctor operation started + */ + 202: { + message: string; + repositoryId: string; + }; +}; + +export type StartDoctorResponse = StartDoctorResponses[keyof StartDoctorResponses]; export type TagSnapshotsData = { body?: { @@ -1977,12 +2036,22 @@ export type ListBackupSchedulesResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; @@ -2349,12 +2418,22 @@ export type GetBackupScheduleResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; @@ -2702,12 +2781,22 @@ export type GetBackupScheduleForVolumeResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; @@ -3263,12 +3352,22 @@ export type GetScheduleMirrorsResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; @@ -3473,12 +3572,22 @@ export type UpdateScheduleMirrorsResponses = { }; }; createdAt: number; + doctorResult: { + completedAt: number; + steps: Array<{ + error: string | null; + output: string | null; + step: string; + success: boolean; + }>; + success: boolean; + } | null; id: string; lastChecked: number | null; lastError: string | null; name: string; shortId: string; - status: "error" | "healthy" | "unknown" | null; + status: "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; diff --git a/app/client/hooks/use-server-events.ts b/app/client/hooks/use-server-events.ts index fe3fc4bf..dd041398 100644 --- a/app/client/hooks/use-server-events.ts +++ b/app/client/hooks/use-server-events.ts @@ -11,7 +11,10 @@ type ServerEventType = | "volume:unmounted" | "volume:updated" | "mirror:started" - | "mirror:completed"; + | "mirror:completed" + | "doctor:started" + | "doctor:completed" + | "doctor:cancelled"; export interface BackupEvent { scheduleId: number; @@ -45,6 +48,21 @@ export interface MirrorEvent { error?: string; } +export interface DoctorEvent { + repositoryId: string; + repositoryName: string; +} + +export interface DoctorCompletedEvent extends DoctorEvent { + success: boolean; + steps: Array<{ + step: string; + success: boolean; + output: string | null; + error: string | null; + }>; +} + type EventHandler = (data: unknown) => void; /** @@ -156,6 +174,39 @@ export function useServerEvents() { }); }); + eventSource.addEventListener("doctor:started", (e) => { + const data = JSON.parse(e.data) as DoctorEvent; + console.info("[SSE] Doctor started:", data); + + void queryClient.invalidateQueries(); + + handlersRef.current.get("doctor:started")?.forEach((handler) => { + handler(data); + }); + }); + + eventSource.addEventListener("doctor:completed", (e) => { + const data = JSON.parse(e.data) as DoctorCompletedEvent; + console.info("[SSE] Doctor completed:", data); + + void queryClient.invalidateQueries(); + + handlersRef.current.get("doctor:completed")?.forEach((handler) => { + handler(data); + }); + }); + + eventSource.addEventListener("doctor:cancelled", (e) => { + const data = JSON.parse(e.data) as DoctorEvent; + console.info("[SSE] Doctor cancelled:", data); + + void queryClient.invalidateQueries(); + + handlersRef.current.get("doctor:cancelled")?.forEach((handler) => { + handler(data); + }); + }); + eventSource.onerror = (error) => { console.error("[SSE] Connection error:", error); }; diff --git a/app/client/modules/repositories/components/doctor-report.tsx b/app/client/modules/repositories/components/doctor-report.tsx new file mode 100644 index 00000000..66bd3ed4 --- /dev/null +++ b/app/client/modules/repositories/components/doctor-report.tsx @@ -0,0 +1,88 @@ +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible"; +import { formatDateTime } from "~/client/lib/datetime"; +import { cn } from "~/client/lib/utils"; + +type DoctorStep = { + step: string; + success: boolean; + output: string | null; + error: string | null; +}; + +type DoctorResult = { + success: boolean; + steps: DoctorStep[]; + completedAt: number; +}; +export const DoctorReport = ({ + result, + repositoryStatus, +}: { + result?: DoctorResult | null; + repositoryStatus: string | null; +}) => { + return ( +
+

Doctor Report

+ {result && ( +
+ Completed {formatDateTime(result.completedAt)} +
+ {result.steps.map((step) => ( + + +
+ {step.step.replace("_", " ")} + {step.success ? ( + + ) : ( + + )} +
+
+ +
+ {step.output && ( +
+												{step.output.startsWith("{") ? JSON.stringify(JSON.parse(step.output), null, 2) : step.output}
+											
+ )} + {step.error && ( +
+
Error
+
+													{step.error}
+												
+
+ )} + {!step.output && !step.error && ( +
No output recorded
+ )} +
+
+
+ ))} +
+
+ )} +
+

No doctor report available.

+
+
+
+
+

Doctor operation is currently running...

+
+
+
+ ); +}; diff --git a/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx index e4512fcd..066997f2 100644 --- a/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx @@ -29,7 +29,7 @@ export const AdvancedForm = ({ form }: Props) => { return ( - Advanced Settings + Advanced Settings
diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index 53f61a77..482f5d63 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -3,10 +3,11 @@ import { redirect, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useState, useEffect } from "react"; import { + cancelDoctorMutation, deleteRepositoryMutation, - doctorRepositoryMutation, getRepositoryOptions, listSnapshotsOptions, + startDoctorMutation, } from "~/client/api-client/@tanstack/react-query.gen"; import { Button } from "~/client/components/ui/button"; import { @@ -25,7 +26,7 @@ import { cn } from "~/client/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; -import { Loader2, Stethoscope, Trash2 } from "lucide-react"; +import { Square, Stethoscope, Trash2 } from "lucide-react"; export const handle = { breadcrumb: (match: Route.MetaArgs) => [ @@ -52,8 +53,6 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { }; export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) { - const [showDoctorResults, setShowDoctorResults] = useState(false); - const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -83,24 +82,22 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro }, }); - const doctorMutation = useMutation({ - ...doctorRepositoryMutation(), - onSuccess: (data) => { - if (data) { - setShowDoctorResults(true); + const startDoctor = useMutation({ + ...startDoctorMutation(), + onError: (error) => { + toast.error("Failed to start doctor", { + description: parseError(error)?.message, + }); + }, + }); - if (data.success) { - toast.success("Repository doctor completed successfully"); - } else { - toast.warning("Doctor completed with some issues", { - description: "Check the details for more information", - richColors: true, - }); - } - } + const cancelDoctor = useMutation({ + ...cancelDoctorMutation(), + onSuccess: () => { + toast.info("Doctor operation cancelled"); }, onError: (error) => { - toast.error("Failed to run doctor", { + toast.error("Failed to cancel doctor", { description: parseError(error)?.message, }); }, @@ -111,21 +108,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro deleteRepo.mutate({ path: { id: data.id } }); }; - const getStepLabel = (step: string) => { - switch (step) { - case "unlock": - return "Unlock Repository"; - case "check": - return "Check Repository"; - case "repair_index": - return "Repair Index"; - case "recheck": - return "Re-check Repository"; - default: - return step; - } - }; - return ( <>
@@ -134,6 +116,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", { "bg-green-500/10 text-green-500": data.status === "healthy", "bg-red-500/10 text-red-500": data.status === "error", + "bg-blue-500/10 text-blue-500": data.status === "doctor", })} > {data.status || "unknown"} @@ -141,23 +124,17 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro {data.type}
- + {data.status === "doctor" ? ( + + ) : ( + + )}
- - - - - Doctor results - Repository doctor operation completed - - - {doctorMutation.data && ( -
- {doctorMutation.data.steps.map((step) => ( -
-
- {getStepLabel(step.step)} - - {step.success ? "Success" : "Warning"} - -
- {step.error &&

{step.error}

} -
- ))} -
- )} - -
- -
-
-
); } diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx index 6b63330d..75d02954 100644 --- a/app/client/modules/repositories/tabs/info.tsx +++ b/app/client/modules/repositories/tabs/info.tsx @@ -22,6 +22,7 @@ import { REPOSITORY_BASE } from "~/client/lib/constants"; import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime"; import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; +import { DoctorReport } from "../components/doctor-report"; type Props = { repository: Repository; @@ -126,7 +127,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
{effectiveLocalPath && (
-
Effective Local Path
+
Local path

{effectiveLocalPath}

)} @@ -179,6 +180,8 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
+ +