From 2ab37e6b672ee7109fe05caeda8b9b92e9669f0d Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 22 Jan 2026 21:55:45 +0100 Subject: [PATCH] refactor: async doctor (#375) * feat: background doctor operation * refactor(repo-details): design layout * refactor(doctor): support abort signal in all operations * chore: fix linting issue * chore: pr feedbacks * chore: merge conflicts * refactor: handle aborted signal in all operations * chore: pr feedbacks * chore: remove old migration --- AGENTS.md | 6 +- .../api-client/@tanstack/react-query.gen.ts | 40 +- app/client/api-client/index.ts | 14 +- app/client/api-client/sdk.gen.ts | 25 +- app/client/api-client/types.gen.ts | 147 +- app/client/components/ui/textarea.tsx | 2 +- app/client/hooks/use-server-events.ts | 55 +- app/client/lib/utils.ts | 8 + .../repositories/components/doctor-report.tsx | 88 + .../repository-forms/advanced-tls-form.tsx | 8 +- .../routes/repository-details.tsx | 189 +- app/client/modules/repositories/tabs/info.tsx | 177 +- app/drizzle/0043_overjoyed_shen.sql | 1 + app/drizzle/meta/0043_snapshot.json | 1635 +++++++++++++++++ app/drizzle/meta/_journal.json | 7 + app/schemas/restic.ts | 19 + .../core/__tests__/repository-mutex.test.ts | 59 + app/server/core/events.ts | 9 + app/server/core/repository-mutex.ts | 64 +- app/server/db/schema.ts | 2 + app/server/modules/backups/backups.service.ts | 2 +- .../modules/events/events.controller.ts | 33 + app/server/modules/repositories/doctor.ts | 199 ++ .../repositories/repositories.controller.ts | 20 +- .../modules/repositories/repositories.dto.ts | 62 +- .../repositories/repositories.service.ts | 139 +- app/server/utils/json.ts | 11 + app/server/utils/restic.ts | 34 +- 28 files changed, 2666 insertions(+), 389 deletions(-) create mode 100644 app/client/modules/repositories/components/doctor-report.tsx create mode 100644 app/drizzle/0043_overjoyed_shen.sql create mode 100644 app/drizzle/meta/0043_snapshot.json create mode 100644 app/server/modules/repositories/doctor.ts create mode 100644 app/server/utils/json.ts diff --git a/AGENTS.md b/AGENTS.md index afac88b4..c44518e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Zerobyte is a backup automation tool built on top of Restic that provides a web - **Validation**: ArkType for runtime schema validation - **Styling**: Tailwind CSS v4 + Radix UI components - **Architecture**: Unified application structure (not a monorepo) -- **Code Quality**: Biome (formatter & linter) +- **Code Quality**: Oxfmt for formatting, Oxlint for linting ## Repository Structure @@ -75,8 +75,6 @@ bun run gen:api-client ### Code Quality ```bash -# Format and lint - # Format bunx oxfmt format --write @@ -238,8 +236,6 @@ On startup, the server detects available capabilities (see `core/capabilities.ts ## Important Notes -- **Code Style**: Uses Biome with tabs (not spaces), 120 char line width, double quotes -- **Imports**: Organize imports is disabled in Biome - do not auto-organize - **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun - **Validation**: Prefer ArkType over Zod - it's used throughout the codebase - **Visibility**: Prefer using the `cn` helper with `{ hidden: condition }` instead of conditional rendering with ternaries or `&&` for toggling element visibility in the DOM. diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index c751274c..eeac0952 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, @@ -47,6 +47,7 @@ import { runBackupNow, runForget, setRegistrationStatus, + startDoctor, stopBackup, tagSnapshots, testConnection, @@ -62,6 +63,8 @@ import { import type { BrowseFilesystemData, BrowseFilesystemResponse, + CancelDoctorData, + CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, @@ -82,8 +85,6 @@ import type { DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, - DoctorRepositoryData, - DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, @@ -144,6 +145,8 @@ import type { RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, + StartDoctorData, + StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, @@ -719,14 +722,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..f093e01a 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, @@ -44,6 +44,7 @@ export { runBackupNow, runForget, setRegistrationStatus, + startDoctor, stopBackup, tagSnapshots, testConnection, @@ -60,6 +61,10 @@ export type { BrowseFilesystemData, BrowseFilesystemResponse, BrowseFilesystemResponses, + CancelDoctorData, + CancelDoctorErrors, + CancelDoctorResponse, + CancelDoctorResponses, ClientOptions, CreateBackupScheduleData, CreateBackupScheduleResponse, @@ -92,9 +97,6 @@ export type { DeleteVolumeData, DeleteVolumeResponse, DeleteVolumeResponses, - DoctorRepositoryData, - DoctorRepositoryResponse, - DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponse, DownloadResticPasswordResponses, @@ -188,6 +190,10 @@ export type { SetRegistrationStatusData, SetRegistrationStatusResponse, SetRegistrationStatusResponses, + StartDoctorData, + StartDoctorErrors, + StartDoctorResponse, + StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponse, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 566dc80c..f8d2362b 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, @@ -91,6 +92,9 @@ import type { RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, + StartDoctorData, + StartDoctorErrors, + StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, @@ -422,12 +426,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..0c356714 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: "cancelled" | "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: "cancelled" | "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: "cancelled" | "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: "cancelled" | "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: "cancelled" | "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: "cancelled" | "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: "cancelled" | "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: "cancelled" | "doctor" | "error" | "healthy" | "unknown" | null; type: "azure" | "gcs" | "local" | "r2" | "rclone" | "rest" | "s3" | "sftp"; updatedAt: number; }; diff --git a/app/client/components/ui/textarea.tsx b/app/client/components/ui/textarea.tsx index 9b7bd929..0fcddea2 100644 --- a/app/client/components/ui/textarea.tsx +++ b/app/client/components/ui/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {