mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
refactor: unify backend and frontend servers (#3)
* refactor: unify backend and frontend servers * refactor: correct paths for openapi & drizzle * refactor: move api-client to client * fix: drizzle paths * chore: fix linting issues * fix: form reset issue
This commit is contained in:
145
app/server/modules/repositories/repositories.controller.ts
Normal file
145
app/server/modules/repositories/repositories.controller.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Hono } from "hono";
|
||||
import { validator } from "hono-openapi";
|
||||
import {
|
||||
createRepositoryBody,
|
||||
createRepositoryDto,
|
||||
deleteRepositoryDto,
|
||||
doctorRepositoryDto,
|
||||
getRepositoryDto,
|
||||
getSnapshotDetailsDto,
|
||||
listRcloneRemotesDto,
|
||||
listRepositoriesDto,
|
||||
listSnapshotFilesDto,
|
||||
listSnapshotFilesQuery,
|
||||
listSnapshotsDto,
|
||||
listSnapshotsFilters,
|
||||
restoreSnapshotBody,
|
||||
restoreSnapshotDto,
|
||||
type DeleteRepositoryDto,
|
||||
type DoctorRepositoryDto,
|
||||
type GetRepositoryDto,
|
||||
type GetSnapshotDetailsDto,
|
||||
type ListRepositoriesDto,
|
||||
type ListSnapshotFilesDto,
|
||||
type ListSnapshotsDto,
|
||||
type RestoreSnapshotDto,
|
||||
} from "./repositories.dto";
|
||||
import { repositoriesService } from "./repositories.service";
|
||||
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
|
||||
|
||||
export const repositoriesController = new Hono()
|
||||
.get("/", listRepositoriesDto, async (c) => {
|
||||
const repositories = await repositoriesService.listRepositories();
|
||||
|
||||
return c.json<ListRepositoriesDto>(repositories, 200);
|
||||
})
|
||||
.post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const res = await repositoriesService.createRepository(body.name, body.config, body.compressionMode);
|
||||
|
||||
return c.json({ message: "Repository created", repository: res.repository }, 201);
|
||||
})
|
||||
.get("/rclone-remotes", listRcloneRemotesDto, async (c) => {
|
||||
const remoteNames = await listRcloneRemotes();
|
||||
|
||||
const remotes = await Promise.all(
|
||||
remoteNames.map(async (name) => {
|
||||
const info = await getRcloneRemoteInfo(name);
|
||||
return {
|
||||
name,
|
||||
type: info?.type ?? "unknown",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json(remotes);
|
||||
})
|
||||
.get("/:name", getRepositoryDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const res = await repositoriesService.getRepository(name);
|
||||
|
||||
return c.json<GetRepositoryDto>(res.repository, 200);
|
||||
})
|
||||
.delete("/:name", deleteRepositoryDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
await repositoriesService.deleteRepository(name);
|
||||
|
||||
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
|
||||
})
|
||||
.get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const { backupId } = c.req.valid("query");
|
||||
|
||||
const res = await repositoriesService.listSnapshots(name, backupId);
|
||||
|
||||
const snapshots = res.map((snapshot) => {
|
||||
const { summary } = snapshot;
|
||||
|
||||
let duration = 0;
|
||||
if (summary) {
|
||||
const { backup_start, backup_end } = summary;
|
||||
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 || 0,
|
||||
time: new Date(snapshot.time).getTime(),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json<ListSnapshotsDto>(snapshots, 200);
|
||||
})
|
||||
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
|
||||
const { name, snapshotId } = c.req.param();
|
||||
const snapshot = await repositoriesService.getSnapshotDetails(name, snapshotId);
|
||||
|
||||
let duration = 0;
|
||||
if (snapshot.summary) {
|
||||
const { backup_start, backup_end } = snapshot.summary;
|
||||
duration = new Date(backup_end).getTime() - new Date(backup_start).getTime();
|
||||
}
|
||||
|
||||
const response = {
|
||||
short_id: snapshot.short_id,
|
||||
duration,
|
||||
time: new Date(snapshot.time).getTime(),
|
||||
paths: snapshot.paths,
|
||||
size: snapshot.summary?.total_bytes_processed || 0,
|
||||
summary: snapshot.summary,
|
||||
};
|
||||
|
||||
return c.json<GetSnapshotDetailsDto>(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);
|
||||
},
|
||||
)
|
||||
.post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const { snapshotId, ...options } = c.req.valid("json");
|
||||
|
||||
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
|
||||
|
||||
return c.json<RestoreSnapshotDto>(result, 200);
|
||||
})
|
||||
.post("/:name/doctor", doctorRepositoryDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
|
||||
const result = await repositoriesService.doctorRepository(name);
|
||||
|
||||
return c.json<DoctorRepositoryDto>(result, 200);
|
||||
});
|
||||
328
app/server/modules/repositories/repositories.dto.ts
Normal file
328
app/server/modules/repositories/repositories.dto.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { type } from "arktype";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryConfigSchema } from "~/schemas/restic";
|
||||
|
||||
export const repositorySchema = type({
|
||||
id: "string",
|
||||
name: "string",
|
||||
type: type.valueOf(REPOSITORY_BACKENDS),
|
||||
config: repositoryConfigSchema,
|
||||
compressionMode: type.valueOf(COMPRESSION_MODES).or("null"),
|
||||
status: type.valueOf(REPOSITORY_STATUS).or("null"),
|
||||
lastChecked: "number | null",
|
||||
lastError: "string | null",
|
||||
createdAt: "number",
|
||||
updatedAt: "number",
|
||||
});
|
||||
|
||||
export type RepositoryDto = typeof repositorySchema.infer;
|
||||
|
||||
/**
|
||||
* List all repositories
|
||||
*/
|
||||
export const listRepositoriesResponse = repositorySchema.array();
|
||||
export type ListRepositoriesDto = typeof listRepositoriesResponse.infer;
|
||||
|
||||
export const listRepositoriesDto = describeRoute({
|
||||
description: "List all repositories",
|
||||
tags: ["Repositories"],
|
||||
operationId: "listRepositories",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of repositories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(listRepositoriesResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new repository
|
||||
*/
|
||||
export const createRepositoryBody = type({
|
||||
name: "string",
|
||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
||||
config: repositoryConfigSchema,
|
||||
});
|
||||
|
||||
export type CreateRepositoryBody = typeof createRepositoryBody.infer;
|
||||
|
||||
export const createRepositoryResponse = type({
|
||||
message: "string",
|
||||
repository: type({
|
||||
id: "string",
|
||||
name: "string",
|
||||
}),
|
||||
});
|
||||
|
||||
export type CreateRepositoryDto = typeof createRepositoryResponse.infer;
|
||||
|
||||
export const createRepositoryDto = describeRoute({
|
||||
description: "Create a new restic repository",
|
||||
operationId: "createRepository",
|
||||
tags: ["Repositories"],
|
||||
responses: {
|
||||
201: {
|
||||
description: "Repository created successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(createRepositoryResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single repository
|
||||
*/
|
||||
export const getRepositoryResponse = repositorySchema;
|
||||
export type GetRepositoryDto = typeof getRepositoryResponse.infer;
|
||||
|
||||
export const getRepositoryDto = describeRoute({
|
||||
description: "Get a single repository by name",
|
||||
tags: ["Repositories"],
|
||||
operationId: "getRepository",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Repository details",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(getRepositoryResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a repository
|
||||
*/
|
||||
export const deleteRepositoryResponse = type({
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export type DeleteRepositoryDto = typeof deleteRepositoryResponse.infer;
|
||||
|
||||
export const deleteRepositoryDto = describeRoute({
|
||||
description: "Delete a repository",
|
||||
tags: ["Repositories"],
|
||||
operationId: "deleteRepository",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Repository deleted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(deleteRepositoryResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List snapshots in a repository
|
||||
*/
|
||||
export const snapshotSchema = type({
|
||||
short_id: "string",
|
||||
time: "number",
|
||||
paths: "string[]",
|
||||
size: "number",
|
||||
duration: "number",
|
||||
});
|
||||
|
||||
const listSnapshotsResponse = snapshotSchema.array();
|
||||
|
||||
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
|
||||
|
||||
export const listSnapshotsFilters = type({
|
||||
backupId: "string?",
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get snapshot details
|
||||
*/
|
||||
export const getSnapshotDetailsResponse = snapshotSchema;
|
||||
|
||||
export type GetSnapshotDetailsDto = typeof getSnapshotDetailsResponse.infer;
|
||||
|
||||
export const getSnapshotDetailsDto = describeRoute({
|
||||
description: "Get details of a specific snapshot",
|
||||
tags: ["Repositories"],
|
||||
operationId: "getSnapshotDetails",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Snapshot details",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(getSnapshotDetailsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Restore a snapshot
|
||||
*/
|
||||
export const restoreSnapshotBody = type({
|
||||
snapshotId: "string",
|
||||
include: "string[]?",
|
||||
exclude: "string[]?",
|
||||
delete: "boolean?",
|
||||
});
|
||||
|
||||
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;
|
||||
|
||||
export const restoreSnapshotResponse = type({
|
||||
success: "boolean",
|
||||
message: "string",
|
||||
filesRestored: "number",
|
||||
filesSkipped: "number",
|
||||
});
|
||||
|
||||
export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer;
|
||||
|
||||
export const restoreSnapshotDto = describeRoute({
|
||||
description: "Restore a snapshot to a target path on the filesystem",
|
||||
tags: ["Repositories"],
|
||||
operationId: "restoreSnapshot",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Snapshot restored successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(restoreSnapshotResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Doctor a repository (unlock, check, repair)
|
||||
*/
|
||||
export const doctorStepSchema = type({
|
||||
step: "string",
|
||||
success: "boolean",
|
||||
output: "string | null",
|
||||
error: "string | null",
|
||||
});
|
||||
|
||||
export const doctorRepositoryResponse = type({
|
||||
success: "boolean",
|
||||
steps: doctorStepSchema.array(),
|
||||
});
|
||||
|
||||
export type DoctorRepositoryDto = typeof doctorRepositoryResponse.infer;
|
||||
|
||||
export const doctorRepositoryDto = describeRoute({
|
||||
description:
|
||||
"Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.",
|
||||
tags: ["Repositories"],
|
||||
operationId: "doctorRepository",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Doctor operation completed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(doctorRepositoryResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List rclone available remotes
|
||||
*/
|
||||
const rcloneRemoteSchema = type({
|
||||
name: "string",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
const listRcloneRemotesResponse = rcloneRemoteSchema.array();
|
||||
|
||||
export const listRcloneRemotesDto = describeRoute({
|
||||
description: "List all configured rclone remotes on the host system",
|
||||
tags: ["Rclone"],
|
||||
operationId: "listRcloneRemotes",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of rclone remotes",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(listRcloneRemotesResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
328
app/server/modules/repositories/repositories.service.ts
Normal file
328
app/server/modules/repositories/repositories.service.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||
import slugify from "slugify";
|
||||
import { db } from "../../db/db";
|
||||
import { repositoriesTable } from "../../db/schema";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { cryptoUtils } from "../../utils/crypto";
|
||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
||||
|
||||
const listRepositories = async () => {
|
||||
const repositories = await db.query.repositoriesTable.findMany({});
|
||||
return repositories;
|
||||
};
|
||||
|
||||
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
|
||||
const encryptedConfig: Record<string, string> = { ...config };
|
||||
|
||||
switch (config.backend) {
|
||||
case "s3":
|
||||
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
|
||||
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
|
||||
break;
|
||||
case "gcs":
|
||||
encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson);
|
||||
break;
|
||||
case "azure":
|
||||
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
|
||||
break;
|
||||
}
|
||||
|
||||
return encryptedConfig as RepositoryConfig;
|
||||
};
|
||||
|
||||
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
|
||||
const slug = slugify(name, { lower: true, strict: true });
|
||||
|
||||
const existing = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, slug),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictError("Repository with this name already exists");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const encryptedConfig = await encryptConfig(config);
|
||||
|
||||
const [created] = await db
|
||||
.insert(repositoriesTable)
|
||||
.values({
|
||||
id,
|
||||
name: slug,
|
||||
type: config.backend,
|
||||
config: encryptedConfig,
|
||||
compressionMode: compressionMode ?? "auto",
|
||||
status: "unknown",
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
throw new InternalServerError("Failed to create repository");
|
||||
}
|
||||
|
||||
const { success, error } = await restic.init(encryptedConfig);
|
||||
|
||||
if (success) {
|
||||
await db
|
||||
.update(repositoriesTable)
|
||||
.set({
|
||||
status: "healthy",
|
||||
lastChecked: Date.now(),
|
||||
lastError: null,
|
||||
})
|
||||
.where(eq(repositoriesTable.id, id));
|
||||
|
||||
return { repository: created, status: 201 };
|
||||
}
|
||||
|
||||
const errorMessage = toMessage(error);
|
||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
|
||||
|
||||
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
|
||||
};
|
||||
|
||||
const getRepository = async (name: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
return { repository };
|
||||
};
|
||||
|
||||
const deleteRepository = async (name: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
// TODO: Add cleanup logic for the actual restic repository files
|
||||
|
||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
|
||||
};
|
||||
|
||||
/**
|
||||
* List snapshots for a given repository
|
||||
* If backupId is provided, filter snapshots by that backup ID (tag)
|
||||
* @param name Repository name
|
||||
* @param backupId Optional backup ID to filter snapshots for a specific backup schedule
|
||||
*
|
||||
* @returns List of snapshots
|
||||
*/
|
||||
const listSnapshots = async (name: string, backupId?: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
let snapshots = [];
|
||||
|
||||
if (backupId) {
|
||||
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
|
||||
} else {
|
||||
snapshots = await restic.snapshots(repository.config);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
const restoreSnapshot = async (
|
||||
name: string,
|
||||
snapshotId: string,
|
||||
options?: { include?: string[]; exclude?: string[]; delete?: boolean },
|
||||
) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
const result = await restic.restore(repository.config, snapshotId, "/", options);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Snapshot restored successfully",
|
||||
filesRestored: result.files_restored,
|
||||
filesSkipped: result.files_skipped,
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshotDetails = async (name: string, snapshotId: 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);
|
||||
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
|
||||
|
||||
if (!snapshot) {
|
||||
throw new NotFoundError("Snapshot not found");
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const checkHealth = async (repositoryId: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, repositoryId),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
const { error, status } = await restic
|
||||
.snapshots(repository.config)
|
||||
.then(() => ({ error: null, status: "healthy" as const }))
|
||||
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
|
||||
|
||||
await db
|
||||
.update(repositoriesTable)
|
||||
.set({
|
||||
status,
|
||||
lastChecked: Date.now(),
|
||||
lastError: error,
|
||||
})
|
||||
.where(eq(repositoriesTable.id, repository.id));
|
||||
|
||||
return { status, lastError: error };
|
||||
};
|
||||
|
||||
const doctorRepository = async (name: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = [];
|
||||
|
||||
const unlockResult = await restic.unlock(repository.config).then(
|
||||
(result) => ({ success: true, message: result.message, error: null }),
|
||||
(error) => ({ success: false, message: null, error: toMessage(error) }),
|
||||
);
|
||||
|
||||
steps.push({
|
||||
step: "unlock",
|
||||
success: unlockResult.success,
|
||||
output: unlockResult.message,
|
||||
error: unlockResult.error,
|
||||
});
|
||||
|
||||
const checkResult = await restic.check(repository.config, { readData: false }).then(
|
||||
(result) => result,
|
||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||
);
|
||||
|
||||
steps.push({
|
||||
step: "check",
|
||||
success: checkResult.success,
|
||||
output: checkResult.output,
|
||||
error: checkResult.error,
|
||||
});
|
||||
|
||||
if (checkResult.hasErrors) {
|
||||
const repairResult = await restic.repairIndex(repository.config).then(
|
||||
(result) => ({ success: true, output: result.output, error: null }),
|
||||
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
||||
);
|
||||
|
||||
steps.push({
|
||||
step: "repair_index",
|
||||
success: repairResult.success,
|
||||
output: repairResult.output,
|
||||
error: repairResult.error,
|
||||
});
|
||||
|
||||
const recheckResult = await restic.check(repository.config, { readData: false }).then(
|
||||
(result) => result,
|
||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||
);
|
||||
|
||||
steps.push({
|
||||
step: "recheck",
|
||||
success: recheckResult.success,
|
||||
output: recheckResult.output,
|
||||
error: recheckResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
const allSuccessful = steps.every((s) => s.success);
|
||||
|
||||
console.log("Doctor steps:", steps);
|
||||
|
||||
await db
|
||||
.update(repositoriesTable)
|
||||
.set({
|
||||
status: allSuccessful ? "healthy" : "error",
|
||||
lastChecked: Date.now(),
|
||||
lastError: allSuccessful ? null : steps.find((s) => !s.success)?.error,
|
||||
})
|
||||
.where(eq(repositoriesTable.id, repository.id));
|
||||
|
||||
return {
|
||||
success: allSuccessful,
|
||||
steps,
|
||||
};
|
||||
};
|
||||
|
||||
export const repositoriesService = {
|
||||
listRepositories,
|
||||
createRepository,
|
||||
getRepository,
|
||||
deleteRepository,
|
||||
listSnapshots,
|
||||
listSnapshotFiles,
|
||||
restoreSnapshot,
|
||||
getSnapshotDetails,
|
||||
checkHealth,
|
||||
doctorRepository,
|
||||
};
|
||||
Reference in New Issue
Block a user