From b45d36e06ac0d3c417f681dfe53a64ca0e69b0bc Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:18:25 +0100 Subject: [PATCH] refactor: make lock errors cleaner and show unlock button (#493) * refactor: make lock errors cleaner and show unlock button * chore: pr feedbacks --- .../api-client/@tanstack/react-query.gen.ts | 22 ++++++ app/client/api-client/index.ts | 4 ++ app/client/api-client/sdk.gen.ts | 13 ++++ app/client/api-client/types.gen.ts | 21 ++++++ app/client/components/restore-form.tsx | 3 +- app/client/lib/errors.ts | 11 --- app/client/lib/errors.tsx | 71 +++++++++++++++++++ .../backups/components/schedule-summary.tsx | 4 +- .../modules/backups/routes/backup-details.tsx | 4 +- app/client/modules/repositories/tabs/info.tsx | 50 +++++++++---- .../repositories/repositories.controller.ts | 9 +++ .../modules/repositories/repositories.dto.ts | 26 +++++++ .../repositories/repositories.service.ts | 18 +++++ 13 files changed, 226 insertions(+), 30 deletions(-) delete mode 100644 app/client/lib/errors.ts create mode 100644 app/client/lib/errors.tsx diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index fb8f2c43..c1269a29 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -62,6 +62,7 @@ import { tagSnapshots, testConnection, testNotificationDestination, + unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, @@ -171,6 +172,8 @@ import type { TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, + UnlockRepositoryData, + UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, @@ -905,6 +908,25 @@ export const startDoctorMutation = ( return mutationOptions; }; +/** + * Unlock a repository by removing all stale locks + */ +export const unlockRepositoryMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await unlockRepository({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + /** * Tag multiple snapshots in a repository */ diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index 6c1c462b..874dad9d 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -53,6 +53,7 @@ export { tagSnapshots, testConnection, testNotificationDestination, + unlockRepository, unmountVolume, updateBackupSchedule, updateNotificationDestination, @@ -222,6 +223,9 @@ export type { TestNotificationDestinationErrors, TestNotificationDestinationResponse, TestNotificationDestinationResponses, + UnlockRepositoryData, + UnlockRepositoryResponse, + UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponse, UnmountVolumeResponses, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 761f3885..73460b6e 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -113,6 +113,8 @@ import type { TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, + UnlockRepositoryData, + UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, @@ -462,6 +464,17 @@ export const startDoctor = (options: Optio ...options, }); +/** + * Unlock a repository by removing all stale locks + */ +export const unlockRepository = ( + options: Options, +) => + (options.client ?? client).post({ + url: "/api/v1/repositories/{id}/unlock", + ...options, + }); + /** * Tag multiple snapshots in a repository */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index aae207ad..46689078 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1856,6 +1856,27 @@ export type StartDoctorResponses = { export type StartDoctorResponse = StartDoctorResponses[keyof StartDoctorResponses]; +export type UnlockRepositoryData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/api/v1/repositories/{id}/unlock"; +}; + +export type UnlockRepositoryResponses = { + /** + * Repository unlocked successfully + */ + 200: { + message: string; + success: boolean; + }; +}; + +export type UnlockRepositoryResponse = UnlockRepositoryResponses[keyof UnlockRepositoryResponses]; + export type TagSnapshotsData = { body?: { snapshotIds: Array; diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index 3c900a46..b6a8c6b4 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -15,6 +15,7 @@ import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api- import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; import type { Repository, Snapshot } from "~/client/lib/types"; +import { handleRepositoryError } from "~/client/lib/errors"; type RestoreLocation = "original" | "custom"; @@ -103,7 +104,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re void navigate(returnPath); }, onError: (error) => { - toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" }); + handleRepositoryError("Restore failed", error, repository.id); }, }); diff --git a/app/client/lib/errors.ts b/app/client/lib/errors.ts deleted file mode 100644 index 0f949df2..00000000 --- a/app/client/lib/errors.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const parseError = (error?: unknown) => { - if (error && typeof error === "object" && "message" in error) { - return { message: error.message as string }; - } - - if (typeof error === "string") { - return { message: error }; - } - - return undefined; -}; diff --git a/app/client/lib/errors.tsx b/app/client/lib/errors.tsx new file mode 100644 index 00000000..01b15c60 --- /dev/null +++ b/app/client/lib/errors.tsx @@ -0,0 +1,71 @@ +import { toast } from "sonner"; +import { unlockRepository } from "~/client/api-client/sdk.gen"; +import { Button } from "~/client/components/ui/button"; +import { Unlock } from "lucide-react"; + +export const isLockError = (error: unknown): boolean => { + const errorMessage = parseError(error)?.message || ""; + + return ( + errorMessage.toLowerCase().includes("unable to create lock") || + errorMessage.toLowerCase().includes("repository is already locked") || + errorMessage.toLowerCase().includes("failed to lock repository") + ); +}; + +export const parseError = (error?: unknown) => { + if (error && typeof error === "object" && "message" in error) { + return { message: error.message as string }; + } + + if (typeof error === "string") { + return { message: error }; + } + + return undefined; +}; + +export const showLockErrorToast = (repositoryId: string, title: string) => { + toast.error(title, { + description: + "The repository is currently locked by another operation. This can happen when a previous operation didn't complete properly.", + duration: 5000, + action: ( + + ), + }); +}; + +export const handleRepositoryError = (title: string, error: unknown, repositoryId: string) => { + if (isLockError(error)) { + showLockErrorToast(repositoryId, title); + return null; + } + + toast.error(parseError(error)?.message || "An unexpected error occurred"); + + return null; +}; diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index 338a7aa6..1452310a 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -17,7 +17,7 @@ import { BackupProgressCard } from "./backup-progress-card"; import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; -import { parseError } from "~/client/lib/errors"; +import { handleRepositoryError } from "~/client/lib/errors"; import { Link } from "react-router"; import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime"; @@ -43,7 +43,7 @@ export const ScheduleSummary = (props: Props) => { toast.success("Retention policy applied successfully"); }, onError: (error) => { - toast.error("Failed to apply retention policy", { description: parseError(error)?.message }); + handleRepositoryError("Failed to apply retention policy", error, schedule.repository.shortId); }, }); diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index fa928ee0..d4d39bf9 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -23,7 +23,7 @@ import { stopBackupMutation, deleteSnapshotMutation, } from "~/client/api-client/@tanstack/react-query.gen"; -import { parseError } from "~/client/lib/errors"; +import { parseError, handleRepositoryError } from "~/client/lib/errors"; import { getCronExpression } from "~/utils/utils"; import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form"; import { ScheduleSummary } from "../components/schedule-summary"; @@ -118,7 +118,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon toast.success("Backup started successfully"); }, onError: (error) => { - toast.error("Failed to start backup", { description: parseError(error)?.message }); + handleRepositoryError("Failed to start backup", error, schedule.repository.shortId); }, }); diff --git a/app/client/modules/repositories/tabs/info.tsx b/app/client/modules/repositories/tabs/info.tsx index 56973628..f71572a1 100644 --- a/app/client/modules/repositories/tabs/info.tsx +++ b/app/client/modules/repositories/tabs/info.tsx @@ -1,7 +1,7 @@ import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; -import { Check, Save, Square, Stethoscope, Trash2 } from "lucide-react"; +import { Check, Save, Square, Stethoscope, Trash2, Unlock } from "lucide-react"; import { Card } from "~/client/components/ui/card"; import { Button } from "~/client/components/ui/button"; import { Input } from "~/client/components/ui/input"; @@ -24,6 +24,7 @@ import { cancelDoctorMutation, deleteRepositoryMutation, startDoctorMutation, + unlockRepositoryMutation, updateRepositoryMutation, } from "~/client/api-client/@tanstack/react-query.gen"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; @@ -102,6 +103,18 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => { }, }); + const unlockRepo = useMutation({ + ...unlockRepositoryMutation(), + onSuccess: () => { + toast.success("Repository unlocked successfully"); + }, + onError: (error) => { + toast.error("Failed to unlock repository", { + description: parseError(error)?.message, + }); + }, + }); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setShowConfirmDialog(true); @@ -132,7 +145,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
Repository Settings
-
+
{repository.status === "doctor" ? ( ) : ( - - )} + )} + + diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 6448bb4a..c9a3d051 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -27,6 +27,7 @@ import { updateRepositoryDto, devPanelExecBody, devPanelExecDto, + unlockRepositoryDto, type DeleteRepositoryDto, type DeleteSnapshotDto, type DeleteSnapshotsResponseDto, @@ -41,6 +42,7 @@ import { type RestoreSnapshotDto, type TagSnapshotsResponseDto, type UpdateRepositoryDto, + type UnlockRepositoryDto, } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; @@ -189,6 +191,13 @@ export const repositoriesController = new Hono() return c.json(result, 200); }) + .post("/:id/unlock", unlockRepositoryDto, async (c) => { + const { id } = c.req.param(); + + const result = await repositoriesService.unlockRepository(id); + + return c.json(result, 200); + }) .delete("/:id/snapshots/:snapshotId", deleteSnapshotDto, async (c) => { const { id, snapshotId } = c.req.param(); await repositoriesService.deleteSnapshot(id, snapshotId); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 5c7946c3..73a8b22a 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -546,3 +546,29 @@ export const devPanelExecDto = describeRoute({ }, }, }); + +/** + * Unlock repository + */ +export const unlockRepositoryResponse = type({ + success: "boolean", + message: "string", +}); + +export type UnlockRepositoryDto = typeof unlockRepositoryResponse.infer; + +export const unlockRepositoryDto = describeRoute({ + description: "Unlock a repository by removing all stale locks", + tags: ["Repositories"], + operationId: "unlockRepository", + responses: { + 200: { + description: "Repository unlocked successfully", + content: { + "application/json": { + schema: resolver(unlockRepositoryResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 62a0c6ef..ae49493b 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -563,6 +563,23 @@ const updateRepository = async (id: string, updates: { name?: string; compressio return { repository: updated }; }; +const unlockRepository = async (id: string) => { + const organizationId = getOrganizationId(); + const repository = await findRepository(id); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + const releaseLock = await repoMutex.acquireExclusive(repository.id, "unlock"); + try { + const result = await restic.unlock(repository.config, { organizationId }); + return result; + } finally { + releaseLock(); + } +}; + const execResticCommand = async ( id: string, command: string, @@ -657,4 +674,5 @@ export const repositoriesService = { refreshSnapshots, execResticCommand, getRetentionCategories, + unlockRepository, };