config export feature
This commit is contained in:
parent
999850dab8
commit
2be65f6069
14 changed files with 752 additions and 15 deletions
20
README.md
20
README.md
|
|
@ -196,6 +196,26 @@ 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
|
||||
|
||||
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
|
||||
- **Secrets handling** (for repositories and notifications):
|
||||
- **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
|
||||
|
||||
Exports are downloaded as JSON files that can be used for reference or future import functionality.
|
||||
|
||||
## Propagating mounts to host
|
||||
|
||||
Zerobyte is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
||||
|
|
|
|||
330
app/client/components/export-dialog.tsx
Normal file
330
app/client/components/export-dialog.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/client/components/ui/select";
|
||||
|
||||
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
||||
|
||||
export type ExportOptions = {
|
||||
includeIds?: boolean;
|
||||
includeTimestamps?: boolean;
|
||||
includeRecoveryKey?: boolean;
|
||||
includePasswordHash?: boolean;
|
||||
secretsMode?: SecretsMode;
|
||||
name?: string;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
function downloadAsJson(data: unknown, filename: string): void {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${filename}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function exportFromApi(endpoint: string, filename: string, options: ExportOptions = {}): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.includeIds === false) params.set("includeIds", "false");
|
||||
if (options.includeTimestamps === false) params.set("includeTimestamps", "false");
|
||||
if (options.includeRecoveryKey === true) params.set("includeRecoveryKey", "true");
|
||||
if (options.includePasswordHash === true) params.set("includePasswordHash", "true");
|
||||
if (options.secretsMode && options.secretsMode !== "exclude") params.set("secretsMode", options.secretsMode);
|
||||
if (options.id !== undefined) params.set("id", String(options.id));
|
||||
if (options.name) params.set("name", options.name);
|
||||
|
||||
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Export failed: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
downloadAsJson(data, filename);
|
||||
}
|
||||
|
||||
export type ExportEntityType = "volumes" | "repositories" | "notifications" | "backups" | "full";
|
||||
|
||||
type ExportConfig = {
|
||||
endpoint: string;
|
||||
label: string;
|
||||
labelPlural: string;
|
||||
getFilename: (options: ExportOptions) => string;
|
||||
};
|
||||
|
||||
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
|
||||
volumes: {
|
||||
endpoint: "/api/v1/config/export/volumes",
|
||||
label: "Volume",
|
||||
labelPlural: "Volumes",
|
||||
getFilename: (opts) => {
|
||||
const identifier = opts.id ?? opts.name;
|
||||
return identifier ? `volume-${identifier}-config` : "volumes-config";
|
||||
},
|
||||
},
|
||||
repositories: {
|
||||
endpoint: "/api/v1/config/export/repositories",
|
||||
label: "Repository",
|
||||
labelPlural: "Repositories",
|
||||
getFilename: (opts) => {
|
||||
const identifier = opts.id ?? opts.name;
|
||||
return identifier ? `repository-${identifier}-config` : "repositories-config";
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
endpoint: "/api/v1/config/export/notifications",
|
||||
label: "Notification",
|
||||
labelPlural: "Notifications",
|
||||
getFilename: (opts) => {
|
||||
const identifier = opts.id ?? opts.name;
|
||||
return identifier ? `notification-${identifier}-config` : "notifications-config";
|
||||
},
|
||||
},
|
||||
backups: {
|
||||
endpoint: "/api/v1/config/export/backups",
|
||||
label: "Backup Schedule",
|
||||
labelPlural: "Backup Schedules",
|
||||
getFilename: (opts) => (opts.id ? `backup-schedule-${opts.id}-config` : "backup-schedules-config"),
|
||||
},
|
||||
full: {
|
||||
endpoint: "/api/v1/config/export",
|
||||
label: "Full Config",
|
||||
labelPlural: "Full Config",
|
||||
getFilename: () => "zerobyte-full-config",
|
||||
},
|
||||
};
|
||||
|
||||
export async function exportConfig(
|
||||
entityType: ExportEntityType,
|
||||
options: ExportOptions = {}
|
||||
): Promise<void> {
|
||||
const config = exportConfigs[entityType];
|
||||
const filename = config.getFilename(options);
|
||||
await exportFromApi(config.endpoint, filename, options);
|
||||
}
|
||||
|
||||
type ExportDialogProps = {
|
||||
entityType: ExportEntityType;
|
||||
name?: string;
|
||||
id?: string | number;
|
||||
trigger?: React.ReactNode;
|
||||
variant?: "default" | "outline" | "ghost" | "card";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
triggerLabel?: string;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export function ExportDialog({
|
||||
entityType,
|
||||
name,
|
||||
id,
|
||||
trigger,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
triggerLabel,
|
||||
showIcon = true,
|
||||
}: ExportDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [includeIds, setIncludeIds] = useState(true);
|
||||
const [includeTimestamps, setIncludeTimestamps] = useState(true);
|
||||
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
|
||||
const [includePasswordHash, setIncludePasswordHash] = useState(false);
|
||||
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
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 !== "backups" && entityType !== "volumes";
|
||||
const entityLabel = isSingleItem ? config.label : config.labelPlural;
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportConfig(entityType, {
|
||||
includeIds,
|
||||
includeTimestamps,
|
||||
includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined,
|
||||
includePasswordHash: isFullExport ? includePasswordHash : undefined,
|
||||
secretsMode: hasSecrets ? secretsMode : undefined,
|
||||
name,
|
||||
id,
|
||||
});
|
||||
toast.success(`${entityLabel} exported successfully`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error("Export failed", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultTrigger =
|
||||
variant === "card" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 cursor-pointer">
|
||||
<Download className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant={variant} size={size}>
|
||||
{showIcon && <Download className="h-4 w-4 mr-2" />}
|
||||
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export {entityLabel}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isSingleItem
|
||||
? `Export the configuration for this ${config.label.toLowerCase()}.`
|
||||
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
|
||||
</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)}
|
||||
/>
|
||||
<Label htmlFor="includeIds" className="cursor-pointer">
|
||||
Include database IDs
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="includeTimestamps"
|
||||
checked={includeTimestamps}
|
||||
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="includeTimestamps" className="cursor-pointer">
|
||||
Include timestamps
|
||||
</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.
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportCard({ entityType, ...props }: Omit<ExportDialogProps, "variant" | "trigger">) {
|
||||
const config = exportConfigs[entityType];
|
||||
|
||||
return (
|
||||
<ExportDialog
|
||||
entityType={entityType}
|
||||
variant="card"
|
||||
triggerLabel={props.triggerLabel ?? `Export ${props.id || props.name ? "config" : "configs"}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
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";
|
||||
|
|
@ -125,6 +126,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
</Button>
|
||||
<ExportDialog entityType="backups" id={schedule.id} size="sm" triggerLabel="Export config" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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";
|
||||
|
|
@ -119,6 +120,11 @@ 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">
|
||||
<ExportDialog entityType="backups" variant="card" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ 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 } from "lucide-react";
|
||||
import { Bell, TestTube2, Trash2 } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||
|
||||
|
|
@ -142,11 +143,13 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
|||
<TestTube2 className="h-4 w-4 mr-2" />
|
||||
Test
|
||||
</Button>
|
||||
<ExportDialog entityType="notifications" name={data.name} triggerLabel="Export config" />
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
variant="destructive"
|
||||
loading={deleteDestination.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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";
|
||||
|
|
@ -122,10 +123,13 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<ExportDialog entityType="notifications" />
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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";
|
||||
|
|
@ -119,10 +120,13 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<ExportDialog entityType="repositories" />
|
||||
<Button onClick={() => navigate("/repositories/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Repository
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ 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 } from "lucide-react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
|
|
@ -157,7 +158,9 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||
"Run Doctor"
|
||||
)}
|
||||
</Button>
|
||||
<ExportDialog entityType="repositories" name={data.name} triggerLabel="Export config" />
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User } from "lucide-react";
|
||||
import { ExportDialog } from "~/client/components/export-dialog";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -143,6 +144,9 @@ 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" />
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ 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,
|
||||
|
|
@ -160,7 +162,9 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
>
|
||||
Unmount
|
||||
</Button>
|
||||
<ExportDialog entityType="volumes" name={volume.name} triggerLabel="Export config" />
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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";
|
||||
|
|
@ -129,10 +130,13 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<ExportDialog entityType="volumes" />
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { logger } from "./utils/logger";
|
|||
import { shutdown } from "./modules/lifecycle/shutdown";
|
||||
import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants";
|
||||
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
|
||||
import { configExportController } from "./modules/lifecycle/config-export.controller";
|
||||
|
||||
export const generalDescriptor = (app: Hono) =>
|
||||
openAPIRouteHandler(app, {
|
||||
|
|
@ -51,7 +52,8 @@ const app = new Hono()
|
|||
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
|
||||
.route("/api/v1/notifications", notificationsController.use(requireAuth))
|
||||
.route("/api/v1/system", systemController.use(requireAuth))
|
||||
.route("/api/v1/events", eventsController.use(requireAuth));
|
||||
.route("/api/v1/events", eventsController.use(requireAuth))
|
||||
.route("/api/v1/config", configExportController.use(requireAuth));
|
||||
|
||||
app.get("/api/v1/openapi.json", generalDescriptor(app));
|
||||
app.get("/api/v1/docs", scalarDescriptor);
|
||||
|
|
|
|||
346
app/server/modules/lifecycle/config-export.controller.ts
Normal file
346
app/server/modules/lifecycle/config-export.controller.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
backupSchedulesTable,
|
||||
notificationDestinationsTable,
|
||||
repositoriesTable,
|
||||
backupScheduleNotificationsTable,
|
||||
usersTable,
|
||||
volumesTable,
|
||||
} from "../../db/schema";
|
||||
import { db } from "../../db/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||
import { cryptoUtils } from "../../utils/crypto";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type SecretsMode = "exclude" | "encrypted" | "cleartext";
|
||||
|
||||
type ExportParams = {
|
||||
includeIds: boolean;
|
||||
includeTimestamps: boolean;
|
||||
secretsMode: SecretsMode;
|
||||
excludeKeys: string[];
|
||||
};
|
||||
|
||||
type FilterOptions = { id?: string; name?: string };
|
||||
|
||||
type FetchResult<T> = { data: T[] } | { error: string; status: 400 | 404 };
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function omitKeys<T extends Record<string, unknown>>(obj: T, keys: string[]): Partial<T> {
|
||||
const result = { ...obj };
|
||||
for (const key of keys) {
|
||||
delete result[key as keyof T];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getExcludeKeys(includeIds: boolean, includeTimestamps: boolean): string[] {
|
||||
const idKeys = ["id", "volumeId", "repositoryId", "scheduleId", "destinationId"];
|
||||
const timestampKeys = ["createdAt", "updatedAt"];
|
||||
return [
|
||||
...(includeIds ? [] : idKeys),
|
||||
...(includeTimestamps ? [] : timestampKeys),
|
||||
];
|
||||
}
|
||||
|
||||
/** Parse common export query parameters from request */
|
||||
function parseExportParams(c: Context): ExportParams {
|
||||
const includeIds = c.req.query("includeIds") !== "false";
|
||||
const includeTimestamps = c.req.query("includeTimestamps") !== "false";
|
||||
const secretsMode = (c.req.query("secretsMode") as SecretsMode) || "exclude";
|
||||
const excludeKeys = getExcludeKeys(includeIds, includeTimestamps);
|
||||
return { includeIds, includeTimestamps, secretsMode, excludeKeys };
|
||||
}
|
||||
|
||||
/** Get filter options from request query params */
|
||||
function getFilterOptions(c: Context): FilterOptions {
|
||||
return { id: c.req.query("id"), name: c.req.query("name") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process secrets in an object based on the secrets mode.
|
||||
* Automatically detects encrypted fields using cryptoUtils.isEncrypted.
|
||||
*/
|
||||
async function processSecrets(
|
||||
obj: Record<string, unknown>,
|
||||
secretsMode: SecretsMode
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (secretsMode === "encrypted") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const result = { ...obj };
|
||||
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
|
||||
if (secretsMode === "exclude") {
|
||||
delete result[key];
|
||||
} else if (secretsMode === "cleartext") {
|
||||
try {
|
||||
result[key] = await cryptoUtils.decrypt(value);
|
||||
} catch {
|
||||
delete result[key];
|
||||
}
|
||||
}
|
||||
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
result[key] = await processSecrets(value as Record<string, unknown>, secretsMode);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Clean and process an entity for export */
|
||||
async function exportEntity(
|
||||
entity: Record<string, unknown>,
|
||||
params: ExportParams
|
||||
): Promise<Record<string, unknown>> {
|
||||
const cleaned = omitKeys(entity, params.excludeKeys);
|
||||
return processSecrets(cleaned, params.secretsMode);
|
||||
}
|
||||
|
||||
/** Export multiple entities */
|
||||
async function exportEntities<T extends Record<string, unknown>>(
|
||||
entities: T[],
|
||||
params: ExportParams
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return Promise.all(entities.map((e) => exportEntity(e as Record<string, unknown>, params)));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Fetchers with Filtering
|
||||
// ============================================================================
|
||||
|
||||
async function fetchVolumes(filter: FilterOptions): Promise<FetchResult<typeof volumesTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
const id = Number.parseInt(filter.id, 10);
|
||||
if (Number.isNaN(id)) return { error: "Invalid volume ID", status: 400 };
|
||||
const result = await db.select().from(volumesTable).where(eq(volumesTable.id, id));
|
||||
if (result.length === 0) return { error: `Volume with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
if (filter.name) {
|
||||
const result = await db.select().from(volumesTable).where(eq(volumesTable.name, filter.name));
|
||||
if (result.length === 0) return { error: `Volume '${filter.name}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(volumesTable) };
|
||||
}
|
||||
|
||||
async function fetchRepositories(filter: FilterOptions): Promise<FetchResult<typeof repositoriesTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.id, filter.id));
|
||||
if (result.length === 0) return { error: `Repository with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
if (filter.name) {
|
||||
const result = await db.select().from(repositoriesTable).where(eq(repositoriesTable.name, filter.name));
|
||||
if (result.length === 0) return { error: `Repository '${filter.name}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(repositoriesTable) };
|
||||
}
|
||||
|
||||
async function fetchNotifications(filter: FilterOptions): Promise<FetchResult<typeof notificationDestinationsTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
const id = Number.parseInt(filter.id, 10);
|
||||
if (Number.isNaN(id)) return { error: "Invalid notification destination ID", status: 400 };
|
||||
const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id));
|
||||
if (result.length === 0) return { error: `Notification destination with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
if (filter.name) {
|
||||
const result = await db.select().from(notificationDestinationsTable).where(eq(notificationDestinationsTable.name, filter.name));
|
||||
if (result.length === 0) return { error: `Notification destination '${filter.name}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(notificationDestinationsTable) };
|
||||
}
|
||||
|
||||
async function fetchBackupSchedules(filter: { id?: string }): Promise<FetchResult<typeof backupSchedulesTable.$inferSelect>> {
|
||||
if (filter.id) {
|
||||
const id = Number.parseInt(filter.id, 10);
|
||||
if (Number.isNaN(id)) return { error: "Invalid backup schedule ID", status: 400 };
|
||||
const result = await db.select().from(backupSchedulesTable).where(eq(backupSchedulesTable.id, id));
|
||||
if (result.length === 0) return { error: `Backup schedule with ID '${filter.id}' not found`, status: 404 };
|
||||
return { data: result };
|
||||
}
|
||||
return { data: await db.select().from(backupSchedulesTable) };
|
||||
}
|
||||
|
||||
/** Transform backup schedules with resolved names and notifications */
|
||||
function transformBackupSchedules(
|
||||
schedules: typeof backupSchedulesTable.$inferSelect[],
|
||||
scheduleNotifications: typeof backupScheduleNotificationsTable.$inferSelect[],
|
||||
volumeMap: Map<number, string>,
|
||||
repoMap: Map<string, string>,
|
||||
notificationMap: Map<number, string>,
|
||||
params: ExportParams
|
||||
) {
|
||||
return schedules.map((schedule) => {
|
||||
const assignments = scheduleNotifications
|
||||
.filter((sn) => sn.scheduleId === schedule.id)
|
||||
.map((sn) => ({
|
||||
...(params.includeIds ? { destinationId: sn.destinationId } : {}),
|
||||
name: notificationMap.get(sn.destinationId) ?? null,
|
||||
notifyOnStart: sn.notifyOnStart,
|
||||
notifyOnSuccess: sn.notifyOnSuccess,
|
||||
notifyOnFailure: sn.notifyOnFailure,
|
||||
}));
|
||||
|
||||
return {
|
||||
...omitKeys(schedule as Record<string, unknown>, params.excludeKeys),
|
||||
volume: volumeMap.get(schedule.volumeId) ?? null,
|
||||
repository: repoMap.get(schedule.repositoryId) ?? null,
|
||||
notifications: assignments,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Controller
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Config Export API
|
||||
*
|
||||
* Query parameters:
|
||||
* - includeIds: "true" | "false" (default: "true") - Include database IDs
|
||||
* - includeTimestamps: "true" | "false" (default: "true") - Include createdAt/updatedAt
|
||||
* - includeRecoveryKey: "true" | "false" (default: "false") - Include recovery key (full export only)
|
||||
* - includePasswordHash: "true" | "false" (default: "false") - Include admin password hash (full export only)
|
||||
* - secretsMode: "exclude" | "encrypted" | "cleartext" (default: "exclude") - How to handle secrets
|
||||
* - id: string (optional) - Filter by ID
|
||||
* - name: string (optional) - Filter by name (not for backups)
|
||||
*/
|
||||
export const configExportController = new Hono()
|
||||
.get("/export", async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const includeRecoveryKey = c.req.query("includeRecoveryKey") === "true";
|
||||
const includePasswordHash = c.req.query("includePasswordHash") === "true";
|
||||
|
||||
const [volumes, repositories, backupSchedulesRaw, notifications, scheduleNotifications, [admin]] = await Promise.all([
|
||||
db.select().from(volumesTable),
|
||||
db.select().from(repositoriesTable),
|
||||
db.select().from(backupSchedulesTable),
|
||||
db.select().from(notificationDestinationsTable),
|
||||
db.select().from(backupScheduleNotificationsTable),
|
||||
db.select().from(usersTable).limit(1),
|
||||
]);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
|
||||
const [exportVolumes, exportRepositories, exportNotifications] = await Promise.all([
|
||||
exportEntities(volumes, params),
|
||||
exportEntities(repositories, params),
|
||||
exportEntities(notifications, params),
|
||||
]);
|
||||
|
||||
let recoveryKey: string | undefined;
|
||||
if (includeRecoveryKey) {
|
||||
try {
|
||||
recoveryKey = await Bun.file(RESTIC_PASS_FILE).text();
|
||||
} catch {
|
||||
logger.warn("Could not read recovery key file");
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
volumes: exportVolumes,
|
||||
repositories: exportRepositories,
|
||||
backupSchedules,
|
||||
notificationDestinations: exportNotifications,
|
||||
admin: admin ? {
|
||||
username: admin.username,
|
||||
...(includePasswordHash ? { passwordHash: admin.passwordHash } : {}),
|
||||
...(recoveryKey ? { recoveryKey } : {}),
|
||||
} : null,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: "Failed to export config" }, 500);
|
||||
}
|
||||
})
|
||||
.get("/export/volumes", async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const result = await fetchVolumes(getFilterOptions(c));
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
|
||||
return c.json({ volumes: await exportEntities(result.data, params) });
|
||||
} catch (err) {
|
||||
logger.error(`Volumes export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: "Failed to export volumes" }, 500);
|
||||
}
|
||||
})
|
||||
.get("/export/repositories", async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const result = await fetchRepositories(getFilterOptions(c));
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
return c.json({ repositories: await exportEntities(result.data, params) });
|
||||
} catch (err) {
|
||||
logger.error(`Repositories export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: "Failed to export repositories" }, 500);
|
||||
}
|
||||
})
|
||||
.get("/export/notifications", async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
const result = await fetchNotifications(getFilterOptions(c));
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
return c.json({ notificationDestinations: await exportEntities(result.data, params) });
|
||||
} catch (err) {
|
||||
logger.error(`Notifications export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: "Failed to export notifications" }, 500);
|
||||
}
|
||||
})
|
||||
.get("/export/backups", async (c) => {
|
||||
try {
|
||||
const params = parseExportParams(c);
|
||||
|
||||
const [volumes, repositories, notifications, scheduleNotifications] = await Promise.all([
|
||||
db.select().from(volumesTable),
|
||||
db.select().from(repositoriesTable),
|
||||
db.select().from(notificationDestinationsTable),
|
||||
db.select().from(backupScheduleNotificationsTable),
|
||||
]);
|
||||
|
||||
const result = await fetchBackupSchedules({ id: c.req.query("id") });
|
||||
if ("error" in result) return c.json({ error: result.error }, result.status);
|
||||
|
||||
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(
|
||||
result.data, scheduleNotifications, volumeMap, repoMap, notificationMap, params
|
||||
);
|
||||
|
||||
return c.json({ backupSchedules });
|
||||
} catch (err) {
|
||||
logger.error(`Backups export failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return c.json({ error: "Failed to export backups" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -5,6 +5,10 @@ const algorithm = "aes-256-gcm" as const;
|
|||
const keyLength = 32;
|
||||
const encryptionPrefix = "encv1";
|
||||
|
||||
const isEncrypted = (val?: string): boolean => {
|
||||
return typeof val === "string" && val.startsWith(encryptionPrefix);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a string, encrypts it using a randomly generated salt
|
||||
*/
|
||||
|
|
@ -58,4 +62,5 @@ const decrypt = async (encryptedData: string) => {
|
|||
export const cryptoUtils = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
isEncrypted,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue