Docker compose support (#111)

* feat(WIP): compose implementation

* feat: add volumes, networks, services name hash generate

* feat: add compose config test unique

* feat: add tests for each unique config

* feat: implement lodash for docker compose parsing

* feat: add tests for generating compose file

* refactor: implement logs docker compose

* refactor: composeFile set not empty

* feat: implement providers for compose deployments

* feat: add Files volumes to compose

* feat: add stop compose button

* refactor: change strategie of building compose

* feat: create .env file in composepath

* refactor: simplify git and github function

* chore: update deps

* refactor: update migrations and add badge to recognize compose type

* chore: update lock yaml

* refactor: use code editor

* feat: add monitoring for app types

* refactor: reset stats on change appName

* refactor: add option to clean monitoring folder

* feat: show current command that will run

* feat: add prefix

* fix: add missing types

* refactor: add docker provider and expose by default as false

* refactor: customize error page

* refactor: unified deployments to be a single one

* feat: add vitest to ci/cd

* revert: back to initial version

* refactor: add maxconcurrency vitest

* refactor: add pool forks to vitest

* feat: add pocketbase template

* fix: update path resolution compose

* removed

* feat: add template pocketbase

* feat: add pocketbase template

* feat: add support button

* feat: add plausible template

* feat: add calcom template

* feat: add version to each template

* feat: add code editor to enviroment variables and swarm settings json

* refactor: add loader when download the image

* fix: use base64 to generate keys plausible

* feat: add recognized domain names by enviroment compose

* refactor: show alert to redeploy in each card advanced tab

* refactor: add validation to prevent create compose if not have permissions

* chore: add templates section to contributing

* chore: add example contributing
This commit is contained in:
Mauricio Siu
2024-06-02 15:26:28 -06:00
committed by GitHub
parent 1df6db738e
commit 8f9d21c0f8
139 changed files with 16513 additions and 1208 deletions

View File

@@ -0,0 +1,121 @@
import { Button } from "@/components/ui/button";
import { ExternalLink, Globe, Terminal } from "lucide-react";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Toggle } from "@/components/ui/toggle";
import { RedbuildCompose } from "./rebuild-compose";
import { DeployCompose } from "./deploy-compose";
import { StopCompose } from "./stop-compose";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
interface Props {
composeId: string;
}
export const ComposeActions = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: update } = api.compose.update.useMutation();
const extractDomains = (env: string) => {
const lines = env.split("\n");
const hostLines = lines.filter((line) => {
const [key, value] = line.split("=");
return key?.trim().endsWith("_HOST");
});
const hosts = hostLines.map((line) => {
const [key, value] = line.split("=");
return value ? value.trim() : "";
});
return hosts;
};
const domains = extractDomains(data?.env || "");
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<DeployCompose composeId={composeId} />
<Toggle
aria-label="Toggle italic"
pressed={data?.autoDeploy || false}
onPressedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
>
Autodeploy
</Toggle>
<RedbuildCompose composeId={composeId} />
{data?.composeType === "docker-compose" && (
<StopCompose composeId={composeId} />
)}
<DockerTerminalModal appName={data?.appName || ""}>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
{domains.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Domains
<Globe className="text-xs size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Domains detected</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{domains.map((host, index) => {
const url =
host.startsWith("http://") || host.startsWith("https://")
? host
: `http://${host}`;
return (
<DropdownMenuItem
key={`domain-${index}`}
className="cursor-pointer"
asChild
>
<Link href={url} target="_blank">
{host}
<ExternalLink className="ml-2 text-xs text-muted-foreground" />
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@@ -0,0 +1,142 @@
import { api } from "@/utils/api";
import { useEffect } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { RandomizeCompose } from "./randomize-compose";
import { CodeEditor } from "@/components/shared/code-editor";
interface Props {
composeId: string;
}
const AddComposeFile = z.object({
composeFile: z.string(),
});
type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => {
const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync, isLoading, error, isError } =
api.compose.update.useMutation();
const form = useForm<AddComposeFile>({
defaultValues: {
composeFile: "",
},
resolver: zodResolver(AddComposeFile),
});
useEffect(() => {
if (data) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: AddComposeFile) => {
const { valid, error } = validateAndFormatYAML(data.composeFile);
if (!valid) {
form.setError("composeFile", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
form.clearErrors("composeFile");
await mutateAsync({
composeId,
composeFile: data.composeFile,
sourceType: "raw",
})
.then(async () => {
toast.success("Compose config Updated");
refetch();
await utils.compose.allServices.invalidate({
composeId,
});
})
.catch((e) => {
console.log(e);
toast.error("Error to update the compose config");
});
};
return (
<>
<div className="w-full flex flex-col lg:flex-row gap-4 ">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full relative gap-4"
>
<FormField
control={form.control}
name="composeFile"
render={({ field }) => (
<FormItem className="overflow-auto">
<FormControl className="">
<div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto">
<CodeEditor
// disabled
value={field.value}
className="font-mono min-h-[20rem] compose-file-editor"
wrapperClassName="min-h-[20rem]"
placeholder={`version: '3'
services:
web:
image: nginx
ports:
- "80:80"
`}
onChange={(value) => {
field.onChange(value);
}}
/>
</div>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
<RandomizeCompose composeId={composeId} />
</div>
<Button
type="submit"
isLoading={isLoading}
className="lg:w-fit w-full"
>
Save
</Button>
</div>
</form>
</Form>
</div>
</>
);
};

View File

@@ -0,0 +1,78 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const DeployCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button isLoading={data?.composeStatus === "running"}>Deploy</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will deploy the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
toast.success("Compose Deploying....");
await refetch();
await deploy({
composeId,
})
.then(() => {
toast.success("Compose Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Compose");
});
await refetch();
})
.catch((e) => {
toast.error(e.message || "Error to deploy Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,255 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon, LockIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GitProviderSchema = z.object({
composePath: z.string().min(1),
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
interface Props {
composeId: string;
}
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.compose.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.compose.removeSSHKey.useMutation();
const form = useForm<GitProvider>({
defaultValues: {
branch: "",
repositoryURL: "",
composePath: "./docker-compose.yml",
},
resolver: zodResolver(GitProviderSchema),
});
useEffect(() => {
if (data) {
form.reset({
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
});
}
}, [form.reset, data, form]);
const onSubmit = async (values: GitProvider) => {
await mutateAsync({
customGitBranch: values.branch,
customGitUrl: values.repositoryURL,
composeId,
sourceType: "git",
composePath: values.composePath,
})
.then(async () => {
toast.success("Git Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error to save the Git provider");
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
<div className="flex gap-2">
<Dialog>
<DialogTrigger className="flex flex-row gap-2">
<LockIcon className="size-4 text-muted-foreground" />?
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Private Repository</DialogTitle>
<DialogDescription>
If your repository is private is necessary to
generate SSH Keys to add to your git provider.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="relative">
<Textarea
placeholder="Please click on Generate SSH Key"
className="no-scrollbar h-64 text-muted-foreground"
disabled={!data?.customGitSSHKey}
contentEditable={false}
value={
data?.customGitSSHKey ||
"Please click on Generate SSH Key"
}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
data?.customGitSSHKey ||
"Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</div>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
{data?.customGitSSHKey && (
<Button
variant="destructive"
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await removeSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Removed");
await refetch();
})
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}>
Save{" "}
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,310 @@
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
repository: z
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
});
type GithubProvider = z.infer<typeof GithubProviderSchema>;
interface Props {
composeId: string;
}
export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGithubProvider } =
api.compose.update.useMutation();
const form = useForm<GithubProvider>({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
owner: "",
repo: "",
},
branch: "",
},
resolver: zodResolver(GithubProviderSchema),
});
const repository = form.watch("repository");
const { data: repositories, isLoading: isLoadingRepositories } =
api.admin.getRepositories.useQuery();
const {
data: branches,
fetchStatus,
status,
} = api.admin.getBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
},
{ enabled: !!repository?.owner && !!repository?.repo },
);
useEffect(() => {
if (data) {
form.reset({
branch: data.branch || "",
repository: {
repo: data.repository || "",
owner: data.owner || "",
},
composePath: data.composePath,
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => {
console.log(data);
await mutateAsync({
branch: data.branch,
repository: data.repository.repo,
composeId: composeId,
owner: data.repository.owner,
sourceType: "github",
composePath: data.composePath,
})
.then(async () => {
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {
toast.error("Error to save the github provider");
});
};
return (
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 py-3"
>
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.login as string,
repo: repo.name,
});
form.setValue("branch", "");
}}
>
{repo.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.repository && (
<p className={cn("text-sm font-medium text-destructive")}>
Repository is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="block w-full">
<FormLabel>Branch</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
" w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
(branch) => branch.name === field.value,
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
)}
{!repository?.owner && (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a repository
</span>
)}
<ScrollArea className="h-96">
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches?.map((branch) => (
<CommandItem
value={branch.name}
key={branch.commit.sha}
onSelect={() => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
branch.name === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
<FormMessage />
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button
isLoading={isSavingGithubProvider}
type="submit"
className="w-fit"
>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -0,0 +1,97 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { GitBranch, LockIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { ComposeFileEditor } from "../compose-file-editor";
import { SaveGitProviderCompose } from "./save-git-provider-compose";
type TabState = "github" | "git" | "raw";
interface Props {
composeId: string;
}
export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: haveGithubConfigured } =
api.admin.haveGithubConfigured.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<Tabs
value={tab}
className="w-full"
onValueChange={(e) => {
setSab(e as TabState);
}}
>
<TabsList className="grid w-fit grid-cols-4 bg-transparent">
<TabsTrigger
value="github"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Github
</TabsTrigger>
<TabsTrigger
value="git"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Git
</TabsTrigger>
<TabsTrigger
value="raw"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Raw
</TabsTrigger>
</TabsList>
<TabsContent value="github" className="w-full p-2">
{haveGithubConfigured ? (
<SaveGithubProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3">
<LockIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
first. Please, go to{" "}
<Link
href="/dashboard/settings/server"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
)}
</TabsContent>
<TabsContent value="git" className="w-full p-2">
<SaveGitProviderCompose composeId={composeId} />
</TabsContent>
<TabsContent value="raw" className="w-full p-2 flex flex-col gap-4">
<ComposeFileEditor composeId={composeId} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,98 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { Dices } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
interface Props {
composeId: string;
}
export const RandomizeCompose = ({ composeId }: Props) => {
const utils = api.useUtils();
const [prefix, setPrefix] = useState<string>("");
const [compose, setCompose] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } =
api.compose.randomizeCompose.useMutation();
const onSubmit = async () => {
await mutateAsync({
composeId,
prefix,
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose randomized");
})
.catch(() => {
toast.error("Error to randomize the compose");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild onClick={() => onSubmit()}>
<Button className="max-lg:w-full" variant="outline">
<Dices className="h-4 w-4" />
Randomize Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file and you
have conflicts with some property like volumes, networks, etc.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a prefix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
<li>configs</li>
<li>secrets</li>
</ul>
</div>
<div className="flex flex-col lg:flex-row gap-2">
<Input
placeholder="Enter a prefix (Optional, example: prod)"
onChange={(e) => setPrefix(e.target.value)}
/>
<Button
type="submit"
onClick={async () => {
await onSubmit();
}}
className="lg:w-fit w-full"
>
Random
</Button>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="p-4 bg-secondary rounded-lg">
<pre>
<code className="language-yaml">{compose}</code>
</pre>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,85 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Hammer } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const RedbuildCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync } = api.compose.redeploy.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to rebuild the compose?
</AlertDialogTitle>
<AlertDialogDescription>
Is required to deploy at least 1 time in order to reuse the same
code
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose rebuild succesfully");
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,47 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import React from "react";
import { ShowProviderFormCompose } from "./generic/show";
import { ComposeActions } from "./actions";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
export const ShowGeneralCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{ composeId },
{
enabled: !!composeId,
},
);
return (
<>
<Card className="bg-background">
<CardHeader>
<div className="flex flex-row gap-2 justify-between flex-wrap">
<CardTitle className="text-xl">Deploy Settings</CardTitle>
<Badge>
{data?.composeType === "docker-compose" ? "Compose" : "Stack"}
</Badge>
</div>
<CardDescription>
Create a compose file to deploy your compose
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 flex-wrap">
<ComposeActions composeId={composeId} />
</CardContent>
</Card>
<ShowProviderFormCompose composeId={composeId} />
</>
);
};

View File

@@ -0,0 +1,79 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Ban } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StopCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Stop
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure to stop the compose?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the compose services
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose rebuild succesfully");
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};