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
|
playwright/temp
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# OpenAPI error logs
|
||||||
|
openapi-ts-error-*.log
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue