reduced the scope of PR to MVP - exportAll only
This commit is contained in:
parent
692dd5aa21
commit
bf36f9aa56
16 changed files with 189 additions and 1162 deletions
19
README.md
19
README.md
|
|
@ -198,26 +198,21 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
|
|||
|
||||
## Exporting configuration
|
||||
|
||||
Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. You can export:
|
||||
|
||||
- **Full configuration** - All volumes, repositories, backup schedules, and notification destinations
|
||||
- **Individual entities** - Export specific volumes, repositories, notifications, or backup schedules
|
||||
Zerobyte allows you to export your configuration for backup, migration, or documentation purposes.
|
||||
|
||||
To export, click the "Export" button on any list page or detail page. A dialog will appear with options to:
|
||||
|
||||
- **Include database IDs** - Useful for debugging or when you need to reference internal identifiers
|
||||
- **Include timestamps** - Include createdAt/updatedAt fields in the export
|
||||
- **Include runtime state** - Include current status, health checks, and last backup information (usually not needed for migration)
|
||||
- **Secrets handling** (for repositories and notifications):
|
||||
- **Include metadata** - Include IDs, timestamps, and runtime state of entities
|
||||
- **Secrets handling**:
|
||||
- **Exclude** - Remove sensitive fields like passwords and API keys
|
||||
- **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import)
|
||||
- **Decrypt** - Export secrets as plaintext (use with caution)
|
||||
- **Include recovery key** (full export only) - Include the master encryption key for all repositories
|
||||
- **Include password hash** (full export only) - Include the hashed admin password for seamless migration
|
||||
- **Include recovery key** - Include the master encryption key for all repositories
|
||||
- **Include password hash** - Include the hashed user passwords for seamless migration
|
||||
|
||||
All exports require password verification for security. You must enter your password to confirm your identity before any configuration can be exported.
|
||||
Export requires password verification for security. You must enter your password to confirm your identity before any configuration can be exported.
|
||||
|
||||
Exports are downloaded as JSON files that can be used for reference or future import functionality.
|
||||
Export is downloaded as JSON file that can be used for reference or future import functionality.
|
||||
|
||||
## Propagating mounts to host
|
||||
|
||||
|
|
|
|||
|
|
@ -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, exportBackupSchedules, exportFullConfig, exportNotificationDestinations, exportRepositories, exportVolumes, 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, ExportBackupSchedulesData, ExportBackupSchedulesError, ExportBackupSchedulesResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, ExportNotificationDestinationsData, ExportNotificationDestinationsError, ExportNotificationDestinationsResponse, ExportRepositoriesData, ExportRepositoriesError, ExportRepositoriesResponse, ExportVolumesData, ExportVolumesError, ExportVolumesResponse, 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, exportFullConfig, 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, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, 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';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
|
|
@ -910,71 +910,3 @@ export const exportFullConfigMutation = (options?: Partial<Options<ExportFullCon
|
|||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export volumes configuration
|
||||
*/
|
||||
export const exportVolumesMutation = (options?: Partial<Options<ExportVolumesData>>): UseMutationOptions<ExportVolumesResponse, ExportVolumesError, Options<ExportVolumesData>> => {
|
||||
const mutationOptions: UseMutationOptions<ExportVolumesResponse, ExportVolumesError, Options<ExportVolumesData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await exportVolumes({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export repositories configuration
|
||||
*/
|
||||
export const exportRepositoriesMutation = (options?: Partial<Options<ExportRepositoriesData>>): UseMutationOptions<ExportRepositoriesResponse, ExportRepositoriesError, Options<ExportRepositoriesData>> => {
|
||||
const mutationOptions: UseMutationOptions<ExportRepositoriesResponse, ExportRepositoriesError, Options<ExportRepositoriesData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await exportRepositories({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export notification destinations configuration
|
||||
*/
|
||||
export const exportNotificationDestinationsMutation = (options?: Partial<Options<ExportNotificationDestinationsData>>): UseMutationOptions<ExportNotificationDestinationsResponse, ExportNotificationDestinationsError, Options<ExportNotificationDestinationsData>> => {
|
||||
const mutationOptions: UseMutationOptions<ExportNotificationDestinationsResponse, ExportNotificationDestinationsError, Options<ExportNotificationDestinationsData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await exportNotificationDestinations({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export backup schedules configuration
|
||||
*/
|
||||
export const exportBackupSchedulesMutation = (options?: Partial<Options<ExportBackupSchedulesData>>): UseMutationOptions<ExportBackupSchedulesResponse, ExportBackupSchedulesError, Options<ExportBackupSchedulesData>> => {
|
||||
const mutationOptions: UseMutationOptions<ExportBackupSchedulesResponse, ExportBackupSchedulesError, Options<ExportBackupSchedulesData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await exportBackupSchedules({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, ExportBackupSchedulesData, ExportBackupSchedulesErrors, ExportBackupSchedulesResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, ExportNotificationDestinationsData, ExportNotificationDestinationsErrors, ExportNotificationDestinationsResponses, ExportRepositoriesData, ExportRepositoriesErrors, ExportRepositoriesResponses, ExportVolumesData, ExportVolumesErrors, ExportVolumesResponses, 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, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, 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';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
|
|
@ -581,59 +581,3 @@ export const exportFullConfig = <ThrowOnError extends boolean = false>(options?:
|
|||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Export volumes configuration
|
||||
*/
|
||||
export const exportVolumes = <ThrowOnError extends boolean = false>(options?: Options<ExportVolumesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<ExportVolumesResponses, ExportVolumesErrors, ThrowOnError>({
|
||||
url: '/api/v1/config/export/volumes',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Export repositories configuration
|
||||
*/
|
||||
export const exportRepositories = <ThrowOnError extends boolean = false>(options?: Options<ExportRepositoriesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<ExportRepositoriesResponses, ExportRepositoriesErrors, ThrowOnError>({
|
||||
url: '/api/v1/config/export/repositories',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Export notification destinations configuration
|
||||
*/
|
||||
export const exportNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ExportNotificationDestinationsData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<ExportNotificationDestinationsResponses, ExportNotificationDestinationsErrors, ThrowOnError>({
|
||||
url: '/api/v1/config/export/notification-destinations',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Export backup schedules configuration
|
||||
*/
|
||||
export const exportBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ExportBackupSchedulesData, ThrowOnError>) => {
|
||||
return (options?.client ?? client).post<ExportBackupSchedulesResponses, ExportBackupSchedulesErrors, ThrowOnError>({
|
||||
url: '/api/v1/config/export/backup-schedules',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2619,11 +2619,9 @@ export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[key
|
|||
export type ExportFullConfigData = {
|
||||
body?: {
|
||||
password: string;
|
||||
includeIds?: boolean;
|
||||
includeMetadata?: boolean;
|
||||
includePasswordHash?: boolean;
|
||||
includeRecoveryKey?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
||||
};
|
||||
path?: never;
|
||||
|
|
@ -2668,222 +2666,3 @@ export type ExportFullConfigResponses = {
|
|||
};
|
||||
|
||||
export type ExportFullConfigResponse = ExportFullConfigResponses[keyof ExportFullConfigResponses];
|
||||
|
||||
export type ExportVolumesData = {
|
||||
body?: {
|
||||
password: string;
|
||||
id?: number | string;
|
||||
includeIds?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
name?: string;
|
||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/config/export/volumes';
|
||||
};
|
||||
|
||||
export type ExportVolumesErrors = {
|
||||
/**
|
||||
* Invalid request
|
||||
*/
|
||||
400: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Password required for export
|
||||
*/
|
||||
401: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Volume not found
|
||||
*/
|
||||
404: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Export failed
|
||||
*/
|
||||
500: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportVolumesError = ExportVolumesErrors[keyof ExportVolumesErrors];
|
||||
|
||||
export type ExportVolumesResponses = {
|
||||
/**
|
||||
* Volumes configuration export
|
||||
*/
|
||||
200: {
|
||||
volumes: Array<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportVolumesResponse = ExportVolumesResponses[keyof ExportVolumesResponses];
|
||||
|
||||
export type ExportRepositoriesData = {
|
||||
body?: {
|
||||
password: string;
|
||||
id?: number | string;
|
||||
includeIds?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
name?: string;
|
||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/config/export/repositories';
|
||||
};
|
||||
|
||||
export type ExportRepositoriesErrors = {
|
||||
/**
|
||||
* Invalid request
|
||||
*/
|
||||
400: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Password required for sensitive export options
|
||||
*/
|
||||
401: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Repository not found
|
||||
*/
|
||||
404: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Export failed
|
||||
*/
|
||||
500: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportRepositoriesError = ExportRepositoriesErrors[keyof ExportRepositoriesErrors];
|
||||
|
||||
export type ExportRepositoriesResponses = {
|
||||
/**
|
||||
* Repositories configuration export
|
||||
*/
|
||||
200: {
|
||||
repositories: Array<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportRepositoriesResponse = ExportRepositoriesResponses[keyof ExportRepositoriesResponses];
|
||||
|
||||
export type ExportNotificationDestinationsData = {
|
||||
body?: {
|
||||
password: string;
|
||||
id?: number | string;
|
||||
includeIds?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
name?: string;
|
||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/config/export/notification-destinations';
|
||||
};
|
||||
|
||||
export type ExportNotificationDestinationsErrors = {
|
||||
/**
|
||||
* Invalid request
|
||||
*/
|
||||
400: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Password required for sensitive export options
|
||||
*/
|
||||
401: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Notification destination not found
|
||||
*/
|
||||
404: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Export failed
|
||||
*/
|
||||
500: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportNotificationDestinationsError = ExportNotificationDestinationsErrors[keyof ExportNotificationDestinationsErrors];
|
||||
|
||||
export type ExportNotificationDestinationsResponses = {
|
||||
/**
|
||||
* Notification destinations configuration export
|
||||
*/
|
||||
200: {
|
||||
notificationDestinations: Array<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportNotificationDestinationsResponse = ExportNotificationDestinationsResponses[keyof ExportNotificationDestinationsResponses];
|
||||
|
||||
export type ExportBackupSchedulesData = {
|
||||
body?: {
|
||||
password: string;
|
||||
id?: number;
|
||||
includeIds?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/config/export/backup-schedules';
|
||||
};
|
||||
|
||||
export type ExportBackupSchedulesErrors = {
|
||||
/**
|
||||
* Invalid request
|
||||
*/
|
||||
400: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Password required for export
|
||||
*/
|
||||
401: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Backup schedule not found
|
||||
*/
|
||||
404: {
|
||||
error: string;
|
||||
};
|
||||
/**
|
||||
* Export failed
|
||||
*/
|
||||
500: {
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportBackupSchedulesError = ExportBackupSchedulesErrors[keyof ExportBackupSchedulesErrors];
|
||||
|
||||
export type ExportBackupSchedulesResponses = {
|
||||
/**
|
||||
* Backup schedules configuration export
|
||||
*/
|
||||
200: {
|
||||
backupSchedules: Array<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportBackupSchedulesResponse = ExportBackupSchedulesResponses[keyof ExportBackupSchedulesResponses];
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
exportBackupSchedulesMutation,
|
||||
exportFullConfigMutation,
|
||||
exportNotificationDestinationsMutation,
|
||||
exportRepositoriesMutation,
|
||||
exportVolumesMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import {
|
||||
|
|
@ -30,7 +24,9 @@ import {
|
|||
SelectValue,
|
||||
} from "~/client/components/ui/select";
|
||||
|
||||
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
||||
type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
||||
|
||||
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
|
||||
|
||||
function downloadAsJson(data: unknown, filename: string): void {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
|
|
@ -44,60 +40,14 @@ function downloadAsJson(data: unknown, filename: string): void {
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full";
|
||||
|
||||
type ExportConfig = {
|
||||
label: string;
|
||||
labelPlural: string;
|
||||
getFilename: (id?: string | number, name?: string) => string;
|
||||
};
|
||||
|
||||
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
|
||||
volumes: {
|
||||
label: "Volume",
|
||||
labelPlural: "Volumes",
|
||||
getFilename: (id, name) => {
|
||||
const identifier = id ?? name;
|
||||
return identifier ? `volume-${identifier}-config` : "volumes-config";
|
||||
},
|
||||
},
|
||||
repositories: {
|
||||
label: "Repository",
|
||||
labelPlural: "Repositories",
|
||||
getFilename: (id, name) => {
|
||||
const identifier = id ?? name;
|
||||
return identifier ? `repository-${identifier}-config` : "repositories-config";
|
||||
},
|
||||
},
|
||||
"notification-destinations": {
|
||||
label: "Notification Destination",
|
||||
labelPlural: "Notification Destinations",
|
||||
getFilename: (id, name) => {
|
||||
const identifier = id ?? name;
|
||||
return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config";
|
||||
},
|
||||
},
|
||||
"backup-schedules": {
|
||||
label: "Backup Schedule",
|
||||
labelPlural: "Backup Schedules",
|
||||
getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"),
|
||||
},
|
||||
full: {
|
||||
label: "Full Config",
|
||||
labelPlural: "Full Config",
|
||||
getFilename: () => "zerobyte-full-config",
|
||||
},
|
||||
};
|
||||
|
||||
type BaseExportDialogProps = {
|
||||
entityType: ExportEntityType;
|
||||
name?: string;
|
||||
id?: string | number;
|
||||
/** Optional custom filename (without extension). Defaults to `zerobyte-full-config`. */
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
type ExportDialogWithTrigger = BaseExportDialogProps & {
|
||||
/** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */
|
||||
trigger: React.ReactNode;
|
||||
trigger: ReactNode;
|
||||
variant?: never;
|
||||
size?: never;
|
||||
triggerLabel?: never;
|
||||
|
|
@ -115,9 +65,7 @@ type ExportDialogWithDefaultTrigger = BaseExportDialogProps & {
|
|||
type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger;
|
||||
|
||||
export function ExportDialog({
|
||||
entityType,
|
||||
name,
|
||||
id,
|
||||
filename = DEFAULT_EXPORT_FILENAME,
|
||||
trigger,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
|
|
@ -125,85 +73,35 @@ export function ExportDialog({
|
|||
showIcon = true,
|
||||
}: ExportDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [includeIds, setIncludeIds] = useState(true);
|
||||
const [includeTimestamps, setIncludeTimestamps] = useState(true);
|
||||
const [includeRuntimeState, setIncludeRuntimeState] = useState(false);
|
||||
const [includeMetadata, setIncludeMetadata] = useState(false);
|
||||
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
|
||||
const [includePasswordHash, setIncludePasswordHash] = useState(false);
|
||||
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const config = exportConfigs[entityType];
|
||||
const isSingleItem = !!(name || id);
|
||||
const isFullExport = entityType === "full";
|
||||
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
|
||||
const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes";
|
||||
const entityLabel = isSingleItem ? config.label : config.labelPlural;
|
||||
const filename = config.getFilename(id, name);
|
||||
|
||||
const handleExportSuccess = (data: unknown) => {
|
||||
downloadAsJson(data, filename);
|
||||
toast.success(`${entityLabel} exported successfully`);
|
||||
toast.success("Configuration exported successfully");
|
||||
setOpen(false);
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
const handleExportError = (error: unknown) => {
|
||||
const message = error && typeof error === "object" && "error" in error
|
||||
? (error as { error: string }).error
|
||||
: "Unknown error";
|
||||
const message =
|
||||
error && typeof error === "object" && "error" in error
|
||||
? (error as { error: string }).error
|
||||
: "Unknown error";
|
||||
toast.error("Export failed", {
|
||||
description: message,
|
||||
});
|
||||
};
|
||||
|
||||
const fullExport = useMutation({
|
||||
const exportMutation = useMutation({
|
||||
...exportFullConfigMutation(),
|
||||
onSuccess: handleExportSuccess,
|
||||
onError: handleExportError,
|
||||
});
|
||||
|
||||
const volumesExport = useMutation({
|
||||
...exportVolumesMutation(),
|
||||
onSuccess: handleExportSuccess,
|
||||
onError: handleExportError,
|
||||
});
|
||||
|
||||
const repositoriesExport = useMutation({
|
||||
...exportRepositoriesMutation(),
|
||||
onSuccess: handleExportSuccess,
|
||||
onError: handleExportError,
|
||||
});
|
||||
|
||||
const notificationsExport = useMutation({
|
||||
...exportNotificationDestinationsMutation(),
|
||||
onSuccess: handleExportSuccess,
|
||||
onError: handleExportError,
|
||||
});
|
||||
|
||||
const backupSchedulesExport = useMutation({
|
||||
...exportBackupSchedulesMutation(),
|
||||
onSuccess: handleExportSuccess,
|
||||
onError: handleExportError,
|
||||
});
|
||||
|
||||
const getMutation = () => {
|
||||
switch (entityType) {
|
||||
case "full":
|
||||
return fullExport;
|
||||
case "volumes":
|
||||
return volumesExport;
|
||||
case "repositories":
|
||||
return repositoriesExport;
|
||||
case "notification-destinations":
|
||||
return notificationsExport;
|
||||
case "backup-schedules":
|
||||
return backupSchedulesExport;
|
||||
}
|
||||
};
|
||||
|
||||
const exportMutation = getMutation();
|
||||
|
||||
const handleExport = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) {
|
||||
|
|
@ -211,60 +109,15 @@ export function ExportDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
const baseBody = {
|
||||
password,
|
||||
includeIds,
|
||||
includeTimestamps,
|
||||
includeRuntimeState,
|
||||
secretsMode: hasSecrets ? secretsMode : undefined,
|
||||
};
|
||||
|
||||
switch (entityType) {
|
||||
case "full":
|
||||
fullExport.mutate({
|
||||
body: {
|
||||
...baseBody,
|
||||
includeRecoveryKey,
|
||||
includePasswordHash,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "volumes":
|
||||
volumesExport.mutate({
|
||||
body: {
|
||||
...baseBody,
|
||||
id: id as number | string | undefined,
|
||||
name,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "repositories":
|
||||
repositoriesExport.mutate({
|
||||
body: {
|
||||
...baseBody,
|
||||
id: id as number | string | undefined,
|
||||
name,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "notification-destinations":
|
||||
notificationsExport.mutate({
|
||||
body: {
|
||||
...baseBody,
|
||||
id: id as number | string | undefined,
|
||||
name,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "backup-schedules":
|
||||
backupSchedulesExport.mutate({
|
||||
body: {
|
||||
...baseBody,
|
||||
id: typeof id === "number" ? id : undefined,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
exportMutation.mutate({
|
||||
body: {
|
||||
password,
|
||||
includeMetadata,
|
||||
secretsMode,
|
||||
includeRecoveryKey,
|
||||
includePasswordHash,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDialogChange = (isOpen: boolean) => {
|
||||
|
|
@ -276,19 +129,19 @@ export function ExportDialog({
|
|||
|
||||
const defaultTrigger =
|
||||
variant === "card" ? (
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full border-0 bg-transparent p-0 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Download className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
|
||||
{triggerLabel ?? "Export Config"}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<Button variant={variant} size={size}>
|
||||
{showIcon && <Download className="h-4 w-4 mr-2" />}
|
||||
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
|
||||
{triggerLabel ?? "Export Config"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
|
|
@ -298,118 +151,83 @@ export function ExportDialog({
|
|||
<DialogContent>
|
||||
<form onSubmit={handleExport}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export {entityLabel}</DialogTitle>
|
||||
<DialogTitle>Export Full Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isFullExport
|
||||
? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications."
|
||||
: isSingleItem
|
||||
? `Export the configuration for this ${config.label.toLowerCase()}.`
|
||||
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
|
||||
Export the complete Zerobyte configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includeIds"
|
||||
checked={includeIds}
|
||||
onCheckedChange={(checked) => setIncludeIds(checked === true)}
|
||||
id="includeMetadata"
|
||||
checked={includeMetadata}
|
||||
onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeIds" className="cursor-pointer">
|
||||
Include database IDs
|
||||
<Label htmlFor="includeMetadata" className="cursor-pointer">
|
||||
Include metadata
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.
|
||||
Include database IDs, timestamps, and runtime state (status, health checks, last backup info).
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<Label className="cursor-pointer">Secrets handling</Label>
|
||||
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exclude">Exclude</SelectItem>
|
||||
<SelectItem value="encrypted">Keep encrypted</SelectItem>
|
||||
<SelectItem value="cleartext">Decrypt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{secretsMode === "exclude" &&
|
||||
"Sensitive fields (passwords, API keys, webhooks) will be removed from the export."}
|
||||
{secretsMode === "encrypted" &&
|
||||
"Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."}
|
||||
{secretsMode === "cleartext" && (
|
||||
<span className="text-yellow-600">
|
||||
⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure!
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-2 border-t">
|
||||
<Checkbox
|
||||
id="includeRecoveryKey"
|
||||
checked={includeRecoveryKey}
|
||||
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
|
||||
Include recovery key
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
<span className="text-yellow-600 font-medium">⚠️ Security sensitive:</span> The recovery
|
||||
key is the master encryption key for all repositories. Keep this export secure and never
|
||||
share it.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includeTimestamps"
|
||||
checked={includeTimestamps}
|
||||
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
|
||||
id="includePasswordHash"
|
||||
checked={includePasswordHash}
|
||||
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeTimestamps" className="cursor-pointer">
|
||||
Include timestamps
|
||||
<Label htmlFor="includePasswordHash" className="cursor-pointer">
|
||||
Include password hash
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.
|
||||
Include the hashed admin password for seamless migration. The password is already securely
|
||||
hashed (argon2).
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includeRuntimeState"
|
||||
checked={includeRuntimeState}
|
||||
onCheckedChange={(checked) => setIncludeRuntimeState(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeRuntimeState" className="cursor-pointer">
|
||||
Include runtime state
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Include current status, health checks, and last backup information. Usually not needed for migration.
|
||||
</p>
|
||||
|
||||
{hasSecrets && (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<Label className="cursor-pointer">Secrets handling</Label>
|
||||
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exclude">Exclude</SelectItem>
|
||||
<SelectItem value="encrypted">Keep encrypted</SelectItem>
|
||||
<SelectItem value="cleartext">Decrypt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."}
|
||||
{secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."}
|
||||
{secretsMode === "cleartext" && (
|
||||
<span className="text-yellow-600">
|
||||
⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure!
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFullExport && (
|
||||
<>
|
||||
<div className="flex items-center space-x-3 pt-2 border-t">
|
||||
<Checkbox
|
||||
id="includeRecoveryKey"
|
||||
checked={includeRecoveryKey}
|
||||
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
|
||||
Include recovery key
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
<span className="text-yellow-600 font-medium">⚠️ Security sensitive:</span> The recovery key is the master encryption key for all repositories. Keep this export secure and never share it.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includePasswordHash"
|
||||
checked={includePasswordHash}
|
||||
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includePasswordHash" className="cursor-pointer">
|
||||
Include password hash
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Include the hashed admin password for seamless migration. The password is already securely hashed (argon2).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<Label htmlFor="export-password">Your Password</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
|
|
@ -126,7 +125,6 @@ export const ScheduleSummary = (props: Props) => {
|
|||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
</Button>
|
||||
<ExportDialog entityType="backup-schedules" id={schedule.id} size="sm" triggerLabel="Export config" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { Link } from "react-router";
|
||||
import { BackupStatusDot } from "../components/backup-status-dot";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
|
|
@ -120,11 +119,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2 h-full w-full">
|
||||
<ExportDialog entityType="backup-schedules" variant="card" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ import { getNotificationDestination } from "~/client/api-client/sdk.gen";
|
|||
import type { Route } from "./+types/notification-details";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Bell, TestTube2, Trash2 } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { Bell, TestTube2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||
|
||||
|
|
@ -143,7 +142,6 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
|||
<TestTube2 className="h-4 w-4 mr-2" />
|
||||
Test
|
||||
</Button>
|
||||
<ExportDialog entityType="notification-destinations" id={data.id} name={data.name} triggerLabel="Export config" />
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
variant="destructive"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Bell, Plus, RotateCcw } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
|
|
@ -123,13 +122,10 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<ExportDialog entityType="notification-destinations" />
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database, Plus, RotateCcw } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listRepositories } from "~/client/api-client/sdk.gen";
|
||||
|
|
@ -120,13 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<ExportDialog entityType="repositories" />
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ import { cn } from "~/client/lib/utils";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import { RepositoryInfoTabContent } from "../tabs/info";
|
||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
|
|
@ -158,7 +157,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||
"Run Doctor"
|
||||
)}
|
||||
</Button>
|
||||
<ExportDialog entityType="repositories" id={data.id} name={data.name} triggerLabel="Export config" />
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -144,9 +144,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
<CardDescription className="mt-1.5">Your account details</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<ExportDialog entityType="full" triggerLabel="Export All Config (JSON)" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
|
||||
|
|
@ -266,6 +263,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="size-5" />
|
||||
Export Configuration
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Export your Zerobyte configuration for backup or migration</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Export all your volumes, repositories, backup schedules, and notification settings to a JSON file.
|
||||
This can be used to restore your configuration on a new instance or as a backup of your settings.
|
||||
</p>
|
||||
<ExportDialog triggerLabel="Export Configuration" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/
|
|||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { getVolume } from "~/client/api-client";
|
||||
import type { VolumeStatus } from "~/client/lib/types";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
getVolumeOptions,
|
||||
|
|
@ -162,7 +160,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
>
|
||||
Unmount
|
||||
</Button>
|
||||
<ExportDialog entityType="volumes" id={volume.id} name={volume.name} triggerLabel="Export config" />
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HardDrive, Plus, RotateCcw } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
|
|
@ -130,13 +129,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<ExportDialog entityType="volumes" />
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import { validator } from "hono-openapi";
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { deleteCookie, getCookie } from "hono/cookie";
|
||||
import {
|
||||
backupSchedulesTable,
|
||||
backupScheduleNotificationsTable,
|
||||
usersTable,
|
||||
} from "../../db/schema";
|
||||
import { backupSchedulesTable, backupScheduleNotificationsTable, usersTable } from "../../db/schema";
|
||||
import { db } from "../../db/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||
|
|
@ -19,17 +14,9 @@ import { notificationsService } from "../notifications/notifications.service";
|
|||
import { backupsService } from "../backups/backups.service";
|
||||
import {
|
||||
fullExportBodySchema,
|
||||
entityExportBodySchema,
|
||||
backupScheduleExportBodySchema,
|
||||
fullExportDto,
|
||||
volumesExportDto,
|
||||
repositoriesExportDto,
|
||||
notificationsExportDto,
|
||||
backupSchedulesExportDto,
|
||||
type SecretsMode,
|
||||
type FullExportBody,
|
||||
type EntityExportBody,
|
||||
type BackupScheduleExportBody,
|
||||
} from "./config-export.dto";
|
||||
|
||||
const COOKIE_NAME = "session_id";
|
||||
|
|
@ -41,56 +28,39 @@ const COOKIE_OPTIONS = {
|
|||
};
|
||||
|
||||
type ExportParams = {
|
||||
includeIds: boolean;
|
||||
includeTimestamps: boolean;
|
||||
includeMetadata: boolean;
|
||||
secretsMode: SecretsMode;
|
||||
excludeKeys: string[];
|
||||
};
|
||||
|
||||
function omitKeys<T extends Record<string, unknown>>(obj: T, keys: string[]): Partial<T> {
|
||||
// Keys to exclude when metadata is not included
|
||||
const METADATA_KEYS = {
|
||||
ids: ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"],
|
||||
timestamps: ["createdAt", "updatedAt", "lastBackupAt", "nextBackupAt", "lastHealthCheck", "lastChecked"],
|
||||
runtimeState: ["status", "lastError", "lastBackupStatus", "lastBackupError", "hasDownloadedResticPassword"],
|
||||
};
|
||||
|
||||
const ALL_METADATA_KEYS = [...METADATA_KEYS.ids, ...METADATA_KEYS.timestamps, ...METADATA_KEYS.runtimeState];
|
||||
|
||||
/** Filter out metadata keys from an object when includeMetadata is false */
|
||||
function filterMetadataOut<T extends Record<string, unknown>>(obj: T, includeMetadata: boolean): Partial<T> {
|
||||
if (includeMetadata) {
|
||||
return obj;
|
||||
}
|
||||
const result = { ...obj };
|
||||
for (const key of keys) {
|
||||
for (const key of ALL_METADATA_KEYS) {
|
||||
delete result[key as keyof T];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean, includeRuntimeState: boolean): string[] {
|
||||
const idKeys = ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"];
|
||||
const timestampKeys = ["createdAt", "updatedAt"];
|
||||
// Runtime state fields (status, health checks, last backup info, etc.)
|
||||
const runtimeStateKeys = [
|
||||
// Volume state
|
||||
"status", "lastError", "lastHealthCheck",
|
||||
// Repository state
|
||||
"lastChecked",
|
||||
// Backup schedule state
|
||||
"lastBackupAt", "lastBackupStatus", "lastBackupError", "nextBackupAt",
|
||||
];
|
||||
// Redundant fields that are already present inside the config object
|
||||
// (e.g., type is duplicated as config.backend or config.type)
|
||||
const redundantKeys = ["type"];
|
||||
return [
|
||||
...redundantKeys,
|
||||
...(includeRuntimeState ? [] : runtimeStateKeys),
|
||||
...(includeIds ? [] : idKeys),
|
||||
...(includeTimestamps ? [] : timestampKeys),
|
||||
];
|
||||
}
|
||||
|
||||
/** Parse export params from request body */
|
||||
function parseExportParamsFromBody(body: {
|
||||
includeIds?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
includeRuntimeState?: boolean;
|
||||
includeMetadata?: boolean;
|
||||
secretsMode?: SecretsMode;
|
||||
}): ExportParams {
|
||||
const includeIds = body.includeIds !== false;
|
||||
const includeTimestamps = body.includeTimestamps !== false;
|
||||
const includeRuntimeState = body.includeRuntimeState === true;
|
||||
const includeMetadata = body.includeMetadata === true;
|
||||
const secretsMode: SecretsMode = body.secretsMode ?? "exclude";
|
||||
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState);
|
||||
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
|
||||
return { includeMetadata, secretsMode };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,7 +137,7 @@ async function exportEntity(
|
|||
entity: Record<string, unknown>,
|
||||
params: ExportParams
|
||||
): Promise<Record<string, unknown>> {
|
||||
const cleaned = omitKeys(entity, params.excludeKeys);
|
||||
const cleaned = filterMetadataOut(entity, params.includeMetadata);
|
||||
return processSecrets(cleaned, params.secretsMode);
|
||||
}
|
||||
|
||||
|
|
@ -181,8 +151,8 @@ async function exportEntities<T extends Record<string, unknown>>(
|
|||
|
||||
/** Transform backup schedules with resolved names and notifications */
|
||||
function transformBackupSchedules(
|
||||
schedules: typeof backupSchedulesTable.$inferSelect[],
|
||||
scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[],
|
||||
schedules: (typeof backupSchedulesTable.$inferSelect)[],
|
||||
scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[],
|
||||
volumeMap: Map<number, string>,
|
||||
repoMap: Map<string, string>,
|
||||
notificationMap: Map<number, string>,
|
||||
|
|
@ -192,15 +162,12 @@ function transformBackupSchedules(
|
|||
const assignments = scheduleNotifications
|
||||
.filter((sn) => sn.scheduleId === schedule.id)
|
||||
.map((sn) => ({
|
||||
...(params.includeIds ? { destinationId: sn.destinationId } : {}),
|
||||
...filterMetadataOut(sn as unknown as Record<string, unknown>, params.includeMetadata),
|
||||
name: notificationMap.get(sn.destinationId) ?? null,
|
||||
notifyOnStart: sn.notifyOnStart,
|
||||
notifyOnSuccess: sn.notifyOnSuccess,
|
||||
notifyOnFailure: sn.notifyOnFailure,
|
||||
}));
|
||||
|
||||
return {
|
||||
...omitKeys(schedule as Record<string, unknown>, params.excludeKeys),
|
||||
...filterMetadataOut(schedule as Record<string, unknown>, params.includeMetadata),
|
||||
volume: volumeMap.get(schedule.volumeId) ?? null,
|
||||
repository: repoMap.get(schedule.repositoryId) ?? null,
|
||||
notifications: assignments,
|
||||
|
|
@ -208,8 +175,11 @@ function transformBackupSchedules(
|
|||
});
|
||||
}
|
||||
|
||||
export const configExportController = new Hono()
|
||||
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => {
|
||||
export const configExportController = new Hono().post(
|
||||
"/export",
|
||||
fullExportDto,
|
||||
validator("json", fullExportBodySchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const body = c.req.valid("json") as FullExportBody;
|
||||
|
||||
|
|
@ -224,21 +194,27 @@ export const configExportController = new Hono()
|
|||
const includePasswordHash = body.includePasswordHash === true;
|
||||
|
||||
// Use services to fetch data
|
||||
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([
|
||||
volumeService.listVolumes(),
|
||||
repositoriesService.listRepositories(),
|
||||
backupsService.listSchedules(),
|
||||
notificationsService.listDestinations(),
|
||||
db.select().from(backupScheduleNotificationsTable),
|
||||
db.select().from(usersTable).limit(1),
|
||||
]);
|
||||
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, users] =
|
||||
await Promise.all([
|
||||
volumeService.listVolumes(),
|
||||
repositoriesService.listRepositories(),
|
||||
backupsService.listSchedules(),
|
||||
notificationsService.listDestinations(),
|
||||
db.select().from(backupScheduleNotificationsTable),
|
||||
db.select().from(usersTable),
|
||||
]);
|
||||
|
||||
const volumeMap = new Map<number, string>(volumes.map((v) => [v.id, v.name]));
|
||||
const repoMap = new Map<string, string>(repositories.map((r) => [r.id, r.name]));
|
||||
const notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
|
||||
|
||||
const backupSchedules = transformBackupSchedules(
|
||||
backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
||||
backupSchedulesRaw,
|
||||
scheduleNotifications,
|
||||
volumeMap,
|
||||
repoMap,
|
||||
notificationMap,
|
||||
params
|
||||
);
|
||||
|
||||
const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([
|
||||
|
|
@ -257,194 +233,27 @@ export const configExportController = new Hono()
|
|||
}
|
||||
}
|
||||
|
||||
// Users need special handling for passwordHash (controlled by separate flag)
|
||||
const exportUsers = (await exportEntities(users, params)).map((user) => {
|
||||
if (!includePasswordHash) {
|
||||
delete user.passwordHash;
|
||||
}
|
||||
return user;
|
||||
});
|
||||
|
||||
return c.json({
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}),
|
||||
...(recoveryKey ? { recoveryKey } : {}),
|
||||
volumes: exportVolumes,
|
||||
repositories: exportRepositories,
|
||||
backupSchedules,
|
||||
notificationDestinations: exportNotifications,
|
||||
admin: admin ? {
|
||||
username: admin.username,
|
||||
...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}),
|
||||
...(recoveryKey ? { recoveryKey } : {}),
|
||||
} : null,
|
||||
users: exportUsers,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: err instanceof Error ? err.message : "Failed to export config" }, 500);
|
||||
}
|
||||
})
|
||||
.post("/export/volumes", volumesExportDto, validator("json", entityExportBodySchema), async (c) => {
|
||||
try {
|
||||
const body = c.req.valid("json") as EntityExportBody;
|
||||
|
||||
// Verify password - required for all exports
|
||||
const verification = await verifyExportPassword(c, body.password);
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 401);
|
||||
}
|
||||
|
||||
const params = parseExportParamsFromBody(body);
|
||||
|
||||
let volumes;
|
||||
// Prefer name over id since volumeService.getVolume expects name (slug)
|
||||
if (body.name) {
|
||||
try {
|
||||
const result = await volumeService.getVolume(body.name);
|
||||
volumes = [result.volume];
|
||||
} catch {
|
||||
return c.json({ error: `Volume '${body.name}' not found` }, 404);
|
||||
}
|
||||
} else if (body.id !== undefined) {
|
||||
// If only ID provided, find volume by numeric ID from list
|
||||
const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id;
|
||||
if (Number.isNaN(id)) {
|
||||
return c.json({ error: "Invalid volume ID" }, 400);
|
||||
}
|
||||
const allVolumes = await volumeService.listVolumes();
|
||||
const volume = allVolumes.find((v) => v.id === id);
|
||||
if (!volume) {
|
||||
return c.json({ error: `Volume with ID '${body.id}' not found` }, 404);
|
||||
}
|
||||
volumes = [volume];
|
||||
} else {
|
||||
volumes = await volumeService.listVolumes();
|
||||
}
|
||||
|
||||
return c.json({ volumes: await exportEntities(volumes, params) });
|
||||
} catch (err) {
|
||||
logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: `Failed to export volumes: ${err instanceof Error ? err.message : String(err)}` }, 500);
|
||||
}
|
||||
})
|
||||
.post("/export/repositories", repositoriesExportDto, validator("json", entityExportBodySchema), async (c) => {
|
||||
try {
|
||||
const body = c.req.valid("json") as EntityExportBody;
|
||||
|
||||
// Verify password - required for all exports
|
||||
const verification = await verifyExportPassword(c, body.password);
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 401);
|
||||
}
|
||||
|
||||
const params = parseExportParamsFromBody(body);
|
||||
|
||||
let repositories;
|
||||
// Prefer name over id since repositoriesService.getRepository expects name (slug)
|
||||
if (body.name) {
|
||||
try {
|
||||
const result = await repositoriesService.getRepository(body.name);
|
||||
repositories = [result.repository];
|
||||
} catch {
|
||||
return c.json({ error: `Repository '${body.name}' not found` }, 404);
|
||||
}
|
||||
} else if (body.id !== undefined) {
|
||||
// If only ID provided, find repository by ID from list
|
||||
// Repository IDs are strings (UUIDs), not numeric
|
||||
const allRepositories = await repositoriesService.listRepositories();
|
||||
const repository = allRepositories.find((r) => r.id === String(body.id));
|
||||
if (!repository) {
|
||||
return c.json({ error: `Repository with ID '${body.id}' not found` }, 404);
|
||||
}
|
||||
repositories = [repository];
|
||||
} else {
|
||||
repositories = await repositoriesService.listRepositories();
|
||||
}
|
||||
|
||||
return c.json({ repositories: await exportEntities(repositories, params) });
|
||||
} catch (err) {
|
||||
logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: `Failed to export repositories: ${err instanceof Error ? err.message : String(err)}` }, 500);
|
||||
}
|
||||
})
|
||||
.post("/export/notification-destinations", notificationsExportDto, validator("json", entityExportBodySchema), async (c) => {
|
||||
try {
|
||||
const body = c.req.valid("json") as EntityExportBody;
|
||||
|
||||
// Verify password - required for all exports
|
||||
const verification = await verifyExportPassword(c, body.password);
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 401);
|
||||
}
|
||||
|
||||
const params = parseExportParamsFromBody(body);
|
||||
|
||||
let notifications;
|
||||
if (body.id !== undefined) {
|
||||
const id = typeof body.id === "string" ? Number.parseInt(body.id, 10) : body.id;
|
||||
if (Number.isNaN(id)) {
|
||||
return c.json({ error: "Invalid notification destination ID" }, 400);
|
||||
}
|
||||
try {
|
||||
const destination = await notificationsService.getDestination(id);
|
||||
notifications = [destination];
|
||||
} catch {
|
||||
return c.json({ error: `Notification destination with ID '${body.id}' not found` }, 404);
|
||||
}
|
||||
} else if (body.name) {
|
||||
// notificationsService doesn't have getByName, so we list and filter
|
||||
const allDestinations = await notificationsService.listDestinations();
|
||||
const destination = allDestinations.find((d) => d.name === body.name);
|
||||
if (!destination) {
|
||||
return c.json({ error: `Notification destination '${body.name}' not found` }, 404);
|
||||
}
|
||||
notifications = [destination];
|
||||
} else {
|
||||
notifications = await notificationsService.listDestinations();
|
||||
}
|
||||
|
||||
return c.json({ notificationDestinations: await exportEntities(notifications, params) });
|
||||
} catch (err) {
|
||||
logger.error(`Notification destinations export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: `Failed to export notification destinations: ${err instanceof Error ? err.message : String(err)}` }, 500);
|
||||
}
|
||||
})
|
||||
.post("/export/backup-schedules", backupSchedulesExportDto, validator("json", backupScheduleExportBodySchema), async (c) => {
|
||||
try {
|
||||
const body = c.req.valid("json") as BackupScheduleExportBody;
|
||||
|
||||
// Verify password - required for all exports
|
||||
const verification = await verifyExportPassword(c, body.password);
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 401);
|
||||
}
|
||||
|
||||
const params = parseExportParamsFromBody(body);
|
||||
|
||||
// Get all related data for name resolution
|
||||
const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([
|
||||
volumeService.listVolumes(),
|
||||
repositoriesService.listRepositories(),
|
||||
notificationsService.listDestinations(),
|
||||
db.select().from(backupScheduleNotificationsTable),
|
||||
]);
|
||||
|
||||
let schedules;
|
||||
if (body.id !== undefined) {
|
||||
try {
|
||||
const schedule = await backupsService.getSchedule(body.id);
|
||||
schedules = [schedule];
|
||||
} catch {
|
||||
return c.json({ error: `Backup schedule with ID '${body.id}' not found` }, 404);
|
||||
}
|
||||
} else {
|
||||
schedules = await backupsService.listSchedules();
|
||||
}
|
||||
|
||||
const volumeMap = new Map<number, string>(volumes.map((v) => [v.id, v.name]));
|
||||
const repoMap = new Map<string, string>(repositories.map((r) => [r.id, r.name]));
|
||||
const notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
|
||||
|
||||
const backupSchedules = transformBackupSchedules(
|
||||
schedules, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
||||
);
|
||||
|
||||
return c.json({ backupSchedules });
|
||||
} catch (err) {
|
||||
logger.error(`Backup schedules export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: `Failed to export backup schedules: ${err instanceof Error ? err.message : String(err)}` }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,47 +3,20 @@ import { describeRoute, resolver } from "hono-openapi";
|
|||
|
||||
const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'");
|
||||
|
||||
const baseExportBodySchema = type({
|
||||
/** Include database IDs in export (default: true) */
|
||||
"includeIds?": "boolean",
|
||||
/** Include createdAt/updatedAt timestamps (default: true) */
|
||||
"includeTimestamps?": "boolean",
|
||||
/** Include runtime state like status, health checks (default: false) */
|
||||
"includeRuntimeState?": "boolean",
|
||||
export const fullExportBodySchema = type({
|
||||
/** Include metadata (IDs, timestamps, runtime state) in export (default: false) */
|
||||
"includeMetadata?": "boolean",
|
||||
/** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */
|
||||
"secretsMode?": secretsModeSchema,
|
||||
/** Password required for authentication */
|
||||
password: "string",
|
||||
/** Include the recovery key (requires password) */
|
||||
"includeRecoveryKey?": "boolean",
|
||||
/** Include the admin password hash */
|
||||
"includePasswordHash?": "boolean",
|
||||
});
|
||||
|
||||
export const fullExportBodySchema = baseExportBodySchema.and(
|
||||
type({
|
||||
/** Include the recovery key (requires password) */
|
||||
"includeRecoveryKey?": "boolean",
|
||||
/** Include the admin password hash */
|
||||
"includePasswordHash?": "boolean",
|
||||
})
|
||||
);
|
||||
|
||||
export const entityExportBodySchema = baseExportBodySchema.and(
|
||||
type({
|
||||
/** Filter by ID */
|
||||
"id?": "string | number",
|
||||
/** Filter by name */
|
||||
"name?": "string",
|
||||
})
|
||||
);
|
||||
|
||||
export const backupScheduleExportBodySchema = baseExportBodySchema.and(
|
||||
type({
|
||||
/** Filter by ID */
|
||||
"id?": "number",
|
||||
})
|
||||
);
|
||||
|
||||
export type FullExportBody = typeof fullExportBodySchema.infer;
|
||||
export type EntityExportBody = typeof entityExportBodySchema.infer;
|
||||
export type BackupScheduleExportBody = typeof backupScheduleExportBodySchema.infer;
|
||||
export type SecretsMode = typeof secretsModeSchema.infer;
|
||||
|
||||
const exportResponseSchema = type({
|
||||
|
|
@ -60,22 +33,6 @@ const exportResponseSchema = type({
|
|||
}).or("null"),
|
||||
});
|
||||
|
||||
const volumesExportResponseSchema = type({
|
||||
volumes: "unknown[]",
|
||||
});
|
||||
|
||||
const repositoriesExportResponseSchema = type({
|
||||
repositories: "unknown[]",
|
||||
});
|
||||
|
||||
const notificationsExportResponseSchema = type({
|
||||
notificationDestinations: "unknown[]",
|
||||
});
|
||||
|
||||
const backupSchedulesExportResponseSchema = type({
|
||||
backupSchedules: "unknown[]",
|
||||
});
|
||||
|
||||
const errorResponseSchema = type({
|
||||
error: "string",
|
||||
});
|
||||
|
|
@ -111,195 +68,3 @@ export const fullExportDto = describeRoute({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const volumesExportDto = describeRoute({
|
||||
description: "Export volumes configuration",
|
||||
operationId: "exportVolumes",
|
||||
tags: ["Config Export"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Volumes configuration export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(volumesExportResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Password required for export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Volume not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Export failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const repositoriesExportDto = describeRoute({
|
||||
description: "Export repositories configuration",
|
||||
operationId: "exportRepositories",
|
||||
tags: ["Config Export"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Repositories configuration export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(repositoriesExportResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Password required for sensitive export options",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Repository not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Export failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const notificationsExportDto = describeRoute({
|
||||
description: "Export notification destinations configuration",
|
||||
operationId: "exportNotificationDestinations",
|
||||
tags: ["Config Export"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Notification destinations configuration export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(notificationsExportResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Password required for sensitive export options",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Notification destination not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Export failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const backupSchedulesExportDto = describeRoute({
|
||||
description: "Export backup schedules configuration",
|
||||
operationId: "exportBackupSchedules",
|
||||
tags: ["Config Export"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backup schedules configuration export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(backupSchedulesExportResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Password required for export",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Backup schedule not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Export failed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(errorResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue