mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
2 Commits
da489fab24
...
v0.16.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6f628dd4 | ||
|
|
16b8be2cd9 |
@@ -3,8 +3,8 @@
|
||||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
@@ -755,6 +755,59 @@ export const updateScheduleNotificationsMutation = (options?: Partial<Options<Up
|
||||
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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||
|
||||
export type Options<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
|
||||
*/
|
||||
|
||||
@@ -1124,6 +1124,7 @@ export type ListSnapshotsResponses = {
|
||||
paths: Array<string>;
|
||||
short_id: string;
|
||||
size: number;
|
||||
tags: Array<string>;
|
||||
time: number;
|
||||
}>;
|
||||
};
|
||||
@@ -1170,6 +1171,7 @@ export type GetSnapshotDetailsResponses = {
|
||||
paths: Array<string>;
|
||||
short_id: string;
|
||||
size: number;
|
||||
tags: Array<string>;
|
||||
time: number;
|
||||
};
|
||||
};
|
||||
@@ -2112,6 +2114,231 @@ export type UpdateScheduleNotificationsResponses = {
|
||||
|
||||
export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses];
|
||||
|
||||
export type GetScheduleMirrorsData = {
|
||||
body?: never;
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/backups/{scheduleId}/mirrors';
|
||||
};
|
||||
|
||||
export type GetScheduleMirrorsResponses = {
|
||||
/**
|
||||
* List of mirror repository assignments for the schedule
|
||||
*/
|
||||
200: Array<{
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
lastCopyAt: number | null;
|
||||
lastCopyError: string | null;
|
||||
lastCopyStatus: 'error' | 'success' | null;
|
||||
repository: {
|
||||
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||
config: {
|
||||
accessKeyId: string;
|
||||
backend: 'r2';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
lastChecked: number | null;
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
shortId: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
scheduleId: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GetScheduleMirrorsResponse = GetScheduleMirrorsResponses[keyof GetScheduleMirrorsResponses];
|
||||
|
||||
export type UpdateScheduleMirrorsData = {
|
||||
body?: {
|
||||
mirrors: Array<{
|
||||
enabled: boolean;
|
||||
repositoryId: string;
|
||||
}>;
|
||||
};
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/backups/{scheduleId}/mirrors';
|
||||
};
|
||||
|
||||
export type UpdateScheduleMirrorsResponses = {
|
||||
/**
|
||||
* Mirror assignments updated successfully
|
||||
*/
|
||||
200: Array<{
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
lastCopyAt: number | null;
|
||||
lastCopyError: string | null;
|
||||
lastCopyStatus: 'error' | 'success' | null;
|
||||
repository: {
|
||||
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||
config: {
|
||||
accessKeyId: string;
|
||||
backend: 'r2';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accessKeyId: string;
|
||||
backend: 's3';
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
secretAccessKey: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
accountKey: string;
|
||||
accountName: string;
|
||||
backend: 'azure';
|
||||
container: string;
|
||||
customPassword?: string;
|
||||
endpointSuffix?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'gcs';
|
||||
bucket: string;
|
||||
credentialsJson: string;
|
||||
projectId: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'local';
|
||||
name: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
path?: string;
|
||||
} | {
|
||||
backend: 'rclone';
|
||||
path: string;
|
||||
remote: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
} | {
|
||||
backend: 'rest';
|
||||
url: string;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
password?: string;
|
||||
path?: string;
|
||||
username?: string;
|
||||
} | {
|
||||
backend: 'sftp';
|
||||
host: string;
|
||||
path: string;
|
||||
privateKey: string;
|
||||
user: string;
|
||||
port?: number;
|
||||
customPassword?: string;
|
||||
isExistingRepository?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
id: string;
|
||||
lastChecked: number | null;
|
||||
lastError: string | null;
|
||||
name: string;
|
||||
shortId: string;
|
||||
status: 'error' | 'healthy' | 'unknown' | null;
|
||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||
updatedAt: number;
|
||||
};
|
||||
repositoryId: string;
|
||||
scheduleId: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses];
|
||||
|
||||
export type GetMirrorCompatibilityData = {
|
||||
body?: never;
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/backups/{scheduleId}/mirrors/compatibility';
|
||||
};
|
||||
|
||||
export type GetMirrorCompatibilityResponses = {
|
||||
/**
|
||||
* List of repositories with their mirror compatibility status
|
||||
*/
|
||||
200: Array<{
|
||||
compatible: boolean;
|
||||
reason: string | null;
|
||||
repositoryId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses];
|
||||
|
||||
export type ListNotificationDestinationsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
@@ -18,18 +18,17 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
import type { ListSnapshotsResponse } from "../api-client";
|
||||
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
|
||||
type Snapshot = ListSnapshotsResponse[number];
|
||||
import type { BackupSchedule, Snapshot } from "../lib/types";
|
||||
|
||||
type Props = {
|
||||
snapshots: Snapshot[];
|
||||
backups: BackupSchedule[];
|
||||
repositoryName: string;
|
||||
};
|
||||
|
||||
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -76,6 +75,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="uppercase">Snapshot ID</TableHead>
|
||||
<TableHead className="uppercase">Schedule</TableHead>
|
||||
<TableHead className="uppercase">Date & Time</TableHead>
|
||||
<TableHead className="uppercase">Size</TableHead>
|
||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||
@@ -84,71 +84,91 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{snapshots.map((snapshot) => (
|
||||
<TableRow
|
||||
key={snapshot.short_id}
|
||||
className="hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(snapshot.short_id)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-strong-accent">{snapshot.short_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
<ByteSize bytes={snapshot.size} base={1024} />
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
||||
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
{snapshot.paths.map((path) => (
|
||||
<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>
|
||||
))}
|
||||
{snapshots.map((snapshot) => {
|
||||
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
|
||||
const backup = backups.find((b) => backupIds.includes(b.id));
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={snapshot.short_id}
|
||||
className="hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(snapshot.short_id)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-strong-accent">{snapshot.short_id}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
hidden={!backup}
|
||||
to={backup ? `/backups/${backup.id}` : "#"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:underline"
|
||||
>
|
||||
<span className="text-sm">{backup ? backup.id : "-"}</span>
|
||||
</Link>
|
||||
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
<ByteSize bytes={snapshot.size} base={1024} />
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
||||
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
{snapshot.paths.map((path) => (
|
||||
<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>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,9 @@ type ServerEventType =
|
||||
| "backup:completed"
|
||||
| "volume:mounted"
|
||||
| "volume:unmounted"
|
||||
| "volume:updated";
|
||||
| "volume:updated"
|
||||
| "mirror:started"
|
||||
| "mirror:completed";
|
||||
|
||||
export interface BackupEvent {
|
||||
scheduleId: number;
|
||||
@@ -35,6 +37,14 @@ export interface VolumeEvent {
|
||||
volumeName: string;
|
||||
}
|
||||
|
||||
export interface MirrorEvent {
|
||||
scheduleId: number;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
status?: "success" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
/**
|
||||
@@ -125,6 +135,27 @@ export function useServerEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("mirror:started", (e) => {
|
||||
const data = JSON.parse(e.data) as MirrorEvent;
|
||||
console.log("[SSE] Mirror copy started:", data);
|
||||
|
||||
handlersRef.current.get("mirror:started")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("mirror:completed", (e) => {
|
||||
const data = JSON.parse(e.data) as MirrorEvent;
|
||||
console.log("[SSE] Mirror copy completed:", data);
|
||||
|
||||
// Invalidate queries to refresh mirror status in the UI
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
handlersRef.current.get("mirror:completed")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("[SSE] Connection error:", error);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -29,8 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary";
|
||||
import type { Route } from "./+types/backup-details";
|
||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||
import { getBackupSchedule, listNotificationDestinations } from "~/client/api-client";
|
||||
import { getBackupSchedule, listNotificationDestinations, listRepositories } from "~/client/api-client";
|
||||
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
||||
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
export const handle = {
|
||||
@@ -53,10 +54,11 @@ export function meta(_: Route.MetaArgs) {
|
||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
const notifs = await listNotificationDestinations();
|
||||
const repos = await listRepositories();
|
||||
|
||||
if (!schedule.data) return redirect("/backups");
|
||||
|
||||
return { schedule: schedule.data, notifs: notifs.data };
|
||||
return { schedule: schedule.data, notifs: notifs.data, repos: repos.data };
|
||||
};
|
||||
|
||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||
@@ -226,6 +228,13 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
<div className={cn({ hidden: !loaderData.notifs?.length })}>
|
||||
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
|
||||
</div>
|
||||
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
|
||||
<ScheduleMirrorsConfig
|
||||
scheduleId={schedule.id}
|
||||
primaryRepositoryId={schedule.repositoryId}
|
||||
repositories={loaderData.repos ?? []}
|
||||
/>
|
||||
</div>
|
||||
<SnapshotTimeline
|
||||
loading={isLoading}
|
||||
snapshots={snapshots ?? []}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database } from "lucide-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 { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
@@ -21,6 +21,10 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const schedules = useQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
|
||||
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
|
||||
if (!searchQuery) return true;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
@@ -132,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
</TableBody>
|
||||
</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">
|
||||
<span>
|
||||
|
||||
139
app/drizzle/0018_bizarre_zzzax.sql
Normal file
139
app/drizzle/0018_bizarre_zzzax.sql
Normal 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`);
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
1
app/drizzle/0019_heavy_shen.sql
Normal file
1
app/drizzle/0019_heavy_shen.sql
Normal 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`);
|
||||
740
app/drizzle/meta/0018_snapshot.json
Normal file
740
app/drizzle/meta/0018_snapshot.json
Normal 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"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedules_table": {
|
||||
"name": "backup_schedules_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"volume_id": {
|
||||
"name": "volume_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"cron_expression": {
|
||||
"name": "cron_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retention_policy": {
|
||||
"name": "retention_policy",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"name": "exclude_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"include_patterns": {
|
||||
"name": "include_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"last_backup_at": {
|
||||
"name": "last_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_status": {
|
||||
"name": "last_backup_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_error": {
|
||||
"name": "last_backup_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"next_backup_at": {
|
||||
"name": "next_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "volumes_table",
|
||||
"columnsFrom": ["volume_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": ["repository_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notification_destinations_table": {
|
||||
"name": "notification_destinations_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notification_destinations_table_name_unique": {
|
||||
"name": "notification_destinations_table_name_unique",
|
||||
"columns": ["name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repositories_table": {
|
||||
"name": "repositories_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"compression_mode": {
|
||||
"name": "compression_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"last_checked": {
|
||||
"name": "last_checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"repositories_table_short_id_unique": {
|
||||
"name": "repositories_table_short_id_unique",
|
||||
"columns": ["short_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"repositories_table_name_unique": {
|
||||
"name": "repositories_table_name_unique",
|
||||
"columns": ["name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions_table": {
|
||||
"name": "sessions_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_table_user_id_users_table_id_fk": {
|
||||
"name": "sessions_table_user_id_users_table_id_fk",
|
||||
"tableFrom": "sessions_table",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users_table": {
|
||||
"name": "users_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"has_downloaded_restic_password": {
|
||||
"name": "has_downloaded_restic_password",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_table_username_unique": {
|
||||
"name": "users_table_username_unique",
|
||||
"columns": ["username"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"volumes_table": {
|
||||
"name": "volumes_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unmounted'"
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_health_check": {
|
||||
"name": "last_health_check",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auto_remount": {
|
||||
"name": "auto_remount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"volumes_table_short_id_unique": {
|
||||
"name": "volumes_table_short_id_unique",
|
||||
"columns": ["short_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"volumes_table_name_unique": {
|
||||
"name": "volumes_table_name_unique",
|
||||
"columns": ["name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
792
app/drizzle/meta/0019_snapshot.json
Normal file
792
app/drizzle/meta/0019_snapshot.json
Normal file
@@ -0,0 +1,792 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "dedfb246-68e7-4590-af52-6476eb2999d1",
|
||||
"prevId": "121ef03c-eb5a-4b97-b2f1-4add6adfb080",
|
||||
"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": {
|
||||
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
|
||||
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
|
||||
"columns": [
|
||||
"schedule_id",
|
||||
"repository_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
|
||||
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
|
||||
"tableFrom": "backup_schedule_mirrors_table",
|
||||
"tableTo": "backup_schedules_table",
|
||||
"columnsFrom": [
|
||||
"schedule_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedule_mirrors_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": [
|
||||
"repository_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedule_notifications_table": {
|
||||
"name": "backup_schedule_notifications_table",
|
||||
"columns": {
|
||||
"schedule_id": {
|
||||
"name": "schedule_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"destination_id": {
|
||||
"name": "destination_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notify_on_start": {
|
||||
"name": "notify_on_start",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"notify_on_success": {
|
||||
"name": "notify_on_success",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"notify_on_failure": {
|
||||
"name": "notify_on_failure",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
|
||||
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
|
||||
"tableFrom": "backup_schedule_notifications_table",
|
||||
"tableTo": "backup_schedules_table",
|
||||
"columnsFrom": [
|
||||
"schedule_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
|
||||
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
|
||||
"tableFrom": "backup_schedule_notifications_table",
|
||||
"tableTo": "notification_destinations_table",
|
||||
"columnsFrom": [
|
||||
"destination_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
|
||||
"columns": [
|
||||
"schedule_id",
|
||||
"destination_id"
|
||||
],
|
||||
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedules_table": {
|
||||
"name": "backup_schedules_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"volume_id": {
|
||||
"name": "volume_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"cron_expression": {
|
||||
"name": "cron_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retention_policy": {
|
||||
"name": "retention_policy",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"name": "exclude_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"include_patterns": {
|
||||
"name": "include_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"last_backup_at": {
|
||||
"name": "last_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_status": {
|
||||
"name": "last_backup_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_error": {
|
||||
"name": "last_backup_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"next_backup_at": {
|
||||
"name": "next_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "volumes_table",
|
||||
"columnsFrom": [
|
||||
"volume_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": [
|
||||
"repository_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notification_destinations_table": {
|
||||
"name": "notification_destinations_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notification_destinations_table_name_unique": {
|
||||
"name": "notification_destinations_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repositories_table": {
|
||||
"name": "repositories_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"compression_mode": {
|
||||
"name": "compression_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"last_checked": {
|
||||
"name": "last_checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"repositories_table_short_id_unique": {
|
||||
"name": "repositories_table_short_id_unique",
|
||||
"columns": [
|
||||
"short_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"repositories_table_name_unique": {
|
||||
"name": "repositories_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions_table": {
|
||||
"name": "sessions_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_table_user_id_users_table_id_fk": {
|
||||
"name": "sessions_table_user_id_users_table_id_fk",
|
||||
"tableFrom": "sessions_table",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users_table": {
|
||||
"name": "users_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"has_downloaded_restic_password": {
|
||||
"name": "has_downloaded_restic_password",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_table_username_unique": {
|
||||
"name": "users_table_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"volumes_table": {
|
||||
"name": "volumes_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unmounted'"
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_health_check": {
|
||||
"name": "last_health_check",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auto_remount": {
|
||||
"name": "auto_remount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"volumes_table_short_id_unique": {
|
||||
"name": "volumes_table_short_id_unique",
|
||||
"columns": [
|
||||
"short_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"volumes_table_name_unique": {
|
||||
"name": "volumes_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -1,132 +1,146 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763644043601,
|
||||
"tag": "0011_familiar_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1764100562084,
|
||||
"tag": "0012_add_short_ids",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1764182159797,
|
||||
"tag": "0013_elite_sprite",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1764182405089,
|
||||
"tag": "0014_wild_echo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1764182465287,
|
||||
"tag": "0015_jazzy_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1764194697035,
|
||||
"tag": "0016_fix-timestamps-to-ms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1764357897219,
|
||||
"tag": "0017_fix-compression-modes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763644043601,
|
||||
"tag": "0011_familiar_stone_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1764100562084,
|
||||
"tag": "0012_add_short_ids",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1764182159797,
|
||||
"tag": "0013_elite_sprite",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1764182405089,
|
||||
"tag": "0014_wild_echo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1764182465287,
|
||||
"tag": "0015_jazzy_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1764194697035,
|
||||
"tag": "0016_fix-timestamps-to-ms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1764357897219,
|
||||
"tag": "0017_fix-compression-modes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1764619898949,
|
||||
"tag": "0018_bizarre_zzzax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1764790151212,
|
||||
"tag": "0019_heavy_shen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,6 +24,14 @@ interface ServerEvents {
|
||||
repositoryName: string;
|
||||
status: "success" | "error" | "stopped" | "warning";
|
||||
}) => void;
|
||||
"mirror:started": (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => void;
|
||||
"mirror:completed": (data: {
|
||||
scheduleId: number;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
status: "success" | "error";
|
||||
error?: string;
|
||||
}) => void;
|
||||
"volume:mounted": (data: { volumeName: string }) => void;
|
||||
"volume:unmounted": (data: { volumeName: string }) => void;
|
||||
"volume:updated": (data: { volumeName: string }) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { int, integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core";
|
||||
import { int, integer, sqliteTable, text, primaryKey, uniqueIndex, unique } from "drizzle-orm/sqlite-core";
|
||||
import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
|
||||
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
|
||||
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
|
||||
@@ -93,6 +93,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||
});
|
||||
|
||||
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
|
||||
volume: one(volumesTable, {
|
||||
fields: [backupSchedulesTable.volumeId],
|
||||
@@ -103,6 +104,7 @@ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, m
|
||||
references: [repositoriesTable.id],
|
||||
}),
|
||||
notifications: many(backupScheduleNotificationsTable),
|
||||
mirrors: many(backupScheduleMirrorsTable),
|
||||
}));
|
||||
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||
|
||||
@@ -154,6 +156,41 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif
|
||||
}));
|
||||
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
|
||||
|
||||
/**
|
||||
* Backup Schedule Mirrors Junction Table (Many-to-Many)
|
||||
* Allows copying snapshots to secondary repositories after backup completes
|
||||
*/
|
||||
export const backupScheduleMirrorsTable = sqliteTable(
|
||||
"backup_schedule_mirrors_table",
|
||||
{
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
scheduleId: int("schedule_id")
|
||||
.notNull()
|
||||
.references(() => backupSchedulesTable.id, { onDelete: "cascade" }),
|
||||
repositoryId: text("repository_id")
|
||||
.notNull()
|
||||
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
|
||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastCopyAt: int("last_copy_at", { mode: "number" }),
|
||||
lastCopyStatus: text("last_copy_status").$type<"success" | "error">(),
|
||||
lastCopyError: text("last_copy_error"),
|
||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||
},
|
||||
(table) => [unique().on(table.scheduleId, table.repositoryId)],
|
||||
);
|
||||
|
||||
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
|
||||
schedule: one(backupSchedulesTable, {
|
||||
fields: [backupScheduleMirrorsTable.scheduleId],
|
||||
references: [backupSchedulesTable.id],
|
||||
}),
|
||||
repository: one(repositoriesTable, {
|
||||
fields: [backupScheduleMirrorsTable.repositoryId],
|
||||
references: [repositoriesTable.id],
|
||||
}),
|
||||
}));
|
||||
export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect;
|
||||
|
||||
/**
|
||||
* App Metadata Table
|
||||
* Used for storing key-value pairs like migration checkpoints
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
stopBackupDto,
|
||||
updateBackupScheduleDto,
|
||||
updateBackupScheduleBody,
|
||||
getScheduleMirrorsDto,
|
||||
updateScheduleMirrorsDto,
|
||||
updateScheduleMirrorsBody,
|
||||
getMirrorCompatibilityDto,
|
||||
type CreateBackupScheduleDto,
|
||||
type DeleteBackupScheduleDto,
|
||||
type GetBackupScheduleDto,
|
||||
@@ -21,6 +25,9 @@ import {
|
||||
type RunForgetDto,
|
||||
type StopBackupDto,
|
||||
type UpdateBackupScheduleDto,
|
||||
type GetScheduleMirrorsDto,
|
||||
type UpdateScheduleMirrorsDto,
|
||||
type GetMirrorCompatibilityDto,
|
||||
} from "./backups.dto";
|
||||
import { backupsService } from "./backups.service";
|
||||
import {
|
||||
@@ -113,4 +120,23 @@ export const backupScheduleController = new Hono()
|
||||
|
||||
return c.json<UpdateScheduleNotificationsDto>(assignments, 200);
|
||||
},
|
||||
);
|
||||
)
|
||||
.get("/:scheduleId/mirrors", getScheduleMirrorsDto, async (c) => {
|
||||
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||
const mirrors = await backupsService.getMirrors(scheduleId);
|
||||
|
||||
return c.json<GetScheduleMirrorsDto>(mirrors, 200);
|
||||
})
|
||||
.put("/:scheduleId/mirrors", updateScheduleMirrorsDto, validator("json", updateScheduleMirrorsBody), async (c) => {
|
||||
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||
const body = c.req.valid("json");
|
||||
const mirrors = await backupsService.updateMirrors(scheduleId, body);
|
||||
|
||||
return c.json<UpdateScheduleMirrorsDto>(mirrors, 200);
|
||||
})
|
||||
.get("/:scheduleId/mirrors/compatibility", getMirrorCompatibilityDto, async (c) => {
|
||||
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||
const compatibility = await backupsService.getMirrorCompatibility(scheduleId);
|
||||
|
||||
return c.json<GetMirrorCompatibilityDto>(compatibility, 200);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,19 @@ const backupScheduleSchema = type({
|
||||
}),
|
||||
);
|
||||
|
||||
const scheduleMirrorSchema = type({
|
||||
scheduleId: "number",
|
||||
repositoryId: "string",
|
||||
enabled: "boolean",
|
||||
lastCopyAt: "number | null",
|
||||
lastCopyStatus: "'success' | 'error' | null",
|
||||
lastCopyError: "string | null",
|
||||
createdAt: "number",
|
||||
repository: repositorySchema,
|
||||
});
|
||||
|
||||
export type ScheduleMirrorDto = typeof scheduleMirrorSchema.infer;
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
@@ -276,3 +289,75 @@ export const runForgetDto = describeRoute({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
||||
export type GetScheduleMirrorsDto = typeof getScheduleMirrorsResponse.infer;
|
||||
|
||||
export const getScheduleMirrorsDto = describeRoute({
|
||||
description: "Get mirror repository assignments for a backup schedule",
|
||||
operationId: "getScheduleMirrors",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of mirror repository assignments for the schedule",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(getScheduleMirrorsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const updateScheduleMirrorsBody = type({
|
||||
mirrors: type({
|
||||
repositoryId: "string",
|
||||
enabled: "boolean",
|
||||
}).array(),
|
||||
});
|
||||
|
||||
export type UpdateScheduleMirrorsBody = typeof updateScheduleMirrorsBody.infer;
|
||||
|
||||
export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
||||
export type UpdateScheduleMirrorsDto = typeof updateScheduleMirrorsResponse.infer;
|
||||
|
||||
export const updateScheduleMirrorsDto = describeRoute({
|
||||
description: "Update mirror repository assignments for a backup schedule",
|
||||
operationId: "updateScheduleMirrors",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Mirror assignments updated successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(updateScheduleMirrorsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mirrorCompatibilitySchema = type({
|
||||
repositoryId: "string",
|
||||
compatible: "boolean",
|
||||
reason: "string | null",
|
||||
});
|
||||
|
||||
export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array();
|
||||
export type GetMirrorCompatibilityDto = typeof getMirrorCompatibilityResponse.infer;
|
||||
|
||||
export const getMirrorCompatibilityDto = describeRoute({
|
||||
description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository",
|
||||
operationId: "getMirrorCompatibility",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of repositories with their mirror compatibility status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(getMirrorCompatibilityResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,15 +3,16 @@ import cron from "node-cron";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||
import { db } from "../../db/db";
|
||||
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||
import { backupSchedulesTable, backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { serverEvents } from "../../core/events";
|
||||
import { notificationsService } from "../notifications/notifications.service";
|
||||
import { repoMutex } from "../../core/repository-mutex";
|
||||
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
|
||||
|
||||
const runningBackups = new Map<number, AbortController>();
|
||||
|
||||
@@ -266,19 +267,25 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
void runForget(schedule.id);
|
||||
}
|
||||
|
||||
copyToMirrors(scheduleId, repository, schedule.retentionPolicy).catch((error) => {
|
||||
logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`);
|
||||
});
|
||||
|
||||
const finalStatus = exitCode === 0 ? "success" : "warning";
|
||||
|
||||
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
||||
await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({
|
||||
lastBackupAt: Date.now(),
|
||||
lastBackupStatus: exitCode === 0 ? "success" : "warning",
|
||||
lastBackupStatus: finalStatus,
|
||||
lastBackupError: null,
|
||||
nextBackupAt: nextBackupAt,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
|
||||
if (exitCode !== 0) {
|
||||
if (finalStatus === "warning") {
|
||||
logger.warn(`Backup completed with warnings for volume ${volume.name} to repository ${repository.name}`);
|
||||
} else {
|
||||
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
||||
@@ -288,11 +295,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
scheduleId,
|
||||
volumeName: volume.name,
|
||||
repositoryName: repository.name,
|
||||
status: exitCode === 0 ? "success" : "warning",
|
||||
status: finalStatus,
|
||||
});
|
||||
|
||||
notificationsService
|
||||
.sendBackupNotification(scheduleId, exitCode === 0 ? "success" : "warning", {
|
||||
.sendBackupNotification(scheduleId, finalStatus === "success" ? "success" : "warning", {
|
||||
volumeName: volume.name,
|
||||
repositoryName: repository.name,
|
||||
})
|
||||
@@ -421,6 +428,174 @@ const runForget = async (scheduleId: number) => {
|
||||
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
|
||||
};
|
||||
|
||||
const getMirrors = async (scheduleId: number) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
|
||||
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
|
||||
with: { repository: true },
|
||||
});
|
||||
|
||||
return mirrors;
|
||||
};
|
||||
|
||||
const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
with: { repository: true },
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
for (const mirror of data.mirrors) {
|
||||
if (mirror.repositoryId === schedule.repositoryId) {
|
||||
throw new BadRequestError("Cannot add the primary repository as a mirror");
|
||||
}
|
||||
|
||||
const repo = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, mirror.repositoryId),
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
throw new NotFoundError(`Repository ${mirror.repositoryId} not found`);
|
||||
}
|
||||
|
||||
const compatibility = await checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id);
|
||||
|
||||
if (!compatibility.compatible) {
|
||||
throw new BadRequestError(
|
||||
getIncompatibleMirrorError(repo.name, schedule.repository.config.backend, repo.config.backend),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(backupScheduleMirrorsTable).where(eq(backupScheduleMirrorsTable.scheduleId, scheduleId));
|
||||
|
||||
if (data.mirrors.length > 0) {
|
||||
await db.insert(backupScheduleMirrorsTable).values(
|
||||
data.mirrors.map((mirror) => ({
|
||||
scheduleId,
|
||||
repositoryId: mirror.repositoryId,
|
||||
enabled: mirror.enabled,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return getMirrors(scheduleId);
|
||||
};
|
||||
|
||||
const copyToMirrors = async (
|
||||
scheduleId: number,
|
||||
sourceRepository: { id: string; config: (typeof repositoriesTable.$inferSelect)["config"] },
|
||||
retentionPolicy: (typeof backupSchedulesTable.$inferSelect)["retentionPolicy"],
|
||||
) => {
|
||||
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
|
||||
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
|
||||
with: { repository: true },
|
||||
});
|
||||
|
||||
const enabledMirrors = mirrors.filter((m) => m.enabled);
|
||||
|
||||
if (enabledMirrors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Background] Copying snapshots to ${enabledMirrors.length} mirror repositories for schedule ${scheduleId}`,
|
||||
);
|
||||
|
||||
for (const mirror of enabledMirrors) {
|
||||
try {
|
||||
logger.info(`[Background] Copying to mirror repository: ${mirror.repository.name}`);
|
||||
|
||||
serverEvents.emit("mirror:started", {
|
||||
scheduleId,
|
||||
repositoryId: mirror.repositoryId,
|
||||
repositoryName: mirror.repository.name,
|
||||
});
|
||||
|
||||
const releaseSource = await repoMutex.acquireShared(sourceRepository.id, `mirror_source:${scheduleId}`);
|
||||
const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`);
|
||||
|
||||
try {
|
||||
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: scheduleId.toString() });
|
||||
} finally {
|
||||
releaseSource();
|
||||
releaseMirror();
|
||||
}
|
||||
|
||||
if (retentionPolicy) {
|
||||
const releaseForget = await repoMutex.acquireExclusive(mirror.repository.id, `forget:mirror:${scheduleId}`);
|
||||
|
||||
try {
|
||||
logger.info(`[Background] Applying retention policy to mirror repository: ${mirror.repository.name}`);
|
||||
await restic.forget(mirror.repository.config, retentionPolicy, { tag: scheduleId.toString() });
|
||||
} finally {
|
||||
releaseForget();
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(backupScheduleMirrorsTable)
|
||||
.set({ lastCopyAt: Date.now(), lastCopyStatus: "success", lastCopyError: null })
|
||||
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
|
||||
|
||||
logger.info(`[Background] Successfully copied to mirror repository: ${mirror.repository.name}`);
|
||||
|
||||
serverEvents.emit("mirror:completed", {
|
||||
scheduleId,
|
||||
repositoryId: mirror.repositoryId,
|
||||
repositoryName: mirror.repository.name,
|
||||
status: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = toMessage(error);
|
||||
logger.error(`[Background] Failed to copy to mirror repository ${mirror.repository.name}: ${errorMessage}`);
|
||||
|
||||
await db
|
||||
.update(backupScheduleMirrorsTable)
|
||||
.set({ lastCopyAt: Date.now(), lastCopyStatus: "error", lastCopyError: errorMessage })
|
||||
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
|
||||
|
||||
serverEvents.emit("mirror:completed", {
|
||||
scheduleId,
|
||||
repositoryId: mirror.repositoryId,
|
||||
repositoryName: mirror.repository.name,
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getMirrorCompatibility = async (scheduleId: number) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
with: { repository: true },
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
const allRepositories = await db.query.repositoriesTable.findMany();
|
||||
const repos = allRepositories.filter((repo) => repo.id !== schedule.repositoryId);
|
||||
|
||||
const compatibility = await Promise.all(
|
||||
repos.map((repo) => checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id)),
|
||||
);
|
||||
|
||||
return compatibility;
|
||||
};
|
||||
|
||||
export const backupsService = {
|
||||
listSchedules,
|
||||
getSchedule,
|
||||
@@ -432,4 +607,7 @@ export const backupsService = {
|
||||
getScheduleForVolume,
|
||||
stopBackup,
|
||||
runForget,
|
||||
getMirrors,
|
||||
updateMirrors,
|
||||
getMirrorCompatibility,
|
||||
};
|
||||
|
||||
@@ -70,12 +70,34 @@ export const eventsController = new Hono().get("/", (c) => {
|
||||
});
|
||||
};
|
||||
|
||||
const onMirrorStarted = (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify(data),
|
||||
event: "mirror:started",
|
||||
});
|
||||
};
|
||||
|
||||
const onMirrorCompleted = (data: {
|
||||
scheduleId: number;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
status: "success" | "error";
|
||||
error?: string;
|
||||
}) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify(data),
|
||||
event: "mirror:completed",
|
||||
});
|
||||
};
|
||||
|
||||
serverEvents.on("backup:started", onBackupStarted);
|
||||
serverEvents.on("backup:progress", onBackupProgress);
|
||||
serverEvents.on("backup:completed", onBackupCompleted);
|
||||
serverEvents.on("volume:mounted", onVolumeMounted);
|
||||
serverEvents.on("volume:unmounted", onVolumeUnmounted);
|
||||
serverEvents.on("volume:updated", onVolumeUpdated);
|
||||
serverEvents.on("mirror:started", onMirrorStarted);
|
||||
serverEvents.on("mirror:completed", onMirrorCompleted);
|
||||
|
||||
let keepAlive = true;
|
||||
|
||||
@@ -88,6 +110,8 @@ export const eventsController = new Hono().get("/", (c) => {
|
||||
serverEvents.off("volume:mounted", onVolumeMounted);
|
||||
serverEvents.off("volume:unmounted", onVolumeUnmounted);
|
||||
serverEvents.off("volume:updated", onVolumeUpdated);
|
||||
serverEvents.off("mirror:started", onMirrorStarted);
|
||||
serverEvents.off("mirror:completed", onMirrorCompleted);
|
||||
});
|
||||
|
||||
while (keepAlive) {
|
||||
|
||||
@@ -90,6 +90,7 @@ export const repositoriesController = new Hono()
|
||||
short_id: snapshot.short_id,
|
||||
duration,
|
||||
paths: snapshot.paths,
|
||||
tags: snapshot.tags ?? [],
|
||||
size: summary?.total_bytes_processed || 0,
|
||||
time: new Date(snapshot.time).getTime(),
|
||||
};
|
||||
@@ -113,6 +114,7 @@ export const repositoriesController = new Hono()
|
||||
time: new Date(snapshot.time).getTime(),
|
||||
paths: snapshot.paths,
|
||||
size: snapshot.summary?.total_bytes_processed || 0,
|
||||
tags: snapshot.tags ?? [],
|
||||
summary: snapshot.summary,
|
||||
};
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ export const snapshotSchema = type({
|
||||
paths: "string[]",
|
||||
size: "number",
|
||||
duration: "number",
|
||||
tags: "string[]",
|
||||
});
|
||||
|
||||
const listSnapshotsResponse = snapshotSchema.array();
|
||||
|
||||
148
app/server/utils/backend-compatibility.ts
Normal file
148
app/server/utils/backend-compatibility.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { cryptoUtils } from "./crypto";
|
||||
|
||||
type BackendConflictGroup = "s3" | "gcs" | "azure" | "rest" | "sftp" | null;
|
||||
|
||||
export const getBackendConflictGroup = (backend: string): BackendConflictGroup => {
|
||||
switch (backend) {
|
||||
case "s3":
|
||||
case "r2":
|
||||
return "s3";
|
||||
case "gcs":
|
||||
return "gcs";
|
||||
case "azure":
|
||||
return "azure";
|
||||
case "rest":
|
||||
return "rest";
|
||||
case "sftp":
|
||||
return "sftp";
|
||||
case "local":
|
||||
case "rclone":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const hasCompatibleCredentials = async (
|
||||
config1: RepositoryConfig,
|
||||
config2: RepositoryConfig,
|
||||
): Promise<boolean> => {
|
||||
const group1 = getBackendConflictGroup(config1.backend);
|
||||
const group2 = getBackendConflictGroup(config2.backend);
|
||||
|
||||
if (!group1 || !group2 || group1 !== group2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (group1) {
|
||||
case "s3": {
|
||||
if (
|
||||
(config1.backend === "s3" || config1.backend === "r2") &&
|
||||
(config2.backend === "s3" || config2.backend === "r2")
|
||||
) {
|
||||
const accessKey1 = await cryptoUtils.decrypt(config1.accessKeyId);
|
||||
const secretKey1 = await cryptoUtils.decrypt(config1.secretAccessKey);
|
||||
|
||||
const accessKey2 = await cryptoUtils.decrypt(config2.accessKeyId);
|
||||
const secretKey2 = await cryptoUtils.decrypt(config2.secretAccessKey);
|
||||
|
||||
return accessKey1 === accessKey2 && secretKey1 === secretKey2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "gcs": {
|
||||
if (config1.backend === "gcs" && config2.backend === "gcs") {
|
||||
const credentials1 = await cryptoUtils.decrypt(config1.credentialsJson);
|
||||
const credentials2 = await cryptoUtils.decrypt(config2.credentialsJson);
|
||||
|
||||
return credentials1 === credentials2 && config1.projectId === config2.projectId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "azure": {
|
||||
if (config1.backend === "azure" && config2.backend === "azure") {
|
||||
const config1Accountkey = await cryptoUtils.decrypt(config1.accountKey);
|
||||
const config2Accountkey = await cryptoUtils.decrypt(config2.accountKey);
|
||||
|
||||
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "rest": {
|
||||
if (config1.backend === "rest" && config2.backend === "rest") {
|
||||
if (!config1.username && !config2.username && !config1.password && !config2.password) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const config1Username = await cryptoUtils.decrypt(config1.username || "");
|
||||
const config1Password = await cryptoUtils.decrypt(config1.password || "");
|
||||
const config2Username = await cryptoUtils.decrypt(config2.username || "");
|
||||
const config2Password = await cryptoUtils.decrypt(config2.password || "");
|
||||
|
||||
return config1Username === config2Username && config1Password === config2Password;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "sftp": {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export interface CompatibilityResult {
|
||||
repositoryId: string;
|
||||
compatible: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export const checkMirrorCompatibility = async (
|
||||
primaryConfig: RepositoryConfig,
|
||||
mirrorConfig: RepositoryConfig,
|
||||
mirrorRepositoryId: string,
|
||||
): Promise<CompatibilityResult> => {
|
||||
const primaryConflictGroup = getBackendConflictGroup(primaryConfig.backend);
|
||||
const mirrorConflictGroup = getBackendConflictGroup(mirrorConfig.backend);
|
||||
|
||||
if (!primaryConflictGroup || !mirrorConflictGroup) {
|
||||
return {
|
||||
repositoryId: mirrorRepositoryId,
|
||||
compatible: true,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (primaryConflictGroup !== mirrorConflictGroup) {
|
||||
return {
|
||||
repositoryId: mirrorRepositoryId,
|
||||
compatible: true,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const compatible = await hasCompatibleCredentials(primaryConfig, mirrorConfig);
|
||||
|
||||
if (compatible) {
|
||||
return {
|
||||
repositoryId: mirrorRepositoryId,
|
||||
compatible: true,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryId: mirrorRepositoryId,
|
||||
compatible: false,
|
||||
reason: `Both use ${primaryConflictGroup.toUpperCase()} backends with different credentials`,
|
||||
};
|
||||
};
|
||||
|
||||
export const getIncompatibleMirrorError = (mirrorRepoName: string, primaryBackend: string, mirrorBackend: string) => {
|
||||
return (
|
||||
`Cannot mirror to ${mirrorRepoName}: both repositories use the same backend type (${primaryBackend}/${mirrorBackend}) with different credentials. ` +
|
||||
"Restic cannot use different credentials for the same backend in a copy operation. " +
|
||||
"Consider creating a new backup scheduler with the desired destination instead."
|
||||
);
|
||||
};
|
||||
@@ -40,6 +40,7 @@ const snapshotInfoSchema = type({
|
||||
time: "string",
|
||||
uid: "number?",
|
||||
username: "string",
|
||||
tags: "string[]?",
|
||||
summary: type({
|
||||
backup_end: "string",
|
||||
backup_start: "string",
|
||||
@@ -201,7 +202,7 @@ const init = async (config: RepositoryConfig) => {
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args = ["init", "--repo", repoUrl];
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -277,7 +278,7 @@ const backup = async (
|
||||
}
|
||||
}
|
||||
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const logData = throttle((data: string) => {
|
||||
logger.info(data.trim());
|
||||
@@ -403,7 +404,7 @@ const restore = async (
|
||||
}
|
||||
}
|
||||
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
@@ -466,7 +467,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -515,7 +516,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
||||
}
|
||||
|
||||
args.push("--prune");
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -533,7 +534,7 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -583,7 +584,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -634,7 +635,7 @@ const unlock = async (config: RepositoryConfig) => {
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -658,7 +659,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
||||
args.push("--read-data");
|
||||
}
|
||||
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -692,7 +693,7 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args = ["repair", "index", "--repo", repoUrl];
|
||||
addCommonArgs(args, config, env);
|
||||
addCommonArgs(args, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
@@ -713,12 +714,65 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
};
|
||||
};
|
||||
|
||||
const addCommonArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
||||
args.push("--retry-lock", "1m", "--json");
|
||||
const copy = async (
|
||||
sourceConfig: RepositoryConfig,
|
||||
destConfig: RepositoryConfig,
|
||||
options: {
|
||||
tag?: string;
|
||||
snapshotId?: string;
|
||||
},
|
||||
) => {
|
||||
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
||||
const destRepoUrl = buildRepoUrl(destConfig);
|
||||
|
||||
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||
const sourceEnv = await buildEnv(sourceConfig);
|
||||
const destEnv = await buildEnv(destConfig);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...sourceEnv,
|
||||
...destEnv,
|
||||
RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE,
|
||||
};
|
||||
|
||||
const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl];
|
||||
|
||||
if (options.tag) {
|
||||
args.push("--tag", options.tag);
|
||||
}
|
||||
|
||||
if (options.snapshotId) {
|
||||
args.push(options.snapshotId);
|
||||
} else {
|
||||
args.push("latest");
|
||||
}
|
||||
|
||||
addCommonArgs(args, env);
|
||||
|
||||
if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) {
|
||||
args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`);
|
||||
}
|
||||
|
||||
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
|
||||
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
|
||||
await cleanupTemporaryKeys(sourceConfig, sourceEnv);
|
||||
await cleanupTemporaryKeys(destConfig, destEnv);
|
||||
|
||||
const stdout = res.text();
|
||||
const stderr = res.stderr.toString();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic copy failed: ${stderr}`);
|
||||
throw new ResticError(res.exitCode, stderr);
|
||||
}
|
||||
|
||||
logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`);
|
||||
return {
|
||||
success: true,
|
||||
output: stdout,
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||
@@ -731,6 +785,14 @@ const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string
|
||||
}
|
||||
};
|
||||
|
||||
const addCommonArgs = (args: string[], env: Record<string, string>) => {
|
||||
args.push("--retry-lock", "1m", "--json");
|
||||
|
||||
if (env._SFTP_SSH_ARGS) {
|
||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const restic = {
|
||||
ensurePassfile,
|
||||
init,
|
||||
@@ -743,4 +805,5 @@ export const restic = {
|
||||
ls,
|
||||
check,
|
||||
repairIndex,
|
||||
copy,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user