refactor: make lock errors cleaner and show unlock button (#493)

* refactor: make lock errors cleaner and show unlock button

* chore: pr feedbacks
This commit is contained in:
Nico
2026-02-10 20:18:25 +01:00
committed by GitHub
parent cb7988b8ed
commit b45d36e06a
13 changed files with 226 additions and 30 deletions

View File

@@ -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<Options<UnlockRepositoryData>>,
): UseMutationOptions<UnlockRepositoryResponse, DefaultError, Options<UnlockRepositoryData>> => {
const mutationOptions: UseMutationOptions<UnlockRepositoryResponse, DefaultError, Options<UnlockRepositoryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await unlockRepository({
...options,
...fnOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Tag multiple snapshots in a repository
*/

View File

@@ -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,

View File

@@ -113,6 +113,8 @@ import type {
TestNotificationDestinationData,
TestNotificationDestinationErrors,
TestNotificationDestinationResponses,
UnlockRepositoryData,
UnlockRepositoryResponses,
UnmountVolumeData,
UnmountVolumeResponses,
UpdateBackupScheduleData,
@@ -462,6 +464,17 @@ export const startDoctor = <ThrowOnError extends boolean = false>(options: Optio
...options,
});
/**
* Unlock a repository by removing all stale locks
*/
export const unlockRepository = <ThrowOnError extends boolean = false>(
options: Options<UnlockRepositoryData, ThrowOnError>,
) =>
(options.client ?? client).post<UnlockRepositoryResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{id}/unlock",
...options,
});
/**
* Tag multiple snapshots in a repository
*/

View File

@@ -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<string>;

View File

@@ -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);
},
});

View File

@@ -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;
};

71
app/client/lib/errors.tsx Normal file
View File

@@ -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: (
<Button
size="sm"
variant="outline"
onClick={() => {
toast.dismiss();
toast.promise(
async () => {
const result = await unlockRepository({
path: { id: repositoryId },
throwOnError: true,
});
return result.data;
},
{
loading: "Unlocking repository...",
success: "Repository unlocked successfully! You can now retry your operation.",
error: (err) => parseError(err)?.message || "Failed to unlock repository",
},
);
}}
>
<Unlock className="h-3 w-3 mr-1" />
Unlock
</Button>
),
});
};
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;
};

View File

@@ -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);
},
});

View File

@@ -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);
},
});

View File

@@ -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) => {
<div>
<span className="text-lg font-semibold mb-4">Repository Settings</span>
</div>
<div className="flex gap-4">
<div className="flex flex-wrap justify-end gap-2 sm:gap-4">
{repository.status === "doctor" ? (
<Button
type="button"
@@ -144,21 +157,30 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<span>Cancel doctor</span>
</Button>
) : (
<Button
type="button"
onClick={() => startDoctor.mutate({ path: { id: repository.id } })}
disabled={startDoctor.isPending}
>
<Stethoscope className="h-4 w-4 mr-2" />
Run doctor
</Button>
)}
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteRepo.isPending}
onClick={() => startDoctor.mutate({ path: { id: repository.id } })}
disabled={startDoctor.isPending}
>
<Stethoscope className="h-4 w-4 mr-2" />
Run doctor
</Button>
)}
<Button
type="button"
variant="outline"
onClick={() => unlockRepo.mutate({ path: { id: repository.id } })}
loading={unlockRepo.isPending}
>
<Unlock className="h-4 w-4 mr-2" />
Unlock
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteRepo.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>

View File

@@ -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<CancelDoctorDto>(result, 200);
})
.post("/:id/unlock", unlockRepositoryDto, async (c) => {
const { id } = c.req.param();
const result = await repositoriesService.unlockRepository(id);
return c.json<UnlockRepositoryDto>(result, 200);
})
.delete("/:id/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
const { id, snapshotId } = c.req.param();
await repositoriesService.deleteSnapshot(id, snapshotId);

View File

@@ -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),
},
},
},
},
});

View File

@@ -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,
};