diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 7ae90be..f27634d 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -27,6 +27,7 @@ import { getSnapshotDetails, listSnapshotFiles, restoreSnapshot, + doctorRepository, listBackupSchedules, createBackupSchedule, deleteBackupSchedule, @@ -76,6 +77,8 @@ import type { ListSnapshotFilesData, RestoreSnapshotData, RestoreSnapshotResponse, + DoctorRepositoryData, + DoctorRepositoryResponse, ListBackupSchedulesData, CreateBackupScheduleData, CreateBackupScheduleResponse, @@ -844,6 +847,46 @@ export const restoreSnapshotMutation = ( return mutationOptions; }; +export const doctorRepositoryQueryKey = (options: Options) => + 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) => { + 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>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await doctorRepository({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey("listBackupSchedules", options); diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 81d8ca7..63b5ba0 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -56,6 +56,8 @@ import type { ListSnapshotFilesResponses, RestoreSnapshotData, RestoreSnapshotResponses, + DoctorRepositoryData, + DoctorRepositoryResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, CreateBackupScheduleData, @@ -408,6 +410,18 @@ 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. + */ +export const doctorRepository = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).post({ + url: "/api/v1/repositories/{name}/doctor", + ...options, + }); +}; + /** * List all backup schedules */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index fecda6b..c483ce8 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -156,6 +156,7 @@ export type ListVolumesResponses = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -166,6 +167,7 @@ export type ListVolumesResponses = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -201,6 +203,7 @@ export type CreateVolumeData = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -211,6 +214,7 @@ export type CreateVolumeData = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -244,6 +248,7 @@ export type CreateVolumeResponses = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -254,6 +259,7 @@ export type CreateVolumeResponses = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -289,6 +295,7 @@ export type TestConnectionData = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -299,6 +306,7 @@ export type TestConnectionData = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -385,6 +393,7 @@ export type GetVolumeResponses = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -395,6 +404,7 @@ export type GetVolumeResponses = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -432,6 +442,7 @@ export type UpdateVolumeData = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -442,6 +453,7 @@ export type UpdateVolumeData = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -483,6 +495,7 @@ export type UpdateVolumeResponses = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -493,6 +506,7 @@ export type UpdateVolumeResponses = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -904,6 +918,33 @@ export type 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 = { body?: never; path?: never; @@ -972,6 +1013,7 @@ export type ListBackupSchedulesResponses = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -982,6 +1024,7 @@ export type ListBackupSchedulesResponses = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -1153,6 +1196,7 @@ export type GetBackupScheduleResponses = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -1163,6 +1207,7 @@ export type GetBackupScheduleResponses = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; @@ -1315,6 +1360,7 @@ export type GetBackupScheduleForVolumeResponses = { server: string; version: "3" | "4" | "4.1"; port?: number; + readOnly?: boolean; } | { backend: "smb"; @@ -1325,6 +1371,7 @@ export type GetBackupScheduleForVolumeResponses = { vers?: "1.0" | "2.0" | "2.1" | "3.0"; port?: number; domain?: string; + readOnly?: boolean; } | { backend: "webdav"; diff --git a/apps/client/app/components/ui/sonner.tsx b/apps/client/app/components/ui/sonner.tsx index 53f162d..3081035 100644 --- a/apps/client/app/components/ui/sonner.tsx +++ b/apps/client/app/components/ui/sonner.tsx @@ -2,7 +2,7 @@ import { useTheme } from "next-themes"; import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme(); + const { theme = "dark" } = useTheme(); return ( void; } export const SnapshotTimeline = (props: Props) => { - const { snapshots, snapshotId, loading, onSnapshotSelect } = props; + const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props; useEffect(() => { if (!snapshotId && snapshots.length > 0) { @@ -20,6 +21,16 @@ export const SnapshotTimeline = (props: Props) => { } }, [snapshotId, snapshots, onSnapshotSelect]); + if (error) { + return ( + +
+

Error loading snapshots: {error}

+
+
+ ); + } + if (loading) { return ( diff --git a/apps/client/app/modules/backups/routes/backup-details.tsx b/apps/client/app/modules/backups/routes/backup-details.tsx index 61ba243..d0f792a 100644 --- a/apps/client/app/modules/backups/routes/backup-details.tsx +++ b/apps/client/app/modules/backups/routes/backup-details.tsx @@ -40,7 +40,11 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon initialData: loaderData, }); - const { data: snapshots, isLoading } = useQuery({ + const { + data: snapshots, + isLoading, + failureReason, + } = useQuery({ ...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() }, @@ -174,6 +178,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon loading={isLoading} snapshots={snapshots ?? []} snapshotId={selectedSnapshot?.short_id} + error={failureReason?.message} onSnapshotSelect={setSelectedSnapshotId} /> {selectedSnapshot && ( diff --git a/apps/client/app/modules/repositories/tabs/info.tsx b/apps/client/app/modules/repositories/tabs/info.tsx index efc32d3..b6e5de4 100644 --- a/apps/client/app/modules/repositories/tabs/info.tsx +++ b/apps/client/app/modules/repositories/tabs/info.tsx @@ -1,62 +1,177 @@ +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; 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 { parseError } from "~/lib/errors"; +import { doctorRepositoryMutation } from "~/api-client/@tanstack/react-query.gen"; +import { cn } from "~/lib/utils"; type Props = { repository: Repository; }; 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 ( - -
-
-

Repository Information

-
-
-
Name
-

{repository.name}

-
-
-
Backend
-

{repository.type}

-
-
-
Compression Mode
-

{repository.compressionMode || "off"}

-
-
-
Status
-

{repository.status || "unknown"}

-
-
-
Created At
-

{new Date(repository.createdAt).toLocaleString()}

-
-
-
Last Checked
-

- {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} -

-
-
-
- - {repository.lastError && ( + <> + +
-

Last Error

-
-

{repository.lastError}

+

Repository Information

+
+
+
Name
+

{repository.name}

+
+
+
Backend
+

{repository.type}

+
+
+
Compression Mode
+

{repository.compressionMode || "off"}

+
+
+
Status
+

{repository.status || "unknown"}

+
+
+
Created At
+

{new Date(repository.createdAt).toLocaleString()}

+
+
+
Last Checked
+

+ {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} +

+
- )} -
-

Configuration

-
-
{JSON.stringify(repository.config, null, 2)}
+ {repository.lastError && ( +
+
+

Last Error

+ +
+
+

{repository.lastError}

+
+
+ )} + +
+

Configuration

+
+
{JSON.stringify(repository.config, null, 2)}
+
-
- + + + + + + Doctor Results + + {doctorMutation.data?.message || "Repository doctor operation completed"} + + + + {doctorMutation.data && ( +
+ {doctorMutation.data.steps.map((step) => ( +
+
+ {getStepLabel(step.step)} + + {step.success ? "Success" : "Warning"} + +
+ {step.error &&

{step.error}

} +
+ ))} +
+ )} + +
+ +
+
+
+ ); }; diff --git a/apps/client/app/modules/repositories/tabs/snapshots.tsx b/apps/client/app/modules/repositories/tabs/snapshots.tsx index 71d630b..341d1b8 100644 --- a/apps/client/app/modules/repositories/tabs/snapshots.tsx +++ b/apps/client/app/modules/repositories/tabs/snapshots.tsx @@ -67,16 +67,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { ); } - if (isFetching && !data.length) { - return ( - - -

Loading snapshots

-
-
- ); - } - if (failureReason) { return ( @@ -89,6 +79,16 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { ); } + if (isFetching && !data.length) { + return ( + + +

Loading snapshots

+
+
+ ); + } + if (!data.length) { return ( diff --git a/apps/server/src/jobs/repository-healthchecks.ts b/apps/server/src/jobs/repository-healthchecks.ts new file mode 100644 index 0000000..1e600c9 --- /dev/null +++ b/apps/server/src/jobs/repository-healthchecks.ts @@ -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() }; + } +} diff --git a/apps/server/src/modules/lifecycle/startup.ts b/apps/server/src/modules/lifecycle/startup.ts index 03d7c6a..0a194fd 100644 --- a/apps/server/src/modules/lifecycle/startup.ts +++ b/apps/server/src/modules/lifecycle/startup.ts @@ -7,6 +7,7 @@ import { restic } from "../../utils/restic"; import { volumeService } from "../volumes/volume.service"; import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling"; import { VolumeHealthCheckJob } from "../../jobs/healthchecks"; +import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks"; import { BackupExecutionJob } from "../../jobs/backup-execution"; import { CleanupSessionsJob } from "../../jobs/cleanup-sessions"; @@ -32,6 +33,7 @@ export const startup = async () => { Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *"); Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *"); + Scheduler.build(RepositoryHealthCheckJob).schedule("*/10 * * * *"); Scheduler.build(BackupExecutionJob).schedule("* * * * *"); Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *"); }; diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index afc564d..7c9c3cf 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -4,6 +4,7 @@ import { createRepositoryBody, createRepositoryDto, deleteRepositoryDto, + doctorRepositoryDto, getRepositoryDto, getSnapshotDetailsDto, listRepositoriesDto, @@ -14,6 +15,7 @@ import { restoreSnapshotBody, restoreSnapshotDto, type DeleteRepositoryDto, + type DoctorRepositoryDto, type GetRepositoryDto, type GetSnapshotDetailsDto, 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(snapshots, 200); }) .get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => { @@ -116,4 +120,11 @@ export const repositoriesController = new Hono() const result = await repositoriesService.restoreSnapshot(name, snapshotId, options); return c.json(result, 200); + }) + .post("/:name/doctor", doctorRepositoryDto, async (c) => { + const { name } = c.req.param(); + + const result = await repositoriesService.doctorRepository(name); + + return c.json(result, 200); }); diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index 6762acc..319c1ca 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -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), + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index 68677de..775043e 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -4,7 +4,7 @@ import { eq } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { db } from "../../db/db"; -import { repositoriesTable, volumesTable } from "../../db/schema"; +import { repositoriesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; @@ -202,6 +202,112 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => { 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 = { listRepositories, createRepository, @@ -211,4 +317,6 @@ export const repositoriesService = { listSnapshotFiles, restoreSnapshot, getSnapshotDetails, + checkHealth, + doctorRepository, }; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 74403b3..c2bcb4f 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -148,7 +148,7 @@ const backup = async ( 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(); if (includeFile) { @@ -334,7 +334,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: args.push("--prune"); 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(); if (res.exitCode !== 0) { @@ -425,6 +425,79 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) = 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 = { ensurePassfile, init, @@ -432,5 +505,8 @@ export const restic = { restore, snapshots, forget, + unlock, ls, + check, + repairIndex, };