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 playwright/temp
.idea/ .idea/
# OpenAPI error logs
openapi-ts-error-*.log

View file

@ -31,6 +31,7 @@ import {
getUpdates, getUpdates,
getUserDeletionImpact, getUserDeletionImpact,
getVolume, getVolume,
refreshSnapshots,
healthCheckVolume, healthCheckVolume,
listBackupSchedules, listBackupSchedules,
listFiles, listFiles,
@ -115,6 +116,8 @@ import type {
GetUserDeletionImpactResponse, GetUserDeletionImpactResponse,
GetVolumeData, GetVolumeData,
GetVolumeResponse, GetVolumeResponse,
RefreshSnapshotsData,
RefreshSnapshotsResponse,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponse, HealthCheckVolumeResponse,
ListBackupSchedulesData, ListBackupSchedulesData,
@ -778,6 +781,26 @@ export const tagSnapshotsMutation = (
return mutationOptions; 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>) => export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
createQueryKey("listBackupSchedules", options); createQueryKey("listBackupSchedules", options);

View file

@ -28,6 +28,7 @@ export {
getUpdates, getUpdates,
getUserDeletionImpact, getUserDeletionImpact,
getVolume, getVolume,
refreshSnapshots,
healthCheckVolume, healthCheckVolume,
listBackupSchedules, listBackupSchedules,
listFiles, listFiles,
@ -144,6 +145,9 @@ export type {
GetVolumeErrors, GetVolumeErrors,
GetVolumeResponse, GetVolumeResponse,
GetVolumeResponses, GetVolumeResponses,
RefreshSnapshotsData,
RefreshSnapshotsResponse,
RefreshSnapshotsResponses,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeErrors, HealthCheckVolumeErrors,
HealthCheckVolumeResponse, HealthCheckVolumeResponse,

View file

@ -61,6 +61,8 @@ import type {
GetVolumeData, GetVolumeData,
GetVolumeErrors, GetVolumeErrors,
GetVolumeResponses, GetVolumeResponses,
RefreshSnapshotsData,
RefreshSnapshotsResponses,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeErrors, HealthCheckVolumeErrors,
HealthCheckVolumeResponses, 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 * List all backup schedules
*/ */

View file

@ -1848,6 +1848,27 @@ export type TagSnapshotsResponses = {
export type TagSnapshotsResponse = TagSnapshotsResponses[keyof 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 = { export type ListBackupSchedulesData = {
body?: never; body?: never;
path?: never; path?: never;

View file

@ -1,12 +1,15 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { redirect, useParams, Link, Await } from "react-router"; import { redirect, useParams, Link, Await } from "react-router";
import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; 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 { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { formatDateTime } from "~/client/lib/datetime"; import { formatDateTime } from "~/client/lib/datetime";
import { parseError } from "~/client/lib/errors";
import { getRepository, getSnapshotDetails } from "~/client/api-client"; import { getRepository, getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details"; import type { Route } from "./+types/snapshot-details";
import { Suspense } from "react"; import { Suspense } from "react";
import { Database } from "lucide-react";
export const handle = { export const handle = {
breadcrumb: (match: Route.MetaArgs) => [ breadcrumb: (match: Route.MetaArgs) => [
@ -34,10 +37,13 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
getRepository({ path: { id: params.id } }), getRepository({ path: { id: params.id } }),
]); ]);
if (!snapshot.data) return redirect(`/repositories/${params.id}`);
if (!repository.data) return redirect("/repositories"); 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) { export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
@ -51,7 +57,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
path: { id: id ?? "", snapshotId: snapshotId ?? "" }, path: { id: id ?? "", snapshotId: snapshotId ?? "" },
query: { path: "/" }, query: { path: "/" },
}), }),
enabled: !!id && !!snapshotId, enabled: !!id && !!snapshotId && !loaderData.snapshotError,
}); });
const schedules = useQuery({ 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View file

@ -1,13 +1,18 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Database, X } from "lucide-react"; import { Database, X, RefreshCw } from "lucide-react";
import { useState } from "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 { SnapshotsTable } from "~/client/components/snapshots-table";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table";
import type { Repository, Snapshot } from "~/client/lib/types"; import type { Repository, Snapshot } from "~/client/lib/types";
import { toast } from "sonner";
type Props = { type Props = {
repository: Repository; repository: Repository;
@ -25,6 +30,20 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
...listBackupSchedulesOptions(), ...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) => { const filteredSnapshots = data.filter((snapshot: Snapshot) => {
if (!searchQuery) return true; if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase(); const searchLower = searchQuery.toLowerCase();
@ -122,6 +141,14 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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>
</div> </div>
</CardHeader> </CardHeader>

View file

@ -11,6 +11,7 @@ import {
cancelDoctorDto, cancelDoctorDto,
getRepositoryDto, getRepositoryDto,
getSnapshotDetailsDto, getSnapshotDetailsDto,
refreshSnapshotsDto,
listRcloneRemotesDto, listRcloneRemotesDto,
listRepositoriesDto, listRepositoriesDto,
listSnapshotFilesDto, listSnapshotFilesDto,
@ -30,6 +31,7 @@ import {
type CancelDoctorDto, type CancelDoctorDto,
type GetRepositoryDto, type GetRepositoryDto,
type GetSnapshotDetailsDto, type GetSnapshotDetailsDto,
type RefreshSnapshotsDto,
type ListRepositoriesDto, type ListRepositoriesDto,
type ListSnapshotFilesDto, type ListSnapshotFilesDto,
type ListSnapshotsDto, type ListSnapshotsDto,
@ -107,6 +109,12 @@ export const repositoriesController = new Hono()
return c.json<ListSnapshotsDto>(snapshots, 200); 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) => { .get("/:id/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
const { id, snapshotId } = c.req.param(); const { id, snapshotId } = c.req.param();
const snapshot = await repositoriesService.getSnapshotDetails(id, snapshotId); 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); const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
if (!snapshot) { if (!snapshot) {
void refreshSnapshots(id).catch(() => {});
throw new NotFoundError("Snapshot not found"); 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 updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
const existing = await findRepository(id); const existing = await findRepository(id);
@ -530,4 +558,5 @@ export const repositoriesService = {
deleteSnapshot, deleteSnapshot,
deleteSnapshots, deleteSnapshots,
tagSnapshots, tagSnapshots,
refreshSnapshots,
}; };