mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-07 06:55:23 +02:00
Merge pull request #4745 from Dokploy/feat/plan-limits
Feat/plan limits
This commit is contained in:
@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Bitbucket Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Github Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Github Account" />
|
||||
<SelectValue placeholder="Select a Github Account">
|
||||
{
|
||||
githubProviders?.find(
|
||||
(githubProvider) =>
|
||||
githubProvider.githubId === field.value,
|
||||
)?.gitProvider.name
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name ?? "Select repository")}
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
? (branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
)?.name ?? field.value)
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Gitlab Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -507,11 +507,20 @@ export const HandleVolumeBackups = ({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{mounts?.map((mount) => (
|
||||
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
||||
{mount.Name}
|
||||
{mounts && mounts.length > 0 ? (
|
||||
mounts.map((mount) => (
|
||||
<SelectItem
|
||||
key={mount.Name}
|
||||
value={mount.Name || ""}
|
||||
>
|
||||
{mount.Name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="none" disabled>
|
||||
No volumes found
|
||||
</SelectItem>
|
||||
))}
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
||||
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Bitbucket Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data?.composeId, form]);
|
||||
}, [form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: GithubProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Github Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name ?? "Select repository")}
|
||||
)?.name ?? field.value.repo)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
? (branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
)?.name ?? field.value)
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Gitlab Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between bg-input!",
|
||||
" w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -409,7 +409,7 @@ export const HandleBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -345,7 +345,7 @@ export const RestoreBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between bg-input!",
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||
error?.message ??
|
||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
Services
|
||||
</Button>
|
||||
|
||||
@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end w-full space-x-4">
|
||||
<div className="flex justify-end w-full gap-4">
|
||||
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
|
||||
<ShowNodeApplications serverId={serverId} />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Config
|
||||
</Button>
|
||||
|
||||
@@ -94,9 +94,10 @@ export function TagFilter({
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
{tags.length === 0
|
||||
? "No tags created yet."
|
||||
: "No tags found."}
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -118,6 +119,9 @@ export function TagFilter({
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -111,19 +111,12 @@ export function TagSelector({
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
{tags.length === 0
|
||||
? "No tags created yet."
|
||||
: "No tags found."}
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{tags.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags created yet.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
@@ -153,6 +146,9 @@ export function TagSelector({
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
function Dialog({
|
||||
@@ -49,6 +50,8 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
onPointerDownOutside,
|
||||
onEscapeKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
@@ -62,6 +65,20 @@ function DialogContent({
|
||||
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (wasNestedPopupJustClosed()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onPointerDownOutside?.(event);
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (wasNestedPopupJustClosed()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onEscapeKeyDown?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,25 @@ import * as React from "react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
|
||||
function DropdownMenu({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
|
||||
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let lastNestedPopupCloseAt = 0;
|
||||
|
||||
export function markNestedPopupClosed() {
|
||||
lastNestedPopupCloseAt = performance.now();
|
||||
}
|
||||
|
||||
export function wasNestedPopupJustClosed() {
|
||||
return performance.now() - lastNestedPopupCloseAt < 100;
|
||||
}
|
||||
@@ -4,11 +4,24 @@ import * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||
|
||||
function Popover({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
return (
|
||||
<PopoverPrimitive.Root
|
||||
data-slot="popover"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
markNestedPopupClosed();
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
|
||||
@@ -58,7 +58,7 @@ function SelectTrigger({
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
|
||||
@@ -1878,7 +1878,7 @@ export async function getServerSideProps(
|
||||
// Try to find default, otherwise use first accessible
|
||||
const targetEnv =
|
||||
accessibleEnvironments.find((env) => env.isDefault) ||
|
||||
accessibleEnvironments[0];
|
||||
accessibleEnvironments[0]!;
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createBackup,
|
||||
findBackupById,
|
||||
findBackupsByDbId,
|
||||
findComposeByBackupId,
|
||||
findComposeById,
|
||||
findLibsqlByBackupId,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
apiCreateBackup,
|
||||
apiFindOneBackup,
|
||||
@@ -94,6 +96,22 @@ export const backupRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
const dbType = (
|
||||
["postgres", "mysql", "mariadb", "mongo", "libsql"] as const
|
||||
).find((type) => input[`${type}Id`]);
|
||||
if (dbType) {
|
||||
const existingBackups = await findBackupsByDbId(
|
||||
input[`${dbType}Id`]!,
|
||||
dbType,
|
||||
);
|
||||
await assertDatabaseBackupLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
existingBackups.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newBackup = await createBackup(input);
|
||||
const backup = await findBackupById(newBackup.backupId);
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
createEnvironment,
|
||||
deleteEnvironment,
|
||||
duplicateEnvironment,
|
||||
filterEnvironmentServices,
|
||||
findEnvironmentById,
|
||||
findEnvironmentsByProjectId,
|
||||
IS_CLOUD,
|
||||
updateEnvironmentById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
@@ -20,6 +22,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
apiCreateEnvironment,
|
||||
apiDuplicateEnvironment,
|
||||
@@ -30,43 +33,18 @@ import {
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
const filterEnvironmentServices = (
|
||||
environment: any,
|
||||
accessedServices: string[],
|
||||
) => ({
|
||||
...environment,
|
||||
applications: environment.applications.filter((app: any) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp: any) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db: any) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db: any) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
mongo: environment.mongo.filter((db: any) =>
|
||||
accessedServices.includes(db.mongoId),
|
||||
),
|
||||
mysql: environment.mysql.filter((db: any) =>
|
||||
accessedServices.includes(db.mysqlId),
|
||||
),
|
||||
postgres: environment.postgres.filter((db: any) =>
|
||||
accessedServices.includes(db.postgresId),
|
||||
),
|
||||
redis: environment.redis.filter((db: any) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
});
|
||||
|
||||
export const environmentRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateEnvironment)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
await checkEnvironmentCreationPermission(ctx, input.projectId);
|
||||
if (IS_CLOUD) {
|
||||
await assertEnvironmentLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.name === "production") {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
assertMemberLimit,
|
||||
assertOrganizationLimit,
|
||||
} from "@/server/api/utils/plan-limits";
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
|
||||
message: "Only the organization owner can create an organization",
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertOrganizationLimit(ctx.user.id);
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(organization)
|
||||
.values({
|
||||
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
|
||||
const orgId = ctx.session.activeOrganizationId;
|
||||
const email = input.email.toLowerCase();
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertMemberLimit(orgId);
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, email),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
schedule: ["create"],
|
||||
});
|
||||
if (IS_CLOUD) {
|
||||
await assertScheduledJobLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
input.applicationId ? "application" : "compose",
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
|
||||
message: "You don't have access to this server.",
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
await assertScheduledJobLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
"server",
|
||||
input.serverId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newSchedule = await createSchedule({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
|
||||
import {
|
||||
type BillingTier,
|
||||
getStripeItems,
|
||||
@@ -31,44 +32,7 @@ import {
|
||||
export const stripeRouter = createTRPCRouter({
|
||||
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
||||
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return null;
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (!activeSub) return null;
|
||||
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
);
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "startup" as const;
|
||||
}
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "hobby" as const;
|
||||
}
|
||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
return "legacy" as const;
|
||||
}
|
||||
return null;
|
||||
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(createVolumeBackupSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const serviceId =
|
||||
input.applicationId ||
|
||||
input.postgresId ||
|
||||
input.mysqlId ||
|
||||
input.mariadbId ||
|
||||
input.mongoId ||
|
||||
input.redisId ||
|
||||
input.libsqlId ||
|
||||
input.composeId;
|
||||
const serviceType = (
|
||||
[
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"libsql",
|
||||
"compose",
|
||||
] as const
|
||||
).find((type) => input[`${type}Id`]);
|
||||
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
volumeBackup: ["create"],
|
||||
});
|
||||
}
|
||||
if (IS_CLOUD && serviceType && serviceId) {
|
||||
const existingVolumeBackups = await db.query.volumeBackups.findMany({
|
||||
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
|
||||
});
|
||||
await assertVolumeBackupLimit(
|
||||
ctx.session.activeOrganizationId,
|
||||
existingVolumeBackups.length,
|
||||
);
|
||||
}
|
||||
const newVolumeBackup = await createVolumeBackup(input);
|
||||
|
||||
if (newVolumeBackup?.enabled) {
|
||||
|
||||
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
environments,
|
||||
member,
|
||||
organization,
|
||||
schedules,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getCurrentPlan, getCurrentPlanForUser } from "@/server/utils/billing";
|
||||
|
||||
export type PlanLimitResource =
|
||||
| "organization"
|
||||
| "member"
|
||||
| "environment"
|
||||
| "volumeBackup"
|
||||
| "databaseBackup"
|
||||
| "scheduledJob";
|
||||
|
||||
const UNLIMITED = Number.POSITIVE_INFINITY;
|
||||
|
||||
export const PLAN_LIMITS: Record<
|
||||
"hobby" | "startup" | "legacy",
|
||||
Record<PlanLimitResource, number>
|
||||
> = {
|
||||
hobby: {
|
||||
organization: 1,
|
||||
member: 1,
|
||||
environment: 2,
|
||||
volumeBackup: 1,
|
||||
databaseBackup: 1,
|
||||
scheduledJob: 1,
|
||||
},
|
||||
startup: {
|
||||
organization: 3,
|
||||
member: UNLIMITED,
|
||||
environment: UNLIMITED,
|
||||
volumeBackup: UNLIMITED,
|
||||
databaseBackup: UNLIMITED,
|
||||
scheduledJob: UNLIMITED,
|
||||
},
|
||||
legacy: {
|
||||
organization: UNLIMITED,
|
||||
member: UNLIMITED,
|
||||
environment: UNLIMITED,
|
||||
volumeBackup: UNLIMITED,
|
||||
databaseBackup: UNLIMITED,
|
||||
scheduledJob: UNLIMITED,
|
||||
},
|
||||
};
|
||||
|
||||
const resourceLabels: Record<PlanLimitResource, string> = {
|
||||
organization: "organizations",
|
||||
member: "users",
|
||||
environment: "environments per project",
|
||||
volumeBackup: "volume backups per application",
|
||||
databaseBackup: "backups per database",
|
||||
scheduledJob: "scheduled jobs per service",
|
||||
};
|
||||
|
||||
const assertLimitForPlan = (
|
||||
plan: "hobby" | "startup" | "legacy" | null,
|
||||
resource: PlanLimitResource,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const limit = PLAN_LIMITS[plan ?? "legacy"][resource];
|
||||
|
||||
if (currentCount >= limit) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `You've reached your plan's limit of ${limit} ${resourceLabels[resource]}. Upgrade your plan to add more.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const assertOrganizationLimit = async (userId: string) => {
|
||||
const plan = await getCurrentPlanForUser(userId);
|
||||
const organizations = await db.query.organization.findMany({
|
||||
where: eq(organization.ownerId, userId),
|
||||
});
|
||||
assertLimitForPlan(plan, "organization", organizations.length);
|
||||
};
|
||||
|
||||
export const assertMemberLimit = async (organizationId: string) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const members = await db.query.member.findMany({
|
||||
where: eq(member.organizationId, organizationId),
|
||||
});
|
||||
assertLimitForPlan(plan, "member", members.length);
|
||||
};
|
||||
|
||||
export const assertEnvironmentLimit = async (
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const envs = await db.query.environments.findMany({
|
||||
where: eq(environments.projectId, projectId),
|
||||
});
|
||||
assertLimitForPlan(plan, "environment", envs.length);
|
||||
};
|
||||
|
||||
export const assertVolumeBackupLimit = async (
|
||||
organizationId: string,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
assertLimitForPlan(plan, "volumeBackup", currentCount);
|
||||
};
|
||||
|
||||
export const assertDatabaseBackupLimit = async (
|
||||
organizationId: string,
|
||||
currentCount: number,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
assertLimitForPlan(plan, "databaseBackup", currentCount);
|
||||
};
|
||||
|
||||
export const assertScheduledJobLimit = async (
|
||||
organizationId: string,
|
||||
scheduleType: "application" | "compose" | "server",
|
||||
serviceId: string,
|
||||
) => {
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const column = `${scheduleType}Id` as const;
|
||||
const rows = await db.query.schedules.findMany({
|
||||
where: eq(schedules[column], serviceId),
|
||||
});
|
||||
assertLimitForPlan(plan, "scheduledJob", rows.length);
|
||||
};
|
||||
69
apps/dokploy/server/utils/billing.ts
Normal file
69
apps/dokploy/server/utils/billing.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { findUserById, IS_CLOUD } from "@dokploy/server";
|
||||
import { getOrganizationOwnerId } from "@dokploy/server/services/proprietary/sso";
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
HOBBY_PRICE_ANNUAL_ID,
|
||||
HOBBY_PRICE_MONTHLY_ID,
|
||||
LEGACY_PRICE_IDS,
|
||||
STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
STARTUP_BASE_PRICE_MONTHLY_ID,
|
||||
} from "@/server/utils/stripe";
|
||||
|
||||
export type BillingPlan = "legacy" | "hobby" | "startup";
|
||||
|
||||
export const getCurrentPlanForUser = async (
|
||||
userId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const owner = await findUserById(userId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
const activeSub = subscriptions.data[0];
|
||||
if (!activeSub) return null;
|
||||
|
||||
const priceIds = activeSub.items.data.map(
|
||||
(item) => (item.price as Stripe.Price).id,
|
||||
);
|
||||
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "startup";
|
||||
}
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||
)
|
||||
) {
|
||||
return "hobby";
|
||||
}
|
||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCurrentPlan = async (
|
||||
organizationId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const ownerId = await getOrganizationOwnerId(organizationId);
|
||||
if (!ownerId) return null;
|
||||
|
||||
return getCurrentPlanForUser(ownerId);
|
||||
};
|
||||
@@ -308,6 +308,48 @@ export const duplicateEnvironment = async (
|
||||
return newEnvironment;
|
||||
};
|
||||
|
||||
interface EnvironmentWithServices {
|
||||
applications: { applicationId: string }[];
|
||||
compose: { composeId: string }[];
|
||||
libsql: { libsqlId: string }[];
|
||||
mariadb: { mariadbId: string }[];
|
||||
mongo: { mongoId: string }[];
|
||||
mysql: { mysqlId: string }[];
|
||||
postgres: { postgresId: string }[];
|
||||
redis: { redisId: string }[];
|
||||
}
|
||||
|
||||
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
|
||||
environment: T,
|
||||
accessedServices: string[],
|
||||
): T => ({
|
||||
...environment,
|
||||
applications: environment.applications.filter((app) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
mongo: environment.mongo.filter((db) =>
|
||||
accessedServices.includes(db.mongoId),
|
||||
),
|
||||
mysql: environment.mysql.filter((db) =>
|
||||
accessedServices.includes(db.mysqlId),
|
||||
),
|
||||
postgres: environment.postgres.filter((db) =>
|
||||
accessedServices.includes(db.postgresId),
|
||||
),
|
||||
redis: environment.redis.filter((db) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
});
|
||||
|
||||
export const createProductionEnvironment = async (projectId: string) => {
|
||||
const newEnvironment = await db
|
||||
.insert(environments)
|
||||
|
||||
Reference in New Issue
Block a user