feat: implement service-based volume selection for backups

- Added a new query to load mounts by service name, enhancing the volume backup form's functionality.
- Updated the form to allow users to select a service and corresponding volume from a dropdown, improving user experience.
- Retained the option for manual input of volume names, ensuring flexibility in volume selection.
- Refactored the component to streamline the handling of service and volume selections.
This commit is contained in:
Mauricio Siu
2025-06-30 01:25:50 -06:00
parent 819a310d48
commit c042c8c0c5
3 changed files with 292 additions and 162 deletions

View File

@@ -154,6 +154,18 @@ export const HandleVolumeBackups = ({
},
);
const serviceName = form.watch("serviceName");
const { data: mountsByService } = api.compose.loadMountsByService.useQuery(
{
composeId: id || "",
serviceName,
},
{
enabled: !!id && volumeBackupType === "compose" && !!serviceName,
},
);
useEffect(() => {
if (volumeBackupId && volumeBackup) {
form.reset({
@@ -261,117 +273,6 @@ export const HandleVolumeBackups = ({
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{serviceTypeForm === "compose" && (
<div className="flex flex-col w-full gap-4">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this compose,
it will read the services from the last
deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="name"
@@ -483,62 +384,207 @@ export const HandleVolumeBackups = ({
</FormItem>
)}
/>
{serviceTypeForm === "application" && (
{serviceTypeForm === "compose" && (
<>
<FormField
control={form.control}
name="volumeName"
render={({ field }) => (
<FormItem>
<FormLabel>Volumes</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a volume name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts?.map((mount) => (
<SelectItem
key={mount.mountId}
value={mount.volumeName || ""}
>
{mount.volumeName}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the volume
here, you can type the volume name manually
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-col w-full gap-4">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
/>
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<FormField
control={form.control}
name="volumeName"
render={({ field }) => (
<FormItem>
<FormLabel>Volume Name</FormLabel>
<FormControl>
<Input placeholder="my-volume-name" {...field} />
</FormControl>
<FormDescription>
The name of the Docker volume to backup
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from the
last deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
{mountsByService && mountsByService.length > 0 && (
<FormField
control={form.control}
name="volumeName"
render={({ field }) => (
<FormItem>
<FormLabel>Volumes</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a volume name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{mountsByService?.map((volume) => (
<SelectItem
key={volume.Name}
value={volume.Name || ""}
>
{volume.Name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the
volume here, you can type the volume name manually
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{serviceTypeForm === "application" && (
<FormField
control={form.control}
name="volumeName"
render={({ field }) => (
<FormItem>
<FormLabel>Volumes</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a volume name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts?.map((mount) => (
<SelectItem
key={mount.mountId}
value={mount.volumeName || ""}
>
{mount.volumeName}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the volume
here, you can type the volume name manually
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="volumeName"
render={({ field }) => (
<FormItem>
<FormLabel>Volume Name</FormLabel>
<FormControl>
<Input placeholder="my-volume-name" {...field} />
</FormControl>
<FormDescription>
The name of the Docker volume to backup
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}

View File

@@ -32,6 +32,7 @@ import {
findProjectById,
findServerById,
findUserById,
getComposeContainer,
loadServices,
randomizeComposeFile,
randomizeIsolatedDeploymentComposeFile,
@@ -241,6 +242,27 @@ export const composeRouter = createTRPCRouter({
}
return await loadServices(input.composeId, input.type);
}),
loadMountsByService: protectedProcedure
.input(
z.object({
composeId: z.string().min(1),
serviceName: z.string().min(1),
}),
)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (compose.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to load this compose",
});
}
const container = await getComposeContainer(compose, input.serviceName);
const mounts = container?.Mounts.filter(
(mount) => mount.Type === "volume" && mount.Source !== "",
);
return mounts;
}),
fetchSourceType: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {

View File

@@ -0,0 +1,62 @@
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import { getComposeContainer } from "../docker/utils";
import { findComposeById, paths, paths } from "../..";
export const createVolumeBackup = async (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
) => {
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
if (serverId) {
} else {
}
};
const backupVolume = async (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
) => {
const { serviceType, volumeName, turnOff } = volumeBackup;
if (turnOff) {
return `docker run --rm \
-v ${volumeName}:/volume_data \
-v $(pwd):/backup \
ubuntu \
bash -c "cd /volume_data && tar cvf /backup/${volumeName}.tar ."`;
}
if (serviceType === "application") {
return `
docker service scale ${volumeBackup.application?.appName}=0
docker run --rm \
-v ${volumeName}:/volume_data \
-v $(pwd):/backup \
ubuntu \
bash -c "cd /volume_data && tar cvf /backup/${volumeName}.tar .
docker service scale ${volumeBackup.application?.appName}=1
"`;
}
if (serviceType === "compose") {
const compose = await findComposeById(
volumeBackup.compose?.composeId || "",
);
const { COMPOSE_PATH } = paths(!!compose.serverId);
let stopCommand = "";
if (compose.composeType === "stack") {
stopCommand = `docker service scale ${compose.appName}_${volumeBackup.serviceName}=0`;
} else {
stopCommand = `docker compose down --remove-orphans`;
}
return `
docker run --rm \
-v ${volumeName}:/volume_data \
-v $(pwd):/backup \
ubuntu \
bash -c "cd /volume_data && tar cvf /backup/${volumeName}.tar ."`;
}
return ``;
};