mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat(compose): add Docker Compose template import functionality
- Implement new ShowImport component for importing Docker Compose configurations - Add processTemplate mutation to handle base64-encoded template processing - Integrate import feature into Compose service management page - Support parsing and displaying template details including domains, environment variables, and mounts
This commit is contained in:
parent
7580a5dcd6
commit
791a6c6f35
@ -0,0 +1,311 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Code2, Globe2, HardDrive } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const ImportSchema = z.object({
|
||||
base64: z.string(),
|
||||
});
|
||||
|
||||
type ImportType = z.infer<typeof ImportSchema>;
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const ShowImport = ({ composeId }: Props) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showMountContent, setShowMountContent] = useState(false);
|
||||
const [selectedMount, setSelectedMount] = useState<{
|
||||
filePath: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const [templateInfo, setTemplateInfo] = useState<{
|
||||
compose: string;
|
||||
template: {
|
||||
domains: Array<{
|
||||
serviceName: string;
|
||||
port: number;
|
||||
path?: string;
|
||||
host?: string;
|
||||
}>;
|
||||
envs: string[];
|
||||
mounts: Array<{
|
||||
filePath: string;
|
||||
content: string;
|
||||
}>;
|
||||
};
|
||||
} | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: processTemplate } =
|
||||
api.compose.processTemplate.useMutation();
|
||||
const { mutateAsync: updateCompose, isLoading } =
|
||||
api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<ImportType>({
|
||||
defaultValues: {
|
||||
base64: "",
|
||||
},
|
||||
resolver: zodResolver(ImportSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
await updateCompose({
|
||||
composeId,
|
||||
composeFile: templateInfo?.compose || "",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Import configuration updated");
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
setShowModal(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the import configuration");
|
||||
});
|
||||
};
|
||||
|
||||
const handleLoadTemplate = async () => {
|
||||
const base64 = form.getValues("base64");
|
||||
if (!base64) {
|
||||
toast.error("Please enter a base64 template");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processTemplate({
|
||||
composeId,
|
||||
base64,
|
||||
});
|
||||
setTemplateInfo(result);
|
||||
setShowModal(true);
|
||||
} catch (_error) {
|
||||
toast.error("Error processing template");
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowMountContent = (mount: {
|
||||
filePath: string;
|
||||
content: string;
|
||||
}) => {
|
||||
setSelectedMount(mount);
|
||||
setShowMountContent(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Import</CardTitle>
|
||||
<CardDescription>
|
||||
Import your Docker Compose configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base64"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Docker Compose (Base64)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter your Docker Compose configuration here..."
|
||||
className="font-mono min-h-[200px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-fit"
|
||||
variant="outline"
|
||||
onClick={handleLoadTemplate}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="max-h-[80vh] max-w-[50vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Template Information
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the template information before importing
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[60vh] pr-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Docker Compose</h3>
|
||||
</div>
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={templateInfo?.compose || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{templateInfo?.template.domains &&
|
||||
templateInfo.template.domains.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe2 className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Domains</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{templateInfo.template.domains.map((domain, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{domain.serviceName}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>Port: {domain.port}</div>
|
||||
{domain.host && <div>Host: {domain.host}</div>}
|
||||
{domain.path && <div>Path: {domain.path}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templateInfo?.template.envs &&
|
||||
templateInfo.template.envs.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
Environment Variables
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{templateInfo.template.envs.map((env, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-2 font-mono text-sm"
|
||||
>
|
||||
{env}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templateInfo?.template.mounts &&
|
||||
templateInfo.template.mounts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Mounts</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{templateInfo.template.mounts.map((mount, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => handleShowMountContent(mount)}
|
||||
>
|
||||
{mount.filePath}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={form.handleSubmit(onSubmit)}>Import</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showMountContent} onOpenChange={setShowMountContent}>
|
||||
<DialogContent className=" max-w-[50vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{selectedMount?.filePath}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Mount File Content</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[25vh] pr-4">
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={selectedMount?.content || ""}
|
||||
className="font-mono"
|
||||
readOnly
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button onClick={() => setShowMountContent(false)}>Close</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
@ -47,6 +47,7 @@ import { useRouter } from "next/router";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
||||
|
||||
type TabState =
|
||||
| "projects"
|
||||
@ -330,6 +331,7 @@ const Service = (
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<AddCommandCompose composeId={composeId} />
|
||||
<ShowVolumes id={composeId} type="compose" />
|
||||
<ShowImport composeId={composeId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
@ -12,13 +12,14 @@ import {
|
||||
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
import {
|
||||
type CompleteTemplate,
|
||||
fetchTemplateFiles,
|
||||
fetchTemplatesList,
|
||||
} from "@dokploy/server/templates/utils/github";
|
||||
import { processTemplate } from "@dokploy/server/templates/utils/processors";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { dump } from "js-yaml";
|
||||
import { dump, load } from "js-yaml";
|
||||
import _ from "lodash";
|
||||
import { nanoid } from "nanoid";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
@ -540,7 +541,6 @@ export const composeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Update the compose's projectId
|
||||
const updatedCompose = await db
|
||||
.update(composeTable)
|
||||
.set({
|
||||
@ -559,4 +559,58 @@ export const composeRouter = createTRPCRouter({
|
||||
|
||||
return updatedCompose;
|
||||
}),
|
||||
|
||||
processTemplate: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
base64: z.string(),
|
||||
composeId: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
console.log(input);
|
||||
try {
|
||||
const decodedData = Buffer.from(input.base64, "base64").toString(
|
||||
"utf-8",
|
||||
);
|
||||
const compose = await findComposeById(input.composeId);
|
||||
|
||||
console.log(compose);
|
||||
|
||||
const admin = await findUserById(ctx.user.ownerId);
|
||||
let serverIp = admin.serverIp || "127.0.0.1";
|
||||
|
||||
if (compose.serverId) {
|
||||
const server = await findServerById(compose.serverId);
|
||||
serverIp = server.ipAddress;
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
serverIp = "127.0.0.1";
|
||||
}
|
||||
const templateData = JSON.parse(decodedData);
|
||||
const config = load(templateData.config) as CompleteTemplate;
|
||||
|
||||
if (!templateData.compose || !config) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Invalid template format. Must contain compose and config fields",
|
||||
});
|
||||
}
|
||||
|
||||
const processedTemplate = processTemplate(config, {
|
||||
serverIp: serverIp,
|
||||
projectName: compose.project.name,
|
||||
});
|
||||
|
||||
return {
|
||||
compose: templateData.compose,
|
||||
template: processedTemplate,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error processing template: ${error instanceof Error ? error.message : error}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user