diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 0e60d3a..11e07df 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -32,7 +32,6 @@ import { getBackupSchedule, updateBackupSchedule, getBackupScheduleForVolume, - upsertBackupSchedule, runBackupNow, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; @@ -83,8 +82,6 @@ import type { UpdateBackupScheduleData, UpdateBackupScheduleResponse, GetBackupScheduleForVolumeData, - UpsertBackupScheduleData, - UpsertBackupScheduleResponse, RunBackupNowData, RunBackupNowResponse, } from "../types.gen"; @@ -957,29 +954,6 @@ export const getBackupScheduleForVolumeOptions = (options: Options>, -): UseMutationOptions> => { - const mutationOptions: UseMutationOptions< - UpsertBackupScheduleResponse, - DefaultError, - Options - > = { - mutationFn: async (localOptions) => { - const { data } = await upsertBackupSchedule({ - ...options, - ...localOptions, - throwOnError: true, - }); - return data; - }, - }; - return mutationOptions; -}; - export const runBackupNowQueryKey = (options: Options) => createQueryKey("runBackupNow", options); /** diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index a6c739a..0b4825d 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -68,8 +68,6 @@ import type { UpdateBackupScheduleResponses, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, - UpsertBackupScheduleData, - UpsertBackupScheduleResponses, RunBackupNowData, RunBackupNowResponses, } from "./types.gen"; @@ -474,22 +472,6 @@ export const getBackupScheduleForVolume = }); }; -/** - * Create or update a backup schedule for a volume - */ -export const upsertBackupSchedule = ( - options?: Options, -) => { - return (options?.client ?? _heyApiClient).put({ - url: "/api/v1/backups/upsert", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); -}; - /** * Trigger a backup immediately for a schedule */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 4b050c1..9a314a0 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -782,7 +782,7 @@ export type ListSnapshotsData = { name: string; }; query?: { - volumeId?: string; + backupId?: string; }; url: "/api/v1/repositories/{name}/snapshots"; }; @@ -1181,11 +1181,11 @@ export type GetBackupScheduleResponse = GetBackupScheduleResponses[keyof GetBack export type UpdateBackupScheduleData = { body?: { - cronExpression?: string; + cronExpression: string; + repositoryId: string; enabled?: boolean; excludePatterns?: Array; includePatterns?: Array; - repositoryId?: string; retentionPolicy?: { keepDaily?: number; keepHourly?: number; @@ -1342,62 +1342,6 @@ export type GetBackupScheduleForVolumeResponses = { export type GetBackupScheduleForVolumeResponse = GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses]; -export type UpsertBackupScheduleData = { - body?: { - cronExpression: string; - enabled: boolean; - repositoryId: string; - volumeId: number; - excludePatterns?: Array; - includePatterns?: Array; - retentionPolicy?: { - keepDaily?: number; - keepHourly?: number; - keepLast?: number; - keepMonthly?: number; - keepWeekly?: number; - keepWithinDuration?: string; - keepYearly?: number; - }; - tags?: Array; - }; - path?: never; - query?: never; - url: "/api/v1/backups/upsert"; -}; - -export type UpsertBackupScheduleResponses = { - /** - * Backup schedule upserted successfully - */ - 200: { - createdAt: number; - cronExpression: string; - enabled: boolean; - excludePatterns: Array | null; - id: number; - includePatterns: Array | null; - lastBackupAt: number | null; - lastBackupError: string | null; - lastBackupStatus: "error" | "success" | null; - nextBackupAt: number | null; - repositoryId: string; - retentionPolicy: { - keepDaily?: number; - keepHourly?: number; - keepLast?: number; - keepMonthly?: number; - keepWeekly?: number; - keepWithinDuration?: string; - keepYearly?: number; - } | null; - updatedAt: number; - volumeId: number; - }; -}; - -export type UpsertBackupScheduleResponse = UpsertBackupScheduleResponses[keyof UpsertBackupScheduleResponses]; - export type RunBackupNowData = { body?: never; path: { diff --git a/apps/client/app/components/snapshots-table.tsx b/apps/client/app/components/snapshots-table.tsx index 54c040d..65331a8 100644 --- a/apps/client/app/components/snapshots-table.tsx +++ b/apps/client/app/components/snapshots-table.tsx @@ -6,7 +6,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~ import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots"; -type Snapshot = ListSnapshotsResponse["snapshots"][0]; +type Snapshot = ListSnapshotsResponse[number]; type Props = { snapshots: Snapshot[]; diff --git a/apps/client/app/modules/backups/routes/backup-details.tsx b/apps/client/app/modules/backups/routes/backup-details.tsx index 0123053..4207fe8 100644 --- a/apps/client/app/modules/backups/routes/backup-details.tsx +++ b/apps/client/app/modules/backups/routes/backup-details.tsx @@ -4,11 +4,11 @@ import { redirect, useNavigate } from "react-router"; import { toast } from "sonner"; import { Button } from "~/components/ui/button"; import { - upsertBackupScheduleMutation, getBackupScheduleOptions, runBackupNowMutation, deleteBackupScheduleMutation, listSnapshotsOptions, + updateBackupScheduleMutation, } from "~/api-client/@tanstack/react-query.gen"; import { parseError } from "~/lib/errors"; import { getCronExpression } from "~/utils/utils"; @@ -26,7 +26,7 @@ export const clientLoader = async ({ params }: Route.LoaderArgs) => { const snapshots = await listSnapshots({ path: { name: data.repository.name }, - query: { volumeId: data.volumeId.toString() }, + query: { backupId: params.id }, }); if (snapshots.data) return { snapshots: snapshots.data, schedule: data }; @@ -49,13 +49,13 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const { data: snapshots } = useQuery({ ...listSnapshotsOptions({ path: { name: schedule.repository.name }, - query: { volumeId: schedule.volumeId.toString() }, + query: { backupId: schedule.id.toString() }, }), initialData: loaderData.snapshots, }); const upsertSchedule = useMutation({ - ...upsertBackupScheduleMutation(), + ...updateBackupScheduleMutation(), onSuccess: () => { toast.success("Backup schedule saved successfully"); setIsEditMode(false); @@ -106,8 +106,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly; upsertSchedule.mutate({ + path: { scheduleId: schedule.id.toString() }, body: { - volumeId: schedule.volumeId, repositoryId: formValues.repositoryId, enabled: schedule.enabled, cronExpression, @@ -122,8 +122,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon if (!schedule) return; upsertSchedule.mutate({ + path: { scheduleId: schedule.id.toString() }, body: { - volumeId: schedule.volumeId, repositoryId: schedule.repositoryId, enabled, cronExpression: schedule.cronExpression, diff --git a/apps/server/src/modules/backups/backups.controller.ts b/apps/server/src/modules/backups/backups.controller.ts index fbd2e7e..fcd8895 100644 --- a/apps/server/src/modules/backups/backups.controller.ts +++ b/apps/server/src/modules/backups/backups.controller.ts @@ -8,10 +8,8 @@ import { getBackupScheduleForVolumeDto, listBackupSchedulesDto, runBackupNowDto, - updateBackupScheduleBody, updateBackupScheduleDto, - upsertBackupScheduleBody, - upsertBackupScheduleDto, + updateBackupScheduleBody, type CreateBackupScheduleDto, type DeleteBackupScheduleDto, type GetBackupScheduleDto, @@ -19,7 +17,6 @@ import { type ListBackupSchedulesResponseDto, type RunBackupNowDto, type UpdateBackupScheduleDto, - type UpsertBackupScheduleDto, } from "./backups.dto"; import { backupsService } from "./backups.service"; @@ -57,13 +54,6 @@ export const backupScheduleController = new Hono() return c.json(schedule, 200); }) - .put("/upsert", upsertBackupScheduleDto, validator("json", upsertBackupScheduleBody), async (c) => { - const body = c.req.valid("json"); - - const schedule = await backupsService.upsertSchedule(body); - - return c.json(schedule, 200); - }) .delete("/:scheduleId", deleteBackupScheduleDto, async (c) => { const scheduleId = c.req.param("scheduleId"); @@ -74,7 +64,7 @@ export const backupScheduleController = new Hono() .post("/:scheduleId/run", runBackupNowDto, async (c) => { const scheduleId = c.req.param("scheduleId"); - backupsService.executeBackup(Number(scheduleId)).catch((error) => { + backupsService.executeBackup(Number(scheduleId), true).catch((error) => { console.error("Backup execution failed:", error); }); diff --git a/apps/server/src/modules/backups/backups.dto.ts b/apps/server/src/modules/backups/backups.dto.ts index 447c1b8..9ec4677 100644 --- a/apps/server/src/modules/backups/backups.dto.ts +++ b/apps/server/src/modules/backups/backups.dto.ts @@ -143,9 +143,9 @@ export const createBackupScheduleDto = describeRoute({ * Update a backup schedule */ export const updateBackupScheduleBody = type({ - repositoryId: "string?", + repositoryId: "string", enabled: "boolean?", - cronExpression: "string?", + cronExpression: "string", retentionPolicy: retentionPolicySchema.optional(), excludePatterns: "string[]?", includePatterns: "string[]?", @@ -174,42 +174,6 @@ export const updateBackupScheduleDto = describeRoute({ }, }); -/** - * Upsert a backup schedule (create or update) - */ -export const upsertBackupScheduleBody = type({ - volumeId: "number", - repositoryId: "string", - enabled: "boolean", - cronExpression: "string", - retentionPolicy: retentionPolicySchema.optional(), - excludePatterns: "string[]?", - includePatterns: "string[]?", - tags: "string[]?", -}); - -export type UpsertBackupScheduleBody = typeof upsertBackupScheduleBody.infer; - -export const upsertBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository"); - -export type UpsertBackupScheduleDto = typeof upsertBackupScheduleResponse.infer; - -export const upsertBackupScheduleDto = describeRoute({ - description: "Create or update a backup schedule for a volume", - operationId: "upsertBackupSchedule", - tags: ["Backups"], - responses: { - 200: { - description: "Backup schedule upserted successfully", - content: { - "application/json": { - schema: resolver(upsertBackupScheduleResponse), - }, - }, - }, - }, -}); - /** * Delete a backup schedule */ diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index 0ed6f84..89f6d91 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -7,7 +7,7 @@ import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/ import { restic } from "../../utils/restic"; import { logger } from "../../utils/logger"; import { getVolumePath } from "../volumes/helpers"; -import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpsertBackupScheduleBody } from "./backups.dto"; +import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto"; import { toMessage } from "../../utils/errors"; const calculateNextRun = (cronExpression: string): number => { @@ -109,14 +109,12 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody throw new BadRequestError("Invalid cron expression"); } - if (data.repositoryId) { - const repository = await db.query.repositoriesTable.findFirst({ - where: eq(repositoriesTable.id, data.repositoryId), - }); + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.id, data.repositoryId), + }); - if (!repository) { - throw new NotFoundError("Repository not found"); - } + if (!repository) { + throw new NotFoundError("Repository not found"); } const cronExpression = data.cronExpression ?? schedule.cronExpression; @@ -147,7 +145,7 @@ const deleteSchedule = async (scheduleId: number) => { await db.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId)); }; -const executeBackup = async (scheduleId: number) => { +const executeBackup = async (scheduleId: number, manual = false) => { const schedule = await db.query.backupSchedulesTable.findFirst({ where: eq(backupSchedulesTable.id, scheduleId), }); @@ -156,7 +154,7 @@ const executeBackup = async (scheduleId: number) => { throw new NotFoundError("Backup schedule not found"); } - if (!schedule.enabled) { + if (!schedule.enabled && !manual) { logger.info(`Backup schedule ${scheduleId} is disabled. Skipping execution.`); return; } @@ -190,7 +188,9 @@ const executeBackup = async (scheduleId: number) => { exclude?: string[]; include?: string[]; tags?: string[]; - } = {}; + } = { + tags: [schedule.id.toString()], + }; if (schedule.excludePatterns && schedule.excludePatterns.length > 0) { backupOptions.exclude = schedule.excludePatterns; @@ -203,7 +203,7 @@ const executeBackup = async (scheduleId: number) => { await restic.backup(repository.config, volumePath, backupOptions); if (schedule.retentionPolicy) { - await restic.forget(repository.config, schedule.retentionPolicy); + await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() }); } const nextBackupAt = calculateNextRun(schedule.cronExpression); @@ -262,77 +262,6 @@ const getScheduleForVolume = async (volumeId: number) => { return schedule ?? null; }; -const upsertSchedule = async (data: UpsertBackupScheduleBody) => { - if (!cron.validate(data.cronExpression)) { - throw new BadRequestError("Invalid cron expression"); - } - - const volume = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.id, data.volumeId), - }); - - if (!volume) { - throw new NotFoundError("Volume not found"); - } - - const repository = await db.query.repositoriesTable.findFirst({ - where: eq(repositoriesTable.id, data.repositoryId), - }); - - if (!repository) { - throw new NotFoundError("Repository not found"); - } - - const existingSchedule = await db.query.backupSchedulesTable.findFirst({ - where: eq(backupSchedulesTable.volumeId, data.volumeId), - }); - - const nextBackupAt = calculateNextRun(data.cronExpression); - - if (existingSchedule) { - const [updated] = await db - .update(backupSchedulesTable) - .set({ - repositoryId: data.repositoryId, - enabled: data.enabled, - cronExpression: data.cronExpression, - retentionPolicy: data.retentionPolicy ?? null, - excludePatterns: data.excludePatterns ?? [], - includePatterns: data.includePatterns ?? [], - nextBackupAt: nextBackupAt, - updatedAt: Date.now(), - }) - .where(eq(backupSchedulesTable.id, existingSchedule.id)) - .returning(); - - if (!updated) { - throw new Error("Failed to update backup schedule"); - } - - return updated; - } - - const [newSchedule] = await db - .insert(backupSchedulesTable) - .values({ - volumeId: data.volumeId, - repositoryId: data.repositoryId, - enabled: data.enabled, - cronExpression: data.cronExpression, - retentionPolicy: data.retentionPolicy ?? null, - excludePatterns: data.excludePatterns ?? [], - includePatterns: data.includePatterns ?? [], - nextBackupAt: nextBackupAt, - }) - .returning(); - - if (!newSchedule) { - throw new Error("Failed to create backup schedule"); - } - - return newSchedule; -}; - export const backupsService = { listSchedules, getSchedule, @@ -342,5 +271,4 @@ export const backupsService = { executeBackup, getSchedulesToExecute, getScheduleForVolume, - upsertSchedule, }; diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index 8c77ceb..e105b1e 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -49,9 +49,9 @@ export const repositoriesController = new Hono() }) .get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => { const { name } = c.req.param(); - const { volumeId } = c.req.valid("query"); + const { backupId } = c.req.valid("query"); - const res = await repositoriesService.listSnapshots(name, Number(volumeId)); + const res = await repositoriesService.listSnapshots(name, backupId); const snapshots = res.map((snapshot) => { const { summary } = snapshot; diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index cd591e7..149ba89 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -144,7 +144,7 @@ const listSnapshotsResponse = snapshotSchema.array(); export type ListSnapshotsDto = typeof listSnapshotsResponse.infer; export const listSnapshotsFilters = type({ - volumeId: "string?", + backupId: "string?", }); export const listSnapshotsDto = describeRoute({ diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index e459b62..fb7d9cb 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -8,7 +8,6 @@ import { repositoriesTable, volumesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; -import { getVolumePath } from "../volumes/helpers"; const listRepositories = async () => { const repositories = await db.query.repositoriesTable.findMany({}); @@ -106,7 +105,15 @@ const deleteRepository = async (name: string) => { await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name)); }; -const listSnapshots = async (name: string, volumeId?: number) => { +/** + * 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), }); @@ -115,20 +122,12 @@ const listSnapshots = async (name: string, volumeId?: number) => { throw new NotFoundError("Repository not found"); } - let snapshots = await restic.snapshots(repository.config); + let snapshots = []; - if (volumeId) { - const volume = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.id, volumeId), - }); - - if (!volume) { - throw new NotFoundError("Volume not found"); - } - - snapshots = snapshots.filter((snapshot) => { - return snapshot.paths.some((path) => path.includes(getVolumePath(volume.name))); - }); + if (backupId) { + snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] }); + } else { + snapshots = await restic.snapshots(repository.config); } return snapshots; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 684260f..9415434 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -114,13 +114,19 @@ const init = async (config: RepositoryConfig) => { const backup = async ( config: RepositoryConfig, source: string, - options?: { exclude?: string[]; include?: string[] }, + options?: { exclude?: string[]; include?: string[]; tags?: string[] }, ) => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"]; + if (options?.tags && options.tags.length > 0) { + for (const tag of options.tags) { + args.push("--tag", tag); + } + } + let includeFile: string | null = null; if (options?.include && options.include.length > 0) { const tmp = await fs.mkdtemp("restic-include"); @@ -263,11 +269,23 @@ const restore = async ( return result; }; -const snapshots = async (config: RepositoryConfig) => { +const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => { + const { tags } = options; + const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); - const res = await $`restic --repo ${repoUrl} snapshots --json`.env(env).nothrow(); + const args = ["--repo", repoUrl, "snapshots"]; + + if (tags && tags.length > 0) { + for (const tag of tags) { + args.push("--tag", tag); + } + } + + args.push("--json"); + + const res = await $`restic ${args}`.env(env).nothrow(); if (res.exitCode !== 0) { logger.error(`Restic snapshots retrieval failed: ${res.stderr}`); @@ -284,11 +302,11 @@ const snapshots = async (config: RepositoryConfig) => { return result; }; -const forget = async (config: RepositoryConfig, options: RetentionPolicy) => { +const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); - const args: string[] = ["--repo", repoUrl, "forget"]; + const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; if (options.keepLast) { args.push("--keep-last", String(options.keepLast)); @@ -322,7 +340,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy) => { throw new Error(`Restic forget failed: ${res.stderr}`); } - logger.info("Restic forget completed successfully"); return { success: true }; };