Compare commits

..

3 Commits

Author SHA1 Message Date
Nicolas Meienberger
ba3e73d76a chore: debug db queries 2025-12-03 21:31:08 +01:00
Nicolas Meienberger
ce07c588ad chore: update dependencies 2025-12-03 20:55:38 +01:00
Nico
e7f0a2828d feat: mirror repositories (#95)
* feat: mirror repositories

feat: mirror backup repositories

* chore: pr feedbacks
2025-12-03 20:52:47 +01:00
27 changed files with 3355 additions and 467 deletions

View File

@@ -1,4 +1,4 @@
ARG BUN_VERSION="1.3.1" ARG BUN_VERSION="1.3.3"
FROM oven/bun:${BUN_VERSION}-alpine AS base FROM oven/bun:${BUN_VERSION}-alpine AS base

View File

@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; 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 { 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, 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 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 * Register a new user
@@ -755,6 +755,59 @@ export const updateScheduleNotificationsMutation = (options?: Partial<Options<Up
return mutationOptions; return mutationOptions;
}; };
export const getScheduleMirrorsQueryKey = (options: Options<GetScheduleMirrorsData>) => createQueryKey("getScheduleMirrors", options);
/**
* Get mirror repository assignments for a backup schedule
*/
export const getScheduleMirrorsOptions = (options: Options<GetScheduleMirrorsData>) => queryOptions<GetScheduleMirrorsResponse, DefaultError, GetScheduleMirrorsResponse, ReturnType<typeof getScheduleMirrorsQueryKey>>({
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<Options<UpdateScheduleMirrorsData>>): UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> => {
const mutationOptions: UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateScheduleMirrors({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey("getMirrorCompatibility", options);
/**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/
export const getMirrorCompatibilityOptions = (options: Options<GetMirrorCompatibilityData>) => queryOptions<GetMirrorCompatibilityResponse, DefaultError, GetMirrorCompatibilityResponse, ReturnType<typeof getMirrorCompatibilityQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMirrorCompatibility({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getMirrorCompatibilityQueryKey(options)
});
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options); export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options);
/** /**

View File

@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; 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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@@ -476,6 +476,40 @@ export const updateScheduleNotifications = <ThrowOnError extends boolean = false
}); });
}; };
/**
* Get mirror repository assignments for a backup schedule
*/
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => {
return (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/mirrors',
...options
});
};
/**
* Update mirror repository assignments for a backup schedule
*/
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => {
return (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => {
return (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/mirrors/compatibility',
...options
});
};
/** /**
* List all notification destinations * List all notification destinations
*/ */

View File

@@ -1124,6 +1124,7 @@ export type ListSnapshotsResponses = {
paths: Array<string>; paths: Array<string>;
short_id: string; short_id: string;
size: number; size: number;
tags: Array<string>;
time: number; time: number;
}>; }>;
}; };
@@ -1170,6 +1171,7 @@ export type GetSnapshotDetailsResponses = {
paths: Array<string>; paths: Array<string>;
short_id: string; short_id: string;
size: number; size: number;
tags: Array<string>;
time: number; time: number;
}; };
}; };
@@ -2112,6 +2114,231 @@ export type UpdateScheduleNotificationsResponses = {
export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof 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 = { export type ListNotificationDestinationsData = {
body?: never; body?: never;
path?: never; path?: never;

View File

@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react"; 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 { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
@@ -18,18 +18,17 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "~/client/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import type { ListSnapshotsResponse } from "../api-client";
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types";
type Snapshot = ListSnapshotsResponse[number];
type Props = { type Props = {
snapshots: Snapshot[]; snapshots: Snapshot[];
backups: BackupSchedule[];
repositoryName: string; repositoryName: string;
}; };
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -76,6 +75,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableHeader className="bg-card-header"> <TableHeader className="bg-card-header">
<TableRow> <TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead> <TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Schedule</TableHead>
<TableHead className="uppercase">Date & Time</TableHead> <TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead> <TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead> <TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
@@ -84,71 +84,91 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{snapshots.map((snapshot) => ( {snapshots.map((snapshot) => {
<TableRow const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
key={snapshot.short_id} const backup = backups.find((b) => backupIds.includes(b.id));
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)} return (
> <TableRow
<TableCell className="font-mono text-sm"> key={snapshot.short_id}
<div className="flex items-center gap-2"> className="hover:bg-accent/50 cursor-pointer"
<HardDrive className="h-4 w-4 text-muted-foreground" /> onClick={() => handleRowClick(snapshot.short_id)}
<span className="text-strong-accent">{snapshot.short_id}</span> >
</div> <TableCell className="font-mono text-sm">
</TableCell> <div className="flex items-center gap-2">
<TableCell> <HardDrive className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> <span className="text-strong-accent">{snapshot.short_id}</span>
<Calendar className="h-4 w-4 text-muted-foreground" /> </div>
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span> </TableCell>
</div> <TableCell>
</TableCell> <div className="flex items-center gap-2">
<TableCell> <Link
<div className="flex items-center gap-2"> hidden={!backup}
<Database className="h-4 w-4 text-muted-foreground" /> to={backup ? `/backups/${backup.id}` : "#"}
<span className="font-medium"> onClick={(e) => e.stopPropagation()}
<ByteSize bytes={snapshot.size} base={1024} /> className="hover:underline"
</span> >
</div> <span className="text-sm">{backup ? backup.id : "-"}</span>
</TableCell> </Link>
<TableCell className="hidden md:table-cell"> <span hidden={!!backup} className="text-sm text-muted-foreground">
<div className="flex items-center justify-end gap-2"> -
<Clock className="h-4 w-4 text-muted-foreground" /> </span>
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span> </div>
</div> </TableCell>
</TableCell> <TableCell>
<TableCell className="hidden lg:table-cell"> <div className="flex items-center gap-2">
<div className="flex items-center justify-end gap-2"> <Calendar className="h-4 w-4 text-muted-foreground" />
<FolderTree className="h-4 w-4 text-muted-foreground" /> <span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
<Tooltip> </div>
<TooltipTrigger asChild> </TableCell>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help"> <TableCell>
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} <div className="flex items-center gap-2">
</span> <Database className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger> <span className="font-medium">
<TooltipContent side="top" className="max-w-md"> <ByteSize bytes={snapshot.size} base={1024} />
<div className="flex flex-col gap-1"> </span>
{snapshot.paths.map((path) => ( </div>
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono"> </TableCell>
{path} <TableCell className="hidden md:table-cell">
</div> <div className="flex items-center justify-end gap-2">
))} <Clock className="h-4 w-4 text-muted-foreground" />
</div> <span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</TooltipContent> </div>
</Tooltip> </TableCell>
</div> <TableCell className="hidden lg:table-cell">
</TableCell> <div className="flex items-center justify-end gap-2">
<TableCell className="text-right"> <FolderTree className="h-4 w-4 text-muted-foreground" />
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
onClick={(e) => handleDeleteClick(e, snapshot.short_id)} {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
disabled={deleteSnapshot.isPending} </span>
> </TooltipTrigger>
<Trash2 className="h-4 w-4 text-destructive" /> <TooltipContent side="top" className="max-w-md">
</Button> <div className="flex flex-col gap-1">
</TableCell> {snapshot.paths.map((path) => (
</TableRow> <div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
))} {path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -9,7 +9,9 @@ type ServerEventType =
| "backup:completed" | "backup:completed"
| "volume:mounted" | "volume:mounted"
| "volume:unmounted" | "volume:unmounted"
| "volume:updated"; | "volume:updated"
| "mirror:started"
| "mirror:completed";
export interface BackupEvent { export interface BackupEvent {
scheduleId: number; scheduleId: number;
@@ -35,6 +37,14 @@ export interface VolumeEvent {
volumeName: string; volumeName: string;
} }
export interface MirrorEvent {
scheduleId: number;
repositoryId: string;
repositoryName: string;
status?: "success" | "error";
error?: string;
}
type EventHandler = (data: unknown) => void; 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) => { eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error); console.error("[SSE] Connection error:", error);
}; };

View File

@@ -0,0 +1,352 @@
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<Map<string, MirrorAssignment>>(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<string, { compatible: boolean; reason: string | null }>();
if (compatibility) {
for (const item of compatibility) {
map.set(item.repositoryId, { compatible: item.compatible, reason: item.reason });
}
}
return map;
}, [compatibility]);
useEffect(() => {
if (currentMirrors && !hasChanges) {
const map = new Map<string, MirrorAssignment>();
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, hasChanges]);
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<string, MirrorAssignment>();
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 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) => repositories?.find((r) => r.id === id))
.filter((r) => r !== undefined);
const getStatusVariant = (status: "success" | "error" | null) => {
if (status === "success") return "success";
if (status === "error") return "error";
return "neutral";
};
const getStatusLabel = (assignment: MirrorAssignment) => {
if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) {
return assignment.lastCopyError;
}
if (assignment.lastCopyStatus === "success") {
return "Last copy successful";
}
return "Never copied";
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
Mirror Repositories
</CardTitle>
<CardDescription>
Configure secondary repositories where snapshots will be automatically copied after each backup
</CardDescription>
</div>
{!isAddingNew && selectableRepositories.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
<Plus className="h-4 w-4 mr-2" />
Add mirror
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isAddingNew && (
<div className="mb-6 flex items-center gap-2 max-w-md">
<Select onValueChange={addRepository}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a repository to mirror to..." />
</SelectTrigger>
<SelectContent>
{selectableRepositories.map((repository) => {
const compat = compatibilityMap.get(repository.id);
return (
<Tooltip key={repository.id}>
<TooltipTrigger asChild>
<div>
<SelectItem value={repository.id} disabled={!compat?.compatible}>
<div className="flex items-center gap-2">
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span>{repository.name}</span>
<span className="text-xs uppercase text-muted-foreground">({repository.type})</span>
</div>
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={cn("max-w-xs", { hidden: compat?.compatible })}>
<p>{compat?.reason || "This repository is not compatible for mirroring."}</p>
<p className="mt-1 text-xs text-muted-foreground">
Consider creating a new backup scheduler with the desired destination instead.
</p>
</TooltipContent>
</Tooltip>
);
})}
{!hasAvailableRepositories && selectableRepositories.length > 0 && (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
All available repositories have conflicting backends.
<br />
<span className="text-xs">
Consider creating a new backup scheduler with the desired destination instead.
</span>
</div>
)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
Cancel
</Button>
</div>
)}
{assignedRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Copy className="h-8 w-8 mb-2 opacity-20" />
<p className="text-sm">No mirror repositories configured for this schedule.</p>
<p className="text-xs mt-1">Click "Add mirror" to replicate backups to additional repositories.</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead className="text-center w-[100px]">Enabled</TableHead>
<TableHead className="w-[180px]">Last Copy</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignedRepositories.map((repository) => {
const assignment = assignments.get(repository.id);
if (!assignment) return null;
return (
<TableRow key={repository.id}>
<TableCell>
<div className="flex items-center gap-2">
<Link
to={`/repositories/${repository.name}`}
className="hover:underline flex items-center gap-2"
>
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span className="font-medium">{repository.name}</span>
</Link>
<Badge variant="outline" className="text-[10px] align-middle">
{repository.type}
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.enabled}
onCheckedChange={() => toggleEnabled(repository.id)}
/>
</TableCell>
<TableCell>
{assignment.lastCopyAt ? (
<div className="flex items-center gap-2">
<StatusDot
variant={getStatusVariant(assignment.lastCopyStatus)}
label={getStatusLabel(assignment)}
/>
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true })}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{hasChanges && (
<div className="flex gap-2 justify-end mt-4 pt-4">
<Button variant="outline" size="sm" onClick={handleReset}>
Cancel
</Button>
<Button variant="default" size="sm" onClick={handleSave} loading={updateMirrors.isPending}>
Save changes
</Button>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -29,8 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary";
import type { Route } from "./+types/backup-details"; import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser"; import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline"; 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 { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
export const handle = { export const handle = {
@@ -53,10 +54,11 @@ export function meta(_: Route.MetaArgs) {
export const clientLoader = async ({ params }: Route.LoaderArgs) => { export const clientLoader = async ({ params }: Route.LoaderArgs) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } }); const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
const notifs = await listNotificationDestinations(); const notifs = await listNotificationDestinations();
const repos = await listRepositories();
if (!schedule.data) return redirect("/backups"); 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) { export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
@@ -226,6 +228,13 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
<div className={cn({ hidden: !loaderData.notifs?.length })}> <div className={cn({ hidden: !loaderData.notifs?.length })}>
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} /> <ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
</div> </div>
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
<ScheduleMirrorsConfig
scheduleId={schedule.id}
primaryRepositoryId={schedule.repositoryId}
repositories={loaderData.repos ?? []}
/>
</div>
<SnapshotTimeline <SnapshotTimeline
loading={isLoading} loading={isLoading}
snapshots={snapshots ?? []} snapshots={snapshots ?? []}

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listBackupSchedulesOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { SnapshotsTable } from "~/client/components/snapshots-table"; import { SnapshotsTable } from "~/client/components/snapshots-table";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
@@ -21,6 +21,10 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
initialData: [], initialData: [],
}); });
const schedules = useQuery({
...listBackupSchedulesOptions(),
});
const filteredSnapshots = data.filter((snapshot: Snapshot) => { const filteredSnapshots = data.filter((snapshot: Snapshot) => {
if (!searchQuery) return true; if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase(); const searchLower = searchQuery.toLowerCase();
@@ -132,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
</TableBody> </TableBody>
</Table> </Table>
) : ( ) : (
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} /> <SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} backups={schedules.data ?? []} />
)} )}
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t"> <div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span> <span>

View File

@@ -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
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`);--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX `backup_schedule_mirrors_table_schedule_id_repository_id_unique` ON `backup_schedule_mirrors_table` (`schedule_id`,`repository_id`);

View File

@@ -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"