feat: add refresh snapshot list and snapshot error page (#434)

Closes #372
This commit is contained in:
Nico 2026-01-29 23:20:13 +01:00 committed by GitHub
parent a3ba6e093c
commit 8ced05c5a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 186 additions and 6 deletions

3
.gitignore vendored
View file

@ -33,3 +33,6 @@ playwright/.auth
playwright/temp
.idea/
# OpenAPI error logs
openapi-ts-error-*.log

View file

@ -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<Options<RefreshSnapshotsData>>,
): UseMutationOptions<RefreshSnapshotsResponse, DefaultError, Options<RefreshSnapshotsData>> => {
const mutationOptions: UseMutationOptions<
RefreshSnapshotsResponse,
DefaultError,
Options<RefreshSnapshotsData>
> = {
mutationFn: async (fnOptions) => {
const { data } = await refreshSnapshots({
...options,
...fnOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
createQueryKey("listBackupSchedules", options);

View file

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

View file

@ -61,6 +61,8 @@ import type {
GetVolumeData,
GetVolumeErrors,
GetVolumeResponses,
RefreshSnapshotsData,
RefreshSnapshotsResponses,
HealthCheckVolumeData,
HealthCheckVolumeErrors,
HealthCheckVolumeResponses,
@ -456,6 +458,17 @@ export const tagSnapshots = <ThrowOnError extends boolean = false>(options: Opti
},
});
/**
* Clear snapshot cache and force refresh from repository
*/
export const refreshSnapshots = <ThrowOnError extends boolean = false>(
options: Options<RefreshSnapshotsData, ThrowOnError>,
) =>
(options.client ?? client).post<RefreshSnapshotsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{id}/snapshots/refresh",
...options,
});
/**
* List all backup schedules
*/

View file

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

View file

@ -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 (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<Database className="mb-4 h-12 w-12 text-destructive" />
<p className="text-destructive font-semibold">Snapshot not found</p>
<p className="text-sm text-muted-foreground mt-2">
This snapshot does not exist in {loaderData.repository.name}.
</p>
<p className="text-sm text-muted-foreground mt-1">It may have been deleted manually outside of Zerobyte.</p>
<div className="mt-4">
<Link to={`/repositories/${id}?tab=snapshots`}>
<Button variant="outline">Back to repository</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">

View file

@ -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)}
/>
<Button
onClick={handleRefresh}
variant="outline"
disabled={refreshMutation.isPending}
title="Refresh snapshot list"
>
<RefreshCw className={refreshMutation.isPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
</Button>
</div>
</div>
</CardHeader>

View file

@ -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<ListSnapshotsDto>(snapshots, 200);
})
.post("/:id/snapshots/refresh", refreshSnapshotsDto, async (c) => {
const { id } = c.req.param();
const result = await repositoriesService.refreshSnapshots(id);
return c.json<RefreshSnapshotsDto>(result, 200);
})
.get("/:id/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
const { id, snapshotId } = c.req.param();
const snapshot = await repositoriesService.getSnapshotDetails(id, snapshotId);

View file

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

View file

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