From 16b8be2cd91eccb8149b368471ca2acda6272401 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 28 Nov 2025 19:17:05 +0100 Subject: [PATCH] feat: mirror repositories feat: mirror backup repositories --- .../api-client/@tanstack/react-query.gen.ts | 57 +- app/client/api-client/sdk.gen.ts | 36 +- app/client/api-client/types.gen.ts | 227 ++++++ app/client/components/snapshots-table.tsx | 160 ++-- app/client/hooks/use-server-events.ts | 33 +- .../components/schedule-mirrors-config.tsx | 356 +++++++++ .../modules/backups/routes/backup-details.tsx | 13 +- .../modules/repositories/tabs/snapshots.tsx | 8 +- app/drizzle/0018_bizarre_zzzax.sql | 139 ++++ app/drizzle/meta/0018_snapshot.json | 740 ++++++++++++++++++ app/drizzle/meta/_journal.json | 7 + app/server/core/events.ts | 8 + app/server/db/schema.ts | 33 + .../modules/backups/backups.controller.ts | 28 +- app/server/modules/backups/backups.dto.ts | 85 ++ app/server/modules/backups/backups.service.ts | 178 ++++- .../modules/events/events.controller.ts | 24 + .../repositories/repositories.controller.ts | 2 + .../modules/repositories/repositories.dto.ts | 1 + app/server/utils/backend-compatibility.ts | 148 ++++ app/server/utils/restic.ts | 70 +- bun.lock | 1 + 22 files changed, 2265 insertions(+), 89 deletions(-) create mode 100644 app/client/modules/backups/components/schedule-mirrors-config.tsx create mode 100644 app/drizzle/0018_bizarre_zzzax.sql create mode 100644 app/drizzle/meta/0018_snapshot.json create mode 100644 app/server/utils/backend-compatibility.ts diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 5beda44..411da6d 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -755,6 +755,59 @@ export const updateScheduleNotificationsMutation = (options?: Partial) => createQueryKey("getScheduleMirrors", options); + +/** + * Get mirror repository assignments for a backup schedule + */ +export const getScheduleMirrorsOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getScheduleMirrors({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getScheduleMirrorsQueryKey(options) +}); + +/** + * Update mirror repository assignments for a backup schedule + */ +export const updateScheduleMirrorsMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateScheduleMirrors({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getMirrorCompatibilityQueryKey = (options: Options) => createQueryKey("getMirrorCompatibility", options); + +/** + * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository + */ +export const getMirrorCompatibilityOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getMirrorCompatibility({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getMirrorCompatibilityQueryKey(options) +}); + export const listNotificationDestinationsQueryKey = (options?: Options) => createQueryKey("listNotificationDestinations", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 9f5afe4..1b8c032 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -476,6 +476,40 @@ export const updateScheduleNotifications = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/v1/backups/{scheduleId}/mirrors', + ...options + }); +}; + +/** + * Update mirror repository assignments for a backup schedule + */ +export const updateScheduleMirrors = (options: Options) => { + return (options.client ?? client).put({ + url: '/api/v1/backups/{scheduleId}/mirrors', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +/** + * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository + */ +export const getMirrorCompatibility = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', + ...options + }); +}; + /** * List all notification destinations */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 7e82ed3..bb15e40 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1124,6 +1124,7 @@ export type ListSnapshotsResponses = { paths: Array; short_id: string; size: number; + tags: Array; time: number; }>; }; @@ -1170,6 +1171,7 @@ export type GetSnapshotDetailsResponses = { paths: Array; short_id: string; size: number; + tags: Array; time: number; }; }; @@ -2112,6 +2114,231 @@ export type UpdateScheduleNotificationsResponses = { export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses]; +export type GetScheduleMirrorsData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors'; +}; + +export type GetScheduleMirrorsResponses = { + /** + * List of mirror repository assignments for the schedule + */ + 200: Array<{ + createdAt: number; + enabled: boolean; + lastCopyAt: number | null; + lastCopyError: string | null; + lastCopyStatus: 'error' | 'success' | null; + repository: { + compressionMode: 'auto' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; + repositoryId: string; + scheduleId: number; + }>; +}; + +export type GetScheduleMirrorsResponse = GetScheduleMirrorsResponses[keyof GetScheduleMirrorsResponses]; + +export type UpdateScheduleMirrorsData = { + body?: { + mirrors: Array<{ + enabled: boolean; + repositoryId: string; + }>; + }; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors'; +}; + +export type UpdateScheduleMirrorsResponses = { + /** + * Mirror assignments updated successfully + */ + 200: Array<{ + createdAt: number; + enabled: boolean; + lastCopyAt: number | null; + lastCopyError: string | null; + lastCopyStatus: 'error' | 'success' | null; + repository: { + compressionMode: 'auto' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; + repositoryId: string; + scheduleId: number; + }>; +}; + +export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses]; + +export type GetMirrorCompatibilityData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors/compatibility'; +}; + +export type GetMirrorCompatibilityResponses = { + /** + * List of repositories with their mirror compatibility status + */ + 200: Array<{ + compatible: boolean; + reason: string | null; + repositoryId: string; + }>; +}; + +export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses]; + export type ListNotificationDestinationsData = { body?: never; path?: never; diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index c03059d..ebd80fb 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react"; -import { useNavigate } from "react-router"; +import { Link, useNavigate } from "react-router"; import { toast } from "sonner"; import { ByteSize } from "~/client/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; @@ -18,18 +18,17 @@ import { AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; import { formatDuration } from "~/utils/utils"; -import type { ListSnapshotsResponse } from "../api-client"; import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; - -type Snapshot = ListSnapshotsResponse[number]; +import type { BackupSchedule, Snapshot } from "../lib/types"; type Props = { snapshots: Snapshot[]; + backups: BackupSchedule[]; repositoryName: string; }; -export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { +export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -76,6 +75,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { Snapshot ID + Schedule Date & Time Size Duration @@ -84,71 +84,91 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { - {snapshots.map((snapshot) => ( - handleRowClick(snapshot.short_id)} - > - -
- - {snapshot.short_id} -
-
- -
- - {new Date(snapshot.time).toLocaleString()} -
-
- -
- - - - -
-
- -
- - {formatDuration(snapshot.duration / 1000)} -
-
- -
- - - - - {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} - - - -
- {snapshot.paths.map((path) => ( -
- {path} -
- ))} -
-
-
-
-
- - - -
- ))} + {snapshots.map((snapshot) => { + const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag)); + const backup = backups.find((b) => backupIds.includes(b.id)); + + return ( + handleRowClick(snapshot.short_id)} + > + +
+ + {snapshot.short_id} +
+
+ +
+ e.stopPropagation()} + className="hover:underline" + > + {backup ? backup.id : "-"} + + +
+
+ +
+ + {new Date(snapshot.time).toLocaleString()} +
+
+ +
+ + + + +
+
+ +
+ + {formatDuration(snapshot.duration / 1000)} +
+
+ +
+ + + + + {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} + + + +
+ {snapshot.paths.map((path) => ( +
+ {path} +
+ ))} +
+
+
+
+
+ + + +
+ ); + })}
diff --git a/app/client/hooks/use-server-events.ts b/app/client/hooks/use-server-events.ts index 43c7205..d7c0377 100644 --- a/app/client/hooks/use-server-events.ts +++ b/app/client/hooks/use-server-events.ts @@ -9,7 +9,9 @@ type ServerEventType = | "backup:completed" | "volume:mounted" | "volume:unmounted" - | "volume:updated"; + | "volume:updated" + | "mirror:started" + | "mirror:completed"; export interface BackupEvent { scheduleId: number; @@ -35,6 +37,14 @@ export interface VolumeEvent { volumeName: string; } +export interface MirrorEvent { + scheduleId: number; + repositoryId: string; + repositoryName: string; + status?: "success" | "error"; + error?: string; +} + type EventHandler = (data: unknown) => void; /** @@ -125,6 +135,27 @@ export function useServerEvents() { }); }); + eventSource.addEventListener("mirror:started", (e) => { + const data = JSON.parse(e.data) as MirrorEvent; + console.log("[SSE] Mirror copy started:", data); + + handlersRef.current.get("mirror:started")?.forEach((handler) => { + handler(data); + }); + }); + + eventSource.addEventListener("mirror:completed", (e) => { + const data = JSON.parse(e.data) as MirrorEvent; + console.log("[SSE] Mirror copy completed:", data); + + // Invalidate queries to refresh mirror status in the UI + queryClient.invalidateQueries(); + + handlersRef.current.get("mirror:completed")?.forEach((handler) => { + handler(data); + }); + }); + eventSource.onerror = (error) => { console.error("[SSE] Connection error:", error); }; diff --git a/app/client/modules/backups/components/schedule-mirrors-config.tsx b/app/client/modules/backups/components/schedule-mirrors-config.tsx new file mode 100644 index 0000000..1abbde2 --- /dev/null +++ b/app/client/modules/backups/components/schedule-mirrors-config.tsx @@ -0,0 +1,356 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Copy, Plus, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "~/client/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import { Switch } from "~/client/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; +import { Badge } from "~/client/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; +import { + getScheduleMirrorsOptions, + getMirrorCompatibilityOptions, + updateScheduleMirrorsMutation, +} from "~/client/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/client/lib/errors"; +import type { Repository } from "~/client/lib/types"; +import { RepositoryIcon } from "~/client/components/repository-icon"; +import { StatusDot } from "~/client/components/status-dot"; +import { formatDistanceToNow } from "date-fns"; +import { Link } from "react-router"; +import { cn } from "~/client/lib/utils"; + +type Props = { + scheduleId: number; + primaryRepositoryId: string; + repositories: Repository[]; +}; + +type MirrorAssignment = { + repositoryId: string; + enabled: boolean; + lastCopyAt: number | null; + lastCopyStatus: "success" | "error" | null; + lastCopyError: string | null; +}; + +export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories }: Props) => { + const [assignments, setAssignments] = useState>(new Map()); + const [hasChanges, setHasChanges] = useState(false); + const [isAddingNew, setIsAddingNew] = useState(false); + + const { data: currentMirrors } = useQuery({ + ...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }), + }); + + const { data: compatibility } = useQuery({ + ...getMirrorCompatibilityOptions({ path: { scheduleId: scheduleId.toString() } }), + }); + + const updateMirrors = useMutation({ + ...updateScheduleMirrorsMutation(), + onSuccess: () => { + toast.success("Mirror settings saved successfully"); + setHasChanges(false); + }, + onError: (error) => { + toast.error("Failed to save mirror settings", { + description: parseError(error)?.message, + }); + }, + }); + + const compatibilityMap = useMemo(() => { + const map = new Map(); + if (compatibility) { + for (const item of compatibility) { + map.set(item.repositoryId, { compatible: item.compatible, reason: item.reason }); + } + } + return map; + }, [compatibility]); + + useEffect(() => { + if (currentMirrors) { + const map = new Map(); + for (const mirror of currentMirrors) { + map.set(mirror.repositoryId, { + repositoryId: mirror.repositoryId, + enabled: mirror.enabled, + lastCopyAt: mirror.lastCopyAt, + lastCopyStatus: mirror.lastCopyStatus, + lastCopyError: mirror.lastCopyError, + }); + } + + setAssignments(map); + } + }, [currentMirrors]); + + const addRepository = (repositoryId: string) => { + const newAssignments = new Map(assignments); + newAssignments.set(repositoryId, { + repositoryId, + enabled: true, + lastCopyAt: null, + lastCopyStatus: null, + lastCopyError: null, + }); + + setAssignments(newAssignments); + setHasChanges(true); + setIsAddingNew(false); + }; + + const removeRepository = (repositoryId: string) => { + const newAssignments = new Map(assignments); + newAssignments.delete(repositoryId); + setAssignments(newAssignments); + setHasChanges(true); + }; + + const toggleEnabled = (repositoryId: string) => { + const assignment = assignments.get(repositoryId); + if (!assignment) return; + + const newAssignments = new Map(assignments); + newAssignments.set(repositoryId, { + ...assignment, + enabled: !assignment.enabled, + }); + + setAssignments(newAssignments); + setHasChanges(true); + }; + + const handleSave = () => { + const mirrorsList = Array.from(assignments.values()).map((a) => ({ + repositoryId: a.repositoryId, + enabled: a.enabled, + })); + updateMirrors.mutate({ + path: { scheduleId: scheduleId.toString() }, + body: { + mirrors: mirrorsList, + }, + }); + }; + + const handleReset = () => { + if (currentMirrors) { + const map = new Map(); + for (const mirror of currentMirrors) { + map.set(mirror.repositoryId, { + repositoryId: mirror.repositoryId, + enabled: mirror.enabled, + lastCopyAt: mirror.lastCopyAt, + lastCopyStatus: mirror.lastCopyStatus, + lastCopyError: mirror.lastCopyError, + }); + } + setAssignments(map); + setHasChanges(false); + } + }; + + const getRepositoryById = (id: string) => { + return repositories?.find((r) => r.id === id); + }; + + const selectableRepositories = + repositories?.filter((r) => { + if (r.id === primaryRepositoryId) return false; + if (assignments.has(r.id)) return false; + return true; + }) || []; + + const hasAvailableRepositories = selectableRepositories.some((r) => { + const compat = compatibilityMap.get(r.id); + return compat?.compatible !== false; + }); + + const assignedRepositories = Array.from(assignments.keys()) + .map((id) => getRepositoryById(id)) + .filter((r) => r !== undefined); + + const getStatusVariant = (status: "success" | "error" | null): "success" | "error" | "neutral" => { + if (status === "success") return "success"; + if (status === "error") return "error"; + return "neutral"; + }; + + const getStatusLabel = (assignment: MirrorAssignment): string => { + if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) { + return assignment.lastCopyError; + } + if (assignment.lastCopyStatus === "success") { + return "Last copy successful"; + } + return "Never copied"; + }; + + return ( + + +
+
+ + + Mirror Repositories + + + Configure secondary repositories where snapshots will be automatically copied after each backup + +
+ {!isAddingNew && selectableRepositories.length > 0 && ( + + )} +
+
+ + {isAddingNew && ( +
+ + +
+ )} + + {assignedRepositories.length === 0 ? ( +
+ +

No mirror repositories configured for this schedule.

+

Click "Add mirror" to replicate backups to additional repositories.

+
+ ) : ( +
+ + + + Repository + Enabled + Last Copy + + + + + {assignedRepositories.map((repository) => { + const assignment = assignments.get(repository.id); + if (!assignment) return null; + + return ( + + +
+ + + {repository.name} + + + {repository.type} + +
+
+ + toggleEnabled(repository.id)} + /> + + + {assignment.lastCopyAt ? ( +
+ + + {formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true })} + +
+ ) : ( + Never + )} +
+ + + +
+ ); + })} +
+
+
+ )} + + {hasChanges && ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 727e939..be07779 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -29,8 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary"; import type { Route } from "./+types/backup-details"; import { SnapshotFileBrowser } from "../components/snapshot-file-browser"; import { SnapshotTimeline } from "../components/snapshot-timeline"; -import { getBackupSchedule, listNotificationDestinations } from "~/client/api-client"; +import { getBackupSchedule, listNotificationDestinations, listRepositories } from "~/client/api-client"; import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config"; +import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config"; import { cn } from "~/client/lib/utils"; export const handle = { @@ -53,10 +54,11 @@ export function meta(_: Route.MetaArgs) { export const clientLoader = async ({ params }: Route.LoaderArgs) => { const schedule = await getBackupSchedule({ path: { scheduleId: params.id } }); const notifs = await listNotificationDestinations(); + const repos = await listRepositories(); if (!schedule.data) return redirect("/backups"); - return { schedule: schedule.data, notifs: notifs.data }; + return { schedule: schedule.data, notifs: notifs.data, repos: repos.data }; }; export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) { @@ -226,6 +228,13 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
+
+ +
{ initialData: [], }); + const schedules = useQuery({ + ...listBackupSchedulesOptions(), + }); + const filteredSnapshots = data.filter((snapshot: Snapshot) => { if (!searchQuery) return true; const searchLower = searchQuery.toLowerCase(); @@ -132,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { ) : ( - + )}
diff --git a/app/drizzle/0018_bizarre_zzzax.sql b/app/drizzle/0018_bizarre_zzzax.sql new file mode 100644 index 0000000..aeabbe0 --- /dev/null +++ b/app/drizzle/0018_bizarre_zzzax.sql @@ -0,0 +1,139 @@ +DROP TABLE IF EXISTS `backup_schedule_mirrors_table`;--> statement-breakpoint +CREATE TABLE `backup_schedule_mirrors_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `schedule_id` integer NOT NULL, + `repository_id` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `last_copy_at` integer, + `last_copy_status` text, + `last_copy_error` text, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_app_metadata` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_app_metadata`("key", "value", "created_at", "updated_at") SELECT "key", "value", "created_at", "updated_at" FROM `app_metadata`;--> statement-breakpoint +DROP TABLE `app_metadata`;--> statement-breakpoint +ALTER TABLE `__new_app_metadata` RENAME TO `app_metadata`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE TABLE `__new_backup_schedule_notifications_table` ( + `schedule_id` integer NOT NULL, + `destination_id` integer NOT NULL, + `notify_on_start` integer DEFAULT false NOT NULL, + `notify_on_success` integer DEFAULT false NOT NULL, + `notify_on_failure` integer DEFAULT true NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + PRIMARY KEY(`schedule_id`, `destination_id`), + FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`destination_id`) REFERENCES `notification_destinations_table`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_backup_schedule_notifications_table`("schedule_id", "destination_id", "notify_on_start", "notify_on_success", "notify_on_failure", "created_at") SELECT "schedule_id", "destination_id", "notify_on_start", "notify_on_success", "notify_on_failure", "created_at" FROM `backup_schedule_notifications_table`;--> statement-breakpoint +DROP TABLE `backup_schedule_notifications_table`;--> statement-breakpoint +ALTER TABLE `__new_backup_schedule_notifications_table` RENAME TO `backup_schedule_notifications_table`;--> statement-breakpoint +CREATE TABLE `__new_backup_schedules_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `volume_id` integer NOT NULL, + `repository_id` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `cron_expression` text NOT NULL, + `retention_policy` text, + `exclude_patterns` text DEFAULT '[]', + `include_patterns` text DEFAULT '[]', + `last_backup_at` integer, + `last_backup_status` text, + `last_backup_error` text, + `next_backup_at` integer, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_backup_schedules_table`("id", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "created_at", "updated_at") SELECT "id", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "created_at", "updated_at" FROM `backup_schedules_table`;--> statement-breakpoint +DROP TABLE `backup_schedules_table`;--> statement-breakpoint +ALTER TABLE `__new_backup_schedules_table` RENAME TO `backup_schedules_table`;--> statement-breakpoint +CREATE TABLE `__new_notification_destinations_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_notification_destinations_table`("id", "name", "enabled", "type", "config", "created_at", "updated_at") SELECT "id", "name", "enabled", "type", "config", "created_at", "updated_at" FROM `notification_destinations_table`;--> statement-breakpoint +DROP TABLE `notification_destinations_table`;--> statement-breakpoint +ALTER TABLE `__new_notification_destinations_table` RENAME TO `notification_destinations_table`;--> statement-breakpoint +CREATE UNIQUE INDEX `notification_destinations_table_name_unique` ON `notification_destinations_table` (`name`);--> statement-breakpoint +CREATE TABLE `__new_repositories_table` ( + `id` text PRIMARY KEY NOT NULL, + `short_id` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `compression_mode` text DEFAULT 'auto', + `status` text DEFAULT 'unknown', + `last_checked` integer, + `last_error` text, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at" FROM `repositories_table`;--> statement-breakpoint +DROP TABLE `repositories_table`;--> statement-breakpoint +ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint +CREATE TABLE `__new_sessions_table` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_sessions_table`("id", "user_id", "expires_at", "created_at") SELECT "id", "user_id", "expires_at", "created_at" FROM `sessions_table`;--> statement-breakpoint +DROP TABLE `sessions_table`;--> statement-breakpoint +ALTER TABLE `__new_sessions_table` RENAME TO `sessions_table`;--> statement-breakpoint +CREATE TABLE `__new_users_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `has_downloaded_restic_password` integer DEFAULT false NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_users_table`("id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at") SELECT "id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at" FROM `users_table`;--> statement-breakpoint +DROP TABLE `users_table`;--> statement-breakpoint +ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint +CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint +CREATE TABLE `__new_volumes_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `short_id` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `status` text DEFAULT 'unmounted' NOT NULL, + `last_error` text, + `last_health_check` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `config` text NOT NULL, + `auto_remount` integer DEFAULT true NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount" FROM `volumes_table`;--> statement-breakpoint +DROP TABLE `volumes_table`;--> statement-breakpoint +ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint +CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`); diff --git a/app/drizzle/meta/0018_snapshot.json b/app/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..dcc5188 --- /dev/null +++ b/app/drizzle/meta/0018_snapshot.json @@ -0,0 +1,740 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "121ef03c-eb5a-4b97-b2f1-4add6adfb080", + "prevId": "d0bfd316-b8f5-459b-ab17-0ce679479321", + "tables": { + "app_metadata": { + "name": "app_metadata", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_mirrors_table": { + "name": "backup_schedule_mirrors_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_copy_at": { + "name": "last_copy_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_status": { + "name": "last_copy_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_error": { + "name": "last_copy_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "backup_schedules_table", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "repositories_table", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_notifications_table": { + "name": "backup_schedule_notifications_table", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notify_on_start": { + "name": "notify_on_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_success": { + "name": "notify_on_success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_failure": { + "name": "notify_on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "backup_schedules_table", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": { + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "notification_destinations_table", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "backup_schedule_notifications_table_schedule_id_destination_id_pk": { + "columns": ["schedule_id", "destination_id"], + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": ["volume_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_destinations_table": { + "name": "notification_destinations_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "notification_destinations_table_name_unique": { + "name": "notification_destinations_table_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": ["short_id"], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_downloaded_restic_password": { + "name": "has_downloaded_restic_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": ["username"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_short_id_unique": { + "name": "volumes_table_short_id_unique", + "columns": ["short_id"], + "isUnique": true + }, + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index e11599d..7625c24 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1764357897219, "tag": "0017_fix-compression-modes", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1764619898949, + "tag": "0018_bizarre_zzzax", + "breakpoints": true } ] } diff --git a/app/server/core/events.ts b/app/server/core/events.ts index 33fbb42..2e7f43d 100644 --- a/app/server/core/events.ts +++ b/app/server/core/events.ts @@ -24,6 +24,14 @@ interface ServerEvents { repositoryName: string; status: "success" | "error" | "stopped" | "warning"; }) => void; + "mirror:started": (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => void; + "mirror:completed": (data: { + scheduleId: number; + repositoryId: string; + repositoryName: string; + status: "success" | "error"; + error?: string; + }) => void; "volume:mounted": (data: { volumeName: string }) => void; "volume:unmounted": (data: { volumeName: string }) => void; "volume:updated": (data: { volumeName: string }) => void; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 9b0ac0c..f2452de 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -93,6 +93,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); + export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({ volume: one(volumesTable, { fields: [backupSchedulesTable.volumeId], @@ -103,6 +104,7 @@ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, m references: [repositoriesTable.id], }), notifications: many(backupScheduleNotificationsTable), + mirrors: many(backupScheduleMirrorsTable), })); export type BackupSchedule = typeof backupSchedulesTable.$inferSelect; @@ -154,6 +156,37 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif })); export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect; +/** + * Backup Schedule Mirrors Junction Table (Many-to-Many) + * Allows copying snapshots to secondary repositories after backup completes + */ +export const backupScheduleMirrorsTable = sqliteTable("backup_schedule_mirrors_table", { + id: int().primaryKey({ autoIncrement: true }), + scheduleId: int("schedule_id") + .notNull() + .references(() => backupSchedulesTable.id, { onDelete: "cascade" }), + repositoryId: text("repository_id") + .notNull() + .references(() => repositoriesTable.id, { onDelete: "cascade" }), + enabled: int("enabled", { mode: "boolean" }).notNull().default(true), + lastCopyAt: int("last_copy_at", { mode: "number" }), + lastCopyStatus: text("last_copy_status").$type<"success" | "error">(), + lastCopyError: text("last_copy_error"), + createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), +}); + +export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({ + schedule: one(backupSchedulesTable, { + fields: [backupScheduleMirrorsTable.scheduleId], + references: [backupSchedulesTable.id], + }), + repository: one(repositoriesTable, { + fields: [backupScheduleMirrorsTable.repositoryId], + references: [repositoriesTable.id], + }), +})); +export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect; + /** * App Metadata Table * Used for storing key-value pairs like migration checkpoints diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index ed6fa4a..0bcc93e 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -12,6 +12,10 @@ import { stopBackupDto, updateBackupScheduleDto, updateBackupScheduleBody, + getScheduleMirrorsDto, + updateScheduleMirrorsDto, + updateScheduleMirrorsBody, + getMirrorCompatibilityDto, type CreateBackupScheduleDto, type DeleteBackupScheduleDto, type GetBackupScheduleDto, @@ -21,6 +25,9 @@ import { type RunForgetDto, type StopBackupDto, type UpdateBackupScheduleDto, + type GetScheduleMirrorsDto, + type UpdateScheduleMirrorsDto, + type GetMirrorCompatibilityDto, } from "./backups.dto"; import { backupsService } from "./backups.service"; import { @@ -113,4 +120,23 @@ export const backupScheduleController = new Hono() return c.json(assignments, 200); }, - ); + ) + .get("/:scheduleId/mirrors", getScheduleMirrorsDto, async (c) => { + const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10); + const mirrors = await backupsService.getMirrors(scheduleId); + + return c.json(mirrors, 200); + }) + .put("/:scheduleId/mirrors", updateScheduleMirrorsDto, validator("json", updateScheduleMirrorsBody), async (c) => { + const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10); + const body = c.req.valid("json"); + const mirrors = await backupsService.updateMirrors(scheduleId, body); + + return c.json(mirrors, 200); + }) + .get("/:scheduleId/mirrors/compatibility", getMirrorCompatibilityDto, async (c) => { + const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10); + const compatibility = await backupsService.getMirrorCompatibility(scheduleId); + + return c.json(compatibility, 200); + }); diff --git a/app/server/modules/backups/backups.dto.ts b/app/server/modules/backups/backups.dto.ts index 95c4d49..464891b 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -37,6 +37,19 @@ const backupScheduleSchema = type({ }), ); +const scheduleMirrorSchema = type({ + scheduleId: "number", + repositoryId: "string", + enabled: "boolean", + lastCopyAt: "number | null", + lastCopyStatus: "'success' | 'error' | null", + lastCopyError: "string | null", + createdAt: "number", + repository: repositorySchema, +}); + +export type ScheduleMirrorDto = typeof scheduleMirrorSchema.infer; + /** * List all backup schedules */ @@ -276,3 +289,75 @@ export const runForgetDto = describeRoute({ }, }, }); + +export const getScheduleMirrorsResponse = scheduleMirrorSchema.array(); +export type GetScheduleMirrorsDto = typeof getScheduleMirrorsResponse.infer; + +export const getScheduleMirrorsDto = describeRoute({ + description: "Get mirror repository assignments for a backup schedule", + operationId: "getScheduleMirrors", + tags: ["Backups"], + responses: { + 200: { + description: "List of mirror repository assignments for the schedule", + content: { + "application/json": { + schema: resolver(getScheduleMirrorsResponse), + }, + }, + }, + }, +}); + +export const updateScheduleMirrorsBody = type({ + mirrors: type({ + repositoryId: "string", + enabled: "boolean", + }).array(), +}); + +export type UpdateScheduleMirrorsBody = typeof updateScheduleMirrorsBody.infer; + +export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array(); +export type UpdateScheduleMirrorsDto = typeof updateScheduleMirrorsResponse.infer; + +export const updateScheduleMirrorsDto = describeRoute({ + description: "Update mirror repository assignments for a backup schedule", + operationId: "updateScheduleMirrors", + tags: ["Backups"], + responses: { + 200: { + description: "Mirror assignments updated successfully", + content: { + "application/json": { + schema: resolver(updateScheduleMirrorsResponse), + }, + }, + }, + }, +}); + +const mirrorCompatibilitySchema = type({ + repositoryId: "string", + compatible: "boolean", + reason: "string | null", +}); + +export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array(); +export type GetMirrorCompatibilityDto = typeof getMirrorCompatibilityResponse.infer; + +export const getMirrorCompatibilityDto = describeRoute({ + description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository", + operationId: "getMirrorCompatibility", + tags: ["Backups"], + responses: { + 200: { + description: "List of repositories with their mirror compatibility status", + content: { + "application/json": { + schema: resolver(getMirrorCompatibilityResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 8b627f3..b60e827 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -3,15 +3,16 @@ import cron from "node-cron"; import { CronExpressionParser } from "cron-parser"; import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; import { db } from "../../db/db"; -import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema"; +import { backupSchedulesTable, backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "../../db/schema"; import { restic } from "../../utils/restic"; import { logger } from "../../utils/logger"; import { getVolumePath } from "../volumes/helpers"; -import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto"; +import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto"; import { toMessage } from "../../utils/errors"; import { serverEvents } from "../../core/events"; import { notificationsService } from "../notifications/notifications.service"; import { repoMutex } from "../../core/repository-mutex"; +import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility"; const runningBackups = new Map(); @@ -266,19 +267,25 @@ const executeBackup = async (scheduleId: number, manual = false) => { void runForget(schedule.id); } + copyToMirrors(scheduleId, repository, schedule.retentionPolicy).catch((error) => { + logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`); + }); + + const finalStatus = exitCode === 0 ? "success" : "warning"; + const nextBackupAt = calculateNextRun(schedule.cronExpression); await db .update(backupSchedulesTable) .set({ lastBackupAt: Date.now(), - lastBackupStatus: exitCode === 0 ? "success" : "warning", + lastBackupStatus: finalStatus, lastBackupError: null, nextBackupAt: nextBackupAt, updatedAt: Date.now(), }) .where(eq(backupSchedulesTable.id, scheduleId)); - if (exitCode !== 0) { + if (finalStatus === "warning") { logger.warn(`Backup completed with warnings for volume ${volume.name} to repository ${repository.name}`); } else { logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`); @@ -288,11 +295,11 @@ const executeBackup = async (scheduleId: number, manual = false) => { scheduleId, volumeName: volume.name, repositoryName: repository.name, - status: exitCode === 0 ? "success" : "warning", + status: finalStatus, }); notificationsService - .sendBackupNotification(scheduleId, exitCode === 0 ? "success" : "warning", { + .sendBackupNotification(scheduleId, finalStatus === "success" ? "success" : "warning", { volumeName: volume.name, repositoryName: repository.name, }) @@ -421,6 +428,162 @@ const runForget = async (scheduleId: number) => { logger.info(`Retention policy applied successfully for schedule ${scheduleId}`); }; +const getMirrors = async (scheduleId: number) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.id, scheduleId), + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + const mirrors = await db.query.backupScheduleMirrorsTable.findMany({ + where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId), + with: { repository: true }, + }); + + return mirrors; +}; + +const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.id, scheduleId), + with: { repository: true }, + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + for (const mirror of data.mirrors) { + if (mirror.repositoryId === schedule.repositoryId) { + throw new BadRequestError("Cannot add the primary repository as a mirror"); + } + + const repo = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.id, mirror.repositoryId), + }); + + if (!repo) { + throw new NotFoundError(`Repository ${mirror.repositoryId} not found`); + } + + const compatibility = await checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id); + + if (!compatibility.compatible) { + throw new BadRequestError( + getIncompatibleMirrorError(repo.name, schedule.repository.config.backend, repo.config.backend), + ); + } + } + + await db.delete(backupScheduleMirrorsTable).where(eq(backupScheduleMirrorsTable.scheduleId, scheduleId)); + + if (data.mirrors.length > 0) { + await db.insert(backupScheduleMirrorsTable).values( + data.mirrors.map((mirror) => ({ + scheduleId, + repositoryId: mirror.repositoryId, + enabled: mirror.enabled, + })), + ); + } + + return getMirrors(scheduleId); +}; + +const copyToMirrors = async ( + scheduleId: number, + sourceRepository: { id: string; config: (typeof repositoriesTable.$inferSelect)["config"] }, + retentionPolicy: (typeof backupSchedulesTable.$inferSelect)["retentionPolicy"], +) => { + const mirrors = await db.query.backupScheduleMirrorsTable.findMany({ + where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId), + with: { repository: true }, + }); + + const enabledMirrors = mirrors.filter((m) => m.enabled); + + if (enabledMirrors.length === 0) { + return; + } + + logger.info( + `[Background] Copying snapshots to ${enabledMirrors.length} mirror repositories for schedule ${scheduleId}`, + ); + + for (const mirror of enabledMirrors) { + try { + logger.info(`[Background] Copying to mirror repository: ${mirror.repository.name}`); + + serverEvents.emit("mirror:started", { + scheduleId, + repositoryId: mirror.repositoryId, + repositoryName: mirror.repository.name, + }); + + await restic.copy(sourceRepository.config, mirror.repository.config, { + tag: scheduleId.toString(), + }); + + if (retentionPolicy) { + logger.info(`[Background] Applying retention policy to mirror repository: ${mirror.repository.name}`); + await restic.forget(mirror.repository.config, retentionPolicy, { tag: scheduleId.toString() }); + } + + await db + .update(backupScheduleMirrorsTable) + .set({ lastCopyAt: Date.now(), lastCopyStatus: "success", lastCopyError: null }) + .where(eq(backupScheduleMirrorsTable.id, mirror.id)); + + logger.info(`[Background] Successfully copied to mirror repository: ${mirror.repository.name}`); + + serverEvents.emit("mirror:completed", { + scheduleId, + repositoryId: mirror.repositoryId, + repositoryName: mirror.repository.name, + status: "success", + }); + } catch (error) { + const errorMessage = toMessage(error); + logger.error(`[Background] Failed to copy to mirror repository ${mirror.repository.name}: ${errorMessage}`); + + await db + .update(backupScheduleMirrorsTable) + .set({ lastCopyAt: Date.now(), lastCopyStatus: "error", lastCopyError: errorMessage }) + .where(eq(backupScheduleMirrorsTable.id, mirror.id)); + + serverEvents.emit("mirror:completed", { + scheduleId, + repositoryId: mirror.repositoryId, + repositoryName: mirror.repository.name, + status: "error", + error: errorMessage, + }); + } + } +}; + +const getMirrorCompatibility = async (scheduleId: number) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.id, scheduleId), + with: { repository: true }, + }); + + if (!schedule) { + throw new NotFoundError("Backup schedule not found"); + } + + const allRepositories = await db.query.repositoriesTable.findMany(); + const repos = allRepositories.filter((repo) => repo.id !== schedule.repositoryId); + + const compatibility = await Promise.all( + repos.map((repo) => checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id)), + ); + + return compatibility; +}; + export const backupsService = { listSchedules, getSchedule, @@ -432,4 +595,7 @@ export const backupsService = { getScheduleForVolume, stopBackup, runForget, + getMirrors, + updateMirrors, + getMirrorCompatibility, }; diff --git a/app/server/modules/events/events.controller.ts b/app/server/modules/events/events.controller.ts index a93dc27..cd40b84 100644 --- a/app/server/modules/events/events.controller.ts +++ b/app/server/modules/events/events.controller.ts @@ -70,12 +70,34 @@ export const eventsController = new Hono().get("/", (c) => { }); }; + const onMirrorStarted = (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => { + stream.writeSSE({ + data: JSON.stringify(data), + event: "mirror:started", + }); + }; + + const onMirrorCompleted = (data: { + scheduleId: number; + repositoryId: string; + repositoryName: string; + status: "success" | "error"; + error?: string; + }) => { + stream.writeSSE({ + data: JSON.stringify(data), + event: "mirror:completed", + }); + }; + serverEvents.on("backup:started", onBackupStarted); serverEvents.on("backup:progress", onBackupProgress); serverEvents.on("backup:completed", onBackupCompleted); serverEvents.on("volume:mounted", onVolumeMounted); serverEvents.on("volume:unmounted", onVolumeUnmounted); serverEvents.on("volume:updated", onVolumeUpdated); + serverEvents.on("mirror:started", onMirrorStarted); + serverEvents.on("mirror:completed", onMirrorCompleted); let keepAlive = true; @@ -88,6 +110,8 @@ export const eventsController = new Hono().get("/", (c) => { serverEvents.off("volume:mounted", onVolumeMounted); serverEvents.off("volume:unmounted", onVolumeUnmounted); serverEvents.off("volume:updated", onVolumeUpdated); + serverEvents.off("mirror:started", onMirrorStarted); + serverEvents.off("mirror:completed", onMirrorCompleted); }); while (keepAlive) { diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index c629dec..a53a3a2 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -90,6 +90,7 @@ export const repositoriesController = new Hono() short_id: snapshot.short_id, duration, paths: snapshot.paths, + tags: snapshot.tags ?? [], size: summary?.total_bytes_processed || 0, time: new Date(snapshot.time).getTime(), }; @@ -113,6 +114,7 @@ export const repositoriesController = new Hono() time: new Date(snapshot.time).getTime(), paths: snapshot.paths, size: snapshot.summary?.total_bytes_processed || 0, + tags: snapshot.tags ?? [], summary: snapshot.summary, }; diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index aa77a62..c659b18 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -174,6 +174,7 @@ export const snapshotSchema = type({ paths: "string[]", size: "number", duration: "number", + tags: "string[]", }); const listSnapshotsResponse = snapshotSchema.array(); diff --git a/app/server/utils/backend-compatibility.ts b/app/server/utils/backend-compatibility.ts new file mode 100644 index 0000000..329db41 --- /dev/null +++ b/app/server/utils/backend-compatibility.ts @@ -0,0 +1,148 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { cryptoUtils } from "./crypto"; + +type BackendConflictGroup = "s3" | "gcs" | "azure" | "rest" | "sftp" | null; + +export const getBackendConflictGroup = (backend: string): BackendConflictGroup => { + switch (backend) { + case "s3": + case "r2": + return "s3"; + case "gcs": + return "gcs"; + case "azure": + return "azure"; + case "rest": + return "rest"; + case "sftp": + return "sftp"; + case "local": + case "rclone": + return null; + default: + return null; + } +}; + +export const hasCompatibleCredentials = async ( + config1: RepositoryConfig, + config2: RepositoryConfig, +): Promise => { + const group1 = getBackendConflictGroup(config1.backend); + const group2 = getBackendConflictGroup(config2.backend); + + if (!group1 || !group2 || group1 !== group2) { + return true; + } + + switch (group1) { + case "s3": { + if ( + (config1.backend === "s3" || config1.backend === "r2") && + (config2.backend === "s3" || config2.backend === "r2") + ) { + const accessKey1 = await cryptoUtils.decrypt(config1.accessKeyId); + const secretKey1 = await cryptoUtils.decrypt(config1.secretAccessKey); + + const accessKey2 = await cryptoUtils.decrypt(config2.accessKeyId); + const secretKey2 = await cryptoUtils.decrypt(config2.secretAccessKey); + + return accessKey1 === accessKey2 && secretKey1 === secretKey2; + } + return false; + } + case "gcs": { + if (config1.backend === "gcs" && config2.backend === "gcs") { + const credentials1 = await cryptoUtils.decrypt(config1.credentialsJson); + const credentials2 = await cryptoUtils.decrypt(config2.credentialsJson); + + return credentials1 === credentials2 && config1.projectId === config2.projectId; + } + return false; + } + case "azure": { + if (config1.backend === "azure" && config2.backend === "azure") { + const config1Accountkey = await cryptoUtils.decrypt(config1.accountKey); + const config2Accountkey = await cryptoUtils.decrypt(config2.accountKey); + + return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey; + } + return false; + } + case "rest": { + if (config1.backend === "rest" && config2.backend === "rest") { + if (!config1.username && !config2.username && !config1.password && !config2.password) { + return true; + } + + const config1Username = await cryptoUtils.decrypt(config1.username || ""); + const config1Password = await cryptoUtils.decrypt(config1.password || ""); + const config2Username = await cryptoUtils.decrypt(config2.username || ""); + const config2Password = await cryptoUtils.decrypt(config2.password || ""); + + return config1Username === config2Username && config1Password === config2Password; + } + return false; + } + case "sftp": { + return false; + } + default: + return false; + } +}; + +export interface CompatibilityResult { + repositoryId: string; + compatible: boolean; + reason: string | null; +} + +export const checkMirrorCompatibility = async ( + primaryConfig: RepositoryConfig, + mirrorConfig: RepositoryConfig, + mirrorRepositoryId: string, +): Promise => { + const primaryConflictGroup = getBackendConflictGroup(primaryConfig.backend); + const mirrorConflictGroup = getBackendConflictGroup(mirrorConfig.backend); + + if (!primaryConflictGroup || !mirrorConflictGroup) { + return { + repositoryId: mirrorRepositoryId, + compatible: true, + reason: null, + }; + } + + if (primaryConflictGroup !== mirrorConflictGroup) { + return { + repositoryId: mirrorRepositoryId, + compatible: true, + reason: null, + }; + } + + const compatible = await hasCompatibleCredentials(primaryConfig, mirrorConfig); + + if (compatible) { + return { + repositoryId: mirrorRepositoryId, + compatible: true, + reason: null, + }; + } + + return { + repositoryId: mirrorRepositoryId, + compatible: false, + reason: `Both use ${primaryConflictGroup.toUpperCase()} backends with different credentials`, + }; +}; + +export const getIncompatibleMirrorError = (mirrorRepoName: string, primaryBackend: string, mirrorBackend: string) => { + return ( + `Cannot mirror to ${mirrorRepoName}: both repositories use the same backend type (${primaryBackend}/${mirrorBackend}) with different credentials. ` + + "Restic cannot use different credentials for the same backend in a copy operation. " + + "Consider creating a new backup scheduler with the desired destination instead." + ); +}; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 317a365..45136ff 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -40,6 +40,7 @@ const snapshotInfoSchema = type({ time: "string", uid: "number?", username: "string", + tags: "string[]?", summary: type({ backup_end: "string", backup_start: "string", @@ -713,12 +714,65 @@ const repairIndex = async (config: RepositoryConfig) => { }; }; -const addCommonArgs = (args: string[], config: RepositoryConfig, env: Record) => { - args.push("--retry-lock", "1m", "--json"); +const copy = async ( + sourceConfig: RepositoryConfig, + destConfig: RepositoryConfig, + options: { + tag?: string; + snapshotId?: string; + }, +) => { + const sourceRepoUrl = buildRepoUrl(sourceConfig); + const destRepoUrl = buildRepoUrl(destConfig); - if (config.backend === "sftp" && env._SFTP_SSH_ARGS) { - args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`); + const sourceEnv = await buildEnv(sourceConfig); + const destEnv = await buildEnv(destConfig); + + const env: Record = { + ...sourceEnv, + ...destEnv, + RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE, + }; + + const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl]; + + if (options.tag) { + args.push("--tag", options.tag); } + + if (options.snapshotId) { + args.push(options.snapshotId); + } else { + args.push("latest"); + } + + addCommonArgs(args, destConfig, destEnv); + + if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) { + args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`); + } + + logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`); + logger.debug(`Executing: restic ${args.join(" ")}`); + + const res = await $`restic ${args}`.env(env).nothrow(); + + await cleanupTemporaryKeys(sourceConfig, sourceEnv); + await cleanupTemporaryKeys(destConfig, destEnv); + + const stdout = res.text(); + const stderr = res.stderr.toString(); + + if (res.exitCode !== 0) { + logger.error(`Restic copy failed: ${stderr}`); + throw new ResticError(res.exitCode, stderr); + } + + logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`); + return { + success: true, + output: stdout, + }; }; const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record) => { @@ -731,6 +785,13 @@ const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record) => { + args.push("--retry-lock", "1m", "--json"); + if (config.backend === "sftp" && env._SFTP_SSH_ARGS) { + args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`); + } +}; + export const restic = { ensurePassfile, init, @@ -743,4 +804,5 @@ export const restic = { ls, check, repairIndex, + copy, }; diff --git a/bun.lock b/bun.lock index 489487d..5d79fdb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "dependencies": {