mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(repositories): healthchecks and doctor command
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
|||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
listSnapshotFiles,
|
listSnapshotFiles,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
|
doctorRepository,
|
||||||
listBackupSchedules,
|
listBackupSchedules,
|
||||||
createBackupSchedule,
|
createBackupSchedule,
|
||||||
deleteBackupSchedule,
|
deleteBackupSchedule,
|
||||||
@@ -76,6 +77,8 @@ import type {
|
|||||||
ListSnapshotFilesData,
|
ListSnapshotFilesData,
|
||||||
RestoreSnapshotData,
|
RestoreSnapshotData,
|
||||||
RestoreSnapshotResponse,
|
RestoreSnapshotResponse,
|
||||||
|
DoctorRepositoryData,
|
||||||
|
DoctorRepositoryResponse,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
CreateBackupScheduleData,
|
CreateBackupScheduleData,
|
||||||
CreateBackupScheduleResponse,
|
CreateBackupScheduleResponse,
|
||||||
@@ -844,6 +847,46 @@ export const restoreSnapshotMutation = (
|
|||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const doctorRepositoryQueryKey = (options: Options<DoctorRepositoryData>) =>
|
||||||
|
createQueryKey("doctorRepository", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
|
*/
|
||||||
|
export const doctorRepositoryOptions = (options: Options<DoctorRepositoryData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await doctorRepository({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: doctorRepositoryQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
|
*/
|
||||||
|
export const doctorRepositoryMutation = (
|
||||||
|
options?: Partial<Options<DoctorRepositoryData>>,
|
||||||
|
): UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await doctorRepository({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||||
createQueryKey("listBackupSchedules", options);
|
createQueryKey("listBackupSchedules", options);
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import type {
|
|||||||
ListSnapshotFilesResponses,
|
ListSnapshotFilesResponses,
|
||||||
RestoreSnapshotData,
|
RestoreSnapshotData,
|
||||||
RestoreSnapshotResponses,
|
RestoreSnapshotResponses,
|
||||||
|
DoctorRepositoryData,
|
||||||
|
DoctorRepositoryResponses,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
ListBackupSchedulesResponses,
|
ListBackupSchedulesResponses,
|
||||||
CreateBackupScheduleData,
|
CreateBackupScheduleData,
|
||||||
@@ -408,6 +410,18 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
|
*/
|
||||||
|
export const doctorRepository = <ThrowOnError extends boolean = false>(
|
||||||
|
options: Options<DoctorRepositoryData, ThrowOnError>,
|
||||||
|
) => {
|
||||||
|
return (options.client ?? _heyApiClient).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/repositories/{name}/doctor",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backup schedules
|
* List all backup schedules
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export type ListVolumesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -166,6 +167,7 @@ export type ListVolumesResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -201,6 +203,7 @@ export type CreateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -211,6 +214,7 @@ export type CreateVolumeData = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -244,6 +248,7 @@ export type CreateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -254,6 +259,7 @@ export type CreateVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -289,6 +295,7 @@ export type TestConnectionData = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -299,6 +306,7 @@ export type TestConnectionData = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -385,6 +393,7 @@ export type GetVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -395,6 +404,7 @@ export type GetVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -432,6 +442,7 @@ export type UpdateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -442,6 +453,7 @@ export type UpdateVolumeData = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -483,6 +495,7 @@ export type UpdateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -493,6 +506,7 @@ export type UpdateVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -904,6 +918,33 @@ export type RestoreSnapshotResponses = {
|
|||||||
|
|
||||||
export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses];
|
export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses];
|
||||||
|
|
||||||
|
export type DoctorRepositoryData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/repositories/{name}/doctor";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoctorRepositoryResponses = {
|
||||||
|
/**
|
||||||
|
* Doctor operation completed
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
steps: Array<{
|
||||||
|
step: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
output?: string;
|
||||||
|
}>;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
|
||||||
|
|
||||||
export type ListBackupSchedulesData = {
|
export type ListBackupSchedulesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -972,6 +1013,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -982,6 +1024,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -1153,6 +1196,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -1163,6 +1207,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -1315,6 +1360,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -1325,6 +1371,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTheme } from "next-themes";
|
|||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
const { theme = "dark" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ interface Props {
|
|||||||
snapshots: ListSnapshotsResponse;
|
snapshots: ListSnapshotsResponse;
|
||||||
snapshotId?: string;
|
snapshotId?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
error?: string;
|
||||||
onSnapshotSelect: (snapshotId: string) => void;
|
onSnapshotSelect: (snapshotId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnapshotTimeline = (props: Props) => {
|
export const SnapshotTimeline = (props: Props) => {
|
||||||
const { snapshots, snapshotId, loading, onSnapshotSelect } = props;
|
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshotId && snapshots.length > 0) {
|
if (!snapshotId && snapshots.length > 0) {
|
||||||
@@ -20,6 +21,16 @@ export const SnapshotTimeline = (props: Props) => {
|
|||||||
}
|
}
|
||||||
}, [snapshotId, snapshots, onSnapshotSelect]);
|
}, [snapshotId, snapshots, onSnapshotSelect]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-center h-24 p-4 text-center">
|
||||||
|
<p className="text-destructive">Error loading snapshots: {error}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: snapshots, isLoading } = useQuery({
|
const {
|
||||||
|
data: snapshots,
|
||||||
|
isLoading,
|
||||||
|
failureReason,
|
||||||
|
} = useQuery({
|
||||||
...listSnapshotsOptions({
|
...listSnapshotsOptions({
|
||||||
path: { name: schedule.repository.name },
|
path: { name: schedule.repository.name },
|
||||||
query: { backupId: schedule.id.toString() },
|
query: { backupId: schedule.id.toString() },
|
||||||
@@ -174,6 +178,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
snapshots={snapshots ?? []}
|
snapshots={snapshots ?? []}
|
||||||
snapshotId={selectedSnapshot?.short_id}
|
snapshotId={selectedSnapshot?.short_id}
|
||||||
|
error={failureReason?.message}
|
||||||
onSnapshotSelect={setSelectedSnapshotId}
|
onSnapshotSelect={setSelectedSnapshotId}
|
||||||
/>
|
/>
|
||||||
{selectedSnapshot && (
|
{selectedSnapshot && (
|
||||||
|
|||||||
@@ -1,12 +1,72 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import type { Repository } from "~/lib/types";
|
import type { Repository } from "~/lib/types";
|
||||||
|
import { parseError } from "~/lib/errors";
|
||||||
|
import { doctorRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
|
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||||
|
|
||||||
|
const doctorMutation = useMutation({
|
||||||
|
...doctorRepositoryMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data) {
|
||||||
|
setShowDoctorResults(true);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to run doctor", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDoctor = () => {
|
||||||
|
doctorMutation.mutate({ path: { name: repository.name } });
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -43,7 +103,19 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||||||
|
|
||||||
{repository.lastError && (
|
{repository.lastError && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4 text-red-500">Last Error</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||||
|
<Button onClick={handleDoctor} disabled={doctorMutation.isPending} variant={"outline"} size="sm">
|
||||||
|
{doctorMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Running Doctor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Run Doctor"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,5 +130,48 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
||||||
|
<AlertDialogContent className="max-w-2xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{doctorMutation.data?.message || "Repository doctor operation completed"}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{doctorMutation.data && (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{doctorMutation.data.steps.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step.step}
|
||||||
|
className={cn("border rounded-md p-3", {
|
||||||
|
"bg-green-500/10 border-green-500/20": step.success,
|
||||||
|
"bg-yellow-500/10 border-yellow-500/20": !step.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
|
||||||
|
<span
|
||||||
|
className={cn("text-xs px-2 py-1 rounded", {
|
||||||
|
"bg-green-500/20 text-green-500": step.success,
|
||||||
|
"bg-yellow-500/20 text-yellow-500": !step.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{step.success ? "Success" : "Warning"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,16 +67,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFetching && !data.length) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground">Loading snapshots</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failureReason) {
|
if (failureReason) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -89,6 +79,16 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFetching && !data.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-muted-foreground">Loading snapshots</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
26
apps/server/src/jobs/repository-healthchecks.ts
Normal file
26
apps/server/src/jobs/repository-healthchecks.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Job } from "../core/scheduler";
|
||||||
|
import { repositoriesService } from "../modules/repositories/repositories.service";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { db } from "../db/db";
|
||||||
|
import { eq, or } from "drizzle-orm";
|
||||||
|
import { repositoriesTable } from "../db/schema";
|
||||||
|
|
||||||
|
export class RepositoryHealthCheckJob extends Job {
|
||||||
|
async run() {
|
||||||
|
logger.debug("Running health check for all repositories...");
|
||||||
|
|
||||||
|
const repositories = await db.query.repositoriesTable.findMany({
|
||||||
|
where: or(eq(repositoriesTable.status, "healthy"), eq(repositoriesTable.status, "error")),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const repository of repositories) {
|
||||||
|
try {
|
||||||
|
await repositoriesService.checkHealth(repository.id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Health check failed for repository ${repository.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { done: true, timestamp: new Date() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { restic } from "../../utils/restic";
|
|||||||
import { volumeService } from "../volumes/volume.service";
|
import { volumeService } from "../volumes/volume.service";
|
||||||
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
||||||
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
||||||
|
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
|
||||||
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
||||||
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export const startup = async () => {
|
|||||||
|
|
||||||
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
||||||
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
|
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
|
||||||
|
Scheduler.build(RepositoryHealthCheckJob).schedule("*/10 * * * *");
|
||||||
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
||||||
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createRepositoryBody,
|
createRepositoryBody,
|
||||||
createRepositoryDto,
|
createRepositoryDto,
|
||||||
deleteRepositoryDto,
|
deleteRepositoryDto,
|
||||||
|
doctorRepositoryDto,
|
||||||
getRepositoryDto,
|
getRepositoryDto,
|
||||||
getSnapshotDetailsDto,
|
getSnapshotDetailsDto,
|
||||||
listRepositoriesDto,
|
listRepositoriesDto,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
restoreSnapshotBody,
|
restoreSnapshotBody,
|
||||||
restoreSnapshotDto,
|
restoreSnapshotDto,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
|
type DoctorRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type GetSnapshotDetailsDto,
|
type GetSnapshotDetailsDto,
|
||||||
type ListRepositoriesDto,
|
type ListRepositoriesDto,
|
||||||
@@ -71,6 +73,8 @@ export const repositoriesController = new Hono()
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
|
||||||
|
|
||||||
return c.json<ListSnapshotsDto>(snapshots, 200);
|
return c.json<ListSnapshotsDto>(snapshots, 200);
|
||||||
})
|
})
|
||||||
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
|
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
|
||||||
@@ -116,4 +120,11 @@ export const repositoriesController = new Hono()
|
|||||||
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
|
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
|
||||||
|
|
||||||
return c.json<RestoreSnapshotDto>(result, 200);
|
return c.json<RestoreSnapshotDto>(result, 200);
|
||||||
|
})
|
||||||
|
.post("/:name/doctor", doctorRepositoryDto, async (c) => {
|
||||||
|
const { name } = c.req.param();
|
||||||
|
|
||||||
|
const result = await repositoriesService.doctorRepository(name);
|
||||||
|
|
||||||
|
return c.json<DoctorRepositoryDto>(result, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -271,3 +271,38 @@ export const restoreSnapshotDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doctor a repository (unlock, check, repair)
|
||||||
|
*/
|
||||||
|
export const doctorStepSchema = type({
|
||||||
|
step: "string",
|
||||||
|
success: "boolean",
|
||||||
|
output: "string?",
|
||||||
|
error: "string?",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const doctorRepositoryResponse = type({
|
||||||
|
success: "boolean",
|
||||||
|
message: "string",
|
||||||
|
steps: doctorStepSchema.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DoctorRepositoryDto = typeof doctorRepositoryResponse.infer;
|
||||||
|
|
||||||
|
export const doctorRepositoryDto = describeRoute({
|
||||||
|
description:
|
||||||
|
"Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.",
|
||||||
|
tags: ["Repositories"],
|
||||||
|
operationId: "doctorRepository",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Doctor operation completed",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(doctorRepositoryResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { repositoriesTable, volumesTable } from "../../db/schema";
|
import { repositoriesTable } from "../../db/schema";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
@@ -202,6 +202,112 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkHealth = async (repositoryId: string) => {
|
||||||
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.id, repositoryId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, status } = await restic
|
||||||
|
.snapshots(repository.config)
|
||||||
|
.then(() => ({ error: null, status: "healthy" as const }))
|
||||||
|
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
lastError: error,
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, repository.id));
|
||||||
|
|
||||||
|
return { status, lastError: error };
|
||||||
|
};
|
||||||
|
|
||||||
|
const doctorRepository = async (name: string) => {
|
||||||
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.name, name),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = [];
|
||||||
|
|
||||||
|
const unlockResult = await restic.unlock(repository.config).then(
|
||||||
|
(result) => ({ success: true, message: result.message, error: null }),
|
||||||
|
(error) => ({ success: false, message: null, error: toMessage(error) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "unlock",
|
||||||
|
success: unlockResult.success,
|
||||||
|
output: unlockResult.message,
|
||||||
|
error: unlockResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkResult = await restic.check(repository.config, { readData: false }).then(
|
||||||
|
(result) => result,
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "check",
|
||||||
|
success: checkResult.success,
|
||||||
|
output: checkResult.output,
|
||||||
|
error: checkResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkResult.hasErrors) {
|
||||||
|
const repairResult = await restic.repairIndex(repository.config).then(
|
||||||
|
(result) => ({ success: true, output: result.output, error: null }),
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "repair_index",
|
||||||
|
success: repairResult.success,
|
||||||
|
output: repairResult.output,
|
||||||
|
error: repairResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recheckResult = await restic.check(repository.config, { readData: false }).then(
|
||||||
|
(result) => result,
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "recheck",
|
||||||
|
success: recheckResult.success,
|
||||||
|
output: recheckResult.output,
|
||||||
|
error: recheckResult.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSuccessful = steps.every((s) => s.success);
|
||||||
|
|
||||||
|
console.log("Doctor steps:", steps);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
status: allSuccessful ? "healthy" : "error",
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
lastError: allSuccessful ? null : steps.find((s) => !s.success)?.error,
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, repository.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: allSuccessful,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const repositoriesService = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
@@ -211,4 +317,6 @@ export const repositoriesService = {
|
|||||||
listSnapshotFiles,
|
listSnapshotFiles,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
|
checkHealth,
|
||||||
|
doctorRepository,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const backup = async (
|
|||||||
|
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
if (includeFile) {
|
if (includeFile) {
|
||||||
@@ -334,7 +334,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
args.push("--prune");
|
args.push("--prune");
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
@@ -425,6 +425,79 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
return { snapshot, nodes };
|
return { snapshot, nodes };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unlock = async (config: RepositoryConfig) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
|
const res = await $`restic unlock --repo ${repoUrl} --json`.env(env).nothrow();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
|
throw new Error(`Restic unlock failed: ${res.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
|
||||||
|
return { success: true, message: "Repository unlocked successfully" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
|
const args: string[] = ["--repo", repoUrl, "check"];
|
||||||
|
|
||||||
|
if (options?.readData) {
|
||||||
|
args.push("--read-data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
|
const stdout = res.text();
|
||||||
|
const stderr = res.stderr.toString();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic check failed: ${stderr}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
hasErrors: true,
|
||||||
|
output: stdout,
|
||||||
|
error: stderr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = stdout.includes("error") || stdout.includes("Fatal");
|
||||||
|
|
||||||
|
logger.info(`Restic check completed for repository: ${repoUrl}`);
|
||||||
|
return {
|
||||||
|
success: !hasErrors,
|
||||||
|
hasErrors,
|
||||||
|
output: stdout,
|
||||||
|
error: hasErrors ? "Repository contains errors" : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const repairIndex = async (config: RepositoryConfig) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
|
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
||||||
|
|
||||||
|
const stdout = res.text();
|
||||||
|
const stderr = res.stderr.toString();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic repair index failed: ${stderr}`);
|
||||||
|
throw new Error(`Restic repair index failed: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Restic repair index completed for repository: ${repoUrl}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: stdout,
|
||||||
|
message: "Index repaired successfully",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const restic = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
@@ -432,5 +505,8 @@ export const restic = {
|
|||||||
restore,
|
restore,
|
||||||
snapshots,
|
snapshots,
|
||||||
forget,
|
forget,
|
||||||
|
unlock,
|
||||||
ls,
|
ls,
|
||||||
|
check,
|
||||||
|
repairIndex,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user