feat(mirrors): add selective snapshot sync to mirror repositories (#755)

* feat(mirrors): add selective snapshot sync to mirror repositories

Allow users to sync missing snapshots from the source repository to a
mirror. A new sync button per mirror opens a dialog showing which
snapshots are missing, with checkboxes to select which ones to copy.

- Add GET /:shortId/mirrors/:mirrorShortId/status endpoint to compare
  snapshots between source and mirror repositories
- Add POST /:shortId/mirrors/:mirrorShortId/sync endpoint accepting
  selected snapshotIds in the request body
- Extend restic copy command to accept multiple snapshotIds
- Add sync preview dialog with snapshot selection to the frontend

* refactor: stylistic changes

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
This commit is contained in:
Eric Hess
2026-04-16 21:28:48 +02:00
committed by GitHub
parent 497fa474a7
commit d2f65716fe
15 changed files with 1011 additions and 22 deletions

View File

@@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@@ -1098,6 +1098,41 @@ export const updateScheduleMirrorsMutation = (options?: Partial<Options<UpdateSc
return mutationOptions;
};
export const getMirrorSyncStatusQueryKey = (options: Options<GetMirrorSyncStatusData>) => createQueryKey('getMirrorSyncStatus', options);
/**
* Get sync status for a specific mirror, including missing snapshots
*/
export const getMirrorSyncStatusOptions = (options: Options<GetMirrorSyncStatusData>) => queryOptions<GetMirrorSyncStatusResponse, DefaultError, GetMirrorSyncStatusResponse, ReturnType<typeof getMirrorSyncStatusQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMirrorSyncStatus({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getMirrorSyncStatusQueryKey(options)
});
/**
* Sync selected snapshots to a specific mirror repository
*/
export const syncMirrorMutation = (options?: Partial<Options<SyncMirrorData>>): UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> => {
const mutationOptions: UseMutationOptions<SyncMirrorResponse, DefaultError, Options<SyncMirrorData>> = {
mutationFn: async (fnOptions) => {
const { data } = await syncMirror({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options);
/**

View File

@@ -26,6 +26,7 @@ export {
getBackupScheduleForVolume,
getDevPanel,
getMirrorCompatibility,
getMirrorSyncStatus,
getNotificationDestination,
getOrgMembers,
getPublicSsoProviders,
@@ -62,6 +63,7 @@ export {
setRegistrationStatus,
startDoctor,
stopBackup,
syncMirror,
tagSnapshots,
testConnection,
testNotificationDestination,
@@ -153,6 +155,9 @@ export type {
GetMirrorCompatibilityData,
GetMirrorCompatibilityResponse,
GetMirrorCompatibilityResponses,
GetMirrorSyncStatusData,
GetMirrorSyncStatusResponse,
GetMirrorSyncStatusResponses,
GetNotificationDestinationData,
GetNotificationDestinationErrors,
GetNotificationDestinationResponse,
@@ -263,6 +268,10 @@ export type {
StopBackupErrors,
StopBackupResponse,
StopBackupResponses,
SyncMirrorData,
SyncMirrorErrors,
SyncMirrorResponse,
SyncMirrorResponses,
TagSnapshotsData,
TagSnapshotsResponse,
TagSnapshotsResponses,

View File

@@ -3,7 +3,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -409,6 +409,23 @@ export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(opti
}
});
/**
* Get sync status for a specific mirror, including missing snapshots
*/
export const getMirrorSyncStatus = <ThrowOnError extends boolean = false>(options: Options<GetMirrorSyncStatusData, ThrowOnError>) => (options.client ?? client).get<GetMirrorSyncStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options });
/**
* Sync selected snapshots to a specific mirror repository
*/
export const syncMirror = <ThrowOnError extends boolean = false>(options: Options<SyncMirrorData, ThrowOnError>) => (options.client ?? client).post<SyncMirrorResponses, SyncMirrorErrors, ThrowOnError>({
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/

View File

@@ -4114,6 +4114,63 @@ export type UpdateScheduleMirrorsResponses = {
export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses];
export type GetMirrorSyncStatusData = {
body?: never;
path: {
shortId: string;
mirrorShortId: string;
};
query?: never;
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status';
};
export type GetMirrorSyncStatusResponses = {
/**
* Mirror sync status with missing snapshots
*/
200: {
sourceCount: number;
mirrorCount: number;
missingSnapshots: Array<{
short_id: string;
time: string;
size: number;
}>;
};
};
export type GetMirrorSyncStatusResponse = GetMirrorSyncStatusResponses[keyof GetMirrorSyncStatusResponses];
export type SyncMirrorData = {
body: {
snapshotIds?: Array<string>;
};
path: {
shortId: string;
mirrorShortId: string;
};
query?: never;
url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync';
};
export type SyncMirrorErrors = {
/**
* Mirror is already syncing
*/
409: unknown;
};
export type SyncMirrorResponses = {
/**
* Mirror sync started successfully
*/
200: {
success: boolean;
};
};
export type SyncMirrorResponse = SyncMirrorResponses[keyof SyncMirrorResponses];
export type GetMirrorCompatibilityData = {
body?: never;
path: {

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { Copy, Plus, Trash2 } from "lucide-react";
import { Copy, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
@@ -8,21 +8,33 @@ 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 { Checkbox } from "~/client/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import {
getScheduleMirrorsOptions,
getMirrorCompatibilityOptions,
getMirrorSyncStatusOptions,
updateScheduleMirrorsMutation,
syncMirrorMutation,
} 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 { ByteSize } from "~/client/components/bytes-size";
import { useServerEvents } from "~/client/hooks/use-server-events";
import { formatDistanceToNow } from "date-fns";
import { cn } from "~/client/lib/utils";
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
import { Link } from "@tanstack/react-router";
import { useTimeFormat } from "~/client/lib/datetime";
type Props = {
scheduleShortId: string;
@@ -56,11 +68,29 @@ const buildAssignments = (mirrors: GetScheduleMirrorsResponse) =>
);
export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, repositories, initialData }: Props) => {
const { formatDateTime, formatTimeAgo } = useTimeFormat();
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false);
const [syncDialogMirrorId, setSyncDialogMirrorId] = useState<string | null>(null);
const [selectedSnapshotIds, setSelectedSnapshotIds] = useState<Set<string>>(new Set());
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
const { addEventListener } = useServerEvents();
const closeSyncDialog = () => {
setSyncDialogOpen(false);
setTimeout(() => {
setSyncDialogMirrorId(null);
setSelectedSnapshotIds(new Set());
}, 300);
};
const openSyncDialog = (mirrorShortId: string) => {
setSyncDialogOpen(true);
setSelectedSnapshotIds(new Set());
setSyncDialogMirrorId(mirrorShortId);
};
const { data: currentMirrors } = useSuspenseQuery({
...getScheduleMirrorsOptions({ path: { shortId: scheduleShortId } }),
});
@@ -88,6 +118,47 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
},
});
const { data: syncStatus, isLoading: isSyncStatusLoading } = useQuery({
...getMirrorSyncStatusOptions({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId ?? "" },
}),
enabled: syncDialogMirrorId !== null,
});
const toggleSnapshotSelection = (shortId: string) => {
setSelectedSnapshotIds((prev) => {
const next = new Set(prev);
if (next.has(shortId)) {
next.delete(shortId);
} else {
next.add(shortId);
}
return next;
});
};
const toggleAllSnapshots = () => {
if (!syncStatus) return;
if (selectedSnapshotIds.size === syncStatus.missingSnapshots.length) {
setSelectedSnapshotIds(new Set());
} else {
setSelectedSnapshotIds(new Set(syncStatus.missingSnapshots.map((s) => s.short_id)));
}
};
const triggerSync = useMutation({
...syncMirrorMutation(),
onSuccess: () => {
toast.success("Full sync started");
closeSyncDialog();
},
onError: (error) => {
toast.error("Failed to start sync", {
description: parseError(error)?.message,
});
},
});
const compatibilityMap = useMemo(() => {
const map = new Map<string, { compatible: boolean; reason: string | null }>();
if (compatibility) {
@@ -221,7 +292,7 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
return "Syncing...";
}
if (assignment.lastCopyAt) {
return formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true });
return formatTimeAgo(assignment.lastCopyAt);
}
return "Never";
};
@@ -239,6 +310,8 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
return "Never copied";
};
const selectedMirrorRepo = repositories.find((r) => r.shortId === syncDialogMirrorId);
return (
<Card>
<CardHeader>
@@ -369,14 +442,30 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.shortId)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => openSyncDialog(repository.shortId)}
disabled={isSyncing(assignment) || hasChanges}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isSyncing(assignment) && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent>Sync more snapshots</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.shortId)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
@@ -396,6 +485,89 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
</Button>
</div>
)}
<Dialog open={syncDialogOpen} onOpenChange={(open) => !open && closeSyncDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sync snapshots</DialogTitle>
<DialogDescription>
{`Sync missing snapshots to ${selectedMirrorRepo?.name || "mirror repository"}.`}
</DialogDescription>
</DialogHeader>
{isSyncStatusLoading && !syncStatus ? (
<div className="py-6 text-center text-muted-foreground text-sm">Loading snapshot status...</div>
) : syncStatus && syncStatus.missingSnapshots.length === 0 ? (
<div className="py-6 text-center text-muted-foreground text-sm">
All {syncStatus.sourceCount} snapshots are already synced to this mirror.
</div>
) : syncStatus ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{syncStatus.missingSnapshots.length} of {syncStatus.sourceCount} snapshots are missing in this mirror.
</p>
<div className="rounded-md border max-h-64 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selectedSnapshotIds.size === syncStatus.missingSnapshots.length}
onCheckedChange={toggleAllSnapshots}
/>
</TableHead>
<TableHead>ID</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Size</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{syncStatus.missingSnapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="cursor-pointer"
onClick={() => toggleSnapshotSelection(snapshot.short_id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedSnapshotIds.has(snapshot.short_id)}
onCheckedChange={() => toggleSnapshotSelection(snapshot.short_id)}
/>
</TableCell>
<TableCell className="font-mono text-xs">{snapshot.short_id}</TableCell>
<TableCell className="text-sm">{formatDateTime(new Date(snapshot.time))}</TableCell>
<TableCell className="text-right text-sm">
<ByteSize bytes={snapshot.size} base={1024} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={closeSyncDialog}>
Cancel
</Button>
<Button
onClick={() => {
if (syncDialogMirrorId) {
triggerSync.mutate({
path: { shortId: scheduleShortId, mirrorShortId: syncDialogMirrorId },
body: { snapshotIds: Array.from(selectedSnapshotIds) },
});
}
}}
loading={triggerSync.isPending}
disabled={selectedSnapshotIds.size === 0}
>
Sync {selectedSnapshotIds.size} snapshots
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);

View File

@@ -54,6 +54,8 @@ describe("backups security", () => {
{ method: "GET", path: "/api/v1/backups/1/mirrors" },
{ method: "PUT", path: "/api/v1/backups/1/mirrors" },
{ method: "GET", path: "/api/v1/backups/1/mirrors/compatibility" },
{ method: "GET", path: "/api/v1/backups/1/mirrors/abc/status" },
{ method: "POST", path: "/api/v1/backups/1/mirrors/abc/sync" },
{ method: "POST", path: "/api/v1/backups/reorder" },
{ method: "GET", path: "/api/v1/backups/1/progress" },
];

View File

@@ -0,0 +1,248 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect";
import { backupsService } from "../backups.service";
import { createTestVolume } from "~/test/helpers/volume";
import { createTestBackupSchedule } from "~/test/helpers/backup";
import { createTestRepository } from "~/test/helpers/repository";
import { createTestBackupScheduleMirror } from "~/test/helpers/backup-mirror";
import { TEST_ORG_ID } from "~/test/helpers/organization";
import * as context from "~/server/core/request-context";
import * as resticModule from "~/server/core/restic";
import * as spawnModule from "@zerobyte/core/node";
import type { ShortId } from "~/server/utils/branded";
const setup = () => {
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
vi.spyOn(spawnModule, "safeSpawn").mockImplementation(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
return {
mockSnapshots: (sourceSnapshots: unknown[], mirrorSnapshots: unknown[]) => {
let callCount = 0;
vi.spyOn(resticModule.restic, "snapshots").mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve(sourceSnapshots as never);
return Promise.resolve(mirrorSnapshots as never);
});
},
mockCopy: () => {
const copyMock = vi
.spyOn(resticModule.restic, "copy")
.mockImplementation(() => Promise.resolve({ success: true, output: "" }));
return copyMock;
},
};
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("getMirrorSyncStatus", () => {
test("should return missing snapshots based on time comparison", async () => {
const { mockSnapshots } = setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const mirrorRepository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
mockSnapshots(
[
{
id: "aaa",
short_id: "aaa",
time: "2025-01-01T10:00:00Z",
paths: ["/data"],
summary: { total_bytes_processed: 100 },
},
{
id: "bbb",
short_id: "bbb",
time: "2025-01-02T10:00:00Z",
paths: ["/data"],
summary: { total_bytes_processed: 200 },
},
{
id: "ccc",
short_id: "ccc",
time: "2025-01-03T10:00:00Z",
paths: ["/data"],
summary: { total_bytes_processed: 300 },
},
],
[
{
id: "xxx",
short_id: "xxx",
time: "2025-01-01T10:00:00Z",
paths: ["/data"],
summary: { total_bytes_processed: 100 },
},
],
);
const status = await backupsService.getMirrorSyncStatus(schedule.shortId, mirrorRepository.shortId as ShortId);
expect(status.sourceCount).toBe(3);
expect(status.mirrorCount).toBe(1);
expect(status.missingSnapshots).toHaveLength(2);
expect(status.missingSnapshots.map((s) => s.short_id)).toEqual(["bbb", "ccc"]);
});
test("should return empty missing list when all snapshots are synced", async () => {
const { mockSnapshots } = setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const mirrorRepository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
mockSnapshots(
[
{
id: "aaa",
short_id: "aaa",
time: "2025-01-01T10:00:00Z",
paths: ["/data"],
summary: { total_bytes_processed: 100 },
},
],
[
{
id: "xxx",
short_id: "xxx",
time: "2025-01-01T10:00:00Z",
paths: ["/data"],
summary: { total_bytes_processed: 100 },
},
],
);
const status = await backupsService.getMirrorSyncStatus(schedule.shortId, mirrorRepository.shortId as ShortId);
expect(status.sourceCount).toBe(1);
expect(status.mirrorCount).toBe(1);
expect(status.missingSnapshots).toHaveLength(0);
});
test("should throw if mirror is not configured for the schedule", async () => {
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const unrelatedRepository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await expect(
backupsService.getMirrorSyncStatus(schedule.shortId, unrelatedRepository.shortId as ShortId),
).rejects.toThrow("Mirror not found for this schedule");
});
});
describe("syncMirror", () => {
test("should trigger sync and return success", async () => {
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const mirrorRepository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
const result = await backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, [
"snap1",
"snap2",
]);
expect(result.success).toBe(true);
});
test("should reject if mirror is already syncing", async () => {
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const mirrorRepository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id, {
lastCopyStatus: "in_progress",
});
await expect(
backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]),
).rejects.toThrow("Mirror is already syncing");
});
test("should reject concurrent sync requests once a sync has started", async () => {
const { mockCopy } = setup();
const copyMock = mockCopy();
let releaseCopy: (() => void) | undefined;
const copyStarted = new Promise<void>((resolve) => {
copyMock.mockImplementation(
() =>
new Promise((copyResolve) => {
releaseCopy = () => copyResolve({ success: true, output: "" });
resolve();
}),
);
});
const volume = await createTestVolume();
const repository = await createTestRepository();
const mirrorRepository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
await expect(
backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]),
).resolves.toEqual({ success: true });
await copyStarted;
await waitForExpect(async () => {
const mirrors = await backupsService.getMirrors(schedule.shortId);
expect(mirrors[0]?.lastCopyStatus).toBe("in_progress");
});
await expect(
backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]),
).rejects.toThrow("Mirror is already syncing");
releaseCopy?.();
await waitForExpect(async () => {
const mirrors = await backupsService.getMirrors(schedule.shortId);
expect(mirrors[0]?.lastCopyStatus).toBe("success");
});
});
test("should throw if mirror is not configured for the schedule", async () => {
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const unrelatedRepository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await expect(
backupsService.syncMirror(schedule.shortId, unrelatedRepository.shortId as ShortId, ["snap1"]),
).rejects.toThrow("Mirror not found for this schedule");
});
});

View File

@@ -5,8 +5,10 @@ import {
createBackupScheduleDto,
deleteBackupScheduleDto,
getBackupScheduleDto,
getBackupScheduleResponse,
getBackupScheduleForVolumeDto,
listBackupSchedulesDto,
listBackupSchedulesResponse,
runBackupNowDto,
runForgetDto,
stopBackupDto,
@@ -16,9 +18,12 @@ import {
updateScheduleMirrorsDto,
updateScheduleMirrorsBody,
getMirrorCompatibilityDto,
getMirrorSyncStatusDto,
reorderBackupSchedulesDto,
reorderBackupSchedulesBody,
getBackupProgressDto,
syncMirrorBody,
syncMirrorDto,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
@@ -33,8 +38,8 @@ import {
type GetMirrorCompatibilityDto,
type ReorderBackupSchedulesDto,
type GetBackupProgressDto,
listBackupSchedulesResponse,
getBackupScheduleResponse,
type GetMirrorSyncStatusDto,
type SyncMirrorDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
import {
@@ -155,6 +160,21 @@ export const backupScheduleController = new Hono()
return c.json<UpdateScheduleMirrorsDto>(mirrors, 200);
})
.get("/:shortId/mirrors/:mirrorShortId/status", getMirrorSyncStatusDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const mirrorShortId = asShortId(c.req.param("mirrorShortId"));
const status = await backupsService.getMirrorSyncStatus(shortId, mirrorShortId);
return c.json<GetMirrorSyncStatusDto>(status, 200);
})
.post("/:shortId/mirrors/:mirrorShortId/sync", syncMirrorDto, validator("json", syncMirrorBody), async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const mirrorShortId = asShortId(c.req.param("mirrorShortId"));
const body = c.req.valid("json");
const result = await backupsService.syncMirror(shortId, mirrorShortId, body.snapshotIds);
return c.json<SyncMirrorDto>(result, 200);
})
.get("/:shortId/mirrors/compatibility", getMirrorCompatibilityDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const compatibility = await backupsService.getMirrorCompatibility(shortId);

View File

@@ -400,6 +400,65 @@ export const reorderBackupSchedulesDto = describeRoute({
},
});
const missingSnapshotSchema = z.object({
short_id: z.string(),
time: z.string(),
size: z.number(),
});
export const getMirrorSyncStatusResponse = z.object({
sourceCount: z.number(),
mirrorCount: z.number(),
missingSnapshots: missingSnapshotSchema.array(),
});
export type GetMirrorSyncStatusDto = z.infer<typeof getMirrorSyncStatusResponse>;
export const getMirrorSyncStatusDto = describeRoute({
description: "Get sync status for a specific mirror, including missing snapshots",
operationId: "getMirrorSyncStatus",
tags: ["Backups"],
responses: {
200: {
description: "Mirror sync status with missing snapshots",
content: {
"application/json": {
schema: resolver(getMirrorSyncStatusResponse),
},
},
},
},
});
export const syncMirrorBody = z.object({
snapshotIds: z.array(z.string()).optional(),
});
export type SyncMirrorBody = z.infer<typeof syncMirrorBody>;
export const syncMirrorResponse = z.object({
success: z.boolean(),
});
export type SyncMirrorDto = z.infer<typeof syncMirrorResponse>;
export const syncMirrorDto = describeRoute({
description: "Sync selected snapshots to a specific mirror repository",
operationId: "syncMirror",
tags: ["Backups"],
responses: {
200: {
description: "Mirror sync started successfully",
content: {
"application/json": {
schema: resolver(syncMirrorResponse),
},
},
},
409: {
description: "Mirror is already syncing",
},
},
});
const getBackupProgressResponse = backupProgressEventSchema.nullable();
export type GetBackupProgressDto = z.infer<typeof getBackupProgressResponse>;

View File

@@ -60,6 +60,13 @@ export const mirrorQueries = {
});
},
findByScheduleAndRepository: async (scheduleId: number, repositoryId: string) => {
return db.query.backupScheduleMirrorsTable.findFirst({
where: { scheduleId, repositoryId },
with: { repository: true },
});
},
updateStatus: async (scheduleId: number, repositoryId: string, status: MirrorStatusUpdate) => {
return db
.update(backupScheduleMirrorsTable)

View File

@@ -25,7 +25,10 @@ import {
validateBackupExecution,
} from "./helpers/backup-lifecycle";
import { getScheduleByIdOrShortId } from "./helpers/backup-schedule-lookups";
import { copyToMirrors, runForget } from "./helpers/backup-maintenance";
import { copyToMirrors, runForget, syncSnapshotsToMirror } from "./helpers/backup-maintenance";
import { restic } from "../../core/restic";
import { mirrorQueries } from "./backups.queries";
import { toMessage } from "../../utils/errors";
const listSchedules = async () => {
const organizationId = getOrganizationId();
@@ -469,6 +472,79 @@ const stopBackup = async (scheduleId: number) => {
}
};
const getMirrorSyncStatus = async (scheduleIdOrShortId: number | string, mirrorShortId: ShortId) => {
const organizationId = getOrganizationId();
const schedule = await getScheduleByIdOrShortId(scheduleIdOrShortId);
const mirrorRepo = await db.query.repositoriesTable.findFirst({
where: {
AND: [{ shortId: { eq: mirrorShortId } }, { organizationId }],
},
});
if (!mirrorRepo) {
throw new NotFoundError("Mirror repository not found");
}
const mirror = await mirrorQueries.findByScheduleAndRepository(schedule.id, mirrorRepo.id);
if (!mirror) {
throw new NotFoundError("Mirror not found for this schedule");
}
const [sourceSnapshots, mirrorSnapshots] = await Promise.all([
restic.snapshots(schedule.repository.config, { tags: [schedule.shortId], organizationId }),
restic.snapshots(mirrorRepo.config, { tags: [schedule.shortId], organizationId }),
]);
const mirrorSnapshotTimes = new Set(mirrorSnapshots.map((s) => s.time));
const missingSnapshots = sourceSnapshots
.filter((s) => !mirrorSnapshotTimes.has(s.time))
.map((s) => ({
short_id: s.short_id,
time: s.time,
size: s.summary?.total_bytes_processed ?? 0,
}));
return {
sourceCount: sourceSnapshots.length,
mirrorCount: mirrorSnapshots.length,
missingSnapshots,
};
};
const syncMirror = async (scheduleIdOrShortId: number | string, mirrorShortId: ShortId, snapshotIds?: string[]) => {
const organizationId = getOrganizationId();
const schedule = await getScheduleByIdOrShortId(scheduleIdOrShortId);
const mirrorRepo = await db.query.repositoriesTable.findFirst({
where: {
AND: [{ shortId: { eq: mirrorShortId } }, { organizationId }],
},
});
if (!mirrorRepo) {
throw new NotFoundError("Mirror repository not found");
}
const mirror = await mirrorQueries.findByScheduleAndRepository(schedule.id, mirrorRepo.id);
if (!mirror) {
throw new NotFoundError("Mirror not found for this schedule");
}
if (mirror.lastCopyStatus === "in_progress") {
throw new ConflictError("Mirror is already syncing");
}
syncSnapshotsToMirror(schedule.id, mirrorRepo.id, organizationId, snapshotIds).catch((error) => {
logger.error(`Error syncing all snapshots to mirror ${mirrorRepo.name}: ${toMessage(error)}`);
});
return { success: true };
};
export const backupsService = {
listSchedules,
createSchedule,
@@ -487,4 +563,6 @@ export const backupsService = {
stopBackup,
runForget,
copyToMirrors,
getMirrorSyncStatus,
syncMirror,
};

View File

@@ -66,6 +66,114 @@ export async function copyToMirrors(
}
}
export async function syncSnapshotsToMirror(
scheduleId: number,
mirrorRepositoryId: string,
organizationIdOverride?: string,
snapshotIds?: string[],
) {
const organizationId = organizationIdOverride ?? getOrganizationId();
const schedule = await scheduleQueries.findById(scheduleId, organizationId);
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
const mirror = await mirrorQueries.findByScheduleAndRepository(scheduleId, mirrorRepositoryId);
if (!mirror) {
throw new NotFoundError("Mirror not found for this schedule");
}
const sourceRepository = await repositoryQueries.findById(schedule.repositoryId, organizationId);
if (!sourceRepository) {
throw new NotFoundError("Source repository not found");
}
const mirrorRepository = await repositoryQueries.findById(mirrorRepositoryId, organizationId);
if (!mirrorRepository) {
throw new NotFoundError("Mirror repository not found");
}
try {
logger.info(`[Background] Syncing all snapshots to mirror repository: ${mirrorRepository.name}`);
serverEvents.emit("mirror:started", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirrorRepository.shortId,
repositoryName: mirrorRepository.name,
});
await mirrorQueries.updateStatus(scheduleId, mirrorRepositoryId, {
lastCopyStatus: "in_progress",
lastCopyError: null,
});
const releaseLocks = await repoMutex.acquireMany([
{ repositoryId: sourceRepository.id, type: "shared", operation: `mirror_sync_source:${scheduleId}` },
{ repositoryId: mirrorRepository.id, type: "exclusive", operation: `mirror_sync:${scheduleId}` },
]);
try {
await restic.copy(sourceRepository.config, mirrorRepository.config, {
tag: schedule.shortId,
organizationId,
snapshotIds,
});
cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id));
} finally {
releaseLocks();
}
if (schedule.retentionPolicy) {
void runForget(scheduleId, mirrorRepository.id, organizationId).catch((error) => {
logger.error(
`Failed to run retention policy for mirror repository ${mirrorRepository.name}: ${toMessage(error)}`,
);
});
}
await mirrorQueries.updateStatus(scheduleId, mirrorRepositoryId, {
lastCopyAt: Date.now(),
lastCopyStatus: "success",
lastCopyError: null,
});
logger.info(`[Background] Successfully synced all snapshots to mirror repository: ${mirrorRepository.name}`);
serverEvents.emit("mirror:completed", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirrorRepository.shortId,
repositoryName: mirrorRepository.name,
status: "success",
});
} catch (error) {
const errorMessage = toMessage(error);
logger.error(
`[Background] Failed to sync all snapshots to mirror repository ${mirrorRepository.name}: ${errorMessage}`,
);
await mirrorQueries.updateStatus(scheduleId, mirrorRepositoryId, {
lastCopyAt: Date.now(),
lastCopyStatus: "error",
lastCopyError: errorMessage,
});
serverEvents.emit("mirror:completed", {
organizationId,
scheduleId: schedule.shortId,
repositoryId: mirrorRepository.shortId,
repositoryName: mirrorRepository.name,
status: "error",
error: errorMessage,
});
}
}
async function copyToSingleMirror(
scheduleId: number,
schedule: BackupSchedule,

View File

@@ -0,0 +1,139 @@
import fs from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { type Page, type TestInfo } from "@playwright/test";
import { expect, test } from "./test";
import { gotoAndWaitForAppReady } from "./helpers/page";
const testDataPath = path.join(process.cwd(), "playwright", "temp");
function getRunId(testInfo: TestInfo) {
return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
}
function getWorkerTestDataPath() {
fs.mkdirSync(testDataPath, { recursive: true });
return testDataPath;
}
async function createRepository(page: Page, name: string) {
await gotoAndWaitForAppReady(page, "/repositories");
await page.getByRole("button", { name: "Create repository" }).click();
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("combobox", { name: "Backend" }).click();
await page.getByRole("option", { name: "Local" }).click();
await page.getByRole("button", { name: "Create repository" }).click();
await expect(page.getByText("Repository created successfully")).toBeVisible({ timeout: 30000 });
}
async function createVolume(page: Page, name: string) {
await gotoAndWaitForAppReady(page, "/volumes");
const volumeNameInput = page.getByRole("textbox", { name: "Name" });
await expect(async () => {
await page.getByRole("button", { name: "Create Volume" }).click();
await expect(volumeNameInput).toBeVisible();
}).toPass({ timeout: 10000 });
await volumeNameInput.fill(name);
await page.getByRole("button", { name: "test-data" }).click();
await page.getByRole("button", { name: "Create Volume" }).click();
await expect(page.getByText("Volume created successfully")).toBeVisible();
}
async function createBackupJob(page: Page, backupName: string, volumeName: string, repositoryName: string) {
await gotoAndWaitForAppReady(page, "/backups");
const createBackupButton = page.getByRole("button", { name: "Create a backup job" }).first();
if (await createBackupButton.isVisible()) {
await createBackupButton.click();
} else {
await page.getByRole("link", { name: "Create a backup job" }).first().click();
}
const volumeSelect = page.getByRole("combobox").filter({ hasText: "Choose a volume to backup" });
const volumeOption = page.getByRole("option", { name: volumeName });
await expect(async () => {
await volumeSelect.click();
await expect(volumeOption).toBeVisible();
}).toPass({ timeout: 10000 });
await volumeOption.click();
await page.getByRole("textbox", { name: "Backup name" }).fill(backupName);
await page.getByRole("combobox").filter({ hasText: "Select a repository" }).click();
await page.getByRole("option", { name: repositoryName }).click();
await page.getByRole("combobox").filter({ hasText: "Select frequency" }).click();
await page.getByRole("option", { name: "Daily" }).click();
await page.getByRole("textbox", { name: "Execution time" }).fill("00:00");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByText("Backup job created successfully")).toBeVisible();
}
test("can sync missing snapshots to a mirror repository", async ({ page }, testInfo) => {
const runId = getRunId(testInfo);
const volumeName = `Volume-${runId}`;
const primaryRepoName = `Primary-${runId}`;
const mirrorRepoName = `Mirror-${runId}`;
const backupName = `Backup-${runId}`;
const workerTestDataPath = getWorkerTestDataPath();
const runPath = path.join(workerTestDataPath, runId);
fs.mkdirSync(runPath, { recursive: true });
fs.writeFileSync(path.join(runPath, "test.json"), JSON.stringify({ data: "mirror sync test" }));
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
// Create volume, two repositories, and a backup job
await createVolume(page, volumeName);
await createRepository(page, primaryRepoName);
await createRepository(page, mirrorRepoName);
await createBackupJob(page, backupName, volumeName, primaryRepoName);
// Run a backup to create a snapshot
await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible();
await expect(page.getByText(/Success|Warning/).first()).toBeVisible({ timeout: 30000 });
// Add mirror repository
await page.getByRole("button", { name: "Add mirror" }).click();
const mirrorSelect = page.getByRole("combobox").filter({ hasText: "Select a repository to mirror to..." });
await mirrorSelect.click();
await page.getByRole("option", { name: mirrorRepoName }).click();
await page.getByRole("button", { name: "Save changes" }).click();
await expect(page.getByText("Mirror settings saved successfully")).toBeVisible();
// Click sync button on the mirror row (first icon button in the actions cell)
const mirrorRow = page.getByRole("row").filter({ hasText: mirrorRepoName });
await mirrorRow.getByRole("button").first().click();
// Verify the sync dialog shows missing snapshots
await expect(page.getByRole("heading", { name: "Sync snapshots" })).toBeVisible();
await expect(page.getByText(/1 of 1 snapshots are missing/)).toBeVisible({ timeout: 15000 });
// Verify there is a checkbox and a snapshot row
const snapshotCheckbox = page.getByRole("dialog").getByRole("checkbox").first();
await expect(snapshotCheckbox).toBeChecked();
// Click sync button
await page.getByRole("button", { name: "Sync 1 snapshots" }).click();
await expect(page.getByText("Full sync started")).toBeVisible();
// Wait for sync to complete
await expect(page.getByText("Syncing...")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Syncing...")).not.toBeVisible({ timeout: 30000 });
// Open sync dialog again and verify all snapshots are synced
await mirrorRow.getByRole("button").first().click();
await expect(page.getByRole("heading", { name: "Sync snapshots" })).toBeVisible();
await expect(page.getByText(/All 1 snapshots are already synced/)).toBeVisible({ timeout: 15000 });
await page.getByRole("button", { name: "Cancel" }).click();
// Verify snapshot appears in the mirror repository's snapshots tab
const response = await page.request.get("/api/v1/repositories");
expect(response.ok()).toBe(true);
const repositories = (await response.json()) as Array<{ name: string; shortId: string }>;
const mirrorRepo = repositories.find((r) => r.name === mirrorRepoName);
expect(mirrorRepo).toBeDefined();
await gotoAndWaitForAppReady(page, `/repositories/${mirrorRepo!.shortId}`);
await page.getByRole("tab", { name: "Snapshots" }).click();
await expect(page.getByText("Backup snapshots stored in this repository.")).toBeVisible();
await page.getByRole("button", { name: "Refresh" }).click();
await expect(page.getByRole("checkbox", { name: /Select snapshot/ })).toHaveCount(1, { timeout: 15000 });
});

View File

@@ -48,12 +48,46 @@ afterEach(() => {
describe("copy command", () => {
test("treats flag-like snapshot IDs as positional args", async () => {
const { getArgs } = setup();
const snapshotId = "--help";
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotIds: ["--help"], tag: "daily" }, mockDeps);
const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs().slice(separatorIndex + 1)).toEqual([snapshotId]);
expect(getArgs().slice(separatorIndex + 1)).toEqual(["--help"]);
});
test("defaults to 'latest' when no snapshotIds are provided", async () => {
const { getArgs } = setup();
await copy(sourceConfig, destConfig, { organizationId: "org-1", tag: "daily" }, mockDeps);
const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs().slice(separatorIndex + 1)).toEqual(["latest"]);
});
test("passes multiple snapshot IDs after separator", async () => {
const { getArgs } = setup();
await copy(
sourceConfig,
destConfig,
{ organizationId: "org-1", snapshotIds: ["abc123", "def456", "ghi789"], tag: "daily" },
mockDeps,
);
const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs().slice(separatorIndex + 1)).toEqual(["abc123", "def456", "ghi789"]);
});
test("defaults to 'latest' when snapshotIds is empty array", async () => {
const { getArgs } = setup();
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotIds: [], tag: "daily" }, mockDeps);
const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs().slice(separatorIndex + 1)).toEqual(["latest"]);
});
});

View File

@@ -11,7 +11,7 @@ import type { ResticDeps } from "../types";
export const copy = async (
sourceConfig: RepositoryConfig,
destConfig: RepositoryConfig,
options: { organizationId: string; tag?: string; snapshotId?: string },
options: { organizationId: string; tag?: string; snapshotIds?: string[] },
deps: ResticDeps,
) => {
const sourceRepoUrl = buildRepoUrl(sourceConfig);
@@ -45,7 +45,11 @@ export const copy = async (
args.push("--limit-upload", destUploadLimit);
}
args.push("--", options.snapshotId ?? "latest");
if (options.snapshotIds && options.snapshotIds.length > 0) {
args.push("--", ...options.snapshotIds);
} else {
args.push("--", "latest");
}
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
logger.debug(`Executing: restic ${args.join(" ")}`);