feat(repositories): list snapshots api

This commit is contained in:
Nicolas Meienberger
2025-10-23 20:22:09 +02:00
parent 4ae738ce41
commit cae8538b2e
9 changed files with 187 additions and 5 deletions

View File

@@ -22,6 +22,7 @@ import {
createRepository,
deleteRepository,
getRepository,
listSnapshots,
} from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
import type {
@@ -57,6 +58,7 @@ import type {
DeleteRepositoryData,
DeleteRepositoryResponse,
GetRepositoryData,
ListSnapshotsData,
} from "../types.gen";
import { client as _heyApiClient } from "../client.gen";
@@ -671,3 +673,23 @@ export const getRepositoryOptions = (options: Options<GetRepositoryData>) => {
queryKey: getRepositoryQueryKey(options),
});
};
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey("listSnapshots", options);
/**
* List all snapshots in a repository
*/
export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listSnapshots({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listSnapshotsQueryKey(options),
});
};

View File

@@ -52,6 +52,8 @@ import type {
DeleteRepositoryResponses,
GetRepositoryData,
GetRepositoryResponses,
ListSnapshotsData,
ListSnapshotsResponses,
} from "./types.gen";
import { client as _heyApiClient } from "./client.gen";
@@ -321,3 +323,15 @@ export const getRepository = <ThrowOnError extends boolean = false>(
...options,
});
};
/**
* List all snapshots in a repository
*/
export const listSnapshots = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<ListSnapshotsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots",
...options,
});
};

View File

@@ -778,6 +778,32 @@ export type GetRepositoryResponses = {
export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryResponses];
export type ListSnapshotsData = {
body?: never;
path: {
name: string;
};
query?: never;
url: "/api/v1/repositories/{name}/snapshots";
};
export type ListSnapshotsResponses = {
/**
* List of snapshots
*/
200: {
snapshots: Array<{
duration: number;
paths: Array<string>;
short_id: string;
size: number;
time: number;
}>;
};
};
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
export type ClientOptions = {
baseUrl: "http://192.168.2.42:4096" | (string & {});
};

View File

@@ -1,4 +1,4 @@
import React from "react";
import type React from "react";
type ByteSizeProps = {
bytes: number;
@@ -54,7 +54,7 @@ export function formatBytes(
idx = Math.max(0, Math.min(idx, units.length - 1));
}
const numeric = (abs / Math.pow(base, idx)) * sign;
const numeric = (abs / base ** idx) * sign;
const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits;

View File

@@ -47,9 +47,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground hidden md:inline-flex">
Welcome,&nbsp;
<span className="text-strong-accent">
{loaderData.user?.username[0].toUpperCase() + loaderData.user?.username.slice(1)}
</span>
<span className="text-strong-accent">{loaderData.user?.username}</span>
</span>
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
Logout

View File

@@ -8,6 +8,8 @@ import {
type GetRepositoryResponseDto,
type ListRepositoriesResponseDto,
listRepositoriesDto,
listSnapshotsDto,
type ListSnapshotsResponseDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
@@ -52,4 +54,26 @@ export const repositoriesController = new Hono()
await repositoriesService.deleteRepository(name);
return c.json({ message: "Repository deleted" }, 200);
})
.get("/:name/snapshots", listSnapshotsDto, async (c) => {
const { name } = c.req.param();
const res = await repositoriesService.listSnapshots(name);
const snapshots = res.map((snapshot) => {
const { summary } = snapshot;
const { backup_start, backup_end } = summary;
const duration = new Date(backup_end).getTime() - new Date(backup_start).getTime();
return {
short_id: snapshot.short_id,
duration,
paths: snapshot.paths,
size: summary.total_bytes_processed,
time: new Date(snapshot.time).getTime(),
};
});
const response = { snapshots } satisfies ListSnapshotsResponseDto;
return c.json(response, 200);
});

View File

@@ -127,3 +127,36 @@ export const deleteRepositoryDto = describeRoute({
},
},
});
/**
* List snapshots in a repository
*/
export const snapshotSchema = type({
short_id: "string",
time: "number",
paths: "string[]",
size: "number",
duration: "number",
});
const listSnapshotsResponse = type({
snapshots: snapshotSchema.array(),
});
export type ListSnapshotsResponseDto = typeof listSnapshotsResponse.infer;
export const listSnapshotsDto = describeRoute({
description: "List all snapshots in a repository",
tags: ["Repositories"],
operationId: "listSnapshots",
responses: {
200: {
description: "List of snapshots",
content: {
"application/json": {
schema: resolver(listSnapshotsResponse),
},
},
},
},
});

View File

@@ -105,9 +105,23 @@ const deleteRepository = async (name: string) => {
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
};
const listSnapshots = async (name: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const snapshots = await restic.snapshots(repository.config);
return snapshots;
};
export const repositoriesService = {
listRepositories,
createRepository,
getRepository,
deleteRepository,
listSnapshots,
};

View File

@@ -25,6 +25,35 @@ const backupOutputSchema = type({
snapshot_id: "string",
});
const snapshotInfoSchema = type({
gid: "number",
hostname: "string",
id: "string",
parent: "string?",
paths: "string[]",
program_version: "string",
short_id: "string",
time: "string",
uid: "number",
username: "string",
summary: type({
backup_end: "string",
backup_start: "string",
data_added: "number",
data_added_packed: "number",
data_blobs: "number",
dirs_changed: "number",
dirs_new: "number",
dirs_unmodified: "number",
files_changed: "number",
files_new: "number",
files_unmodified: "number",
total_bytes_processed: "number",
total_files_processed: "number",
tree_blobs: "number",
}),
});
const ensurePassfile = async () => {
await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true });
@@ -115,9 +144,31 @@ const restore = async (config: RepositoryConfig, snapshotId: string, target: str
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
};
const snapshots = async (config: RepositoryConfig) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const res = await $`restic --repo ${repoUrl} snapshots --json`.env(env).nothrow();
if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`);
}
const result = snapshotInfoSchema.array()(res.json());
if (result instanceof type.errors) {
logger.error(`Restic snapshots output validation failed: ${result}`);
throw new Error(`Restic snapshots output validation failed: ${result}`);
}
return result;
};
export const restic = {
ensurePassfile,
init,
backup,
restore,
snapshots,
};