feat: add refresh snapshot list and snapshot error page (#434)
Closes #372
This commit is contained in:
parent
a3ba6e093c
commit
8ced05c5a9
10 changed files with 186 additions and 6 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -33,3 +33,6 @@ playwright/.auth
|
|||
playwright/temp
|
||||
|
||||
.idea/
|
||||
|
||||
# OpenAPI error logs
|
||||
openapi-ts-error-*.log
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue