feat(snapshots): list files in snapshots api

This commit is contained in:
Nicolas Meienberger
2025-10-30 18:58:57 +01:00
parent ed73ca73fb
commit b80a187108
8 changed files with 251 additions and 2 deletions

View File

@@ -23,6 +23,7 @@ import {
deleteRepository,
getRepository,
listSnapshots,
listSnapshotFiles,
listBackupSchedules,
createBackupSchedule,
deleteBackupSchedule,
@@ -67,6 +68,7 @@ import type {
DeleteRepositoryResponse,
GetRepositoryData,
ListSnapshotsData,
ListSnapshotFilesData,
ListBackupSchedulesData,
CreateBackupScheduleData,
CreateBackupScheduleResponse,
@@ -715,6 +717,27 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => {
});
};
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) =>
createQueryKey("listSnapshotFiles", options);
/**
* List files and directories in a snapshot
*/
export const listSnapshotFilesOptions = (options: Options<ListSnapshotFilesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listSnapshotFiles({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listSnapshotFilesQueryKey(options),
});
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
createQueryKey("listBackupSchedules", options);

View File

@@ -50,6 +50,8 @@ import type {
GetRepositoryResponses,
ListSnapshotsData,
ListSnapshotsResponses,
ListSnapshotFilesData,
ListSnapshotFilesResponses,
ListBackupSchedulesData,
ListBackupSchedulesResponses,
CreateBackupScheduleData,
@@ -348,6 +350,18 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(
});
};
/**
* List files and directories in a snapshot
*/
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
options: Options<ListSnapshotFilesData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files",
...options,
});
};
/**
* List all backup schedules
*/

View File

@@ -788,7 +788,7 @@ export type ListSnapshotsData = {
name: string;
};
query?: {
volumeId?: number;
volumeId?: string;
};
url: "/api/v1/repositories/{name}/snapshots";
};
@@ -810,6 +810,47 @@ export type ListSnapshotsResponses = {
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
export type ListSnapshotFilesData = {
body?: never;
path: {
name: string;
snapshotId: string;
};
query?: {
path?: string;
};
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}/files";
};
export type ListSnapshotFilesResponses = {
/**
* List of files and directories in the snapshot
*/
200: {
files: Array<{
name: string;
path: string;
type: string;
atime?: string;
ctime?: string;
gid?: number;
mode?: number;
mtime?: string;
size?: number;
uid?: number;
}>;
snapshot: {
hostname: string;
id: string;
paths: Array<string>;
short_id: string;
time: string;
};
};
};
export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses];
export type ListBackupSchedulesData = {
body?: never;
path?: never;

View File

@@ -23,7 +23,7 @@ export const ScheduleSummary = (props: Props) => {
const { data: snapshots, isLoading: loadingSnapshots } = useQuery({
...listSnapshotsOptions({
path: { name: repository.name },
query: { volumeId: volume.id },
query: { volumeId: volume.id.toString() },
}),
refetchInterval: 10000,
refetchOnWindowFocus: true,

View File

@@ -8,10 +8,13 @@ import {
listRepositoriesDto,
listSnapshotsDto,
listSnapshotsFilters,
listSnapshotFilesDto,
listSnapshotFilesQuery,
type DeleteRepositoryDto,
type GetRepositoryDto,
type ListRepositoriesDto,
type ListSnapshotsDto,
type ListSnapshotFilesDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
@@ -68,4 +71,14 @@ export const repositoriesController = new Hono()
c.header("Cache-Control", "max-age=30, stale-while-revalidate=300");
return c.json<ListSnapshotsDto>(response, 200);
})
.get("/:name/snapshots/:snapshotId/files", listSnapshotFilesDto, validator("query", listSnapshotFilesQuery), async (c) => {
const { name, snapshotId } = c.req.param();
const { path } = c.req.valid("query");
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
return c.json<ListSnapshotFilesDto>(result, 200);
});

View File

@@ -164,3 +164,52 @@ export const listSnapshotsDto = describeRoute({
},
},
});
/**
* List files in a snapshot
*/
export const snapshotFileNodeSchema = type({
name: "string",
type: "string",
path: "string",
uid: "number?",
gid: "number?",
size: "number?",
mode: "number?",
mtime: "string?",
atime: "string?",
ctime: "string?",
});
export const listSnapshotFilesResponse = type({
snapshot: type({
id: "string",
short_id: "string",
time: "string",
hostname: "string",
paths: "string[]",
}),
files: snapshotFileNodeSchema.array(),
});
export type ListSnapshotFilesDto = typeof listSnapshotFilesResponse.infer;
export const listSnapshotFilesQuery = type({
path: "string?",
});
export const listSnapshotFilesDto = describeRoute({
description: "List files and directories in a snapshot",
tags: ["Repositories"],
operationId: "listSnapshotFiles",
responses: {
200: {
description: "List of files and directories in the snapshot",
content: {
"application/json": {
schema: resolver(listSnapshotFilesResponse),
},
},
},
},
});

View File

@@ -134,10 +134,38 @@ const listSnapshots = async (name: string, volumeId?: number) => {
return snapshots;
};
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const result = await restic.ls(repository.config, snapshotId, path);
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
}
return {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
};
};
export const repositoriesService = {
listRepositories,
createRepository,
getRepository,
deleteRepository,
listSnapshots,
listSnapshotFiles,
};

View File

@@ -236,6 +236,86 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy) => {
return { success: true };
};
const lsNodeSchema = type({
name: "string",
type: "string",
path: "string",
uid: "number?",
gid: "number?",
size: "number?",
mode: "number?",
mtime: "string?",
atime: "string?",
ctime: "string?",
struct_type: "'node'",
});
const lsSnapshotInfoSchema = type({
time: "string",
parent: "string?",
tree: "string",
paths: "string[]",
hostname: "string",
username: "string?",
id: "string",
short_id: "string",
struct_type: "'snapshot'",
message_type: "'snapshot'",
});
const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--json", "--long"];
if (path) {
args.push(path);
}
const res = await $`restic ${args}`.env(env).nothrow();
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
throw new Error(`Restic ls failed: ${res.stderr}`);
}
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
const stdout = res.text();
const lines = stdout
.trim()
.split("\n")
.filter((line) => line.trim());
if (lines.length === 0) {
return { snapshot: null, nodes: [] };
}
// First line is snapshot info
const snapshotLine = JSON.parse(lines[0] ?? "{}");
const snapshot = lsSnapshotInfoSchema(snapshotLine);
if (snapshot instanceof type.errors) {
logger.error(`Restic ls snapshot info validation failed: ${snapshot}`);
throw new Error(`Restic ls snapshot info validation failed: ${snapshot}`);
}
const nodes: Array<typeof lsNodeSchema.infer> = [];
for (let i = 1; i < lines.length; i++) {
const nodeLine = JSON.parse(lines[i] ?? "{}");
const nodeValidation = lsNodeSchema(nodeLine);
if (nodeValidation instanceof type.errors) {
logger.warn(`Skipping invalid node: ${nodeValidation}`);
continue;
}
nodes.push(nodeValidation);
}
return { snapshot, nodes };
};
export const restic = {
ensurePassfile,
init,
@@ -243,4 +323,5 @@ export const restic = {
restore,
snapshots,
forget,
ls,
};