mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge branch 'canary' into feat/migration-templates
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const AddAiAssistant = ({ projectId }: Props) => {
|
||||
return <TemplateGenerator projectId={projectId} />;
|
||||
};
|
||||
@@ -103,7 +103,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
.catch((_e) => {
|
||||
toast.error("Error creating the service");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,7 +18,6 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -49,7 +48,6 @@ import { z } from "zod";
|
||||
|
||||
type DbType = typeof mySchema._type.type;
|
||||
|
||||
// TODO: Change to a real docker images
|
||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mongo: "mongo:6",
|
||||
mariadb: "mariadb:11",
|
||||
@@ -496,7 +494,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
autoComplete="off"
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
BookText,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Code,
|
||||
Github,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
@@ -418,7 +417,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
side="top"
|
||||
>
|
||||
<span>
|
||||
If ot server is selected, the application
|
||||
If no server is selected, the application
|
||||
will be deployed on the server where the
|
||||
user is logged in.
|
||||
</span>
|
||||
@@ -469,7 +468,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Setting up...",
|
||||
success: (data) => {
|
||||
success: (_data) => {
|
||||
utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
|
||||
102
apps/dokploy/components/dashboard/project/ai/step-one.tsx
Normal file
102
apps/dokploy/components/dashboard/project/ai/step-one.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
|
||||
const examples = [
|
||||
"Make a personal blog",
|
||||
"Add a photo studio portfolio",
|
||||
"Create a personal ad blocker",
|
||||
"Build a social media dashboard",
|
||||
"Sendgrid service opensource analogue",
|
||||
];
|
||||
|
||||
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
|
||||
// Get servers from the API
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
const handleExampleClick = (example: string) => {
|
||||
setTemplateInfo({ ...templateInfo, userInput: example });
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
<div className="">
|
||||
<div className="space-y-4 ">
|
||||
<h2 className="text-lg font-semibold">Step 1: Describe Your Needs</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-needs">Describe your template needs</Label>
|
||||
<Textarea
|
||||
id="user-needs"
|
||||
placeholder="Describe the type of template you need, its purpose, and any specific features you'd like to include."
|
||||
value={templateInfo?.userInput}
|
||||
onChange={(e) =>
|
||||
setTemplateInfo({ ...templateInfo, userInput: e.target.value })
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-deploy">
|
||||
Select the server where you want to deploy (optional)
|
||||
</Label>
|
||||
<Select
|
||||
value={templateInfo.server?.serverId}
|
||||
onValueChange={(value) => {
|
||||
const server = servers?.find((s) => s.serverId === value);
|
||||
if (server) {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
server: server,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Examples:</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examples.map((example, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExampleClick(example)}
|
||||
>
|
||||
{example}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
apps/dokploy/components/dashboard/project/ai/step-three.tsx
Normal file
109
apps/dokploy/components/dashboard/project/ai/step-three.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { StepProps } from "./step-two";
|
||||
|
||||
export const StepThree = ({ templateInfo }: StepProps) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-grow">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Step 3: Review and Finalize</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Name</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateInfo?.details?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Description</h3>
|
||||
<ReactMarkdown className="text-sm text-muted-foreground">
|
||||
{templateInfo?.details?.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-md font-semibold">Server</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateInfo?.server?.name || "Dokploy Server"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Docker Compose</h3>
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
value={templateInfo?.details?.dockerCompose}
|
||||
disabled
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Environment Variables</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{templateInfo?.details?.envVariables.map(
|
||||
(
|
||||
env: {
|
||||
name: string;
|
||||
value: string;
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<li key={index}>
|
||||
<strong className="text-sm font-semibold">
|
||||
{env.name}
|
||||
</strong>
|
||||
:
|
||||
<span className="text-sm ml-2 text-muted-foreground">
|
||||
{env.value}
|
||||
</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Domains</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{templateInfo?.details?.domains.map(
|
||||
(
|
||||
domain: {
|
||||
host: string;
|
||||
port: number;
|
||||
serviceName: string;
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<li key={index}>
|
||||
<strong className="text-sm font-semibold">
|
||||
{domain.host}
|
||||
</strong>
|
||||
:
|
||||
<span className="text-sm ml-2 text-muted-foreground">
|
||||
{domain.port} - {domain.serviceName}
|
||||
</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{templateInfo?.details?.configFiles.map((file, index) => (
|
||||
<li key={index}>
|
||||
<strong className="text-sm font-semibold">
|
||||
{file.filePath}
|
||||
</strong>
|
||||
:
|
||||
<span className="text-sm ml-2 text-muted-foreground">
|
||||
{file.content}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
530
apps/dokploy/components/dashboard/project/ai/step-two.tsx
Normal file
530
apps/dokploy/components/dashboard/project/ai/step-two.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { api } from "@/utils/api";
|
||||
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import type { TemplateInfo } from "./template-generator";
|
||||
|
||||
export interface StepProps {
|
||||
stepper?: any;
|
||||
templateInfo: TemplateInfo;
|
||||
setTemplateInfo: React.Dispatch<React.SetStateAction<TemplateInfo>>;
|
||||
}
|
||||
|
||||
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
const suggestions = templateInfo.suggestions || [];
|
||||
const selectedVariant = templateInfo.details;
|
||||
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.ai.suggest.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (suggestions?.length > 0) {
|
||||
return;
|
||||
}
|
||||
mutateAsync({
|
||||
aiId: templateInfo.aiId,
|
||||
serverId: templateInfo.server?.serverId || "",
|
||||
input: templateInfo.userInput,
|
||||
})
|
||||
.then((data) => {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
suggestions: data,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error("Error generating suggestions", {
|
||||
description: error.message,
|
||||
});
|
||||
});
|
||||
}, [templateInfo.userInput]);
|
||||
|
||||
const toggleShowValue = (name: string) => {
|
||||
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||
};
|
||||
|
||||
const handleEnvVariableChange = (
|
||||
index: number,
|
||||
field: "name" | "value",
|
||||
value: string,
|
||||
) => {
|
||||
if (!selectedVariant) return;
|
||||
|
||||
const updatedEnvVariables = [...selectedVariant.envVariables];
|
||||
// @ts-ignore
|
||||
updatedEnvVariables[index] = {
|
||||
...updatedEnvVariables[index],
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
...(templateInfo.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
envVariables: updatedEnvVariables,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const removeEnvVariable = (index: number) => {
|
||||
if (!selectedVariant) return;
|
||||
|
||||
const updatedEnvVariables = selectedVariant.envVariables.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
...(templateInfo.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
envVariables: updatedEnvVariables,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDomainChange = (
|
||||
index: number,
|
||||
field: "host" | "port" | "serviceName",
|
||||
value: string | number,
|
||||
) => {
|
||||
if (!selectedVariant) return;
|
||||
|
||||
const updatedDomains = [...selectedVariant.domains];
|
||||
// @ts-ignore
|
||||
updatedDomains[index] = {
|
||||
...updatedDomains[index],
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
...(templateInfo.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
domains: updatedDomains,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const removeDomain = (index: number) => {
|
||||
if (!selectedVariant) return;
|
||||
|
||||
const updatedDomains = selectedVariant.domains.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
...(templateInfo.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
domains: updatedDomains,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const addEnvVariable = () => {
|
||||
if (!selectedVariant) return;
|
||||
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
|
||||
...(templateInfo.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
envVariables: [
|
||||
...selectedVariant.envVariables,
|
||||
{ name: "", value: "" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const addDomain = () => {
|
||||
if (!selectedVariant) return;
|
||||
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
...(templateInfo.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
domains: [
|
||||
...selectedVariant.domains,
|
||||
{ host: "", port: 80, serviceName: "" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4">
|
||||
<Bot className="w-16 h-16 text-primary animate-pulse" />
|
||||
<h2 className="text-2xl font-semibold animate-pulse">Error</h2>
|
||||
<AlertBlock type="error">
|
||||
{error?.message || "Error generating suggestions"}
|
||||
</AlertBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4">
|
||||
<Bot className="w-16 h-16 text-primary animate-pulse" />
|
||||
<h2 className="text-2xl font-semibold animate-pulse">
|
||||
AI is processing your request
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Generating template suggestions based on your input...
|
||||
</p>
|
||||
<pre>{templateInfo.userInput}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-6">
|
||||
<div className="flex-grow overflow-auto pb-8">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Step 2: Choose a Variant</h2>
|
||||
{!selectedVariant && (
|
||||
<div className="space-y-4">
|
||||
<div>Based on your input, we suggest the following variants:</div>
|
||||
<RadioGroup
|
||||
// value={selectedVariant?.}
|
||||
onValueChange={(value) => {
|
||||
const element = suggestions?.find((s) => s?.id === value);
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
details: element,
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{suggestions?.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion?.id}
|
||||
className="flex items-start space-x-3"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={suggestion?.id || ""}
|
||||
id={suggestion?.id}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={suggestion?.id} className="font-medium">
|
||||
{suggestion?.name}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{suggestion?.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
{selectedVariant && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold">{selectedVariant?.name}</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{selectedVariant?.shortDescription}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="description">
|
||||
<AccordionTrigger>Description</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ScrollArea className=" w-full rounded-md border p-4">
|
||||
<ReactMarkdown className="text-muted-foreground text-sm">
|
||||
{selectedVariant?.description}
|
||||
</ReactMarkdown>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="docker-compose">
|
||||
<AccordionTrigger>Docker Compose</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<CodeEditor
|
||||
value={selectedVariant?.dockerCompose}
|
||||
className="font-mono"
|
||||
onChange={(value) => {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
...(templateInfo?.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
dockerCompose: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="env-variables">
|
||||
<AccordionTrigger>Environment Variables</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ScrollArea className=" w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{selectedVariant?.envVariables.map((env, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Input
|
||||
value={env.name}
|
||||
onChange={(e) =>
|
||||
handleEnvVariableChange(
|
||||
index,
|
||||
"name",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Variable Name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type={
|
||||
showValues[env.name] ? "text" : "password"
|
||||
}
|
||||
value={env.value}
|
||||
onChange={(e) =>
|
||||
handleEnvVariableChange(
|
||||
index,
|
||||
"value",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Variable Value"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
onClick={() => toggleShowValue(env.name)}
|
||||
>
|
||||
{showValues[env.name] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeEnvVariable(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={addEnvVariable}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="domains">
|
||||
<AccordionTrigger>Domains</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ScrollArea className=" w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{selectedVariant?.domains.map((domain, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Input
|
||||
value={domain.host}
|
||||
onChange={(e) =>
|
||||
handleDomainChange(
|
||||
index,
|
||||
"host",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Domain Host"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={domain.port}
|
||||
onChange={(e) =>
|
||||
handleDomainChange(
|
||||
index,
|
||||
"port",
|
||||
Number.parseInt(e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder="Port"
|
||||
className="w-24"
|
||||
/>
|
||||
<Input
|
||||
value={domain.serviceName}
|
||||
onChange={(e) =>
|
||||
handleDomainChange(
|
||||
index,
|
||||
"serviceName",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Service Name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeDomain(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={addDomain}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="mounts">
|
||||
<AccordionTrigger>Configuration Files</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ScrollArea className="w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{selectedVariant?.configFiles?.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
This template requires the following
|
||||
configuration files to be mounted:
|
||||
</div>
|
||||
{selectedVariant.configFiles.map(
|
||||
(config, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-2 border rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-primary">
|
||||
{config.filePath}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will be mounted as: ../files
|
||||
{config.filePath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CodeEditor
|
||||
value={config.content}
|
||||
className="font-mono"
|
||||
onChange={(value) => {
|
||||
if (!selectedVariant?.configFiles)
|
||||
return;
|
||||
const updatedConfigFiles = [
|
||||
...selectedVariant.configFiles,
|
||||
];
|
||||
updatedConfigFiles[index] = {
|
||||
filePath: config.filePath,
|
||||
content: value,
|
||||
};
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
...(templateInfo.details && {
|
||||
details: {
|
||||
...templateInfo.details,
|
||||
configFiles: updatedConfigFiles,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<p>
|
||||
This template doesn't require any configuration
|
||||
files.
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
All necessary configurations are handled through
|
||||
environment variables.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="flex justify-between">
|
||||
{selectedVariant && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const { details, ...rest } = templateInfo;
|
||||
setTemplateInfo(rest);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Change Variant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { api } from "@/utils/api";
|
||||
import { defineStepper } from "@stepperize/react";
|
||||
import { Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { StepOne } from "./step-one";
|
||||
import { StepThree } from "./step-three";
|
||||
import { StepTwo } from "./step-two";
|
||||
|
||||
interface EnvVariable {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
host: string;
|
||||
port: number;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
interface Details {
|
||||
name: string;
|
||||
id: string;
|
||||
description: string;
|
||||
dockerCompose: string;
|
||||
envVariables: EnvVariable[];
|
||||
shortDescription: string;
|
||||
domains: Domain[];
|
||||
configFiles: Mount[];
|
||||
}
|
||||
|
||||
interface Mount {
|
||||
filePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TemplateInfo {
|
||||
userInput: string;
|
||||
details?: Details | null;
|
||||
suggestions?: Details[];
|
||||
server?: {
|
||||
serverId: string;
|
||||
name: string;
|
||||
};
|
||||
aiId: string;
|
||||
}
|
||||
|
||||
const defaultTemplateInfo: TemplateInfo = {
|
||||
aiId: "",
|
||||
userInput: "",
|
||||
server: undefined,
|
||||
details: null,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
export const { useStepper, steps, Scoped } = defineStepper(
|
||||
{
|
||||
id: "needs",
|
||||
title: "Describe your needs",
|
||||
},
|
||||
{
|
||||
id: "variant",
|
||||
title: "Choose a Variant",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
title: "Review and Finalize",
|
||||
},
|
||||
);
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export const TemplateGenerator = ({ projectId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const { data: aiSettings } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync } = api.ai.deploy.useMutation();
|
||||
const [templateInfo, setTemplateInfo] =
|
||||
useState<TemplateInfo>(defaultTemplateInfo);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const haveAtleasOneProviderEnabled = aiSettings?.some(
|
||||
(ai) => ai.isEnabled === true,
|
||||
);
|
||||
|
||||
const isDisabled = () => {
|
||||
if (stepper.current.id === "needs") {
|
||||
return !templateInfo.aiId || !templateInfo.userInput;
|
||||
}
|
||||
|
||||
if (stepper.current.id === "variant") {
|
||||
return !templateInfo?.details?.id;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
await mutateAsync({
|
||||
projectId,
|
||||
id: templateInfo.details?.id || "",
|
||||
name: templateInfo?.details?.name || "",
|
||||
description: templateInfo?.details?.shortDescription || "",
|
||||
dockerCompose: templateInfo?.details?.dockerCompose || "",
|
||||
envVariables: (templateInfo?.details?.envVariables || [])
|
||||
.map((env: any) => `${env.name}=${env.value}`)
|
||||
.join("\n"),
|
||||
domains: templateInfo?.details?.domains || [],
|
||||
...(templateInfo.server?.serverId && {
|
||||
serverId: templateInfo.server?.serverId || "",
|
||||
}),
|
||||
configFiles: templateInfo?.details?.configFiles || [],
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Compose Created");
|
||||
setOpen(false);
|
||||
await utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating the compose");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild className="w-full">
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Bot className="size-4 text-muted-foreground" />
|
||||
<span>AI Assistant</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl w-full flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Assistant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom template based on your needs
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-lg font-semibold">Steps</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Step {stepper.current.index + 1} of {steps.length}
|
||||
</span>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<Scoped>
|
||||
<nav aria-label="Checkout Steps" className="group my-4">
|
||||
<ol
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{stepper.all.map((step, index, array) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<li className="flex items-center gap-4 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
role="tab"
|
||||
variant={
|
||||
index <= stepper.current.index ? "secondary" : "ghost"
|
||||
}
|
||||
aria-current={
|
||||
stepper.current.id === step.id ? "step" : undefined
|
||||
}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={steps.length}
|
||||
aria-selected={stepper.current.id === step.id}
|
||||
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{step.title}</span>
|
||||
</li>
|
||||
{index < array.length - 1 && (
|
||||
<Separator
|
||||
className={`flex-1 ${
|
||||
index < stepper.current.index
|
||||
? "bg-primary"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
{stepper.switch({
|
||||
needs: () => (
|
||||
<>
|
||||
{!haveAtleasOneProviderEnabled && (
|
||||
<AlertBlock type="warning">
|
||||
<div className="flex flex-col w-full">
|
||||
<span>AI features are not enabled</span>
|
||||
<span>
|
||||
To use AI-powered template generation, please{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/ai"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
enable AI in your settings
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
{haveAtleasOneProviderEnabled &&
|
||||
aiSettings &&
|
||||
aiSettings?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="user-needs"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Select AI Provider
|
||||
</label>
|
||||
<Select
|
||||
value={templateInfo.aiId}
|
||||
onValueChange={(value) =>
|
||||
setTemplateInfo((prev) => ({
|
||||
...prev,
|
||||
aiId: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an AI provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aiSettings.map((ai) => (
|
||||
<SelectItem key={ai.aiId} value={ai.aiId}>
|
||||
{ai.name} ({ai.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{templateInfo.aiId && (
|
||||
<StepOne
|
||||
setTemplateInfo={setTemplateInfo}
|
||||
templateInfo={templateInfo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
variant: () => (
|
||||
<StepTwo
|
||||
templateInfo={templateInfo}
|
||||
setTemplateInfo={setTemplateInfo}
|
||||
/>
|
||||
),
|
||||
review: () => (
|
||||
<StepThree
|
||||
templateInfo={templateInfo}
|
||||
setTemplateInfo={setTemplateInfo}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</Scoped>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={stepper.prev}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isDisabled()}
|
||||
onClick={async () => {
|
||||
if (stepper.current.id === "needs") {
|
||||
setTemplateInfo((prev) => ({
|
||||
...prev,
|
||||
suggestions: [],
|
||||
details: null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (stepper.isLast) {
|
||||
await onSubmit();
|
||||
return;
|
||||
}
|
||||
stepper.next();
|
||||
// if (stepper.isLast) {
|
||||
// // setIsOpen(false);
|
||||
// // push("/dashboard/projects");
|
||||
// } else {
|
||||
// stepper.next();
|
||||
// }
|
||||
}}
|
||||
>
|
||||
{stepper.isLast ? "Create" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user