mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(repositories): list snapshots api
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
|||||||
createRepository,
|
createRepository,
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
getRepository,
|
getRepository,
|
||||||
|
listSnapshots,
|
||||||
} from "../sdk.gen";
|
} from "../sdk.gen";
|
||||||
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
@@ -57,6 +58,7 @@ import type {
|
|||||||
DeleteRepositoryData,
|
DeleteRepositoryData,
|
||||||
DeleteRepositoryResponse,
|
DeleteRepositoryResponse,
|
||||||
GetRepositoryData,
|
GetRepositoryData,
|
||||||
|
ListSnapshotsData,
|
||||||
} from "../types.gen";
|
} from "../types.gen";
|
||||||
import { client as _heyApiClient } from "../client.gen";
|
import { client as _heyApiClient } from "../client.gen";
|
||||||
|
|
||||||
@@ -671,3 +673,23 @@ export const getRepositoryOptions = (options: Options<GetRepositoryData>) => {
|
|||||||
queryKey: getRepositoryQueryKey(options),
|
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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import type {
|
|||||||
DeleteRepositoryResponses,
|
DeleteRepositoryResponses,
|
||||||
GetRepositoryData,
|
GetRepositoryData,
|
||||||
GetRepositoryResponses,
|
GetRepositoryResponses,
|
||||||
|
ListSnapshotsData,
|
||||||
|
ListSnapshotsResponses,
|
||||||
} from "./types.gen";
|
} from "./types.gen";
|
||||||
import { client as _heyApiClient } from "./client.gen";
|
import { client as _heyApiClient } from "./client.gen";
|
||||||
|
|
||||||
@@ -321,3 +323,15 @@ export const getRepository = <ThrowOnError extends boolean = false>(
|
|||||||
...options,
|
...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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -778,6 +778,32 @@ export type GetRepositoryResponses = {
|
|||||||
|
|
||||||
export type GetRepositoryResponse = GetRepositoryResponses[keyof 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 = {
|
export type ClientOptions = {
|
||||||
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
type ByteSizeProps = {
|
type ByteSizeProps = {
|
||||||
bytes: number;
|
bytes: number;
|
||||||
@@ -54,7 +54,7 @@ export function formatBytes(
|
|||||||
idx = Math.max(0, Math.min(idx, units.length - 1));
|
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 = (() => {
|
const maxFrac = (() => {
|
||||||
if (!smartRounding) return maximumFractionDigits;
|
if (!smartRounding) return maximumFractionDigits;
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-muted-foreground hidden md:inline-flex">
|
<span className="text-sm text-muted-foreground hidden md:inline-flex">
|
||||||
Welcome,
|
Welcome,
|
||||||
<span className="text-strong-accent">
|
<span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||||
{loaderData.user?.username[0].toUpperCase() + loaderData.user?.username.slice(1)}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
type GetRepositoryResponseDto,
|
type GetRepositoryResponseDto,
|
||||||
type ListRepositoriesResponseDto,
|
type ListRepositoriesResponseDto,
|
||||||
listRepositoriesDto,
|
listRepositoriesDto,
|
||||||
|
listSnapshotsDto,
|
||||||
|
type ListSnapshotsResponseDto,
|
||||||
} from "./repositories.dto";
|
} from "./repositories.dto";
|
||||||
import { repositoriesService } from "./repositories.service";
|
import { repositoriesService } from "./repositories.service";
|
||||||
|
|
||||||
@@ -52,4 +54,26 @@ export const repositoriesController = new Hono()
|
|||||||
await repositoriesService.deleteRepository(name);
|
await repositoriesService.deleteRepository(name);
|
||||||
|
|
||||||
return c.json({ message: "Repository deleted" }, 200);
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,9 +105,23 @@ const deleteRepository = async (name: string) => {
|
|||||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
|
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 = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
getRepository,
|
getRepository,
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
|
listSnapshots,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,35 @@ const backupOutputSchema = type({
|
|||||||
snapshot_id: "string",
|
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 () => {
|
const ensurePassfile = async () => {
|
||||||
await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true });
|
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}`);
|
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 = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
backup,
|
backup,
|
||||||
restore,
|
restore,
|
||||||
|
snapshots,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user