diff --git a/.gitignore b/.gitignore index cbe33bc5..b0cc64c6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ playwright/.auth playwright/temp .idea/ + +# OpenAPI error logs +openapi-ts-error-*.log diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index eeac0952..60f0ad71 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -31,6 +31,7 @@ import { getUpdates, getUserDeletionImpact, getVolume, + refreshSnapshots, healthCheckVolume, listBackupSchedules, listFiles, @@ -115,6 +116,8 @@ import type { GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, + RefreshSnapshotsData, + RefreshSnapshotsResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, @@ -778,6 +781,26 @@ export const tagSnapshotsMutation = ( return mutationOptions; }; +export const refreshSnapshotsMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions< + RefreshSnapshotsResponse, + DefaultError, + Options + > = { + mutationFn: async (fnOptions) => { + const { data } = await refreshSnapshots({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey("listBackupSchedules", options); diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index f093e01a..d5ae8aab 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -28,6 +28,7 @@ export { getUpdates, getUserDeletionImpact, getVolume, + refreshSnapshots, healthCheckVolume, listBackupSchedules, listFiles, @@ -144,6 +145,9 @@ export type { GetVolumeErrors, GetVolumeResponse, GetVolumeResponses, + RefreshSnapshotsData, + RefreshSnapshotsResponse, + RefreshSnapshotsResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponse, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index f8d2362b..265943d9 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -61,6 +61,8 @@ import type { GetVolumeData, GetVolumeErrors, GetVolumeResponses, + RefreshSnapshotsData, + RefreshSnapshotsResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, @@ -456,6 +458,17 @@ export const tagSnapshots = (options: Opti }, }); +/** + * Clear snapshot cache and force refresh from repository + */ +export const refreshSnapshots = ( + options: Options, +) => + (options.client ?? client).post({ + url: "/api/v1/repositories/{id}/snapshots/refresh", + ...options, + }); + /** * List all backup schedules */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 73c55985..07a9d067 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1848,6 +1848,27 @@ export type TagSnapshotsResponses = { export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses]; +export type RefreshSnapshotsData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: "/api/v1/repositories/{id}/snapshots/refresh"; +}; + +export type RefreshSnapshotsResponses = { + /** + * Snapshot cache cleared and refreshed + */ + 200: { + message: string; + count: number; + }; +}; + +export type RefreshSnapshotsResponse = RefreshSnapshotsResponses[keyof RefreshSnapshotsResponses]; + export type ListBackupSchedulesData = { body?: never; path?: never; diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index dd31f9a8..f3508cdb 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -1,12 +1,15 @@ import { useQuery } from "@tanstack/react-query"; import { redirect, useParams, Link, Await } from "react-router"; import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; import { formatDateTime } from "~/client/lib/datetime"; +import { parseError } from "~/client/lib/errors"; import { getRepository, getSnapshotDetails } from "~/client/api-client"; import type { Route } from "./+types/snapshot-details"; import { Suspense } from "react"; +import { Database } from "lucide-react"; export const handle = { breadcrumb: (match: Route.MetaArgs) => [ @@ -34,10 +37,13 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { getRepository({ path: { id: params.id } }), ]); - if (!snapshot.data) return redirect(`/repositories/${params.id}`); if (!repository.data) return redirect("/repositories"); - return { snapshot: snapshot, repository: repository.data }; + return { + snapshot: snapshot, + repository: repository.data, + snapshotError: parseError(snapshot.error)?.message ?? null, + }; }; export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) { @@ -51,7 +57,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps path: { id: id ?? "", snapshotId: snapshotId ?? "" }, query: { path: "/" }, }), - enabled: !!id && !!snapshotId, + enabled: !!id && !!snapshotId && !loaderData.snapshotError, }); const schedules = useQuery({ @@ -66,6 +72,26 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps ); } + if (loaderData.snapshotError) { + return ( + + + +

Snapshot not found

+

+ This snapshot does not exist in {loaderData.repository.name}. +

+

It may have been deleted manually outside of Zerobyte.

+
+ + + +
+
+
+ ); + } + return (
diff --git a/app/client/modules/repositories/tabs/snapshots.tsx b/app/client/modules/repositories/tabs/snapshots.tsx index 8b124755..c7b9e211 100644 --- a/app/client/modules/repositories/tabs/snapshots.tsx +++ b/app/client/modules/repositories/tabs/snapshots.tsx @@ -1,13 +1,18 @@ -import { useQuery } from "@tanstack/react-query"; -import { Database, X } from "lucide-react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Database, X, RefreshCw } from "lucide-react"; import { useState } from "react"; -import { listBackupSchedulesOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { + listBackupSchedulesOptions, + listSnapshotsOptions, + refreshSnapshotsMutation, +} from "~/client/api-client/@tanstack/react-query.gen"; import { SnapshotsTable } from "~/client/components/snapshots-table"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Input } from "~/client/components/ui/input"; import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table"; import type { Repository, Snapshot } from "~/client/lib/types"; +import { toast } from "sonner"; type Props = { repository: Repository; @@ -25,6 +30,20 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { ...listBackupSchedulesOptions(), }); + const refreshMutation = useMutation({ + ...refreshSnapshotsMutation(), + onSuccess: (data) => { + toast.success(`Snapshot cache refreshed. Found ${data.count} snapshots.`); + }, + onError: (error) => { + toast.error(`Failed to refresh snapshots: ${error.message}`); + }, + }); + + const handleRefresh = () => { + refreshMutation.mutate({ path: { id: repository.id } }); + }; + const filteredSnapshots = data.filter((snapshot: Snapshot) => { if (!searchQuery) return true; const searchLower = searchQuery.toLowerCase(); @@ -122,6 +141,14 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> +
diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index fae89aca..4bfcc345 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -11,6 +11,7 @@ import { cancelDoctorDto, getRepositoryDto, getSnapshotDetailsDto, + refreshSnapshotsDto, listRcloneRemotesDto, listRepositoriesDto, listSnapshotFilesDto, @@ -30,6 +31,7 @@ import { type CancelDoctorDto, type GetRepositoryDto, type GetSnapshotDetailsDto, + type RefreshSnapshotsDto, type ListRepositoriesDto, type ListSnapshotFilesDto, type ListSnapshotsDto, @@ -107,6 +109,12 @@ export const repositoriesController = new Hono() return c.json(snapshots, 200); }) + .post("/:id/snapshots/refresh", refreshSnapshotsDto, async (c) => { + const { id } = c.req.param(); + const result = await repositoriesService.refreshSnapshots(id); + + return c.json(result, 200); + }) .get("/:id/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => { const { id, snapshotId } = c.req.param(); const snapshot = await repositoriesService.getSnapshotDetails(id, snapshotId); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 3e3f21ea..7ceb5e67 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -487,3 +487,29 @@ export const tagSnapshotsDto = describeRoute({ }, }, }); + +/** + * Refresh snapshots cache + */ +export const refreshSnapshotsResponse = type({ + message: "string", + count: "number", +}); + +export type RefreshSnapshotsDto = typeof refreshSnapshotsResponse.infer; + +export const refreshSnapshotsDto = describeRoute({ + description: "Clear snapshot cache and force refresh from repository", + tags: ["Repositories"], + operationId: "refreshSnapshots", + responses: { + 200: { + description: "Snapshot cache cleared and refreshed", + content: { + "application/json": { + schema: resolver(refreshSnapshotsResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 47c8120d..ec2ecf56 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -314,6 +314,8 @@ const getSnapshotDetails = async (id: string, snapshotId: string) => { const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId); if (!snapshot) { + void refreshSnapshots(id).catch(() => {}); + throw new NotFoundError("Snapshot not found"); } @@ -477,6 +479,32 @@ const tagSnapshots = async ( } }; +const refreshSnapshots = async (id: string) => { + const organizationId = getOrganizationId(); + const repository = await findRepository(id); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + cache.delByPrefix(`snapshots:${repository.id}:`); + cache.delByPrefix(`ls:${repository.id}:`); + + const releaseLock = await repoMutex.acquireShared(repository.id, "refresh"); + try { + const snapshots = await restic.snapshots(repository.config, { organizationId }); + const cacheKey = `snapshots:${repository.id}:all`; + cache.set(cacheKey, snapshots); + + return { + message: "Snapshot cache cleared and refreshed", + count: snapshots.length, + }; + } finally { + releaseLock(); + } +}; + const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => { const existing = await findRepository(id); @@ -530,4 +558,5 @@ export const repositoriesService = { deleteSnapshot, deleteSnapshots, tagSnapshots, + refreshSnapshots, };