mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-05-19 06:03:01 -04:00
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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
71
app/client/lib/errors.tsx
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user