reduced the scope of PR to MVP - exportAll only

This commit is contained in:
Jakub Trávník 2025-12-16 11:39:35 +01:00
parent 692dd5aa21
commit bf36f9aa56
16 changed files with 189 additions and 1162 deletions

View file

@ -198,26 +198,21 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
## Exporting configuration ## Exporting configuration
Zerobyte allows you to export your configuration for backup, migration, or documentation purposes. You can export: Zerobyte allows you to export your configuration for backup, migration, or documentation purposes.
- **Full configuration** - All volumes, repositories, backup schedules, and notification destinations
- **Individual entities** - Export specific volumes, repositories, notifications, or backup schedules
To export, click the "Export" button on any list page or detail page. A dialog will appear with options to: 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 metadata** - Include IDs, timestamps, and runtime state of entities
- **Include timestamps** - Include createdAt/updatedAt fields in the export - **Secrets handling**:
- **Include runtime state** - Include current status, health checks, and last backup information (usually not needed for migration)
- **Secrets handling** (for repositories and notifications):
- **Exclude** - Remove sensitive fields like passwords and API keys - **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) - **Keep encrypted** - Export secrets in encrypted form (requires the same recovery key to decrypt on import)
- **Decrypt** - Export secrets as plaintext (use with caution) - **Decrypt** - Export secrets as plaintext (use with caution)
- **Include recovery key** (full export only) - Include the master encryption key for all repositories - **Include recovery key** - Include the master encryption key for all repositories
- **Include password hash** (full export only) - Include the hashed admin password for seamless migration - **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 ## Propagating mounts to host

View file

@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, 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 { 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, 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 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 * Register a new user
@ -910,71 +910,3 @@ export const exportFullConfigMutation = (options?: Partial<Options<ExportFullCon
}; };
return mutationOptions; 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;
};

View file

@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, 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> & { 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
}
});
};

View file

@ -2619,11 +2619,9 @@ export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[key
export type ExportFullConfigData = { export type ExportFullConfigData = {
body?: { body?: {
password: string; password: string;
includeIds?: boolean; includeMetadata?: boolean;
includePasswordHash?: boolean; includePasswordHash?: boolean;
includeRecoveryKey?: boolean; includeRecoveryKey?: boolean;
includeRuntimeState?: boolean;
includeTimestamps?: boolean;
secretsMode?: 'cleartext' | 'encrypted' | 'exclude'; secretsMode?: 'cleartext' | 'encrypted' | 'exclude';
}; };
path?: never; path?: never;
@ -2668,222 +2666,3 @@ export type ExportFullConfigResponses = {
}; };
export type ExportFullConfigResponse = ExportFullConfigResponses[keyof 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];

View file

@ -1,14 +1,8 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import { useState } from "react"; import { type ReactNode, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { exportFullConfigMutation } from "~/client/api-client/@tanstack/react-query.gen";
exportBackupSchedulesMutation,
exportFullConfigMutation,
exportNotificationDestinationsMutation,
exportRepositoriesMutation,
exportVolumesMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { import {
@ -30,7 +24,9 @@ import {
SelectValue, SelectValue,
} from "~/client/components/ui/select"; } 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 { function downloadAsJson(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); 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); 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 = { type BaseExportDialogProps = {
entityType: ExportEntityType; /** Optional custom filename (without extension). Defaults to `zerobyte-full-config`. */
name?: string; filename?: string;
id?: string | number;
}; };
type ExportDialogWithTrigger = BaseExportDialogProps & { type ExportDialogWithTrigger = BaseExportDialogProps & {
/** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */ /** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */
trigger: React.ReactNode; trigger: ReactNode;
variant?: never; variant?: never;
size?: never; size?: never;
triggerLabel?: never; triggerLabel?: never;
@ -115,9 +65,7 @@ type ExportDialogWithDefaultTrigger = BaseExportDialogProps & {
type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger; type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger;
export function ExportDialog({ export function ExportDialog({
entityType, filename = DEFAULT_EXPORT_FILENAME,
name,
id,
trigger, trigger,
variant = "outline", variant = "outline",
size = "default", size = "default",
@ -125,85 +73,35 @@ export function ExportDialog({
showIcon = true, showIcon = true,
}: ExportDialogProps) { }: ExportDialogProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [includeIds, setIncludeIds] = useState(true); const [includeMetadata, setIncludeMetadata] = useState(false);
const [includeTimestamps, setIncludeTimestamps] = useState(true);
const [includeRuntimeState, setIncludeRuntimeState] = useState(false);
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false); const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
const [includePasswordHash, setIncludePasswordHash] = useState(false); const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude"); const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [password, setPassword] = useState(""); 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) => { const handleExportSuccess = (data: unknown) => {
downloadAsJson(data, filename); downloadAsJson(data, filename);
toast.success(`${entityLabel} exported successfully`); toast.success("Configuration exported successfully");
setOpen(false); setOpen(false);
setPassword(""); setPassword("");
}; };
const handleExportError = (error: unknown) => { const handleExportError = (error: unknown) => {
const message = error && typeof error === "object" && "error" in error const message =
? (error as { error: string }).error error && typeof error === "object" && "error" in error
: "Unknown error"; ? (error as { error: string }).error
: "Unknown error";
toast.error("Export failed", { toast.error("Export failed", {
description: message, description: message,
}); });
}; };
const fullExport = useMutation({ const exportMutation = useMutation({
...exportFullConfigMutation(), ...exportFullConfigMutation(),
onSuccess: handleExportSuccess, onSuccess: handleExportSuccess,
onError: handleExportError, 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) => { const handleExport = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!password) { if (!password) {
@ -211,60 +109,15 @@ export function ExportDialog({
return; return;
} }
const baseBody = { exportMutation.mutate({
password, body: {
includeIds, password,
includeTimestamps, includeMetadata,
includeRuntimeState, secretsMode,
secretsMode: hasSecrets ? secretsMode : undefined, includeRecoveryKey,
}; includePasswordHash,
},
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;
}
}; };
const handleDialogChange = (isOpen: boolean) => { const handleDialogChange = (isOpen: boolean) => {
@ -282,13 +135,13 @@ export function ExportDialog({
> >
<Download className="h-8 w-8 text-muted-foreground" /> <Download className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`} {triggerLabel ?? "Export Config"}
</span> </span>
</button> </button>
) : ( ) : (
<Button variant={variant} size={size}> <Button variant={variant} size={size}>
{showIcon && <Download className="h-4 w-4 mr-2" />} {showIcon && <Download className="h-4 w-4 mr-2" />}
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`} {triggerLabel ?? "Export Config"}
</Button> </Button>
); );
@ -298,118 +151,83 @@ export function ExportDialog({
<DialogContent> <DialogContent>
<form onSubmit={handleExport}> <form onSubmit={handleExport}>
<DialogHeader> <DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle> <DialogTitle>Export Full Configuration</DialogTitle>
<DialogDescription> <DialogDescription>
{isFullExport Export the complete Zerobyte configuration.
? "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.`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Checkbox <Checkbox
id="includeIds" id="includeMetadata"
checked={includeIds} checked={includeMetadata}
onCheckedChange={(checked) => setIncludeIds(checked === true)} onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
/> />
<Label htmlFor="includeIds" className="cursor-pointer"> <Label htmlFor="includeMetadata" className="cursor-pointer">
Include database IDs Include metadata
</Label> </Label>
</div> </div>
<p className="text-xs text-muted-foreground ml-7"> <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> </p>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Checkbox <Checkbox
id="includeTimestamps" id="includePasswordHash"
checked={includeTimestamps} checked={includePasswordHash}
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)} onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
/> />
<Label htmlFor="includeTimestamps" className="cursor-pointer"> <Label htmlFor="includePasswordHash" className="cursor-pointer">
Include timestamps Include password hash
</Label> </Label>
</div> </div>
<p className="text-xs text-muted-foreground ml-7"> <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> </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"> <div className="space-y-2 pt-2 border-t">
<Label htmlFor="export-password">Your Password</Label> <Label htmlFor="export-password">Your Password</Label>
<Input <Input

View file

@ -1,5 +1,4 @@
import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react"; import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff"; import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
@ -126,7 +125,6 @@ export const ScheduleSummary = (props: Props) => {
<Pencil className="h-4 w-4 mr-2" /> <Pencil className="h-4 w-4 mr-2" />
<span className="sm:inline">Edit schedule</span> <span className="sm:inline">Edit schedule</span>
</Button> </Button>
<ExportDialog entityType="backup-schedules" id={schedule.id} size="sm" triggerLabel="Export config" />
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react"; import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { Link } from "react-router"; import { Link } from "react-router";
import { BackupStatusDot } from "../components/backup-status-dot"; import { BackupStatusDot } from "../components/backup-status-dot";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
@ -120,11 +119,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
</CardContent> </CardContent>
</Card> </Card>
</Link> </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>
</div> </div>
); );

View file

@ -24,8 +24,7 @@ import { getNotificationDestination } from "~/client/api-client/sdk.gen";
import type { Route } from "./+types/notification-details"; import type { Route } from "./+types/notification-details";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, TestTube2, Trash2 } from "lucide-react"; import { Bell, TestTube2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { Alert, AlertDescription } from "~/client/components/ui/alert"; import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form"; 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" /> <TestTube2 className="h-4 w-4 mr-2" />
Test Test
</Button> </Button>
<ExportDialog entityType="notification-destinations" id={data.id} name={data.name} triggerLabel="Export config" />
<Button <Button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
variant="destructive" variant="destructive"

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Bell, Plus, RotateCcw } from "lucide-react"; import { Bell, Plus, RotateCcw } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
@ -123,13 +122,10 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
</Button> </Button>
)} )}
</span> </span>
<div className="flex gap-2"> <Button onClick={() => navigate("/notifications/create")}>
<ExportDialog entityType="notification-destinations" /> <Plus size={16} className="mr-2" />
<Button onClick={() => navigate("/notifications/create")}> Create Destination
<Plus size={16} className="mr-2" /> </Button>
Create Destination
</Button>
</div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Database, Plus, RotateCcw } from "lucide-react"; import { Database, Plus, RotateCcw } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { listRepositories } from "~/client/api-client/sdk.gen"; import { listRepositories } from "~/client/api-client/sdk.gen";
@ -120,13 +119,10 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</Button> </Button>
)} )}
</span> </span>
<div className="flex gap-2"> <Button onClick={() => navigate("/repositories/create")}>
<ExportDialog entityType="repositories" /> <Plus size={16} className="mr-2" />
<Button onClick={() => navigate("/repositories/create")}> Create Repository
<Plus size={16} className="mr-2" /> </Button>
Create Repository
</Button>
</div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">

View file

@ -25,8 +25,7 @@ import { cn } from "~/client/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2, Trash2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
export const handle = { export const handle = {
breadcrumb: (match: Route.MetaArgs) => [ breadcrumb: (match: Route.MetaArgs) => [
@ -158,7 +157,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
"Run Doctor" "Run Doctor"
)} )}
</Button> </Button>
<ExportDialog entityType="repositories" id={data.id} name={data.name} triggerLabel="Export config" />
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
Delete Delete
</Button> </Button>

View file

@ -144,9 +144,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<CardDescription className="mt-1.5">Your account details</CardDescription> <CardDescription className="mt-1.5">Your account details</CardDescription>
</div> </div>
<CardContent className="p-6 space-y-4"> <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"> <div className="space-y-2">
<Label>Username</Label> <Label>Username</Label>
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" /> <Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
@ -266,6 +263,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardContent> </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> </Card>
); );
} }

View file

@ -25,8 +25,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client"; import { getVolume } from "~/client/api-client";
import type { VolumeStatus } from "~/client/lib/types"; import type { VolumeStatus } from "~/client/lib/types";
import { Trash2 } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { import {
deleteVolumeMutation, deleteVolumeMutation,
getVolumeOptions, getVolumeOptions,
@ -162,7 +160,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
> >
Unmount Unmount
</Button> </Button>
<ExportDialog entityType="volumes" id={volume.id} name={volume.name} triggerLabel="Export config" />
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
Delete Delete
</Button> </Button>

View file

@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { HardDrive, Plus, RotateCcw } from "lucide-react"; import { HardDrive, Plus, RotateCcw } from "lucide-react";
import { ExportDialog } from "~/client/components/export-dialog";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
@ -130,13 +129,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
</Button> </Button>
)} )}
</span> </span>
<div className="flex gap-2"> <Button onClick={() => navigate("/volumes/create")}>
<ExportDialog entityType="volumes" /> <Plus size={16} className="mr-2" />
<Button onClick={() => navigate("/volumes/create")}> Create Volume
<Plus size={16} className="mr-2" /> </Button>
Create Volume
</Button>
</div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">

View file

@ -1,13 +1,8 @@
import { validator } from "hono-openapi"; import { validator } from "hono-openapi";
import { Hono } from "hono"; import { Hono } from "hono";
import type { Context } from "hono"; import type { Context } from "hono";
import { deleteCookie, getCookie } from "hono/cookie"; import { deleteCookie, getCookie } from "hono/cookie";
import { import { backupSchedulesTable, backupScheduleNotificationsTable, usersTable } from "../../db/schema";
backupSchedulesTable,
backupScheduleNotificationsTable,
usersTable,
} from "../../db/schema";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { RESTIC_PASS_FILE } from "../../core/constants"; import { RESTIC_PASS_FILE } from "../../core/constants";
@ -19,17 +14,9 @@ import { notificationsService } from "../notifications/notifications.service";
import { backupsService } from "../backups/backups.service"; import { backupsService } from "../backups/backups.service";
import { import {
fullExportBodySchema, fullExportBodySchema,
entityExportBodySchema,
backupScheduleExportBodySchema,
fullExportDto, fullExportDto,
volumesExportDto,
repositoriesExportDto,
notificationsExportDto,
backupSchedulesExportDto,
type SecretsMode, type SecretsMode,
type FullExportBody, type FullExportBody,
type EntityExportBody,
type BackupScheduleExportBody,
} from "./config-export.dto"; } from "./config-export.dto";
const COOKIE_NAME = "session_id"; const COOKIE_NAME = "session_id";
@ -41,56 +28,39 @@ const COOKIE_OPTIONS = {
}; };
type ExportParams = { type ExportParams = {
includeIds: boolean; includeMetadata: boolean;
includeTimestamps: boolean;
secretsMode: SecretsMode; 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 }; const result = { ...obj };
for (const key of keys) { for (const key of ALL_METADATA_KEYS) {
delete result[key as keyof T]; delete result[key as keyof T];
} }
return result; 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 */ /** Parse export params from request body */
function parseExportParamsFromBody(body: { function parseExportParamsFromBody(body: {
includeIds?: boolean; includeMetadata?: boolean;
includeTimestamps?: boolean;
includeRuntimeState?: boolean;
secretsMode?: SecretsMode; secretsMode?: SecretsMode;
}): ExportParams { }): ExportParams {
const includeIds = body.includeIds !== false; const includeMetadata = body.includeMetadata === true;
const includeTimestamps = body.includeTimestamps !== false;
const includeRuntimeState = body.includeRuntimeState === true;
const secretsMode: SecretsMode = body.secretsMode ?? "exclude"; const secretsMode: SecretsMode = body.secretsMode ?? "exclude";
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps, includeRuntimeState); return { includeMetadata, secretsMode };
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
} }
/** /**
@ -167,7 +137,7 @@ async function exportEntity(
entity: Record<string, unknown>, entity: Record<string, unknown>,
params: ExportParams params: ExportParams
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const cleaned = omitKeys(entity, params.excludeKeys); const cleaned = filterMetadataOut(entity, params.includeMetadata);
return processSecrets(cleaned, params.secretsMode); 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 */ /** Transform backup schedules with resolved names and notifications */
function transformBackupSchedules( function transformBackupSchedules(
schedules: typeof backupSchedulesTable.$inferSelect[], schedules: (typeof backupSchedulesTable.$inferSelect)[],
scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[], scheduleNotifications: (typeof backupScheduleNotificationsTable.$inferSelect)[],
volumeMap: Map<number, string>, volumeMap: Map<number, string>,
repoMap: Map<string, string>, repoMap: Map<string, string>,
notificationMap: Map<number, string>, notificationMap: Map<number, string>,
@ -192,15 +162,12 @@ function transformBackupSchedules(
const assignments = scheduleNotifications const assignments = scheduleNotifications
.filter((sn) => sn.scheduleId === schedule.id) .filter((sn) => sn.scheduleId === schedule.id)
.map((sn) => ({ .map((sn) => ({
...(params.includeIds ? { destinationId: sn.destinationId } : {}), ...filterMetadataOut(sn as unknown as Record<string, unknown>, params.includeMetadata),
name: notificationMap.get(sn.destinationId) ?? null, name: notificationMap.get(sn.destinationId) ?? null,
notifyOnStart: sn.notifyOnStart,
notifyOnSuccess: sn.notifyOnSuccess,
notifyOnFailure: sn.notifyOnFailure,
})); }));
return { return {
...omitKeys(schedule as Record<string, unknown>, params.excludeKeys), ...filterMetadataOut(schedule as Record<string, unknown>, params.includeMetadata),
volume: volumeMap.get(schedule.volumeId) ?? null, volume: volumeMap.get(schedule.volumeId) ?? null,
repository: repoMap.get(schedule.repositoryId) ?? null, repository: repoMap.get(schedule.repositoryId) ?? null,
notifications: assignments, notifications: assignments,
@ -208,8 +175,11 @@ function transformBackupSchedules(
}); });
} }
export const configExportController = new Hono() export const configExportController = new Hono().post(
.post("/export", fullExportDto, validator("json", fullExportBodySchema), async (c) => { "/export",
fullExportDto,
validator("json", fullExportBodySchema),
async (c) => {
try { try {
const body = c.req.valid("json") as FullExportBody; const body = c.req.valid("json") as FullExportBody;
@ -224,21 +194,27 @@ export const configExportController = new Hono()
const includePasswordHash = body.includePasswordHash === true; const includePasswordHash = body.includePasswordHash === true;
// Use services to fetch data // Use services to fetch data
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([ const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, users] =
volumeService.listVolumes(), await Promise.all([
repositoriesService.listRepositories(), volumeService.listVolumes(),
backupsService.listSchedules(), repositoriesService.listRepositories(),
notificationsService.listDestinations(), backupsService.listSchedules(),
db.select().from(backupScheduleNotificationsTable), notificationsService.listDestinations(),
db.select().from(usersTable).limit(1), db.select().from(backupScheduleNotificationsTable),
]); db.select().from(usersTable),
]);
const volumeMap = new Map<number, string>(volumes.map((v) => [v.id, v.name])); 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 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 notificationMap = new Map<number, string>(notifications.map((n) => [n.id, n.name]));
const backupSchedules = transformBackupSchedules( const backupSchedules = transformBackupSchedules(
backupSchedulesRaw, scheduleNotifications, volumeMap, repoMap, notificationMap, params backupSchedulesRaw,
scheduleNotifications,
volumeMap,
repoMap,
notificationMap,
params
); );
const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([ 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({ return c.json({
version: 1, version: 1,
exportedAt: new Date().toISOString(), ...(params.includeMetadata ? { exportedAt: new Date().toISOString() } : {}),
...(recoveryKey ? { recoveryKey } : {}),
volumes: exportVolumes, volumes: exportVolumes,
repositories: exportRepositories, repositories: exportRepositories,
backupSchedules, backupSchedules,
notificationDestinations: exportNotifications, notificationDestinations: exportNotifications,
admin: admin ? { users: exportUsers,
username: admin.username,
...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}),
...(recoveryKey ? { recoveryKey } : {}),
} : null,
}); });
} catch (err) { } catch (err) {
logger.error(`Config export failed: ${err instanceof Error ? err.message : String(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); 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);
}
});

View file

@ -3,47 +3,20 @@ import { describeRoute, resolver } from "hono-openapi";
const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'"); const secretsModeSchema = type("'exclude' | 'encrypted' | 'cleartext'");
const baseExportBodySchema = type({ export const fullExportBodySchema = type({
/** Include database IDs in export (default: true) */ /** Include metadata (IDs, timestamps, runtime state) in export (default: false) */
"includeIds?": "boolean", "includeMetadata?": "boolean",
/** Include createdAt/updatedAt timestamps (default: true) */
"includeTimestamps?": "boolean",
/** Include runtime state like status, health checks (default: false) */
"includeRuntimeState?": "boolean",
/** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */ /** How to handle secrets: exclude, encrypted, or cleartext (default: exclude) */
"secretsMode?": secretsModeSchema, "secretsMode?": secretsModeSchema,
/** Password required for authentication */ /** Password required for authentication */
password: "string", 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 FullExportBody = typeof fullExportBodySchema.infer;
export type EntityExportBody = typeof entityExportBodySchema.infer;
export type BackupScheduleExportBody = typeof backupScheduleExportBodySchema.infer;
export type SecretsMode = typeof secretsModeSchema.infer; export type SecretsMode = typeof secretsModeSchema.infer;
const exportResponseSchema = type({ const exportResponseSchema = type({
@ -60,22 +33,6 @@ const exportResponseSchema = type({
}).or("null"), }).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({ const errorResponseSchema = type({
error: "string", 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),
},
},
},
},
});