diff --git a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx
index 797a317a8..86f7a0dff 100644
--- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx
+++ b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -36,16 +36,19 @@ interface Props {
}
export const ShowEnvironment = ({ id, type }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canWrite = permissions?.envVars.write ?? false;
const queryMap = {
- postgres: () =>
- api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
- redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
- mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ compose: () =>
+ api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
+ libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
- compose: () =>
- api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
+ mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -53,14 +56,15 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
- postgres: () => api.postgres.update.useMutation(),
- redis: () => api.redis.update.useMutation(),
- mysql: () => api.mysql.update.useMutation(),
+ compose: () => api.compose.update.useMutation(),
+ libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
- compose: () => api.compose.update.useMutation(),
+ mysql: () => api.mysql.update.useMutation(),
+ postgres: () => api.postgres.update.useMutation(),
+ redis: () => api.redis.update.useMutation(),
};
- const { mutateAsync, isLoading } = mutationMap[type]
+ const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
@@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
+ composeId: id || "",
+ libsqlId: id || "",
+ mariadbId: id || "",
mongoId: id || "",
+ mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
- mysqlId: id || "",
- mariadbId: id || "",
- composeId: id || "",
env: formData.environment,
})
.then(async () => {
@@ -111,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -121,7 +126,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
- }, [form, onSubmit, isLoading]);
+ }, [form, onSubmit, isPending]);
return (
@@ -185,25 +190,27 @@ PORT=3000
)}
/>
-
- {hasChanges && (
+ {canWrite && (
+
+ {hasChanges && (
+
+ Cancel
+
+ )}
- Cancel
+ Save
- )}
-
- Save
-
-
+
+ )}
diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx
index 48e978880..fcfd81778 100644
--- a/apps/dokploy/components/dashboard/application/environment/show.tsx
+++ b/apps/dokploy/components/dashboard/application/environment/show.tsx
@@ -1,18 +1,27 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
-import { Form } from "@/components/ui/form";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+} from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets";
+import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
+ createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer
;
@@ -22,7 +31,9 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
- const { mutateAsync, isLoading } =
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canWrite = permissions?.envVars.write ?? false;
+ const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation();
const { data, refetch } = api.application.one.useQuery(
@@ -39,6 +50,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: "",
buildArgs: "",
buildSecrets: "",
+ createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
@@ -47,10 +59,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
+ const currentCreateEnvFile = form.watch("createEnvFile");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
- currentBuildSecrets !== (data?.buildSecrets || "");
+ currentBuildSecrets !== (data?.buildSecrets || "") ||
+ currentCreateEnvFile !== (data?.createEnvFile ?? true);
useEffect(() => {
if (data) {
@@ -58,6 +72,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data.env || "",
buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
+ createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, form]);
@@ -67,6 +82,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: formData.env,
buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
+ createEnvFile: formData.createEnvFile,
applicationId,
})
.then(async () => {
@@ -83,13 +99,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data?.env || "",
buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || "",
+ createEnvFile: data?.createEnvFile ?? true,
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -99,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
- }, [form, onSubmit, isLoading]);
+ }, [form, onSubmit, isPending]);
return (
@@ -167,21 +184,49 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
-
- {hasChanges && (
-
- Cancel
+ {data?.buildType === "dockerfile" && (
+ (
+
+
+ Create Environment File
+
+ When enabled, an .env file will be created in the same
+ directory as your Dockerfile during the build process.
+ Disable this if you don't want to generate an environment
+ file.
+
+
+
+
+
+
+ )}
+ />
+ )}
+ {canWrite && (
+
+ {hasChanges && (
+
+ Cancel
+
+ )}
+
+ Save
- )}
-
- Save
-
-
+
+ )}
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx
index 1f54ddd58..a4fab46d9 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx
@@ -1,5 +1,5 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
+ slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -73,15 +74,16 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
- const { mutateAsync, isLoading: isSavingBitbucketProvider } =
+ const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.application.saveBitbucketProvider.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
owner: "",
repo: "",
+ slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
- repo: repository?.repo,
+ repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
- enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
+ enabled:
+ !!repository?.owner &&
+ !!(repository?.slug || repository?.repo) &&
+ !!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
+ slug: data.bitbucketRepositorySlug || "",
},
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
+ bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
@@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
+ slug: "",
});
form.setValue("branch", "");
}}
@@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
Repository
{field.value.owner && field.value.repo && (
{
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo) => repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -255,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!bitbucketId ? (
+
+ Select a Bitbucket account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -271,6 +283,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
+ slug: repo.slug,
});
form.setValue("branch", "");
}}
@@ -320,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -337,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
- {status === "loading" && fetchStatus === "fetching" && (
+ {status === "pending" && fetchStatus === "fetching" && (
Loading Branches....
@@ -403,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
Watch Paths
-
-
- ?
-
+
+
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx
index fcdcf0a93..078271bca 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx
index 00e18c2ab..583b865c5 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { TrashIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -24,10 +24,10 @@ interface Props {
export const SaveDragNDrop = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
- const { mutateAsync, isLoading } =
+ const { mutateAsync, isPending } =
api.application.dropDeployment.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {},
resolver: zodResolver(uploadFileSchema),
});
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
Deploy{" "}
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx
index e9be3a2f5..37a387bb5 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx
@@ -1,5 +1,5 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { KeyRoundIcon, LockIcon, X } from "lucide-react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
- const { mutateAsync, isLoading } =
+ const { mutateAsync, isPending } =
api.application.saveGitProvider.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
branch: "",
buildPath: "/",
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
Watch Paths
-
-
- ?
-
+
+
@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
-
+
Save
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx
index 2198f4a97..02cae2c4a 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
- const { mutateAsync, isLoading: isSavingGiteaProvider } =
+ const { mutateAsync, isPending: isSavingGiteaProvider } =
api.application.saveGiteaProvider.useMutation();
- const form = useForm
({
+ const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!giteaId ? (
+
+ Select a Gitea account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -349,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -367,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
- {status === "loading" && fetchStatus === "fetching" && (
+ {status === "pending" && fetchStatus === "fetching" && (
Loading Branches....
@@ -459,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
{
- const newPaths = [...field.value];
+ const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
@@ -477,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
- field.onChange([...field.value, path]);
+ field.onChange([...(field.value || []), path]);
input.value = "";
}
}
@@ -494,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
- field.onChange([...field.value, path]);
+ field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx
index 80d6850ca..6bce2d243 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
- const { mutateAsync, isLoading: isSavingGithubProvider } =
+ const { mutateAsync, isPending: isSavingGithubProvider } =
api.application.saveGithubProvider.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
- const { data: repositories, isLoading: isLoadingRepositories } =
+ const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
githubId,
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo) => repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!githubId ? (
+
+ Select a GitHub account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -316,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -333,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
- {status === "loading" && fetchStatus === "fetching" && (
+ {status === "pending" && fetchStatus === "fetching" && (
Loading Branches....
@@ -455,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx
index d6f65caf3..b49a1658f 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo } from "react";
@@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
- const { mutateAsync, isLoading: isSavingGitlabProvider } =
+ const { mutateAsync, isPending: isSavingGitlabProvider } =
api.application.saveGitlabProvider.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
buildPath: "/",
repository: {
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
Repository
- {field.value.owner && field.value.repo && (
+ {field.value.gitlabPathNamespace && (
{
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo) => repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!gitlabId ? (
+
+ Select a GitLab account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -347,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -364,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
- {status === "loading" && fetchStatus === "fetching" && (
+ {status === "pending" && fetchStatus === "fetching" && (
Loading Branches....
@@ -444,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => (
diff --git a/apps/dokploy/components/dashboard/application/general/generic/show.tsx b/apps/dokploy/components/dashboard/application/general/generic/show.tsx
index a60db800c..9a49b204e 100644
--- a/apps/dokploy/components/dashboard/application/general/generic/show.tsx
+++ b/apps/dokploy/components/dashboard/application/general/generic/show.tsx
@@ -36,13 +36,13 @@ interface Props {
}
export const ShowProviderForm = ({ applicationId }: Props) => {
- const { data: githubProviders, isLoading: isLoadingGithub } =
+ const { data: githubProviders, isPending: isLoadingGithub } =
api.github.githubProviders.useQuery();
- const { data: gitlabProviders, isLoading: isLoadingGitlab } =
+ const { data: gitlabProviders, isPending: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
- const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
+ const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery();
- const { data: giteaProviders, isLoading: isLoadingGitea } =
+ const { data: giteaProviders, isPending: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: application, refetch } = api.application.one.useQuery({
diff --git a/apps/dokploy/components/dashboard/application/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx
index 5387659ad..01fc9e84a 100644
--- a/apps/dokploy/components/dashboard/application/general/show.tsx
+++ b/apps/dokploy/components/dashboard/application/general/show.tsx
@@ -30,6 +30,9 @@ interface Props {
export const ShowGeneralApplication = ({ applicationId }: Props) => {
const router = useRouter();
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDeploy = permissions?.deployment.create ?? false;
+ const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: update } = api.application.update.useMutation();
- const { mutateAsync: start, isLoading: isStarting } =
+ const { mutateAsync: start, isPending: isStarting } =
api.application.start.useMutation();
- const { mutateAsync: stop, isLoading: isStopping } =
+ const { mutateAsync: stop, isPending: isStopping } =
api.application.stop.useMutation();
const { mutateAsync: deploy } = api.application.deploy.useMutation();
- const { mutateAsync: reload, isLoading: isReloading } =
+ const { mutateAsync: reload, isPending: isReloading } =
api.application.reload.useMutation();
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
- {
- await deploy({
- applicationId: applicationId,
- })
- .then(() => {
- toast.success("Application deployed successfully");
- refetch();
- router.push(
- `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
- );
+ {canDeploy && (
+ {
+ await deploy({
+ applicationId: applicationId,
})
- .catch(() => {
- toast.error("Error deploying application");
- });
- }}
- >
- {
+ toast.success("Application deployed successfully");
+ refetch();
+ router.push(
+ `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
+ );
+ })
+ .catch(() => {
+ toast.error("Error deploying application");
+ });
+ }}
>
-
-
-
-
- Deploy
-
-
-
-
-
- Downloads the source code and performs a complete build
-
-
-
-
-
-
- {
- await reload({
- applicationId: applicationId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Application reloaded successfully");
- refetch();
+
+
+
+
+
+ Deploy
+
+
+
+
+
+ Downloads the source code and performs a complete
+ build
+
+
+
+
+
+
+ )}
+ {canDeploy && (
+ {
+ await reload({
+ applicationId: applicationId,
+ appName: data?.appName || "",
})
- .catch(() => {
- toast.error("Error reloading application");
- });
- }}
- >
- {
+ toast.success("Application reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading application");
+ });
+ }}
>
-
-
-
-
- Reload
-
-
-
-
- Reload the application without rebuilding it
-
-
-
-
-
- {
- await redeploy({
- applicationId: applicationId,
- })
- .then(() => {
- toast.success("Application rebuilt successfully");
- refetch();
+
+
+
+
+
+ Reload
+
+
+
+
+ Reload the application without rebuilding it
+
+
+
+
+
+ )}
+ {canDeploy && (
+ {
+ await redeploy({
+ applicationId: applicationId,
})
- .catch(() => {
- toast.error("Error rebuilding application");
- });
- }}
- >
- {
+ toast.success("Application rebuilt successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error rebuilding application");
+ });
+ }}
>
-
-
-
-
- Rebuild
-
-
-
-
-
- Only rebuilds the application without downloading new
- code
-
-
-
-
-
-
+
+
+
+
+
+ Rebuild
+
+
+
+
+
+ Only rebuilds the application without downloading new
+ code
+
+
+
+
+
+
+ )}
- {data?.applicationStatus === "idle" ? (
+ {canDeploy && data?.applicationStatus === "idle" ? (
{
- ) : (
+ ) : canDeploy ? (
{
- )}
+ ) : null}
{
Open Terminal
-
-
Autodeploy
-
{
- await update({
- applicationId,
- autoDeploy: enabled,
- })
- .then(async () => {
- toast.success("Auto Deploy Updated");
- await refetch();
+ {canUpdateService && (
+
+ Autodeploy
+ {
+ await update({
+ applicationId,
+ autoDeploy: enabled,
})
- .catch(() => {
- toast.error("Error updating Auto Deploy");
- });
- }}
- className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
- />
-
+ .then(async () => {
+ toast.success("Auto Deploy Updated");
+ await refetch();
+ })
+ .catch(() => {
+ toast.error("Error updating Auto Deploy");
+ });
+ }}
+ className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
+ />
+
+ )}
-
-
Clean Cache
-
{
- await update({
- applicationId,
- cleanCache: enabled,
- })
- .then(async () => {
- toast.success("Clean Cache Updated");
- await refetch();
+ {canUpdateService && (
+
+ Clean Cache
+ {
+ await update({
+ applicationId,
+ cleanCache: enabled,
})
- .catch(() => {
- toast.error("Error updating Clean Cache");
- });
- }}
- className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
- />
-
+ .then(async () => {
+ toast.success("Clean Cache Updated");
+ await refetch();
+ })
+ .catch(() => {
+ toast.error("Error updating Clean Cache");
+ });
+ }}
+ className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
+ />
+
+ )}
diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx
index e5dff075e..cbb6bce09 100644
--- a/apps/dokploy/components/dashboard/application/logs/show.tsx
+++ b/apps/dokploy/components/dashboard/application/logs/show.tsx
@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
export const badgeStateColor = (state: string) => {
switch (state) {
case "running":
+ case "ready":
return "green";
case "exited":
case "shutdown":
@@ -55,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const [containerId, setContainerId] = useState();
const [option, setOption] = useState<"swarm" | "native">("native");
- const { data: services, isLoading: servicesLoading } =
+ const { data: services, isPending: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery(
{
appName,
@@ -66,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
},
);
- const { data: containers, isLoading: containersLoading } =
+ const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
{container.state}
+ {container.status ? ` ${container.status}` : ""}
))}
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
{container.state}
+ {container.currentState
+ ? ` ${container.currentState}`
+ : ""}
))}
>
@@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
+ {option === "swarm" &&
+ services?.find((c) => c.containerId === containerId)?.error && (
+
+ Error:
+ {services?.find((c) => c.containerId === containerId)?.error}
+
+ )}
void;
+ onOpenChange: (open: boolean) => void;
+ alwaysVisible?: boolean;
+}
+
+export const CreateFileDialog = ({
+ folderPath,
+ onCreate,
+ onOpenChange,
+ alwaysVisible = false,
+}: Props) => {
+ const [filename, setFilename] = useState("");
+ const [content, setContent] = useState("");
+
+ const handleCreate = () => {
+ if (!filename.trim()) return;
+ onCreate(filename.trim(), content);
+ setFilename("");
+ setContent("");
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx
new file mode 100644
index 000000000..8c5a42836
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx
@@ -0,0 +1,102 @@
+import { Loader2, Pencil } from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { CodeEditor } from "@/components/shared/code-editor";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { api } from "@/utils/api";
+
+interface Props {
+ patchId: string;
+ entityId: string;
+ type: "application" | "compose";
+ onSuccess?: () => void;
+}
+
+export const EditPatchDialog = ({
+ patchId,
+ entityId,
+ type,
+ onSuccess,
+}: Props) => {
+ const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery(
+ { patchId },
+ { enabled: !!patchId },
+ );
+ const [content, setContent] = useState("");
+
+ useEffect(() => {
+ if (patch) {
+ setContent(patch.content);
+ }
+ }, [patch]);
+
+ const utils = api.useUtils();
+ const updatePatch = api.patch.update.useMutation();
+
+ const handleSave = () => {
+ updatePatch
+ .mutateAsync({ patchId, content })
+ .then(() => {
+ toast.success("Patch saved");
+ utils.patch.byEntityId.invalidate({ id: entityId, type });
+ onSuccess?.();
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Edit Patch
+
+ {patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
+
+
+ {isPatchLoading ? (
+
+
+
+ ) : (
+
+ setContent(value ?? "")}
+ className="h-[400px] w-full"
+ wrapperClassName="h-[400px]"
+ lineWrapping
+ />
+
+ )}
+
+
+ Cancel
+
+
+ {updatePatch.isPending && (
+
+ )}
+ Save
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/patches/index.ts b/apps/dokploy/components/dashboard/application/patches/index.ts
new file mode 100644
index 000000000..1854bd3e5
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/patches/index.ts
@@ -0,0 +1,2 @@
+export * from "./show-patches";
+export * from "./patch-editor";
diff --git a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx
new file mode 100644
index 000000000..4b212b004
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx
@@ -0,0 +1,368 @@
+import {
+ ArrowLeft,
+ ChevronRight,
+ File,
+ Folder,
+ Loader2,
+ Save,
+ Trash2,
+} from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { CodeEditor } from "@/components/shared/code-editor";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { api } from "@/utils/api";
+import { CreateFileDialog } from "./create-file-dialog";
+
+interface Props {
+ id: string;
+ type: "application" | "compose";
+ repoPath: string;
+ onClose: () => void;
+}
+
+type DirectoryEntry = {
+ name: string;
+ path: string;
+ type: "file" | "directory";
+ children?: DirectoryEntry[];
+};
+
+export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [fileContent, setFileContent] = useState("");
+ const [createFolderPath, setCreateFolderPath] = useState(null);
+ const [expandedFolders, setExpandedFolders] = useState>(
+ new Set(),
+ );
+
+ const utils = api.useUtils();
+ const { data: directories, isPending: isDirLoading } =
+ api.patch.readRepoDirectories.useQuery(
+ { id: id, type, repoPath },
+ { enabled: !!repoPath },
+ );
+
+ const { data: patches } = api.patch.byEntityId.useQuery(
+ { id, type },
+ { enabled: !!id },
+ );
+
+ const { mutateAsync: saveAsPatch, isPending: isSavingPatch } =
+ api.patch.saveFileAsPatch.useMutation();
+
+ const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } =
+ api.patch.markFileForDeletion.useMutation();
+
+ const updatePatch = api.patch.update.useMutation();
+
+ const { data: fileData, isFetching: isFileLoading } =
+ api.patch.readRepoFile.useQuery(
+ {
+ id,
+ type,
+ filePath: selectedFile || "",
+ },
+ {
+ enabled: !!selectedFile,
+ },
+ );
+
+ useEffect(() => {
+ if (fileData !== undefined) {
+ setFileContent(fileData);
+ }
+ }, [fileData]);
+
+ const handleFileSelect = (filePath: string) => {
+ setSelectedFile(filePath);
+ };
+
+ const toggleFolder = (path: string) => {
+ setExpandedFolders((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ };
+
+ const handleSave = () => {
+ if (!selectedFile) return;
+ saveAsPatch({
+ id,
+ type,
+ filePath: selectedFile,
+ content: fileContent,
+ patchType: "update",
+ })
+ .then(() => {
+ toast.success("Patch saved");
+ utils.patch.byEntityId.invalidate({ id, type });
+ })
+ .catch(() => {
+ toast.error("Failed to save patch");
+ });
+ };
+
+ const handleMarkForDeletion = () => {
+ if (!selectedFile) return;
+ markForDeletion({ id, type, filePath: selectedFile })
+ .then(() => {
+ toast.success("File marked for deletion");
+ utils.patch.byEntityId.invalidate({ id, type });
+ })
+ .catch(() => {
+ toast.error("Failed to mark file for deletion");
+ });
+ };
+
+ const handleCreateFile = useCallback(
+ (folderPath: string, filename: string, content: string) => {
+ const filePath = folderPath ? `${folderPath}/${filename}` : filename;
+ saveAsPatch({
+ id,
+ type,
+ filePath,
+ content,
+ patchType: "create",
+ })
+ .then(() => {
+ toast.success("File created");
+ utils.patch.byEntityId.invalidate({ id, type });
+ })
+ .catch(() => {
+ toast.error("Failed to create file");
+ });
+ },
+ [id, type, saveAsPatch, utils],
+ );
+
+ const selectedFilePatch = patches?.find(
+ (p) => p.filePath === selectedFile && p.type === "delete",
+ );
+
+ const handleUnmarkDeletion = () => {
+ if (!selectedFilePatch) return;
+ updatePatch
+ .mutateAsync({
+ patchId: selectedFilePatch.patchId,
+ type: "update",
+ content: fileData || "",
+ })
+ .then(() => {
+ toast.success("Deletion unmarked");
+ utils.patch.byEntityId.invalidate({ id, type });
+ })
+ .catch(() => {
+ toast.error("Failed to unmark deletion");
+ });
+ };
+
+ const hasChanges = fileData !== undefined && fileContent !== fileData;
+
+ const renderTree = useCallback(
+ (entries: DirectoryEntry[], depth = 0) => {
+ return entries
+ .sort((a, b) => {
+ // Directories first, then alphabetically
+ if (a.type !== b.type) {
+ return a.type === "directory" ? -1 : 1;
+ }
+ return a.name.localeCompare(b.name);
+ })
+ .map((entry) => {
+ const isExpanded = expandedFolders.has(entry.path);
+ const isSelected = selectedFile === entry.path;
+
+ if (entry.type === "directory") {
+ return (
+
+
+ toggleFolder(entry.path)}
+ className={
+ "flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0"
+ }
+ style={{ paddingLeft: `${depth * 12 + 8}px` }}
+ >
+
+
+ {entry.name}
+
+
+ handleCreateFile(entry.path, filename, content)
+ }
+ onOpenChange={(open) =>
+ setCreateFolderPath(open ? entry.path : null)
+ }
+ />
+
+ {isExpanded && entry.children && (
+
{renderTree(entry.children, depth + 1)}
+ )}
+
+ );
+ }
+
+ const isMarkedForDeletion = patches?.some(
+ (p) => p.filePath === entry.path && p.type === "delete",
+ );
+
+ return (
+ handleFileSelect(entry.path)}
+ className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
+ isSelected ? "bg-muted" : ""
+ } ${isMarkedForDeletion ? "text-destructive" : ""}`}
+ style={{ paddingLeft: `${depth * 12 + 28}px` }}
+ >
+
+ {entry.name}
+ {isMarkedForDeletion && (
+
+ )}
+
+ );
+ });
+ },
+ [expandedFolders, selectedFile, patches, handleCreateFile],
+ );
+
+ return (
+
+
+
+
+
+
+
+ Edit File
+
+ {selectedFile
+ ? `Editing: ${selectedFile}`
+ : "Select a file from the tree to edit"}
+
+
+
+ {selectedFile && (
+
+ {selectedFilePatch ? (
+
+ {updatePatch.isPending && (
+
+ )}
+ Unmark deletion
+
+ ) : (
+ <>
+
+ {isMarkingDeletion && (
+
+ )}
+
+ Mark for deletion
+
+
+ {isSavingPatch && (
+
+ )}
+
+ Save Patch
+
+ >
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+ handleCreateFile("", filename, content)
+ }
+ onOpenChange={(open) =>
+ setCreateFolderPath(open ? "" : null)
+ }
+ />
+
+ New file in root
+
+
+ {isDirLoading ? (
+
+
+
+ ) : directories ? (
+ renderTree(directories)
+ ) : (
+
+ No files found
+
+ )}
+
+
+
+
+ {isFileLoading ? (
+
+
+
+ ) : selectedFile ? (
+
setFileContent(value || "")}
+ className="h-full w-full"
+ wrapperClassName="h-full"
+ lineWrapping
+ />
+ ) : (
+
+ Select a file to edit
+
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx
new file mode 100644
index 000000000..e471b3fc1
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx
@@ -0,0 +1,225 @@
+import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Switch } from "@/components/ui/switch";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { api } from "@/utils/api";
+import { EditPatchDialog } from "./edit-patch-dialog";
+import { PatchEditor } from "./patch-editor";
+
+interface Props {
+ id: string;
+ type: "application" | "compose";
+}
+
+export const ShowPatches = ({ id, type }: Props) => {
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [repoPath, setRepoPath] = useState(null);
+ const [isLoadingRepo, setIsLoadingRepo] = useState(false);
+
+ const utils = api.useUtils();
+
+ const { data: patches, isPending: isPatchesLoading } =
+ api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
+
+ const mutationMap = {
+ application: () => api.patch.delete.useMutation(),
+ compose: () => api.patch.delete.useMutation(),
+ };
+
+ const ensureRepo = api.patch.ensureRepo.useMutation();
+
+ const togglePatch = api.patch.toggleEnabled.useMutation();
+
+ const { mutateAsync } = mutationMap[type]
+ ? mutationMap[type]()
+ : api.patch.delete.useMutation();
+
+ const handleCloseEditor = () => {
+ setSelectedFile(null);
+ setRepoPath(null);
+ };
+
+ if (repoPath) {
+ return (
+
+ );
+ }
+
+ const handleOpenEditor = async () => {
+ setIsLoadingRepo(true);
+ await ensureRepo
+ .mutateAsync({ id, type })
+ .then((result) => {
+ setRepoPath(result);
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ })
+ .finally(() => {
+ setIsLoadingRepo(false);
+ });
+ };
+
+ return (
+
+
+
+ Patches
+
+ Apply code patches to your repository during build. Patches are
+ applied after cloning the repository and before building.
+
+
+ {patches && patches?.length > 0 && (
+
+ {isLoadingRepo && }
+
+ Create Patch
+
+ )}
+
+
+ {isPatchesLoading ? (
+
+
+
+ ) : patches?.length === 0 ? (
+
+
+
+
+
+
No patches yet
+
+ Add file patches to modify your repo before each build—configs,
+ env, or code. Create your first patch to get started.
+
+
+
+ {isLoadingRepo && (
+
+ )}
+
+ Create Patch
+
+
+ ) : (
+
+
+
+ File Path
+ Type
+ Enabled
+ Actions
+
+
+
+ {patches?.map((patch) => (
+
+
+
+
+ {patch.filePath}
+
+
+
+
+ {patch.type}
+
+
+
+ {
+ togglePatch
+ .mutateAsync({
+ patchId: patch.patchId,
+ enabled: checked,
+ })
+ .then(() => {
+ toast.success("Patch updated");
+ utils.patch.byEntityId.invalidate({
+ id,
+ type,
+ });
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ })
+ .finally(() => {
+ setIsLoadingRepo(false);
+ });
+ }}
+ />
+
+
+
+ {(patch.type === "update" || patch.type === "create") && (
+
+ )}
+ {
+ mutateAsync({ patchId: patch.patchId })
+ .then(() => {
+ toast.success("Patch deleted");
+ utils.patch.byEntityId.invalidate({
+ id,
+ type,
+ });
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ }}
+ title="Delete patch"
+ >
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx
index eac4559f1..72815fd8f 100644
--- a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx
+++ b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Dices } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -75,17 +75,20 @@ export const AddPreviewDomain = ({
},
);
- const { mutateAsync, isError, error, isLoading } = domainId
+ const { mutateAsync, isError, error, isPending } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
- const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
+ const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const form = useForm({
resolver: zodResolver(domain),
});
+ const host = form.watch("host");
+ const isTraefikMeDomain = host?.includes("traefik.me") || false;
+
useEffect(() => {
if (data) {
form.reset({
@@ -100,7 +103,7 @@ export const AddPreviewDomain = ({
if (!domainId) {
form.reset({});
}
- }, [form, form.reset, data, isLoading]);
+ }, [form, form.reset, data, isPending]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
name="host"
render={({ field }) => (
+ {isTraefikMeDomain && (
+
+ Note: traefik.me is a public HTTP
+ service and does not support SSL/HTTPS. HTTPS and
+ certificate options will not have any effect.
+
+ )}
Host
@@ -291,7 +301,7 @@ export const AddPreviewDomain = ({
-
+
{dictionary.submit}
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx
index d93bbd1c8..e12400a7c 100644
--- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx
+++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx
@@ -1,7 +1,9 @@
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
ExternalLink,
FileText,
GitPullRequest,
+ Hammer,
Loader2,
PenSquare,
RocketIcon,
@@ -22,6 +24,12 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
@@ -35,9 +43,12 @@ interface Props {
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery({ applicationId });
- const { mutateAsync: deletePreviewDeployment, isLoading } =
+ const { mutateAsync: deletePreviewDeployment, isPending } =
api.previewDeployment.delete.useMutation();
+ const { mutateAsync: redeployPreviewDeployment } =
+ api.previewDeployment.redeploy.useMutation();
+
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
@@ -46,6 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId },
{
enabled: !!applicationId,
+ refetchInterval: 2000,
},
);
@@ -182,7 +194,68 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
id={deployment.previewDeploymentId}
type="previewDeployment"
serverId={data?.serverId || ""}
- />
+ >
+
+
+ Deployments
+
+
+
+ {
+ await redeployPreviewDeployment({
+ previewDeploymentId:
+ deployment.previewDeploymentId,
+ })
+ .then(() => {
+ toast.success(
+ "Preview deployment rebuild started",
+ );
+ refetchPreviewDeployments();
+ })
+ .catch(() => {
+ toast.error(
+ "Error rebuilding preview deployment",
+ );
+ });
+ }}
+ >
+
+
+
+
+
+
+ Rebuild
+
+
+
+
+
+ Rebuild the preview deployment without
+ downloading new code
+
+
+
+
+
+
+
{
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
index 862d5f87a..d2840cd67 100644
--- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
+++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
@@ -1,9 +1,10 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
+import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -79,7 +80,7 @@ interface Props {
export const ShowPreviewSettings = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
- const { mutateAsync: updateApplication, isLoading } =
+ const { mutateAsync: updateApplication, isPending } =
api.application.update.useMutation();
const { data, refetch } = api.application.one.useQuery({ applicationId });
@@ -100,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
});
const previewHttps = form.watch("previewHttps");
+ const wildcardDomain = form.watch("wildcardDomain");
+ const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -120,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
previewRequireCollaboratorPermissions:
- data.previewRequireCollaboratorPermissions || true,
+ data.previewRequireCollaboratorPermissions ?? true,
});
}
}, [data]);
@@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
+ {isTraefikMeDomain && (
+
+ Note: traefik.me is a public HTTP service and
+ does not support SSL/HTTPS. HTTPS and certificate options will
+ not have any effect.
+
+ )}
diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx
index 8273d0e2b..36ddb53f1 100644
--- a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx
+++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx
@@ -1,5 +1,7 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import {
+ CheckIcon,
+ ChevronsUpDown,
DatabaseZap,
Info,
PenBoxIcon,
@@ -13,6 +15,14 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -31,6 +41,12 @@ import {
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 {
Select,
SelectContent,
@@ -48,6 +64,7 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
+import { getTimezoneLabel, TIMEZONES } from "./timezones";
export const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
@@ -75,6 +92,7 @@ const formSchema = z
"dokploy-server",
]),
script: z.string(),
+ timezone: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
@@ -202,8 +220,8 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState
("cache");
const utils = api.useUtils();
- const form = useForm>({
- resolver: zodResolver(formSchema),
+ const form = useForm({
+ resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
cronExpression: "",
@@ -213,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
+ timezone: undefined,
},
});
@@ -251,15 +270,16 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
+ timezone: schedule.timezone || undefined,
});
}
}, [form, schedule, scheduleId]);
- const { mutateAsync, isLoading } = scheduleId
+ const { mutateAsync, isPending } = scheduleId
? api.schedule.update.useMutation()
: api.schedule.create.useMutation();
- const onSubmit = async (values: z.infer) => {
+ const onSubmit = async (values: z.output) => {
if (!id && !scheduleId) return;
await mutateAsync({
@@ -464,6 +484,89 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
formControl={form.control}
/>
+ (
+
+
+ Timezone
+
+
+
+
+
+
+
+ Select a timezone for the schedule. If not
+ specified, UTC will be used.
+
+
+
+
+
+
+
+
+
+ {getTimezoneLabel(field.value)}
+
+
+
+
+
+
+
+
+ No timezone found.
+
+ {Object.entries(TIMEZONES).map(
+ ([region, zones]) => (
+
+ {zones.map((tz) => (
+ {
+ field.onChange(tz.value);
+ }}
+ >
+ {tz.value}
+
+
+ ))}
+
+ ),
+ )}
+
+
+
+
+
+
+ Optional: Choose a timezone for the schedule execution time
+
+
+
+ )}
+ />
+
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>
@@ -559,7 +662,7 @@ echo "Hello, world!"
)}
/>
-
+
{scheduleId ? "Update" : "Create"} Schedule
diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx
index 26bfa9421..a9550fda2 100644
--- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx
+++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx
@@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
},
);
const utils = api.useUtils();
- const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
+ const { mutateAsync: deleteSchedule, isPending: isDeleting } =
api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
diff --git a/apps/dokploy/components/dashboard/application/schedules/timezones.ts b/apps/dokploy/components/dashboard/application/schedules/timezones.ts
new file mode 100644
index 000000000..44891b909
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/schedules/timezones.ts
@@ -0,0 +1,458 @@
+// Complete list of IANA timezones grouped by region
+export const TIMEZONES: Record<
+ string,
+ Array<{ label: string; value: string }>
+> = {
+ Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }],
+ Africa: [
+ { label: "Abidjan", value: "Africa/Abidjan" },
+ { label: "Accra", value: "Africa/Accra" },
+ { label: "Addis Ababa", value: "Africa/Addis_Ababa" },
+ { label: "Algiers", value: "Africa/Algiers" },
+ { label: "Asmara", value: "Africa/Asmara" },
+ { label: "Bamako", value: "Africa/Bamako" },
+ { label: "Bangui", value: "Africa/Bangui" },
+ { label: "Banjul", value: "Africa/Banjul" },
+ { label: "Bissau", value: "Africa/Bissau" },
+ { label: "Blantyre", value: "Africa/Blantyre" },
+ { label: "Brazzaville", value: "Africa/Brazzaville" },
+ { label: "Bujumbura", value: "Africa/Bujumbura" },
+ { label: "Cairo", value: "Africa/Cairo" },
+ { label: "Casablanca", value: "Africa/Casablanca" },
+ { label: "Ceuta", value: "Africa/Ceuta" },
+ { label: "Conakry", value: "Africa/Conakry" },
+ { label: "Dakar", value: "Africa/Dakar" },
+ { label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" },
+ { label: "Djibouti", value: "Africa/Djibouti" },
+ { label: "Douala", value: "Africa/Douala" },
+ { label: "El Aaiun", value: "Africa/El_Aaiun" },
+ { label: "Freetown", value: "Africa/Freetown" },
+ { label: "Gaborone", value: "Africa/Gaborone" },
+ { label: "Harare", value: "Africa/Harare" },
+ { label: "Johannesburg", value: "Africa/Johannesburg" },
+ { label: "Juba", value: "Africa/Juba" },
+ { label: "Kampala", value: "Africa/Kampala" },
+ { label: "Khartoum", value: "Africa/Khartoum" },
+ { label: "Kigali", value: "Africa/Kigali" },
+ { label: "Kinshasa", value: "Africa/Kinshasa" },
+ { label: "Lagos", value: "Africa/Lagos" },
+ { label: "Libreville", value: "Africa/Libreville" },
+ { label: "Lome", value: "Africa/Lome" },
+ { label: "Luanda", value: "Africa/Luanda" },
+ { label: "Lubumbashi", value: "Africa/Lubumbashi" },
+ { label: "Lusaka", value: "Africa/Lusaka" },
+ { label: "Malabo", value: "Africa/Malabo" },
+ { label: "Maputo", value: "Africa/Maputo" },
+ { label: "Maseru", value: "Africa/Maseru" },
+ { label: "Mbabane", value: "Africa/Mbabane" },
+ { label: "Mogadishu", value: "Africa/Mogadishu" },
+ { label: "Monrovia", value: "Africa/Monrovia" },
+ { label: "Nairobi", value: "Africa/Nairobi" },
+ { label: "Ndjamena", value: "Africa/Ndjamena" },
+ { label: "Niamey", value: "Africa/Niamey" },
+ { label: "Nouakchott", value: "Africa/Nouakchott" },
+ { label: "Ouagadougou", value: "Africa/Ouagadougou" },
+ { label: "Porto-Novo", value: "Africa/Porto-Novo" },
+ { label: "Sao Tome", value: "Africa/Sao_Tome" },
+ { label: "Tripoli", value: "Africa/Tripoli" },
+ { label: "Tunis", value: "Africa/Tunis" },
+ { label: "Windhoek", value: "Africa/Windhoek" },
+ ],
+ America: [
+ { label: "Adak", value: "America/Adak" },
+ { label: "Anchorage", value: "America/Anchorage" },
+ { label: "Anguilla", value: "America/Anguilla" },
+ { label: "Antigua", value: "America/Antigua" },
+ { label: "Araguaina", value: "America/Araguaina" },
+ {
+ label: "Argentina/Buenos Aires",
+ value: "America/Argentina/Buenos_Aires",
+ },
+ { label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" },
+ { label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" },
+ { label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" },
+ { label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" },
+ { label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" },
+ {
+ label: "Argentina/Rio Gallegos",
+ value: "America/Argentina/Rio_Gallegos",
+ },
+ { label: "Argentina/Salta", value: "America/Argentina/Salta" },
+ { label: "Argentina/San Juan", value: "America/Argentina/San_Juan" },
+ { label: "Argentina/San Luis", value: "America/Argentina/San_Luis" },
+ { label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" },
+ { label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" },
+ { label: "Aruba", value: "America/Aruba" },
+ { label: "Asuncion", value: "America/Asuncion" },
+ { label: "Atikokan", value: "America/Atikokan" },
+ { label: "Bahia", value: "America/Bahia" },
+ { label: "Bahia Banderas", value: "America/Bahia_Banderas" },
+ { label: "Barbados", value: "America/Barbados" },
+ { label: "Belem", value: "America/Belem" },
+ { label: "Belize", value: "America/Belize" },
+ { label: "Blanc-Sablon", value: "America/Blanc-Sablon" },
+ { label: "Boa Vista", value: "America/Boa_Vista" },
+ { label: "Bogota", value: "America/Bogota" },
+ { label: "Boise", value: "America/Boise" },
+ { label: "Cambridge Bay", value: "America/Cambridge_Bay" },
+ { label: "Campo Grande", value: "America/Campo_Grande" },
+ { label: "Cancun", value: "America/Cancun" },
+ { label: "Caracas", value: "America/Caracas" },
+ { label: "Cayenne", value: "America/Cayenne" },
+ { label: "Cayman", value: "America/Cayman" },
+ { label: "Chicago (Central Time)", value: "America/Chicago" },
+ { label: "Chihuahua", value: "America/Chihuahua" },
+ { label: "Ciudad Juarez", value: "America/Ciudad_Juarez" },
+ { label: "Costa Rica", value: "America/Costa_Rica" },
+ { label: "Creston", value: "America/Creston" },
+ { label: "Cuiaba", value: "America/Cuiaba" },
+ { label: "Curacao", value: "America/Curacao" },
+ { label: "Danmarkshavn", value: "America/Danmarkshavn" },
+ { label: "Dawson", value: "America/Dawson" },
+ { label: "Dawson Creek", value: "America/Dawson_Creek" },
+ { label: "Denver (Mountain Time)", value: "America/Denver" },
+ { label: "Detroit", value: "America/Detroit" },
+ { label: "Dominica", value: "America/Dominica" },
+ { label: "Edmonton", value: "America/Edmonton" },
+ { label: "Eirunepe", value: "America/Eirunepe" },
+ { label: "El Salvador", value: "America/El_Salvador" },
+ { label: "Fort Nelson", value: "America/Fort_Nelson" },
+ { label: "Fortaleza", value: "America/Fortaleza" },
+ { label: "Glace Bay", value: "America/Glace_Bay" },
+ { label: "Goose Bay", value: "America/Goose_Bay" },
+ { label: "Grand Turk", value: "America/Grand_Turk" },
+ { label: "Grenada", value: "America/Grenada" },
+ { label: "Guadeloupe", value: "America/Guadeloupe" },
+ { label: "Guatemala", value: "America/Guatemala" },
+ { label: "Guayaquil", value: "America/Guayaquil" },
+ { label: "Guyana", value: "America/Guyana" },
+ { label: "Halifax", value: "America/Halifax" },
+ { label: "Havana", value: "America/Havana" },
+ { label: "Hermosillo", value: "America/Hermosillo" },
+ { label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" },
+ { label: "Indiana/Knox", value: "America/Indiana/Knox" },
+ { label: "Indiana/Marengo", value: "America/Indiana/Marengo" },
+ { label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" },
+ { label: "Indiana/Tell City", value: "America/Indiana/Tell_City" },
+ { label: "Indiana/Vevay", value: "America/Indiana/Vevay" },
+ { label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" },
+ { label: "Indiana/Winamac", value: "America/Indiana/Winamac" },
+ { label: "Inuvik", value: "America/Inuvik" },
+ { label: "Iqaluit", value: "America/Iqaluit" },
+ { label: "Jamaica", value: "America/Jamaica" },
+ { label: "Juneau", value: "America/Juneau" },
+ { label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" },
+ { label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" },
+ { label: "Kralendijk", value: "America/Kralendijk" },
+ { label: "La Paz", value: "America/La_Paz" },
+ { label: "Lima", value: "America/Lima" },
+ { label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" },
+ { label: "Lower Princes", value: "America/Lower_Princes" },
+ { label: "Maceio", value: "America/Maceio" },
+ { label: "Managua", value: "America/Managua" },
+ { label: "Manaus", value: "America/Manaus" },
+ { label: "Marigot", value: "America/Marigot" },
+ { label: "Martinique", value: "America/Martinique" },
+ { label: "Matamoros", value: "America/Matamoros" },
+ { label: "Mazatlan", value: "America/Mazatlan" },
+ { label: "Menominee", value: "America/Menominee" },
+ { label: "Merida", value: "America/Merida" },
+ { label: "Metlakatla", value: "America/Metlakatla" },
+ { label: "Mexico City (Central Mexico)", value: "America/Mexico_City" },
+ { label: "Miquelon", value: "America/Miquelon" },
+ { label: "Moncton", value: "America/Moncton" },
+ { label: "Monterrey", value: "America/Monterrey" },
+ { label: "Montevideo", value: "America/Montevideo" },
+ { label: "Montserrat", value: "America/Montserrat" },
+ { label: "Nassau", value: "America/Nassau" },
+ { label: "New York (Eastern Time)", value: "America/New_York" },
+ { label: "Nome", value: "America/Nome" },
+ { label: "Noronha", value: "America/Noronha" },
+ { label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" },
+ { label: "North Dakota/Center", value: "America/North_Dakota/Center" },
+ {
+ label: "North Dakota/New Salem",
+ value: "America/North_Dakota/New_Salem",
+ },
+ { label: "Nuuk", value: "America/Nuuk" },
+ { label: "Ojinaga", value: "America/Ojinaga" },
+ { label: "Panama", value: "America/Panama" },
+ { label: "Paramaribo", value: "America/Paramaribo" },
+ { label: "Phoenix", value: "America/Phoenix" },
+ { label: "Port-au-Prince", value: "America/Port-au-Prince" },
+ { label: "Port of Spain", value: "America/Port_of_Spain" },
+ { label: "Porto Velho", value: "America/Porto_Velho" },
+ { label: "Puerto Rico", value: "America/Puerto_Rico" },
+ { label: "Punta Arenas", value: "America/Punta_Arenas" },
+ { label: "Rankin Inlet", value: "America/Rankin_Inlet" },
+ { label: "Recife", value: "America/Recife" },
+ { label: "Regina", value: "America/Regina" },
+ { label: "Resolute", value: "America/Resolute" },
+ { label: "Rio Branco", value: "America/Rio_Branco" },
+ { label: "Santarem", value: "America/Santarem" },
+ { label: "Santiago", value: "America/Santiago" },
+ { label: "Santo Domingo", value: "America/Santo_Domingo" },
+ { label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
+ { label: "Scoresbysund", value: "America/Scoresbysund" },
+ { label: "Sitka", value: "America/Sitka" },
+ { label: "St Barthelemy", value: "America/St_Barthelemy" },
+ { label: "St Johns", value: "America/St_Johns" },
+ { label: "St Kitts", value: "America/St_Kitts" },
+ { label: "St Lucia", value: "America/St_Lucia" },
+ { label: "St Thomas", value: "America/St_Thomas" },
+ { label: "St Vincent", value: "America/St_Vincent" },
+ { label: "Swift Current", value: "America/Swift_Current" },
+ { label: "Tegucigalpa", value: "America/Tegucigalpa" },
+ { label: "Thule", value: "America/Thule" },
+ { label: "Tijuana", value: "America/Tijuana" },
+ { label: "Toronto", value: "America/Toronto" },
+ { label: "Tortola", value: "America/Tortola" },
+ { label: "Vancouver", value: "America/Vancouver" },
+ { label: "Whitehorse", value: "America/Whitehorse" },
+ { label: "Winnipeg", value: "America/Winnipeg" },
+ { label: "Yakutat", value: "America/Yakutat" },
+ ],
+ Antarctica: [
+ { label: "Casey", value: "Antarctica/Casey" },
+ { label: "Davis", value: "Antarctica/Davis" },
+ { label: "DumontDUrville", value: "Antarctica/DumontDUrville" },
+ { label: "Macquarie", value: "Antarctica/Macquarie" },
+ { label: "Mawson", value: "Antarctica/Mawson" },
+ { label: "McMurdo", value: "Antarctica/McMurdo" },
+ { label: "Palmer", value: "Antarctica/Palmer" },
+ { label: "Rothera", value: "Antarctica/Rothera" },
+ { label: "Syowa", value: "Antarctica/Syowa" },
+ { label: "Troll", value: "Antarctica/Troll" },
+ { label: "Vostok", value: "Antarctica/Vostok" },
+ ],
+ Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }],
+ Asia: [
+ { label: "Aden", value: "Asia/Aden" },
+ { label: "Almaty", value: "Asia/Almaty" },
+ { label: "Amman", value: "Asia/Amman" },
+ { label: "Anadyr", value: "Asia/Anadyr" },
+ { label: "Aqtau", value: "Asia/Aqtau" },
+ { label: "Aqtobe", value: "Asia/Aqtobe" },
+ { label: "Ashgabat", value: "Asia/Ashgabat" },
+ { label: "Atyrau", value: "Asia/Atyrau" },
+ { label: "Baghdad", value: "Asia/Baghdad" },
+ { label: "Bahrain", value: "Asia/Bahrain" },
+ { label: "Baku", value: "Asia/Baku" },
+ { label: "Bangkok", value: "Asia/Bangkok" },
+ { label: "Barnaul", value: "Asia/Barnaul" },
+ { label: "Beirut", value: "Asia/Beirut" },
+ { label: "Bishkek", value: "Asia/Bishkek" },
+ { label: "Brunei", value: "Asia/Brunei" },
+ { label: "Chita", value: "Asia/Chita" },
+ { label: "Choibalsan", value: "Asia/Choibalsan" },
+ { label: "Colombo", value: "Asia/Colombo" },
+ { label: "Damascus", value: "Asia/Damascus" },
+ { label: "Dhaka", value: "Asia/Dhaka" },
+ { label: "Dili", value: "Asia/Dili" },
+ { label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
+ { label: "Dushanbe", value: "Asia/Dushanbe" },
+ { label: "Famagusta", value: "Asia/Famagusta" },
+ { label: "Gaza", value: "Asia/Gaza" },
+ { label: "Hebron", value: "Asia/Hebron" },
+ { label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" },
+ { label: "Hong Kong", value: "Asia/Hong_Kong" },
+ { label: "Hovd", value: "Asia/Hovd" },
+ { label: "Irkutsk", value: "Asia/Irkutsk" },
+ { label: "Jakarta", value: "Asia/Jakarta" },
+ { label: "Jayapura", value: "Asia/Jayapura" },
+ { label: "Jerusalem", value: "Asia/Jerusalem" },
+ { label: "Kabul", value: "Asia/Kabul" },
+ { label: "Kamchatka", value: "Asia/Kamchatka" },
+ { label: "Karachi", value: "Asia/Karachi" },
+ { label: "Kathmandu", value: "Asia/Kathmandu" },
+ { label: "Khandyga", value: "Asia/Khandyga" },
+ { label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" },
+ { label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" },
+ { label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" },
+ { label: "Kuching", value: "Asia/Kuching" },
+ { label: "Kuwait", value: "Asia/Kuwait" },
+ { label: "Macau", value: "Asia/Macau" },
+ { label: "Magadan", value: "Asia/Magadan" },
+ { label: "Makassar", value: "Asia/Makassar" },
+ { label: "Manila", value: "Asia/Manila" },
+ { label: "Muscat", value: "Asia/Muscat" },
+ { label: "Nicosia", value: "Asia/Nicosia" },
+ { label: "Novokuznetsk", value: "Asia/Novokuznetsk" },
+ { label: "Novosibirsk", value: "Asia/Novosibirsk" },
+ { label: "Omsk", value: "Asia/Omsk" },
+ { label: "Oral", value: "Asia/Oral" },
+ { label: "Phnom Penh", value: "Asia/Phnom_Penh" },
+ { label: "Pontianak", value: "Asia/Pontianak" },
+ { label: "Pyongyang", value: "Asia/Pyongyang" },
+ { label: "Qatar", value: "Asia/Qatar" },
+ { label: "Qostanay", value: "Asia/Qostanay" },
+ { label: "Qyzylorda", value: "Asia/Qyzylorda" },
+ { label: "Riyadh", value: "Asia/Riyadh" },
+ { label: "Sakhalin", value: "Asia/Sakhalin" },
+ { label: "Samarkand", value: "Asia/Samarkand" },
+ { label: "Seoul", value: "Asia/Seoul" },
+ { label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" },
+ { label: "Singapore", value: "Asia/Singapore" },
+ { label: "Srednekolymsk", value: "Asia/Srednekolymsk" },
+ { label: "Taipei", value: "Asia/Taipei" },
+ { label: "Tashkent", value: "Asia/Tashkent" },
+ { label: "Tbilisi", value: "Asia/Tbilisi" },
+ { label: "Tehran", value: "Asia/Tehran" },
+ { label: "Thimphu", value: "Asia/Thimphu" },
+ { label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
+ { label: "Tomsk", value: "Asia/Tomsk" },
+ { label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" },
+ { label: "Urumqi", value: "Asia/Urumqi" },
+ { label: "Ust-Nera", value: "Asia/Ust-Nera" },
+ { label: "Vientiane", value: "Asia/Vientiane" },
+ { label: "Vladivostok", value: "Asia/Vladivostok" },
+ { label: "Yakutsk", value: "Asia/Yakutsk" },
+ { label: "Yangon", value: "Asia/Yangon" },
+ { label: "Yekaterinburg", value: "Asia/Yekaterinburg" },
+ { label: "Yerevan", value: "Asia/Yerevan" },
+ ],
+ Atlantic: [
+ { label: "Azores", value: "Atlantic/Azores" },
+ { label: "Bermuda", value: "Atlantic/Bermuda" },
+ { label: "Canary", value: "Atlantic/Canary" },
+ { label: "Cape Verde", value: "Atlantic/Cape_Verde" },
+ { label: "Faroe", value: "Atlantic/Faroe" },
+ { label: "Madeira", value: "Atlantic/Madeira" },
+ { label: "Reykjavik", value: "Atlantic/Reykjavik" },
+ { label: "South Georgia", value: "Atlantic/South_Georgia" },
+ { label: "St Helena", value: "Atlantic/St_Helena" },
+ { label: "Stanley", value: "Atlantic/Stanley" },
+ ],
+ Australia: [
+ { label: "Adelaide", value: "Australia/Adelaide" },
+ { label: "Brisbane", value: "Australia/Brisbane" },
+ { label: "Broken Hill", value: "Australia/Broken_Hill" },
+ { label: "Darwin", value: "Australia/Darwin" },
+ { label: "Eucla", value: "Australia/Eucla" },
+ { label: "Hobart", value: "Australia/Hobart" },
+ { label: "Lindeman", value: "Australia/Lindeman" },
+ { label: "Lord Howe", value: "Australia/Lord_Howe" },
+ { label: "Melbourne", value: "Australia/Melbourne" },
+ { label: "Perth", value: "Australia/Perth" },
+ { label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" },
+ ],
+ Europe: [
+ { label: "Amsterdam", value: "Europe/Amsterdam" },
+ { label: "Andorra", value: "Europe/Andorra" },
+ { label: "Astrakhan", value: "Europe/Astrakhan" },
+ { label: "Athens", value: "Europe/Athens" },
+ { label: "Belgrade", value: "Europe/Belgrade" },
+ { label: "Berlin (Central European Time)", value: "Europe/Berlin" },
+ { label: "Bratislava", value: "Europe/Bratislava" },
+ { label: "Brussels", value: "Europe/Brussels" },
+ { label: "Bucharest", value: "Europe/Bucharest" },
+ { label: "Budapest", value: "Europe/Budapest" },
+ { label: "Busingen", value: "Europe/Busingen" },
+ { label: "Chisinau", value: "Europe/Chisinau" },
+ { label: "Copenhagen", value: "Europe/Copenhagen" },
+ { label: "Dublin", value: "Europe/Dublin" },
+ { label: "Gibraltar", value: "Europe/Gibraltar" },
+ { label: "Guernsey", value: "Europe/Guernsey" },
+ { label: "Helsinki", value: "Europe/Helsinki" },
+ { label: "Isle of Man", value: "Europe/Isle_of_Man" },
+ { label: "Istanbul", value: "Europe/Istanbul" },
+ { label: "Jersey", value: "Europe/Jersey" },
+ { label: "Kaliningrad", value: "Europe/Kaliningrad" },
+ { label: "Kirov", value: "Europe/Kirov" },
+ { label: "Kyiv", value: "Europe/Kyiv" },
+ { label: "Lisbon", value: "Europe/Lisbon" },
+ { label: "Ljubljana", value: "Europe/Ljubljana" },
+ { label: "London (Greenwich Mean Time)", value: "Europe/London" },
+ { label: "Luxembourg", value: "Europe/Luxembourg" },
+ { label: "Madrid", value: "Europe/Madrid" },
+ { label: "Malta", value: "Europe/Malta" },
+ { label: "Mariehamn", value: "Europe/Mariehamn" },
+ { label: "Minsk", value: "Europe/Minsk" },
+ { label: "Monaco", value: "Europe/Monaco" },
+ { label: "Moscow", value: "Europe/Moscow" },
+ { label: "Oslo", value: "Europe/Oslo" },
+ { label: "Paris (Central European Time)", value: "Europe/Paris" },
+ { label: "Podgorica", value: "Europe/Podgorica" },
+ { label: "Prague", value: "Europe/Prague" },
+ { label: "Riga", value: "Europe/Riga" },
+ { label: "Rome", value: "Europe/Rome" },
+ { label: "Samara", value: "Europe/Samara" },
+ { label: "San Marino", value: "Europe/San_Marino" },
+ { label: "Sarajevo", value: "Europe/Sarajevo" },
+ { label: "Saratov", value: "Europe/Saratov" },
+ { label: "Simferopol", value: "Europe/Simferopol" },
+ { label: "Skopje", value: "Europe/Skopje" },
+ { label: "Sofia", value: "Europe/Sofia" },
+ { label: "Stockholm", value: "Europe/Stockholm" },
+ { label: "Tallinn", value: "Europe/Tallinn" },
+ { label: "Tirane", value: "Europe/Tirane" },
+ { label: "Ulyanovsk", value: "Europe/Ulyanovsk" },
+ { label: "Vaduz", value: "Europe/Vaduz" },
+ { label: "Vatican", value: "Europe/Vatican" },
+ { label: "Vienna", value: "Europe/Vienna" },
+ { label: "Vilnius", value: "Europe/Vilnius" },
+ { label: "Volgograd", value: "Europe/Volgograd" },
+ { label: "Warsaw", value: "Europe/Warsaw" },
+ { label: "Zagreb", value: "Europe/Zagreb" },
+ { label: "Zurich", value: "Europe/Zurich" },
+ ],
+ Indian: [
+ { label: "Antananarivo", value: "Indian/Antananarivo" },
+ { label: "Chagos", value: "Indian/Chagos" },
+ { label: "Christmas", value: "Indian/Christmas" },
+ { label: "Cocos", value: "Indian/Cocos" },
+ { label: "Comoro", value: "Indian/Comoro" },
+ { label: "Kerguelen", value: "Indian/Kerguelen" },
+ { label: "Mahe", value: "Indian/Mahe" },
+ { label: "Maldives", value: "Indian/Maldives" },
+ { label: "Mauritius", value: "Indian/Mauritius" },
+ { label: "Mayotte", value: "Indian/Mayotte" },
+ { label: "Reunion", value: "Indian/Reunion" },
+ ],
+ Pacific: [
+ { label: "Apia", value: "Pacific/Apia" },
+ { label: "Auckland", value: "Pacific/Auckland" },
+ { label: "Bougainville", value: "Pacific/Bougainville" },
+ { label: "Chatham", value: "Pacific/Chatham" },
+ { label: "Chuuk", value: "Pacific/Chuuk" },
+ { label: "Easter", value: "Pacific/Easter" },
+ { label: "Efate", value: "Pacific/Efate" },
+ { label: "Fakaofo", value: "Pacific/Fakaofo" },
+ { label: "Fiji", value: "Pacific/Fiji" },
+ { label: "Funafuti", value: "Pacific/Funafuti" },
+ { label: "Galapagos", value: "Pacific/Galapagos" },
+ { label: "Gambier", value: "Pacific/Gambier" },
+ { label: "Guadalcanal", value: "Pacific/Guadalcanal" },
+ { label: "Guam", value: "Pacific/Guam" },
+ { label: "Honolulu", value: "Pacific/Honolulu" },
+ { label: "Kanton", value: "Pacific/Kanton" },
+ { label: "Kiritimati", value: "Pacific/Kiritimati" },
+ { label: "Kosrae", value: "Pacific/Kosrae" },
+ { label: "Kwajalein", value: "Pacific/Kwajalein" },
+ { label: "Majuro", value: "Pacific/Majuro" },
+ { label: "Marquesas", value: "Pacific/Marquesas" },
+ { label: "Midway", value: "Pacific/Midway" },
+ { label: "Nauru", value: "Pacific/Nauru" },
+ { label: "Niue", value: "Pacific/Niue" },
+ { label: "Norfolk", value: "Pacific/Norfolk" },
+ { label: "Noumea", value: "Pacific/Noumea" },
+ { label: "Pago Pago", value: "Pacific/Pago_Pago" },
+ { label: "Palau", value: "Pacific/Palau" },
+ { label: "Pitcairn", value: "Pacific/Pitcairn" },
+ { label: "Pohnpei", value: "Pacific/Pohnpei" },
+ { label: "Port Moresby", value: "Pacific/Port_Moresby" },
+ { label: "Rarotonga", value: "Pacific/Rarotonga" },
+ { label: "Saipan", value: "Pacific/Saipan" },
+ { label: "Tahiti", value: "Pacific/Tahiti" },
+ { label: "Tarawa", value: "Pacific/Tarawa" },
+ { label: "Tongatapu", value: "Pacific/Tongatapu" },
+ { label: "Wake", value: "Pacific/Wake" },
+ { label: "Wallis", value: "Pacific/Wallis" },
+ ],
+};
+
+// Helper to get display label for a timezone value
+export function getTimezoneLabel(value: string | undefined): string {
+ if (!value) return "UTC (default)";
+ return value;
+}
diff --git a/apps/dokploy/components/dashboard/application/update-application.tsx b/apps/dokploy/components/dashboard/application/update-application.tsx
index 754074d75..98c49a999 100644
--- a/apps/dokploy/components/dashboard/application/update-application.tsx
+++ b/apps/dokploy/components/dashboard/application/update-application.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -43,7 +43,7 @@ interface Props {
export const UpdateApplication = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.application.update.useMutation();
const { data } = api.application.one.useQuery(
{
@@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
/>
diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx
index 804b4c39b..0d87080d7 100644
--- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx
+++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -47,7 +47,13 @@ const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
- volumeName: z.string().min(1, "Volume name is required"),
+ volumeName: z
+ .string()
+ .min(1, "Volume name is required")
+ .regex(
+ /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
+ "Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
+ ),
prefix: z.string(),
keepLatestCount: z.coerce
.number()
@@ -65,6 +71,7 @@ const formSchema = z
"mongo",
"mysql",
"redis",
+ "libsql",
]),
serviceName: z.string(),
destinationId: z.string().min(1, "Destination required"),
@@ -110,7 +117,7 @@ export const HandleVolumeBackups = ({
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
const utils = api.useUtils();
- const form = useForm>({
+ const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
@@ -189,7 +196,7 @@ export const HandleVolumeBackups = ({
}
}, [form, volumeBackup, volumeBackupId]);
- const { mutateAsync, isLoading } = volumeBackupId
+ const { mutateAsync, isPending } = volumeBackupId
? api.volumeBackups.update.useMutation()
: api.volumeBackups.create.useMutation();
@@ -201,7 +208,7 @@ export const HandleVolumeBackups = ({
await mutateAsync({
...values,
- keepLatestCount: preparedKeepLatestCount,
+ keepLatestCount: preparedKeepLatestCount ?? undefined,
destinationId: values.destinationId,
volumeBackupId: volumeBackupId || "",
serviceType: volumeBackupType,
@@ -624,7 +631,7 @@ export const HandleVolumeBackups = ({
)}
/>
-
+
{volumeBackupId ? "Update" : "Create"} Volume Backup
diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx
index 6eda33648..684620947 100644
--- a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx
+++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx
@@ -1,6 +1,6 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
-import { debounce } from "lodash";
+import debounce from "lodash/debounce";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -53,27 +53,15 @@ interface Props {
}
const RestoreBackupSchema = z.object({
- destinationId: z
- .string({
- required_error: "Please select a destination",
- })
- .min(1, {
- message: "Destination is required",
- }),
- backupFile: z
- .string({
- required_error: "Please select a backup file",
- })
- .min(1, {
- message: "Backup file is required",
- }),
- volumeName: z
- .string({
- required_error: "Please enter a volume name",
- })
- .min(1, {
- message: "Volume name is required",
- }),
+ destinationId: z.string().min(1, {
+ message: "Destination is required",
+ }),
+ backupFile: z.string().min(1, {
+ message: "Backup file is required",
+ }),
+ volumeName: z.string().min(1, {
+ message: "Volume name is required",
+ }),
});
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
@@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
const { data: destinations = [] } = api.destination.all.useQuery();
- const form = useForm>({
+ const form = useForm({
defaultValues: {
destinationId: "",
backupFile: "",
@@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
debouncedSetSearch(value);
};
- const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
+ const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destinationId,
search: debouncedSearchTerm,
@@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
onValueChange={handleSearchChange}
className="h-9"
/>
- {isLoading ? (
+ {isPending ? (
Loading backup files...
diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx
index 092538150..526bcfa77 100644
--- a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx
+++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx
@@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({
},
);
const utils = api.useUtils();
- const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
+ const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } =
api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually } =
api.volumeBackups.runManually.useMutation();
@@ -86,7 +86,7 @@ export const ShowVolumeBackups = ({
Schedule volume backups to run automatically at specified
- intervals.
+ intervals
diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx
index 52eb18907..c5f9334ec 100644
--- a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx
+++ b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const utils = api.useUtils();
- const { mutateAsync, isLoading } = api.compose.update.useMutation();
+ const { mutateAsync, isPending } = api.compose.update.useMutation();
const form = useForm
({
defaultValues: {
@@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
/>
-
+
Save
diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx
index 5b6e04154..0fad7d20e 100644
--- a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx
+++ b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx
index 5c8577dff..35fe01ff9 100644
--- a/apps/dokploy/components/dashboard/compose/delete-service.tsx
+++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx
@@ -1,5 +1,5 @@
import type { ServiceType } from "@dokploy/server/db/schema";
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/router";
@@ -46,6 +46,8 @@ interface Props {
}
export const DeleteService = ({ id, type }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDelete = permissions?.service.delete ?? false;
const [isOpen, setIsOpen] = useState(false);
const queryMap = {
@@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => {
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
@@ -70,11 +73,12 @@ export const DeleteService = ({ id, type }: Props) => {
redis: () => api.redis.remove.useMutation(),
mysql: () => api.mysql.remove.useMutation(),
mariadb: () => api.mariadb.remove.useMutation(),
+ libsql: () => api.libsql.remove.useMutation(),
application: () => api.application.delete.useMutation(),
mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(),
};
- const { mutateAsync, isLoading } = mutationMap[type]
+ const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.remove.useMutation();
const { push } = useRouter();
@@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => {
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
+ libsqlId: id || "",
applicationId: id || "",
composeId: id || "",
deleteVolumes,
@@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => {
data?.applicationStatus === "running") ||
(data && "composeStatus" in data && data?.composeStatus === "running");
+ if (!canDelete) return null;
+
return (
@@ -130,7 +137,7 @@ export const DeleteService = ({ id, type }: Props) => {
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
- isLoading={isLoading}
+ isLoading={isPending}
>
@@ -228,7 +235,7 @@ export const DeleteService = ({ id, type }: Props) => {
{
const router = useRouter();
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDeploy = permissions?.deployment.create ?? false;
+ const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
@@ -28,169 +31,176 @@ export const ComposeActions = ({ composeId }: Props) => {
const { mutateAsync: update } = api.compose.update.useMutation();
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
- const { mutateAsync: start, isLoading: isStarting } =
+ const { mutateAsync: start, isPending: isStarting } =
api.compose.start.useMutation();
- const { mutateAsync: stop, isLoading: isStopping } =
+ const { mutateAsync: stop, isPending: isStopping } =
api.compose.stop.useMutation();
return (
- {
- await deploy({
- composeId: composeId,
- })
- .then(() => {
- toast.success("Compose deployed successfully");
- refetch();
- router.push(
- `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
- );
- })
- .catch(() => {
- toast.error("Error deploying compose");
- });
- }}
- >
-
-
-
-
-
- Deploy
-
-
-
-
- Downloads the source code and performs a complete build
-
-
-
-
-
- {
- await redeploy({
- composeId: composeId,
- })
- .then(() => {
- toast.success("Compose reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading compose");
- });
- }}
- >
-
-
-
-
-
- Reload
-
-
-
-
- Reload the compose without rebuilding it
-
-
-
-
-
- {data?.composeType === "docker-compose" &&
- data?.composeStatus === "idle" ? (
+ {canDeploy && (
{
- await start({
+ await deploy({
composeId: composeId,
})
.then(() => {
- toast.success("Compose started successfully");
+ toast.success("Compose deployed successfully");
refetch();
+ router.push(
+ `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
+ );
})
.catch(() => {
- toast.error("Error starting compose");
+ toast.error("Error deploying compose");
});
}}
>
-
- Start
+
+ Deploy
- Start the compose (requires a previous successful build)
+ Downloads the source code and performs a complete build
- ) : (
+ )}
+ {canDeploy && (
{
- await stop({
+ await redeploy({
composeId: composeId,
})
.then(() => {
- toast.success("Compose stopped successfully");
+ toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
- toast.error("Error stopping compose");
+ toast.error("Error reloading compose");
});
}}
>
-
- Stop
+
+ Reload
- Stop the currently running compose
+ Reload the compose without rebuilding it
)}
+ {canDeploy &&
+ (data?.composeType === "docker-compose" &&
+ data?.composeStatus === "idle" ? (
+ {
+ await start({
+ composeId: composeId,
+ })
+ .then(() => {
+ toast.success("Compose started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting compose");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the compose (requires a previous successful build)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ composeId: composeId,
+ })
+ .then(() => {
+ toast.success("Compose stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping compose");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running compose
+
+
+
+
+
+ ))}
{
Open Terminal
-
-
Autodeploy
-
{
- await update({
- composeId,
- autoDeploy: enabled,
- })
- .then(async () => {
- toast.success("Auto Deploy Updated");
- await refetch();
+ {canUpdateService && (
+
+ Autodeploy
+ {
+ await update({
+ composeId,
+ autoDeploy: enabled,
})
- .catch(() => {
- toast.error("Error updating Auto Deploy");
- });
- }}
- className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
- />
-
+ .then(async () => {
+ toast.success("Auto Deploy Updated");
+ await refetch();
+ })
+ .catch(() => {
+ toast.error("Error updating Auto Deploy");
+ });
+ }}
+ className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
+ />
+
+ )}
);
};
diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx
index cb727e2a9..28f958e3e 100644
--- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
type AddComposeFile = z.infer;
export const ComposeFileEditor = ({ composeId }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canUpdate = permissions?.service.create ?? false;
const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery(
{
@@ -34,7 +36,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
{ enabled: !!composeId },
);
- const { mutateAsync, isLoading } = api.compose.update.useMutation();
+ const { mutateAsync, isPending } = api.compose.update.useMutation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm({
@@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -103,7 +105,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
- }, [form, onSubmit, isLoading]);
+ }, [form, onSubmit, isPending]);
return (
<>
@@ -164,14 +166,16 @@ services:
-
- Save
-
+ {canUpdate && (
+
+ Save
+
+ )}
>
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx
index 06c88fff4..3e099251e 100644
--- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
+ slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -73,15 +74,16 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
- const { mutateAsync, isLoading: isSavingBitbucketProvider } =
+ const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.compose.update.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
owner: "",
repo: "",
+ slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
- repo: repository?.repo,
+ repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
- enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
+ enabled:
+ !!repository?.owner &&
+ !!(repository?.slug || repository?.repo) &&
+ !!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
+ slug: data.bitbucketRepositorySlug || "",
},
composePath: data.composePath,
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
+ bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketId: data.bitbucketId,
composePath: data.composePath,
@@ -183,6 +190,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
+ slug: "",
});
form.setValue("branch", "");
}}
@@ -219,7 +227,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
Repository
{field.value.owner && field.value.repo && (
{
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo) => repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -257,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!bitbucketId ? (
+
+ Select a Bitbucket account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -273,6 +285,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
+ slug: repo.slug,
});
form.setValue("branch", "");
}}
@@ -322,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -339,7 +352,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
- {status === "loading" && fetchStatus === "fetching" && (
+ {status === "pending" && fetchStatus === "fetching" && (
Loading Branches....
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx
index d8c9d4d8f..c84a55bb3 100644
--- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx
@@ -1,5 +1,5 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { KeyRoundIcon, LockIcon, X } from "lucide-react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -58,9 +58,9 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
- const { mutateAsync, isLoading } = api.compose.update.useMutation();
+ const { mutateAsync, isPending } = api.compose.update.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
branch: "",
repositoryURL: "",
@@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
Watch Paths
-
-
- ?
-
+
+
@@ -318,7 +316,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
-
+
Save{" "}
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx
index fce562285..39f025438 100644
--- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -72,10 +72,10 @@ interface Props {
export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
- const { mutateAsync, isLoading: isSavingGiteaProvider } =
+ const { mutateAsync, isPending: isSavingGiteaProvider } =
api.compose.update.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo) => repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!giteaId ? (
+
+ Select a Gitea account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -327,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx
index 5ad950e4c..827ce1a8a 100644
--- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
- const { mutateAsync, isLoading: isSavingGithubProvider } =
+ const { mutateAsync, isPending: isSavingGithubProvider } =
api.compose.update.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
- const { data: repositories, isLoading: isLoadingRepositories } =
+ const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
githubId,
@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo) => repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!githubId ? (
+
+ Select a GitHub account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -317,7 +321,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -334,7 +338,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
- {status === "loading" && fetchStatus === "fetching" && (
+ {status === "pending" && fetchStatus === "fetching" && (
Loading Branches....
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx
index 933abd1a2..63de87d8f 100644
--- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx
@@ -1,7 +1,7 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -74,10 +74,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
- const { mutateAsync, isLoading: isSavingGitlabProvider } =
+ const { mutateAsync, isPending: isSavingGitlabProvider } =
api.compose.update.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const gitlabId = form.watch("gitlabId");
+ const gitlabUrl = useMemo(() => {
+ const url = gitlabProviders?.find(
+ (provider) => provider.gitlabId === gitlabId,
+ )?.gitlabUrl;
+
+ const gitlabUrl = url?.replace(/\/$/, "");
+
+ return gitlabUrl || "https://gitlab.com";
+ }, [gitlabId, gitlabProviders]);
+
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
Repository
- {field.value.owner && field.value.repo && (
+ {field.value.gitlabPathNamespace && (
{
!field.value && "text-muted-foreground",
)}
>
- {isLoadingRepositories
- ? "Loading...."
- : field.value.owner
- ? repositories?.find(
+ {!field.value.owner
+ ? "Select repository"
+ : isLoadingRepositories
+ ? "Loading...."
+ : (repositories?.find(
(repo) => repo.name === field.value.repo,
- )?.name
- : "Select repository"}
+ )?.name ?? "Select repository")}
@@ -264,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
placeholder="Search repository..."
className="h-9"
/>
- {isLoadingRepositories && (
+ {!gitlabId ? (
+
+ Select a GitLab account first
+
+ ) : isLoadingRepositories ? (
Loading Repositories....
- )}
+ ) : null}
No repositories found.
@@ -339,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground",
)}
>
- {status === "loading" && fetchStatus === "fetching"
+ {status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
@@ -356,7 +370,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..."
className="h-9"
/>
- {status === "loading" && fetchStatus === "fetching" && (
+ {status === "pending" && fetchStatus === "fetching" && (
Loading Branches....
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx
index 798f72249..759fe728c 100644
--- a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx
@@ -27,13 +27,13 @@ interface Props {
}
export const ShowProviderFormCompose = ({ composeId }: Props) => {
- const { data: githubProviders, isLoading: isLoadingGithub } =
+ const { data: githubProviders, isPending: isLoadingGithub } =
api.github.githubProviders.useQuery();
- const { data: gitlabProviders, isLoading: isLoadingGitlab } =
+ const { data: gitlabProviders, isPending: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
- const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
+ const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery();
- const { data: giteaProviders, isLoading: isLoadingGitea } =
+ const { data: giteaProviders, isPending: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { mutateAsync: disconnectGitProvider } =
diff --git a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx
index 2c488aefe..99c749c26 100644
--- a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
diff --git a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx
index fac6c2a34..211f5f5c7 100644
--- a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx
@@ -32,7 +32,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
},
);
- const { mutateAsync, isLoading } = api.compose.fetchSourceType.useMutation();
+ const { mutateAsync, isPending } = api.compose.fetchSourceType.useMutation();
useEffect(() => {
if (isOpen) {
@@ -66,7 +66,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
- {isLoading ? (
+ {isPending ? (
@@ -82,7 +82,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
{
mutateAsync({ composeId })
.then(() => {
diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx
index 98c6c0470..159ab3485 100644
--- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx
+++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx
@@ -41,7 +41,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState();
- const { data: services, isLoading: servicesLoading } =
+ const { data: services, isPending: servicesLoading } =
api.docker.getStackContainersByAppName.useQuery(
{
appName,
@@ -52,7 +52,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
},
);
- const { data: containers, isLoading: containersLoading } =
+ const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
@@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
{container.state}
+ {container.status ? ` ${container.status}` : ""}
))}
@@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
{container.state}
+ {container.currentState
+ ? ` ${container.currentState}`
+ : ""}
))}
>
@@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
+ {option === "swarm" &&
+ services?.find((c) => c.containerId === containerId)?.error && (
+
+ Error:
+ {services.find((c) => c.containerId === containerId)?.error}
+
+ )}
{
- const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
+ const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
@@ -73,7 +73,7 @@ export const ShowDockerLogsCompose = ({
Select a container to view logs
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -93,6 +93,7 @@ export const ShowDockerLogsCompose = ({
{container.state}
+ {container.status ? ` ${container.status}` : ""}
))}
Containers ({data?.length})
diff --git a/apps/dokploy/components/dashboard/compose/update-compose.tsx b/apps/dokploy/components/dashboard/compose/update-compose.tsx
index 7564988e2..91e04950a 100644
--- a/apps/dokploy/components/dashboard/compose/update-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/update-compose.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -43,7 +43,7 @@ interface Props {
export const UpdateCompose = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.compose.update.useMutation();
const { data } = api.compose.one.useQuery(
{
@@ -148,7 +148,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
/>
diff --git a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx
index f2ca41b85..26880e9b5 100644
--- a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx
+++ b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import {
CheckIcon,
ChevronsUpDown,
@@ -65,7 +65,13 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
type CacheType = "cache" | "fetch";
-type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
+type DatabaseType =
+ | "postgres"
+ | "mariadb"
+ | "mysql"
+ | "mongo"
+ | "web-server"
+ | "libsql";
const Schema = z
.object({
@@ -77,7 +83,7 @@ const Schema = z
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
databaseType: z
- .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
+ .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(),
backupType: z.enum(["database", "compose"]),
metadata: z
@@ -192,7 +198,7 @@ export const HandleBackup = ({
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
- const { data, isLoading } = api.destination.all.useQuery();
+ const { data, isPending } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
backupId: backupId ?? "",
@@ -202,14 +208,19 @@ export const HandleBackup = ({
},
);
const [cacheType, setCacheType] = useState("cache");
- const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
+ const { mutateAsync: createBackup, isPending: isCreatingPostgresBackup } =
backupId
? api.backup.update.useMutation()
: api.backup.create.useMutation();
- const form = useForm>({
+ const form = useForm({
defaultValues: {
- database: databaseType === "web-server" ? "dokploy" : "",
+ database:
+ databaseType === "web-server"
+ ? "dokploy"
+ : databaseType === "libsql"
+ ? "iku.db"
+ : "",
destinationId: "",
enabled: true,
prefix: "/",
@@ -246,7 +257,9 @@ export const HandleBackup = ({
? backup?.database
: databaseType === "web-server"
? "dokploy"
- : "",
+ : databaseType === "libsql"
+ ? "iku.db"
+ : "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/",
@@ -281,11 +294,15 @@ export const HandleBackup = ({
? {
mongoId: id,
}
- : databaseType === "web-server"
+ : databaseType === "libsql"
? {
- userId: id,
+ libsqlId: id,
}
- : undefined;
+ : databaseType === "web-server"
+ ? {
+ userId: id,
+ }
+ : undefined;
await createBackup({
destinationId: data.destinationId,
@@ -396,7 +413,7 @@ export const HandleBackup = ({
!field.value && "text-muted-foreground",
)}
>
- {isLoading
+ {isPending
? "Loading...."
: field.value
? data?.find(
@@ -415,7 +432,7 @@ export const HandleBackup = ({
placeholder="Search Destination..."
className="h-9"
/>
- {isLoading && (
+ {isPending && (
Loading Destinations....
@@ -568,7 +585,10 @@ export const HandleBackup = ({
Database
@@ -613,6 +633,7 @@ export const HandleBackup = ({
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
+ value={field.value as string}
/>
diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
index 6a0fb030a..00647aea7 100644
--- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
+++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
@@ -1,6 +1,6 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
-import { debounce } from "lodash";
+import debounce from "lodash/debounce";
import {
CheckIcon,
ChevronsUpDown,
@@ -78,29 +78,17 @@ interface Props {
const RestoreBackupSchema = z
.object({
- destinationId: z
- .string({
- required_error: "Please select a destination",
- })
- .min(1, {
- message: "Destination is required",
- }),
- backupFile: z
- .string({
- required_error: "Please select a backup file",
- })
- .min(1, {
- message: "Backup file is required",
- }),
- databaseName: z
- .string({
- required_error: "Please enter a database name",
- })
- .min(1, {
- message: "Database name is required",
- }),
+ destinationId: z.string().min(1, {
+ message: "Destination is required",
+ }),
+ backupFile: z.string().min(1, {
+ message: "Backup file is required",
+ }),
+ databaseName: z.string().min(1, {
+ message: "Database name is required",
+ }),
databaseType: z
- .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
+ .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(),
backupType: z.enum(["database", "compose"]).default("database"),
metadata: z
@@ -219,11 +207,16 @@ export const RestoreBackup = ({
const { data: destinations = [] } = api.destination.all.useQuery();
- const form = useForm>({
+ const form = useForm({
defaultValues: {
destinationId: "",
backupFile: "",
- databaseName: databaseType === "web-server" ? "dokploy" : "",
+ databaseName:
+ databaseType === "web-server"
+ ? "dokploy"
+ : databaseType === "libsql"
+ ? "iku.db"
+ : "",
databaseType:
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
backupType: backupType,
@@ -245,7 +238,7 @@ export const RestoreBackup = ({
debouncedSetSearch(value);
};
- const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
+ const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destionationId,
search: debouncedSearchTerm,
@@ -454,7 +447,7 @@ export const RestoreBackup = ({
onValueChange={handleSearchChange}
className="h-9"
/>
- {isLoading ? (
+ {isPending ? (
Loading backup files...
@@ -535,7 +528,10 @@ export const RestoreBackup = ({
diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx
index 55a09b25f..ebffaccb3 100644
--- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx
+++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx
@@ -53,14 +53,16 @@ export const ShowBackups = ({
const queryMap =
backupType === "database"
? {
- postgres: () =>
- api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
- mysql: () =>
- api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ mysql: () =>
+ api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ libsql: () =>
+ api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
@@ -77,10 +79,11 @@ export const ShowBackups = ({
const mutationMap =
backupType === "database"
? {
- postgres: api.backup.manualBackupPostgres.useMutation(),
- mysql: api.backup.manualBackupMySql.useMutation(),
mariadb: api.backup.manualBackupMariadb.useMutation(),
mongo: api.backup.manualBackupMongo.useMutation(),
+ mysql: api.backup.manualBackupMySql.useMutation(),
+ postgres: api.backup.manualBackupPostgres.useMutation(),
+ libsql: api.backup.manualBackupLibsql.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(),
}
: {
@@ -89,11 +92,11 @@ export const ShowBackups = ({
const mutation = mutationMap[key as keyof typeof mutationMap];
- const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
+ const { mutateAsync: manualBackup, isPending: isManualBackup } = mutation
? mutation
: api.backup.manualBackupMongo.useMutation();
- const { mutateAsync: deleteBackup, isLoading: isRemoving } =
+ const { mutateAsync: deleteBackup, isPending: isRemoving } =
api.backup.remove.useMutation();
return (
diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx
new file mode 100644
index 000000000..770d4efd0
--- /dev/null
+++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx
@@ -0,0 +1,613 @@
+"use client";
+
+import {
+ type ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ type PaginationState,
+ type SortingState,
+ useReactTable,
+} from "@tanstack/react-table";
+import type { inferRouterOutputs } from "@trpc/server";
+import {
+ ArrowUpDown,
+ Boxes,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ Loader2,
+ Rocket,
+ Server,
+} from "lucide-react";
+import Link from "next/link";
+import { useMemo, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { AppRouter } from "@/server/api/root";
+import { api } from "@/utils/api";
+
+type DeploymentRow =
+ inferRouterOutputs["deployment"]["allCentralized"][number];
+
+const statusVariants: Record<
+ string,
+ | "default"
+ | "secondary"
+ | "destructive"
+ | "outline"
+ | "yellow"
+ | "green"
+ | "red"
+> = {
+ running: "yellow",
+ done: "green",
+ error: "red",
+ cancelled: "outline",
+};
+
+function getServiceInfo(d: DeploymentRow) {
+ const app = d.application;
+ const comp = d.compose;
+ if (app?.environment?.project && app.environment) {
+ return {
+ type: "Application" as const,
+ name: app.name,
+ projectId: app.environment.project.projectId,
+ environmentId: app.environment.environmentId,
+ projectName: app.environment.project.name,
+ environmentName: app.environment.name,
+ serviceId: app.applicationId,
+ href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
+ };
+ }
+ if (comp?.environment?.project && comp.environment) {
+ return {
+ type: "Compose" as const,
+ name: comp.name,
+ projectId: comp.environment.project.projectId,
+ environmentId: comp.environment.environmentId,
+ projectName: comp.environment.project.name,
+ environmentName: comp.environment.name,
+ serviceId: comp.composeId,
+ href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
+ };
+ }
+ return null;
+}
+
+export function ShowDeploymentsTable() {
+ const [sorting, setSorting] = useState([
+ { id: "createdAt", desc: true },
+ ]);
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [globalFilter, setGlobalFilter] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [typeFilter, setTypeFilter] = useState("all");
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: 50,
+ });
+
+ const { data: deploymentsList, isLoading } =
+ api.deployment.allCentralized.useQuery(undefined, {
+ refetchInterval: 5000,
+ });
+
+ const filteredData = useMemo(() => {
+ if (!deploymentsList) return [];
+ let list = deploymentsList;
+ if (statusFilter !== "all") {
+ list = list.filter((d) => d.status === statusFilter);
+ }
+ if (typeFilter === "application") {
+ list = list.filter((d) => d.applicationId != null);
+ } else if (typeFilter === "compose") {
+ list = list.filter((d) => d.composeId != null);
+ }
+ if (globalFilter.trim()) {
+ const q = globalFilter.toLowerCase();
+ list = list.filter((d) => {
+ const info = getServiceInfo(d);
+ const serverName =
+ d.server?.name ??
+ d.application?.server?.name ??
+ d.compose?.server?.name ??
+ "";
+ const buildServerName =
+ d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
+ if (!info) return false;
+ return (
+ info.name.toLowerCase().includes(q) ||
+ info.projectName.toLowerCase().includes(q) ||
+ info.environmentName.toLowerCase().includes(q) ||
+ (d.title?.toLowerCase().includes(q) ?? false) ||
+ serverName.toLowerCase().includes(q) ||
+ buildServerName.toLowerCase().includes(q)
+ );
+ });
+ }
+ return list;
+ }, [deploymentsList, statusFilter, typeFilter, globalFilter]);
+
+ const columns = useMemo(
+ () => [
+ {
+ id: "serviceName",
+ accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "",
+ header: ({
+ column,
+ }: {
+ column: {
+ getIsSorted: () => false | "asc" | "desc";
+ toggleSorting: (asc: boolean) => void;
+ };
+ }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Service
+
+
+ ),
+ cell: ({ row }: { row: { original: DeploymentRow } }) => {
+ const info = getServiceInfo(row.original);
+ if (!info) return — ;
+ return (
+
+ {info.type === "Application" ? (
+
+ ) : (
+
+ )}
+
+ {info.name}
+
+ {info.type}
+
+
+
+ );
+ },
+ },
+ {
+ id: "projectName",
+ accessorFn: (row: DeploymentRow) =>
+ getServiceInfo(row)?.projectName ?? "",
+ header: ({
+ column,
+ }: {
+ column: {
+ getIsSorted: () => false | "asc" | "desc";
+ toggleSorting: (asc: boolean) => void;
+ };
+ }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Project
+
+
+ ),
+ cell: ({ row }: { row: { original: DeploymentRow } }) => {
+ const info = getServiceInfo(row.original);
+ return (
+
+ {info?.projectName ?? "—"}
+
+ );
+ },
+ },
+ {
+ id: "environmentName",
+ accessorFn: (row: DeploymentRow) =>
+ getServiceInfo(row)?.environmentName ?? "",
+ header: ({
+ column,
+ }: {
+ column: {
+ getIsSorted: () => false | "asc" | "desc";
+ toggleSorting: (asc: boolean) => void;
+ };
+ }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Environment
+
+
+ ),
+ cell: ({ row }: { row: { original: DeploymentRow } }) => {
+ const info = getServiceInfo(row.original);
+ return (
+
+ {info?.environmentName ?? "—"}
+
+ );
+ },
+ },
+ {
+ id: "serverName",
+ accessorFn: (row: DeploymentRow) =>
+ row.server?.name ??
+ row.application?.server?.name ??
+ row.compose?.server?.name ??
+ "",
+ header: ({
+ column,
+ }: {
+ column: {
+ getIsSorted: () => false | "asc" | "desc";
+ toggleSorting: (asc: boolean) => void;
+ };
+ }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Server
+
+
+ ),
+ cell: ({ row }: { row: { original: DeploymentRow } }) => {
+ const d = row.original;
+ const serverName =
+ d.server?.name ??
+ d.application?.server?.name ??
+ d.compose?.server?.name ??
+ null;
+ const serverType =
+ d.server?.serverType ??
+ d.application?.server?.serverType ??
+ d.compose?.server?.serverType ??
+ null;
+ const buildServerName =
+ d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
+ const buildServerType =
+ d.buildServer?.serverType ??
+ d.application?.buildServer?.serverType ??
+ null;
+ const showBuild =
+ buildServerName != null && buildServerName !== serverName;
+ if (!serverName && !showBuild) {
+ return — ;
+ }
+ return (
+
+ {serverName && (
+
+
+ {serverName}
+ {serverType && (
+
+ {serverType}
+
+ )}
+
+ )}
+ {showBuild && buildServerName && (
+
+ Build:
+ {buildServerName}
+ {buildServerType && (
+
+ {buildServerType}
+
+ )}
+
+ )}
+
+ );
+ },
+ },
+ {
+ accessorKey: "title",
+ header: ({
+ column,
+ }: {
+ column: {
+ getIsSorted: () => false | "asc" | "desc";
+ toggleSorting: (asc: boolean) => void;
+ };
+ }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Title
+
+
+ ),
+ cell: ({ row }: { row: { original: DeploymentRow } }) => (
+
+ {row.original.title || "—"}
+
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: ({
+ column,
+ }: {
+ column: {
+ getIsSorted: () => false | "asc" | "desc";
+ toggleSorting: (asc: boolean) => void;
+ };
+ }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Status
+
+
+ ),
+ cell: ({ row }: { row: { original: DeploymentRow } }) => {
+ const status = row.original.status ?? "running";
+ return (
+
+ {status}
+
+ );
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({
+ column,
+ }: {
+ column: {
+ getIsSorted: () => false | "asc" | "desc";
+ toggleSorting: (asc: boolean) => void;
+ };
+ }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Created
+
+
+ ),
+ cell: ({ row }: { row: { original: DeploymentRow } }) => (
+
+ {row.original.createdAt
+ ? new Date(row.original.createdAt).toLocaleString()
+ : "—"}
+
+ ),
+ },
+ {
+ header: "",
+ id: "actions",
+ enableSorting: false,
+ cell: ({ row }: { row: { original: DeploymentRow } }) => {
+ const info = getServiceInfo(row.original);
+ if (!info) return null;
+ return (
+
+
+
+ Open
+
+
+ );
+ },
+ },
+ ],
+ [],
+ );
+
+ const table = useReactTable({
+ data: filteredData,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ pagination,
+ },
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ });
+
+ return (
+
+
+ setGlobalFilter(e.target.value)}
+ className="max-w-xs"
+ />
+
+
+
+
+
+ All statuses
+ Running
+ Done
+ Error
+ Cancelled
+
+
+
+
+
+
+
+ All types
+ Application
+ Compose
+
+
+
+
+ {isLoading ? (
+
+
+ Loading deployments...
+
+ ) : (
+ <>
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+
+
+
No deployments found
+
+ Deployments from applications and compose will
+ appear here.
+
+
+
+
+ )}
+
+
+
+
+
+
+ Rows per page
+
+ {
+ setPagination((p) => ({
+ ...p,
+ pageSize: Number(value),
+ pageIndex: 0,
+ }));
+ }}
+ >
+
+
+
+
+ {[10, 25, 50, 100].map((size) => (
+
+ {size}
+
+ ))}
+
+
+
+ Showing{" "}
+ {filteredData.length === 0
+ ? 0
+ : pagination.pageIndex * pagination.pageSize + 1}{" "}
+ to{" "}
+ {Math.min(
+ (pagination.pageIndex + 1) * pagination.pageSize,
+ filteredData.length,
+ )}{" "}
+ of {filteredData.length} entries
+
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+
+ Previous
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Next
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx
new file mode 100644
index 000000000..e46b33a6a
--- /dev/null
+++ b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+import type { inferRouterOutputs } from "@trpc/server";
+import Link from "next/link";
+import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { AppRouter } from "@/server/api/root";
+import { api } from "@/utils/api";
+
+type QueueRow =
+ inferRouterOutputs["deployment"]["queueList"][number];
+
+const stateVariants: Record<
+ string,
+ | "default"
+ | "secondary"
+ | "destructive"
+ | "outline"
+ | "yellow"
+ | "green"
+ | "red"
+> = {
+ pending: "secondary",
+ waiting: "secondary",
+ active: "yellow",
+ delayed: "outline",
+ completed: "green",
+ failed: "destructive",
+ cancelled: "outline",
+ paused: "outline",
+};
+
+function formatTs(ts?: number): string {
+ if (ts == null) return "—";
+ const d = new Date(ts);
+ return d.toLocaleString();
+}
+
+function getJobLabel(row: QueueRow): string {
+ const d = row.data as {
+ applicationType?: string;
+ applicationId?: string;
+ composeId?: string;
+ previewDeploymentId?: string;
+ titleLog?: string;
+ type?: string;
+ };
+ if (!d) return String(row.id);
+ const type = d.applicationType ?? "job";
+ const title = d.titleLog ?? "";
+ if (title) return title;
+ if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}…`;
+ if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}…`;
+ if (d.previewDeploymentId)
+ return `Preview ${d.previewDeploymentId.slice(0, 8)}…`;
+ return `${type} ${String(row.id)}`;
+}
+
+export function ShowQueueTable(props: { embedded?: boolean }) {
+ const { embedded: _embedded = false } = props;
+ const { data: queueList, isLoading } = api.deployment.queueList.useQuery(
+ undefined,
+ { refetchInterval: 3000 },
+ );
+ const { data: isCloud } = api.settings.isCloud.useQuery();
+ const utils = api.useUtils();
+ const {
+ mutateAsync: cancelApplicationDeployment,
+ isPending: isCancellingApp,
+ } = api.application.cancelDeployment.useMutation({
+ onSuccess: () => void utils.deployment.queueList.invalidate(),
+ });
+ const {
+ mutateAsync: cancelComposeDeployment,
+ isPending: isCancellingCompose,
+ } = api.compose.cancelDeployment.useMutation({
+ onSuccess: () => void utils.deployment.queueList.invalidate(),
+ });
+ const isCancelling = isCancellingApp || isCancellingCompose;
+
+ return (
+
+ {isLoading ? (
+
+
+ Loading queue...
+
+ ) : (
+
+
+
+
+ Job ID
+ Label
+ Type
+ State
+ Added
+ Processed
+ Finished
+ Error
+ Actions
+
+
+
+ {queueList?.length ? (
+ queueList.map((row) => {
+ const d = row.data as Record;
+ const appType = d?.applicationType as string | undefined;
+ const pathInfo = row.servicePath;
+ const hasLink = pathInfo?.href != null;
+ return (
+
+
+ {String(row.id)}
+
+
+ {getJobLabel(row)}
+
+ {appType ?? row.name ?? "—"}
+
+
+ {row.state}
+
+
+
+ {formatTs(row.timestamp)}
+
+
+ {formatTs(row.processedOn)}
+
+
+ {formatTs(row.finishedOn)}
+
+
+ {row.failedReason ?? "—"}
+
+
+
+ {hasLink ? (
+
+
+
+ Service
+
+
+ ) : (
+
+ —
+
+ )}
+ {isCloud &&
+ row.state === "active" &&
+ (d?.applicationId != null ||
+ d?.composeId != null) && (
+
{
+ const appId =
+ typeof d.applicationId === "string"
+ ? d.applicationId
+ : undefined;
+ const compId =
+ typeof d.composeId === "string"
+ ? d.composeId
+ : undefined;
+ if (appId) {
+ void cancelApplicationDeployment({
+ applicationId: appId,
+ });
+ } else if (compId) {
+ void cancelComposeDeployment({
+ composeId: compId,
+ });
+ }
+ }}
+ >
+
+ Cancel
+
+ )}
+
+
+
+ );
+ })
+ ) : (
+
+
+
+
+
Queue is empty
+
+ Deployment jobs will appear here when they are queued.
+
+
+
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx
index bf0173956..59b939008 100644
--- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx
+++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx
@@ -402,7 +402,7 @@ export const DockerLogsId: React.FC = ({
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
\d+)\s+)?(?(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?[\s\S]*)$/;
return logString
.split("\n")
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
const match = line.match(logRegex);
if (!match) return null;
- const [, , timestamp, message] = match;
+ const { timestamp, message } = match.groups ?? {};
if (!message?.trim()) return null;
@@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => {
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
- /\b(?:unstable|experimental)\b/i.test(lowerMessage)
+ /\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
+ /⚠|⚠️/i.test(lowerMessage)
) {
return LOG_STYLES.warning;
}
diff --git a/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx
new file mode 100644
index 000000000..3b6cd9875
--- /dev/null
+++ b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx
@@ -0,0 +1,66 @@
+import { toast } from "sonner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
+import { api } from "@/utils/api";
+
+interface Props {
+ containerId: string;
+ serverId?: string;
+}
+
+export const RemoveContainerDialog = ({ containerId, serverId }: Props) => {
+ const utils = api.useUtils();
+ const { mutateAsync, isPending } = api.docker.removeContainer.useMutation();
+
+ return (
+
+
+ e.preventDefault()}
+ >
+ Remove Container
+
+
+
+
+ Are you sure?
+
+ This will permanently remove the container{" "}
+ {containerId} . If the
+ container is running, it will be forcefully stopped and removed.
+ This action cannot be undone.
+
+
+
+ Cancel
+ {
+ await mutateAsync({ containerId, serverId })
+ .then(async () => {
+ toast.success("Container removed successfully");
+ await utils.docker.getContainers.invalidate();
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ }}
+ >
+ Confirm
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/docker/show/colums.tsx b/apps/dokploy/components/dashboard/docker/show/colums.tsx
index 74fe6819e..d506d3742 100644
--- a/apps/dokploy/components/dashboard/docker/show/colums.tsx
+++ b/apps/dokploy/components/dashboard/docker/show/colums.tsx
@@ -10,6 +10,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
+import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import type { Container } from "./show-containers";
@@ -127,6 +128,10 @@ export const columns: ColumnDef[] = [
>
Terminal
+
);
diff --git a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx
index 52398aabe..69b0a0da2 100644
--- a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx
+++ b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx
@@ -45,7 +45,7 @@ interface Props {
}
export const ShowContainers = ({ serverId }: Props) => {
- const { data, isLoading } = api.docker.getContainers.useQuery({
+ const { data, isPending } = api.docker.getContainers.useQuery({
serverId,
});
@@ -137,7 +137,7 @@ export const ShowContainers = ({ serverId }: Props) => {
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -192,7 +192,7 @@ export const ShowContainers = ({ serverId }: Props) => {
colSpan={columns.length}
className="h-24 text-center"
>
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx
index 8c848a0dc..288208fb1 100644
--- a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx
+++ b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@@ -16,6 +17,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
+import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
@@ -47,8 +49,9 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
},
);
const [canEdit, setCanEdit] = useState(true);
+ const [skipYamlValidation, setSkipYamlValidation] = useState(false);
- const { mutateAsync, isLoading, error, isError } =
+ const { mutateAsync, isPending, error, isError } =
api.settings.updateTraefikFile.useMutation();
const form = useForm({
@@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
- const { valid, error } = validateAndFormatYAML(data.traefikConfig);
- if (!valid) {
- form.setError("traefikConfig", {
- type: "manual",
- message: error || "Invalid YAML",
- });
- return;
+ if (!skipYamlValidation) {
+ const { valid, error } = validateAndFormatYAML(data.traefikConfig);
+ if (!valid) {
+ form.setError("traefikConfig", {
+ type: "manual",
+ message: error || "Invalid YAML",
+ });
+ return;
+ }
}
form.clearErrors("traefikConfig");
await mutateAsync({
@@ -153,14 +158,37 @@ routers:
/>
)}
-
-
- Update
-
+
+
+
+ setSkipYamlValidation(checked === true)
+ }
+ />
+
+ Skip YAML validation (for Go templating)
+
+
+
+ Traefik supports Go templating in dynamic configs (e.g.{" "}
+ {"{{range}}"}). Configs using
+ templates will fail standard YAML validation. Check this to save
+ without validation.
+
+
+
+ Update
+
+
diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx
index b96b7c866..1f0c6924c 100644
--- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx
+++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx
@@ -45,10 +45,12 @@ import {
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
+import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type User = typeof authClient.$Infer.Session.user;
export const ImpersonationBar = () => {
+ const { config: whitelabeling } = useWhitelabeling();
const [users, setUsers] = useState
([]);
const [selectedUser, setSelectedUser] = useState(null);
const [isImpersonating, setIsImpersonating] = useState(false);
@@ -103,7 +105,7 @@ export const ImpersonationBar = () => {
setOpen(false);
toast.success("Successfully impersonating user", {
- description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
+ description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
});
window.location.reload();
} catch (error) {
@@ -180,7 +182,10 @@ export const ImpersonationBar = () => {
)}
>
-
+
{!isImpersonating ? (
@@ -195,7 +200,8 @@ export const ImpersonationBar = () => {
- {selectedUser.name || ""}
+ {`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
+ ""}
{selectedUser.email}
@@ -242,7 +248,8 @@ export const ImpersonationBar = () => {
- {user.name || ""}
+ {`${user.name} ${user.lastName}`.trim() ||
+ ""}
{user.email} • {user.role}
@@ -283,10 +290,14 @@ export const ImpersonationBar = () => {
- {data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
+ {`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
+ "U"}
@@ -299,7 +310,8 @@ export const ImpersonationBar = () => {
Impersonating
- {data?.user?.name || ""}
+ {`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
+ ""}
diff --git a/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx
new file mode 100644
index 000000000..378d0d944
--- /dev/null
+++ b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx
@@ -0,0 +1,251 @@
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { api } from "@/utils/api";
+
+const DockerProviderSchema = z.object({
+ externalPort: z.preprocess((a) => {
+ if (a === null || a === undefined || a === "") return null;
+ const parsed = Number.parseInt(String(a), 10);
+ return Number.isNaN(parsed) ? null : parsed;
+ }, z
+ .number()
+ .gte(0, "Range must be 0 - 65535")
+ .lte(65535, "Range must be 0 - 65535")
+ .nullable()),
+ externalGRPCPort: z.preprocess((a) => {
+ if (a === null || a === undefined || a === "") return null;
+ const parsed = Number.parseInt(String(a), 10);
+ return Number.isNaN(parsed) ? null : parsed;
+ }, z
+ .number()
+ .gte(0, "Range must be 0 - 65535")
+ .lte(65535, "Range must be 0 - 65535")
+ .nullable()),
+ externalAdminPort: z.preprocess((a) => {
+ if (a === null || a === undefined || a === "") return null;
+ const parsed = Number.parseInt(String(a), 10);
+ return Number.isNaN(parsed) ? null : parsed;
+ }, z
+ .number()
+ .gte(0, "Range must be 0 - 65535")
+ .lte(65535, "Range must be 0 - 65535")
+ .nullable()),
+});
+
+type DockerProvider = z.infer
;
+
+interface Props {
+ libsqlId: string;
+}
+export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
+ const { data: ip } = api.settings.getIp.useQuery();
+ const { data, refetch } = api.libsql.one.useQuery({ libsqlId });
+ const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation();
+ const [connectionUrl, setConnectionUrl] = useState("");
+ const [connectionGRPCUrl, setGRPCConnectionUrl] = useState("");
+ const getIp = data?.server?.ipAddress || ip;
+
+ const form = useForm({
+ defaultValues: {},
+ resolver: zodResolver(DockerProviderSchema),
+ });
+
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ externalPort: data.externalPort,
+ externalGRPCPort: data.externalGRPCPort,
+ externalAdminPort: data.externalAdminPort,
+ });
+ }
+ }, [form.reset, data, form]);
+
+ const onSubmit = async (values: DockerProvider) => {
+ await mutateAsync({
+ externalPort: values.externalPort,
+ externalGRPCPort: values.externalGRPCPort,
+ externalAdminPort: values.externalAdminPort,
+ libsqlId,
+ })
+ .then(async () => {
+ toast.success("External port/ports updated");
+ await refetch();
+ })
+ .catch((error: Error) => {
+ toast.error(error?.message || "Error saving the external port/ports");
+ });
+ };
+
+ useEffect(() => {
+ const port = form.watch("externalPort") || data?.externalPort;
+ setConnectionUrl(
+ `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`,
+ );
+
+ if (data?.sqldNode !== "replica") {
+ const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort;
+ setGRPCConnectionUrl(
+ `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`,
+ );
+ }
+ }, [
+ data?.externalGRPCPort,
+ data?.databasePassword,
+ form,
+ data?.databaseUser,
+ getIp,
+ ]);
+
+ return (
+
+
+
+ External Credentials
+
+ In order to make the database reachable through the internet, you
+ must set a port and ensure that the port is not being used by
+ another application or database
+
+
+
+ {!getIp && (
+
+ You need to set an IP address in your{" "}
+
+ {data?.serverId
+ ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
+ : "Web Server -> Server -> Update Server IP"}
+ {" "}
+ to fix the database url connection.
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx
new file mode 100644
index 000000000..1727bb2b1
--- /dev/null
+++ b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx
@@ -0,0 +1,268 @@
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { DialogAction } from "@/components/shared/dialog-action";
+import { DrawerLogs } from "@/components/shared/drawer-logs";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { api } from "@/utils/api";
+import { type LogLine, parseLogs } from "../../docker/logs/utils";
+import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
+
+interface Props {
+ libsqlId: string;
+}
+
+export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
+ const { data, refetch } = api.libsql.one.useQuery(
+ {
+ libsqlId,
+ },
+ { enabled: !!libsqlId },
+ );
+
+ const { mutateAsync: reload, isPending: isReloading } =
+ api.libsql.reload.useMutation();
+
+ const { mutateAsync: start, isPending: isStarting } =
+ api.libsql.start.useMutation();
+
+ const { mutateAsync: stop, isPending: isStopping } =
+ api.libsql.stop.useMutation();
+
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const [filteredLogs, setFilteredLogs] = useState([]);
+ const [isDeploying, setIsDeploying] = useState(false);
+ api.libsql.deployWithLogs.useSubscription(
+ {
+ libsqlId: libsqlId,
+ },
+ {
+ enabled: isDeploying,
+ onData(log) {
+ if (!isDrawerOpen) {
+ setIsDrawerOpen(true);
+ }
+
+ if (log === "Deployment completed successfully!") {
+ setIsDeploying(false);
+ }
+ const parsedLogs = parseLogs(log);
+ setFilteredLogs((prev) => [...prev, ...parsedLogs]);
+ },
+ onError(error) {
+ console.error("Deployment logs error:", error);
+ setIsDeploying(false);
+ },
+ },
+ );
+
+ return (
+ <>
+
+
+
+ Deploy Settings
+
+
+
+ {
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
+ }}
+ >
+
+
+
+
+
+ Deploy
+
+
+
+
+ Downloads and sets up the Libsql database
+
+
+
+
+
+
+
+ {
+ await reload({
+ libsqlId: libsqlId,
+ appName: data?.appName || "",
+ })
+ .then(() => {
+ toast.success("Libsql reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading Libsql");
+ });
+ }}
+ >
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the Libsql service without rebuilding
+
+
+
+
+
+
+ {data?.applicationStatus === "idle" ? (
+
+ {
+ await start({
+ libsqlId: libsqlId,
+ })
+ .then(() => {
+ toast.success("Libsql started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting Libsql");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the Libsql database (requires a previous
+ successful setup)
+
+
+
+
+
+
+
+ ) : (
+
+ {
+ await stop({
+ libsqlId: libsqlId,
+ })
+ .then(() => {
+ toast.success("Libsql stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping Libsql");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running Libsql database
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ Open Terminal
+
+
+
+
+ Open a terminal to the Libsql container
+
+
+
+
+
+
+
+
{
+ setIsDrawerOpen(false);
+ setFilteredLogs([]);
+ setIsDeploying(false);
+ refetch();
+ }}
+ filteredLogs={filteredLogs}
+ />
+
+ >
+ );
+};
diff --git a/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx
new file mode 100644
index 000000000..6c1350242
--- /dev/null
+++ b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx
@@ -0,0 +1,121 @@
+import { SelectGroup } from "@radix-ui/react-select";
+import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+interface Props {
+ libsqlId: string;
+}
+export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
+ const { data } = api.libsql.one.useQuery({ libsqlId });
+ return (
+ <>
+
+
+
+ Internal Credentials
+
+
+
+
+ User
+
+
+
+ Sqld Node
+
+
+
+
+
+ {["primary", "replica"].map((node) => (
+
+ {node.charAt(0).toUpperCase() + node.slice(1)}
+
+ ))}
+
+
+
+
+
+
+
+ Internal Host
+
+
+
+ Enable Namespaces
+
+
+
+
+
+
+ {["false", "true"].map((node) => (
+
+ {node.charAt(0).toUpperCase() + node.slice(1)}
+
+ ))}
+
+
+
+
+
+
+ Internal Connection URL
+
+
+
+ Internal Replication Connection URL
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/apps/dokploy/components/dashboard/libsql/update-libsql.tsx b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx
new file mode 100644
index 000000000..99455531a
--- /dev/null
+++ b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx
@@ -0,0 +1,163 @@
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { PenBoxIcon } from "lucide-react";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+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 {
+ 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";
+
+const updateLibsqlSchema = z.object({
+ name: z.string().min(1, {
+ message: "Name is required",
+ }),
+ description: z.string().optional(),
+});
+
+type UpdateLibsql = z.infer;
+
+interface Props {
+ libsqlId: string;
+}
+
+export const UpdateLibsql = ({ libsqlId }: Props) => {
+ const utils = api.useUtils();
+ const { mutateAsync, error, isError, isPending } =
+ api.libsql.update.useMutation();
+ const { data } = api.libsql.one.useQuery(
+ {
+ libsqlId,
+ },
+ {
+ enabled: !!libsqlId,
+ },
+ );
+ const form = useForm({
+ defaultValues: {
+ description: data?.description ?? "",
+ name: data?.name ?? "",
+ },
+ resolver: zodResolver(updateLibsqlSchema),
+ });
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ description: data.description ?? "",
+ name: data.name,
+ });
+ }
+ }, [data, form, form.reset]);
+
+ const onSubmit = async (formData: UpdateLibsql) => {
+ await mutateAsync({
+ name: formData.name,
+ libsqlId: libsqlId,
+ description: formData.description || "",
+ })
+ .then(() => {
+ toast.success("Libsql updated successfully");
+ utils.libsql.one.invalidate({
+ libsqlId: libsqlId,
+ });
+ })
+ .catch(() => {
+ toast.error("Error updating the Libsql");
+ })
+ .finally(() => {});
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Modify Libsql
+ Update the Libsql data
+
+ {isError && {error?.message} }
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx
index 8745db286..9917bc21b 100644
--- a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx
+++ b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -48,10 +48,10 @@ interface Props {
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
- const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
+ const { mutateAsync, isPending } = api.mariadb.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
- const form = useForm({
+ const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
toast.success("External Port updated");
await refetch();
})
- .catch(() => {
- toast.error("Error saving the external port");
+ .catch((error: Error) => {
+ toast.error(error?.message || "Error saving the external port");
});
};
@@ -140,7 +140,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
@@ -161,7 +161,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
)}
-
+
Save
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx
index 8e996846f..7c89d7b52 100644
--- a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx
+++ b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx
@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mariadb.one.useQuery(
{
mariadbId,
@@ -28,13 +30,13 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
{ enabled: !!mariadbId },
);
- const { mutateAsync: reload, isLoading: isReloading } =
+ const { mutateAsync: reload, isPending: isReloading } =
api.mariadb.reload.useMutation();
- const { mutateAsync: start, isLoading: isStarting } =
+ const { mutateAsync: start, isPending: isStarting } =
api.mariadb.start.useMutation();
- const { mutateAsync: stop, isLoading: isStopping } =
+ const { mutateAsync: stop, isPending: isStopping } =
api.mariadb.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
@@ -72,154 +74,33 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
Deploy Settings
-
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
-
-
-
-
- Deploy
-
-
-
-
- Downloads and sets up the MariaDB database
-
-
-
-
-
-
-
- {
- await reload({
- mariadbId: mariadbId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Mariadb reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading Mariadb");
- });
- }}
- >
-
-
-
-
-
- Reload
-
-
-
-
- Restart the MariaDB service without rebuilding
-
-
-
-
-
-
- {data?.applicationStatus === "idle" ? (
+ {canDeploy && (
{
- await start({
- mariadbId: mariadbId,
- })
- .then(() => {
- toast.success("Mariadb started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting Mariadb");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
+
+ Deploy
-
- Start the MariaDB database (requires a previous
- successful setup)
-
-
-
-
-
-
-
- ) : (
-
- {
- await stop({
- mariadbId: mariadbId,
- })
- .then(() => {
- toast.success("Mariadb stopped successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error stopping Mariadb");
- });
- }}
- >
-
-
-
-
-
- Stop
-
-
-
-
- Stop the currently running MariaDB database
+ Downloads and sets up the MariaDB database
@@ -227,6 +108,132 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
)}
+ {canDeploy && (
+
+ {
+ await reload({
+ mariadbId: mariadbId,
+ appName: data?.appName || "",
+ })
+ .then(() => {
+ toast.success("Mariadb reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading Mariadb");
+ });
+ }}
+ >
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the MariaDB service without rebuilding
+
+
+
+
+
+
+ )}
+ {canDeploy &&
+ (data?.applicationStatus === "idle" ? (
+
+ {
+ await start({
+ mariadbId: mariadbId,
+ })
+ .then(() => {
+ toast.success("Mariadb started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting Mariadb");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the MariaDB database (requires a previous
+ successful setup)
+
+
+
+
+
+
+
+ ) : (
+
+ {
+ await stop({
+ mariadbId: mariadbId,
+ })
+ .then(() => {
+ toast.success("Mariadb stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping Mariadb");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running MariaDB database
+
+
+
+
+
+
+ ))}
{
+ const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.mariadb.update.useMutation();
const { data } = api.mariadb.one.useQuery(
{
@@ -79,6 +80,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
utils.mariadb.one.invalidate({
mariadbId: mariadbId,
});
+ setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the Mariadb");
@@ -87,7 +89,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
};
return (
-
+
{
/>
diff --git a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx
index d30061db5..ac79410b4 100644
--- a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx
+++ b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -48,10 +48,10 @@ interface Props {
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
- const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
+ const { mutateAsync, isPending } = api.mongo.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
- const form = useForm({
+ const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
toast.success("External Port updated");
await refetch();
})
- .catch(() => {
- toast.error("Error saving the external port");
+ .catch((error: Error) => {
+ toast.error(error?.message || "Error saving the external port");
});
};
@@ -140,7 +140,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
@@ -160,7 +160,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
)}
-
+
Save
diff --git a/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx b/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx
index 23fbe51d3..72a1848fc 100644
--- a/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx
+++ b/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx
@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralMongo = ({ mongoId }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mongo.one.useQuery(
{
mongoId,
@@ -28,13 +30,13 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
{ enabled: !!mongoId },
);
- const { mutateAsync: reload, isLoading: isReloading } =
+ const { mutateAsync: reload, isPending: isReloading } =
api.mongo.reload.useMutation();
- const { mutateAsync: start, isLoading: isStarting } =
+ const { mutateAsync: start, isPending: isStarting } =
api.mongo.start.useMutation();
- const { mutateAsync: stop, isLoading: isStopping } =
+ const { mutateAsync: stop, isPending: isStopping } =
api.mongo.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
@@ -73,153 +75,158 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
-
-
-
-
- Deploy
-
-
-
-
- Downloads and sets up the MongoDB database
-
-
-
-
-
- {
- await reload({
- mongoId: mongoId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Mongo reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading Mongo");
- });
- }}
- >
-
-
-
-
-
- Reload
-
-
-
-
- Restart the MongoDB service without rebuilding
-
-
-
-
-
- {data?.applicationStatus === "idle" ? (
+ {canDeploy && (
{
- await start({
- mongoId: mongoId,
- })
- .then(() => {
- toast.success("Mongo started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting Mongo");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
+
+ Deploy
-
- Start the MongoDB database (requires a previous
- successful setup)
-
-
-
-
-
-
- ) : (
- {
- await stop({
- mongoId: mongoId,
- })
- .then(() => {
- toast.success("Mongo stopped successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error stopping Mongo");
- });
- }}
- >
-
-
-
-
-
- Stop
-
-
-
-
- Stop the currently running MongoDB database
+ Downloads and sets up the MongoDB database
)}
+ {canDeploy && (
+ {
+ await reload({
+ mongoId: mongoId,
+ appName: data?.appName || "",
+ })
+ .then(() => {
+ toast.success("Mongo reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading Mongo");
+ });
+ }}
+ >
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the MongoDB service without rebuilding
+
+
+
+
+
+ )}
+ {canDeploy &&
+ (data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ mongoId: mongoId,
+ })
+ .then(() => {
+ toast.success("Mongo started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting Mongo");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the MongoDB database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ mongoId: mongoId,
+ })
+ .then(() => {
+ toast.success("Mongo stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping Mongo");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running MongoDB database
+
+
+
+
+
+ ))}
{
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.mongo.update.useMutation();
const { data } = api.mongo.one.useQuery(
{
@@ -148,7 +148,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
/>
diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx
index 246ae296d..fc57221bd 100644
--- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx
+++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx
@@ -34,7 +34,7 @@ export const ComposeFreeMonitoring = ({
appType = "stack",
serverId,
}: Props) => {
- const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
+ const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName: appName,
appType,
@@ -51,7 +51,7 @@ export const ComposeFreeMonitoring = ({
const [containerId, setContainerId] = useState();
- const { mutateAsync: restart, isLoading: isRestarting } =
+ const { mutateAsync: restart, isPending: isRestarting } =
api.docker.restartContainer.useMutation();
useEffect(() => {
@@ -81,7 +81,7 @@ export const ComposeFreeMonitoring = ({
value={containerAppName}
>
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx
index b28c4d9b6..42bb361bb 100644
--- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx
+++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
const defaultData = {
cpu: {
- value: 0,
+ value: "0%",
time: "",
},
memory: {
@@ -46,7 +46,7 @@ interface Props {
}
export interface DockerStats {
cpu: {
- value: number;
+ value: string;
time: string;
};
memory: {
@@ -183,12 +183,13 @@ export const ContainerFreeMonitoring = ({
setCurrentData(data);
+ const MAX_DATA_POINTS = 300;
setAcummulativeData((prevData) => ({
- cpu: [...prevData.cpu, data.cpu],
- memory: [...prevData.memory, data.memory],
- block: [...prevData.block, data.block],
- network: [...prevData.network, data.network],
- disk: [...prevData.disk, data.disk],
+ cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
+ memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
+ block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
+ network: [...prevData.network, data.network].slice(-MAX_DATA_POINTS),
+ disk: [...prevData.disk, data.disk].slice(-MAX_DATA_POINTS),
}));
};
@@ -220,7 +221,13 @@ export const ContainerFreeMonitoring = ({
Used: {currentData.cpu.value}
-
+
diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring.tsx
index 026043806..1f584beea 100644
--- a/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring.tsx
+++ b/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring.tsx
@@ -39,7 +39,7 @@ export const ComposePaidMonitoring = ({
baseUrl,
token,
}: Props) => {
- const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
+ const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName: appName,
appType,
@@ -56,7 +56,7 @@ export const ComposePaidMonitoring = ({
const [containerId, setContainerId] = useState();
- const { mutateAsync: restart, isLoading: isRestarting } =
+ const { mutateAsync: restart, isPending: isRestarting } =
api.docker.restartContainer.useMutation();
useEffect(() => {
@@ -87,7 +87,7 @@ export const ComposePaidMonitoring = ({
value={containerAppName}
>
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx
index dfaa36f6b..b9ddad916 100644
--- a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx
+++ b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -48,10 +48,10 @@ interface Props {
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
- const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
+ const { mutateAsync, isPending } = api.mysql.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
- const form = useForm
({
+ const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
toast.success("External Port updated");
await refetch();
})
- .catch(() => {
- toast.error("Error saving the external port");
+ .catch((error: Error) => {
+ toast.error(error?.message || "Error saving the external port");
});
};
@@ -140,7 +140,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
@@ -160,7 +160,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
)}
-
+
Save
diff --git a/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx b/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx
index 045a717b7..0e6ff08d9 100644
--- a/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx
+++ b/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx
@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralMysql = ({ mysqlId }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mysql.one.useQuery(
{
mysqlId,
@@ -28,12 +30,12 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
{ enabled: !!mysqlId },
);
- const { mutateAsync: reload, isLoading: isReloading } =
+ const { mutateAsync: reload, isPending: isReloading } =
api.mysql.reload.useMutation();
- const { mutateAsync: start, isLoading: isStarting } =
+ const { mutateAsync: start, isPending: isStarting } =
api.mysql.start.useMutation();
- const { mutateAsync: stop, isLoading: isStopping } =
+ const { mutateAsync: stop, isPending: isStopping } =
api.mysql.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
@@ -71,153 +73,158 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
-
-
-
-
- Deploy
-
-
-
-
- Downloads and sets up the MySQL database
-
-
-
-
-
- {
- await reload({
- mysqlId: mysqlId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("MySQL reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading MySQL");
- });
- }}
- >
-
-
-
-
-
- Reload
-
-
-
-
- Restart the MySQL service without rebuilding
-
-
-
-
-
- {data?.applicationStatus === "idle" ? (
+ {canDeploy && (
{
- await start({
- mysqlId: mysqlId,
- })
- .then(() => {
- toast.success("MySQL started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting MySQL");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
+
+ Deploy
-
- Start the MySQL database (requires a previous
- successful setup)
-
-
-
-
-
-
- ) : (
- {
- await stop({
- mysqlId: mysqlId,
- })
- .then(() => {
- toast.success("MySQL stopped successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error stopping MySQL");
- });
- }}
- >
-
-
-
-
-
- Stop
-
-
-
-
- Stop the currently running MySQL database
+ Downloads and sets up the MySQL database
)}
+ {canDeploy && (
+ {
+ await reload({
+ mysqlId: mysqlId,
+ appName: data?.appName || "",
+ })
+ .then(() => {
+ toast.success("MySQL reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading MySQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the MySQL service without rebuilding
+
+
+
+
+
+ )}
+ {canDeploy &&
+ (data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ mysqlId: mysqlId,
+ })
+ .then(() => {
+ toast.success("MySQL started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting MySQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the MySQL database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ mysqlId: mysqlId,
+ })
+ .then(() => {
+ toast.success("MySQL stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping MySQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running MySQL database
+
+
+
+
+
+ ))}
{
+ const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.mysql.update.useMutation();
const { data } = api.mysql.one.useQuery(
{
@@ -79,6 +80,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
utils.mysql.one.invalidate({
mysqlId: mysqlId,
});
+ setIsOpen(false);
})
.catch(() => {
toast.error("Error updating MySQL");
@@ -87,7 +89,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
};
return (
-
+
{
/>
diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx
index c676e0233..ecc3e7c9c 100644
--- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx
+++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -24,7 +24,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
-import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const organizationSchema = z.object({
@@ -52,11 +51,9 @@ export function AddOrganization({ organizationId }: Props) {
enabled: !!organizationId,
},
);
- const { mutateAsync, isLoading } = organizationId
+ const { mutateAsync, isPending } = organizationId
? api.organization.update.useMutation()
: api.organization.create.useMutation();
- const { refetch: refetchActiveOrganization } =
- authClient.useActiveOrganization();
const form = useForm({
resolver: zodResolver(organizationSchema),
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
utils.organization.all.invalidate();
if (organizationId) {
utils.organization.one.invalidate({ organizationId });
- refetchActiveOrganization();
+ utils.organization.active.invalidate();
}
setOpen(false);
})
@@ -177,7 +174,7 @@ export function AddOrganization({ organizationId }: Props) {
)}
/>
-
+
{organizationId ? "Update organization" : "Create organization"}
diff --git a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx
index febaa8644..07e059ce8 100644
--- a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx
+++ b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx
@@ -1,6 +1,7 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
-import { useForm } from "react-hook-form";
+import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
+ args: z
+ .array(
+ z.object({
+ value: z.string().min(1, "Argument cannot be empty"),
+ }),
+ )
+ .optional(),
});
interface Props {
@@ -34,6 +42,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
@@ -48,6 +57,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
+ libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
@@ -61,18 +71,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
defaultValues: {
dockerImage: "",
command: "",
+ args: [],
},
resolver: zodResolver(addDockerImage),
});
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "args",
+ });
+
useEffect(() => {
if (data) {
form.reset({
dockerImage: data.dockerImage,
command: data.command || "",
+ args: (data as any).args?.map((arg: string) => ({ value: arg })) || [],
});
}
- }, [data, form, form.reset]);
+ }, [data, form]);
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
@@ -80,9 +97,11 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
+ libsqlId: id || "",
mariadbId: id || "",
dockerImage: formData?.dockerImage,
command: formData?.command,
+ args: formData?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Custom Command Updated");
@@ -113,7 +132,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
Docker Image
-
+
@@ -128,13 +147,75 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
Command
-
+
)}
/>
+
+
+
+
Arguments (Args)
+
append({ value: "" })}
+ >
+
+ Add Argument
+
+
+
+ {fields.length === 0 && (
+
+ No arguments added yet. Click "Add Argument" to add one.
+
+ )}
+
+ {fields.map((field, index) => (
+
(
+
+
+
+
+
+ remove(index)}
+ >
+
+
+
+
+
+ )}
+ />
+ ))}
+
+
Save
diff --git a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx
index 46b3772a0..c38240a3f 100644
--- a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx
+++ b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -48,12 +48,12 @@ interface Props {
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
- const { mutateAsync, isLoading } =
+ const { mutateAsync, isPending } =
api.postgres.saveExternalPort.useMutation();
const getIp = data?.server?.ipAddress || ip;
const [connectionUrl, setConnectionUrl] = useState("");
- const form = useForm({
+ const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
toast.success("External Port updated");
await refetch();
})
- .catch(() => {
- toast.error("Error saving the external port");
+ .catch((error: Error) => {
+ toast.error(error?.message || "Error saving the external port");
});
};
@@ -142,7 +142,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
@@ -162,7 +162,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
)}
-
+
Save
diff --git a/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx b/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx
index de520053d..fd8f9ff70 100644
--- a/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx
+++ b/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx
@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralPostgres = ({ postgresId }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.postgres.one.useQuery(
{
postgresId: postgresId,
@@ -28,13 +30,13 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
{ enabled: !!postgresId },
);
- const { mutateAsync: reload, isLoading: isReloading } =
+ const { mutateAsync: reload, isPending: isReloading } =
api.postgres.reload.useMutation();
- const { mutateAsync: stop, isLoading: isStopping } =
+ const { mutateAsync: stop, isPending: isStopping } =
api.postgres.stop.useMutation();
- const { mutateAsync: start, isLoading: isStarting } =
+ const { mutateAsync: start, isPending: isStarting } =
api.postgres.start.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
@@ -73,153 +75,162 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
-
-
-
-
- Deploy
-
-
-
-
- Downloads and sets up the PostgreSQL database
-
-
-
-
-
- {
- await reload({
- postgresId: postgresId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("PostgreSQL reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading PostgreSQL");
- });
- }}
- >
-
-
-
-
-
- Reload
-
-
-
-
- Restart the PostgreSQL service without rebuilding
-
-
-
-
-
- {data?.applicationStatus === "idle" ? (
+ {canDeploy && (
{
- await start({
- postgresId: postgresId,
- })
- .then(() => {
- toast.success("PostgreSQL started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting PostgreSQL");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
+
+ Deploy
-
- Start the PostgreSQL database (requires a previous
- successful setup)
-
-
-
-
-
-
- ) : (
- {
- await stop({
- postgresId: postgresId,
- })
- .then(() => {
- toast.success("PostgreSQL stopped successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error stopping PostgreSQL");
- });
- }}
- >
-
-
-
-
-
- Stop
-
-
-
-
- Stop the currently running PostgreSQL database
+ Downloads and sets up the PostgreSQL database
)}
+ {canDeploy && (
+ {
+ await reload({
+ postgresId: postgresId,
+ appName: data?.appName || "",
+ })
+ .then(() => {
+ toast.success("PostgreSQL reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading PostgreSQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Reload
+
+
+
+
+
+ Restart the PostgreSQL service without rebuilding
+
+
+
+
+
+
+ )}
+ {canDeploy &&
+ (data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ postgresId: postgresId,
+ })
+ .then(() => {
+ toast.success("PostgreSQL started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting PostgreSQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the PostgreSQL database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ postgresId: postgresId,
+ })
+ .then(() => {
+ toast.success("PostgreSQL stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping PostgreSQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+
+ Stop the currently running PostgreSQL database
+
+
+
+
+
+
+ ))}
{
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.postgres.update.useMutation();
const { data } = api.postgres.one.useQuery(
{
@@ -148,7 +148,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
/>
{
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
- const { mutateAsync, isLoading, error, isError } =
+ const { mutateAsync, isPending, error, isError } =
api.application.create.useMutation();
const form = useForm({
@@ -150,8 +150,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
placeholder="Frontend"
{...field}
onChange={(e) => {
- const val = e.target.value?.trim() || "";
- const serviceName = slugify(val);
+ const val = e.target.value || "";
+ const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
@@ -283,7 +283,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
-
+
Create
diff --git a/apps/dokploy/components/dashboard/project/add-compose.tsx b/apps/dokploy/components/dashboard/project/add-compose.tsx
index a187104ec..0d6d7a7bc 100644
--- a/apps/dokploy/components/dashboard/project/add-compose.tsx
+++ b/apps/dokploy/components/dashboard/project/add-compose.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CircuitBoard, HelpCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -75,11 +75,11 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
- const { mutateAsync, isLoading, error, isError } =
+ const { mutateAsync, isPending, error, isError } =
api.compose.create.useMutation();
// Get environment data to extract projectId
- const { data: environment } = api.environment.one.useQuery({ environmentId });
+ // const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
@@ -117,6 +117,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
await utils.environment.one.invalidate({
environmentId,
});
+ // Invalidate the project query to refresh the project data for the advance-breadcrumb
+ await utils.project.all.invalidate();
})
.catch(() => {
toast.error("Error creating the compose");
@@ -161,8 +163,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
placeholder="Frontend"
{...field}
onChange={(e) => {
- const val = e.target.value?.trim() || "";
- const serviceName = slugify(val);
+ const val = e.target.value || "";
+ const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
@@ -307,7 +309,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
-
+
Create
diff --git a/apps/dokploy/components/dashboard/project/add-database.tsx b/apps/dokploy/components/dashboard/project/add-database.tsx
index c0600a2d9..a3d85304a 100644
--- a/apps/dokploy/components/dashboard/project/add-database.tsx
+++ b/apps/dokploy/components/dashboard/project/add-database.tsx
@@ -1,10 +1,11 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle, Database, HelpCircle } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
+ LibsqlIcon,
MariadbIcon,
MongodbIcon,
MysqlIcon,
@@ -52,13 +53,14 @@ import {
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
-type DbType = typeof mySchema._type.type;
+type DbType = z.infer["type"];
const dockerImageDefaultPlaceholder: Record = {
+ libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
- postgres: "postgres:15",
+ postgres: "postgres:18",
redis: "redis:7",
};
@@ -66,8 +68,9 @@ const databasesUserDefaultPlaceholder: Record<
Exclude,
string
> = {
- mongo: "mongo",
+ libsql: "libsql",
mariadb: "mariadb",
+ mongo: "mongo",
mysql: "mysql",
postgres: "postgres",
};
@@ -94,56 +97,88 @@ const baseDatabaseSchema = z.object({
serverId: z.string().nullable(),
});
-const mySchema = z.discriminatedUnion("type", [
- z
- .object({
- type: z.literal("postgres"),
- databaseName: z.string().default("postgres"),
- databaseUser: z.string().default("postgres"),
- })
- .merge(baseDatabaseSchema),
- z
- .object({
- type: z.literal("mongo"),
- databaseUser: z.string().default("mongo"),
- replicaSets: z.boolean().default(false),
- })
- .merge(baseDatabaseSchema),
- z
- .object({
- type: z.literal("redis"),
- })
- .merge(baseDatabaseSchema),
- z
- .object({
- type: z.literal("mysql"),
- databaseRootPassword: z
- .string()
- .regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
+const mySchema = z
+ .discriminatedUnion("type", [
+ z
+ .object({
+ type: z.literal("libsql"),
+ dockerImage: z
+ .string()
+ .default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
+ databaseUser: z.string().default("libsql"),
+ sqldNode: z.enum(["primary", "replica"]).default("primary"),
+ sqldPrimaryUrl: z.string().optional(),
+ enableNamespaces: z.boolean().default(false),
+ })
+ .merge(baseDatabaseSchema),
+ z
+ .object({
+ type: z.literal("mariadb"),
+ dockerImage: z.string().default("mariadb:4"),
+ databaseRootPassword: z
+ .string()
+ .regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
+ message:
+ "Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
+ })
+ .optional(),
+ databaseUser: z.string().default("mariadb"),
+ databaseName: z.string().default("mariadb"),
+ })
+ .merge(baseDatabaseSchema),
+ z
+ .object({
+ type: z.literal("mongo"),
+ databaseUser: z.string().default("mongo"),
+ replicaSets: z.boolean().default(false),
+ })
+ .merge(baseDatabaseSchema),
+ z
+ .object({
+ type: z.literal("mysql"),
+ databaseRootPassword: z
+ .string()
+ .regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
+ message:
+ "Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
+ })
+ .optional(),
+ databaseUser: z.string().default("mysql"),
+ databaseName: z.string().default("mysql"),
+ })
+ .merge(baseDatabaseSchema),
+ z
+ .object({
+ type: z.literal("postgres"),
+ databaseName: z.string().default("postgres"),
+ databaseUser: z.string().default("postgres"),
+ })
+ .merge(baseDatabaseSchema),
+ z
+ .object({
+ type: z.literal("redis"),
+ })
+ .merge(baseDatabaseSchema),
+ ])
+ .superRefine((data, ctx) => {
+ if (data.type === "libsql") {
+ if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["sqldPrimaryUrl"],
+ message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
+ });
+ }
+ if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["sqldPrimaryUrl"],
message:
- "Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
- })
- .optional(),
- databaseUser: z.string().default("mysql"),
- databaseName: z.string().default("mysql"),
- })
- .merge(baseDatabaseSchema),
- z
- .object({
- type: z.literal("mariadb"),
- dockerImage: z.string().default("mariadb:4"),
- databaseRootPassword: z
- .string()
- .regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
- message:
- "Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
- })
- .optional(),
- databaseUser: z.string().default("mariadb"),
- databaseName: z.string().default("mariadb"),
- })
- .merge(baseDatabaseSchema),
-]);
+ "sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
+ });
+ }
+ }
+ });
const databasesMap = {
postgres: {
@@ -166,6 +201,10 @@ const databasesMap = {
icon: ,
label: "Redis",
},
+ libsql: {
+ icon: ,
+ label: "libSQL",
+ },
};
type AddDatabase = z.infer;
@@ -181,11 +220,12 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
- const postgresMutation = api.postgres.create.useMutation();
- const mongoMutation = api.mongo.create.useMutation();
- const redisMutation = api.redis.create.useMutation();
+ const libsqlMutation = api.libsql.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
+ const mongoMutation = api.mongo.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
+ const postgresMutation = api.postgres.create.useMutation();
+ const redisMutation = api.redis.create.useMutation();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
@@ -196,7 +236,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
- const form = useForm({
+ const form = useForm({
defaultValues: {
type: "postgres",
dockerImage: "",
@@ -210,13 +250,15 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
},
resolver: zodResolver(mySchema),
});
+ const sqldNode = form.watch("sqldNode");
const type = form.watch("type");
const activeMutation = {
- postgres: postgresMutation,
- mongo: mongoMutation,
- redis: redisMutation,
+ libsql: libsqlMutation,
mariadb: mariadbMutation,
+ mongo: mongoMutation,
mysql: mysqlMutation,
+ postgres: postgresMutation,
+ redis: redisMutation,
};
const onSubmit = async (data: AddDatabase) => {
@@ -233,12 +275,23 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
description: data.description,
};
- if (data.type === "postgres") {
- promise = postgresMutation.mutateAsync({
+ if (data.type === "libsql") {
+ promise = libsqlMutation.mutateAsync({
+ ...commonParams,
+ sqldNode: data.sqldNode,
+ sqldPrimaryUrl: data.sqldPrimaryUrl ?? null,
+ enableNamespaces: data.enableNamespaces,
+ databasePassword: data.databasePassword,
+ databaseUser:
+ data.databaseUser || databasesUserDefaultPlaceholder[data.type],
+ serverId: data.serverId === "dokploy" ? null : data.serverId,
+ });
+ } else if (data.type === "mariadb") {
+ promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
- databaseName: data.databaseName || "postgres",
-
+ databaseRootPassword: data.databaseRootPassword || "",
+ databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
@@ -252,22 +305,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
serverId: data.serverId === "dokploy" ? null : data.serverId,
replicaSets: data.replicaSets,
});
- } else if (data.type === "redis") {
- promise = redisMutation.mutateAsync({
- ...commonParams,
- databasePassword: data.databasePassword,
- serverId: data.serverId === "dokploy" ? null : data.serverId,
- });
- } else if (data.type === "mariadb") {
- promise = mariadbMutation.mutateAsync({
- ...commonParams,
- databasePassword: data.databasePassword,
- databaseRootPassword: data.databaseRootPassword || "",
- databaseName: data.databaseName || "mariadb",
- databaseUser:
- data.databaseUser || databasesUserDefaultPlaceholder[data.type],
- serverId: data.serverId === "dokploy" ? null : data.serverId,
- });
} else if (data.type === "mysql") {
promise = mysqlMutation.mutateAsync({
...commonParams,
@@ -278,6 +315,21 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "",
});
+ } else if (data.type === "postgres") {
+ promise = postgresMutation.mutateAsync({
+ ...commonParams,
+ databasePassword: data.databasePassword,
+ databaseName: data.databaseName || "postgres",
+ databaseUser:
+ data.databaseUser || databasesUserDefaultPlaceholder[data.type],
+ serverId: data.serverId === "dokploy" ? null : data.serverId,
+ });
+ } else if (data.type === "redis") {
+ promise = redisMutation.mutateAsync({
+ ...commonParams,
+ databasePassword: data.databasePassword,
+ serverId: data.serverId === "dokploy" ? null : data.serverId,
+ });
}
if (promise) {
@@ -305,6 +357,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
});
}
};
+
return (
@@ -395,8 +448,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
placeholder="Name"
{...field}
onChange={(e) => {
- const val = e.target.value?.trim() || "";
- const serviceName = slugify(val);
+ const val = e.target.value || "";
+ const serviceName = slugify(val.trim());
form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
@@ -506,8 +559,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
)}
/>
- {(type === "mysql" ||
- type === "mariadb" ||
+ {(type === "mariadb" ||
+ type === "mysql" ||
type === "postgres") && (
{
)}
/>
)}
- {(type === "mysql" ||
+
+ {type === "libsql" && (
+ (
+
+ Sqld Node
+
+
+
+
+
+
+ {["primary", "replica"].map((node) => (
+
+ {node.charAt(0).toUpperCase() + node.slice(1)}
+
+ ))}
+
+
+
+
+
+ )}
+ />
+ )}
+ {type === "libsql" && sqldNode === "replica" && (
+ (
+
+ Sqld Primary URL
+
+ :"}
+ autoComplete="off"
+ {...field}
+ />
+
+
+
+
+ )}
+ />
+ )}
+ {type === "libsql" && (
+ {
+ console.log(field.value);
+ return (
+
+ Enable Namespaces
+
+
+ field.onChange(Boolean(value))
+ }
+ defaultValue={
+ field.value ? String(field.value) : "false"
+ }
+ >
+
+
+
+
+
+ {["false", "true"].map((node) => (
+
+ {node.charAt(0).toUpperCase() +
+ node.slice(1)}
+
+ ))}
+
+
+
+
+
+
+
+ );
+ }}
+ />
+ )}
+ {(type === "libsql" ||
type === "mariadb" ||
- type === "postgres" ||
- type === "mongo") && (
+ type === "mongo" ||
+ type === "mysql" ||
+ type === "postgres") && (
{
type="password"
placeholder="******************"
autoComplete="one-time-code"
+ enablePasswordGenerator={true}
{...field}
/>
@@ -567,7 +712,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
)}
/>
- {(type === "mysql" || type === "mariadb") && (
+ {(type === "mariadb" || type === "mysql") && (
{
diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx
index 72c42da49..fd37e6a0c 100644
--- a/apps/dokploy/components/dashboard/project/add-template.tsx
+++ b/apps/dokploy/components/dashboard/project/add-template.tsx
@@ -116,7 +116,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
- const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery(
+ const { data: tags, isPending: isLoadingTags } = api.compose.getTags.useQuery(
{ baseUrl: customBaseUrl },
{
enabled: open,
@@ -125,7 +125,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
const utils = api.useUtils();
const [serverId, setServerId] = useState(undefined);
- const { mutateAsync, isLoading, error, isError } =
+ const { mutateAsync, isPending, error, isError } =
api.compose.deployTemplate.useMutation();
const templates =
@@ -332,6 +332,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
viewMode === "detailed" && "border-b",
)}
>
+ {/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */}
{
Cancel
{
const promise = mutateAsync({
serverId:
diff --git a/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx
index 172e8bbaf..f5d61fc10 100644
--- a/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx
+++ b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx
@@ -1,15 +1,8 @@
import type { findEnvironmentsByProjectId } from "@dokploy/server";
-import {
- ChevronDownIcon,
- PencilIcon,
- PlusIcon,
- Terminal,
- TrashIcon,
-} from "lucide-react";
+import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
-import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -64,19 +57,13 @@ export const AdvancedEnvironmentSelector = ({
const [description, setDescription] = useState("");
// Get current user's permissions
- const { data: currentUser } = api.user.get.useQuery();
+ const { data: permissions } = api.user.getPermissions.useQuery();
// Check if user can create environments
- const canCreateEnvironments =
- currentUser?.role === "owner" ||
- currentUser?.role === "admin" ||
- currentUser?.canCreateEnvironments === true;
+ const canCreateEnvironments = !!permissions?.environment.create;
// Check if user can delete environments
- const canDeleteEnvironments =
- currentUser?.role === "owner" ||
- currentUser?.role === "admin" ||
- currentUser?.canDeleteEnvironments === true;
+ const canDeleteEnvironments = !!permissions?.environment.delete;
const haveServices =
selectedEnvironment &&
@@ -100,16 +87,20 @@ export const AdvancedEnvironmentSelector = ({
await createEnvironment.mutateAsync({
projectId,
name: name.trim(),
- description: description.trim() || null,
+ description: description.trim() || undefined,
});
toast.success("Environment created successfully");
utils.environment.byProjectId.invalidate({ projectId });
+ // Invalidate the project query to refresh the project data for the advance-breadcrumb
+ utils.project.all.invalidate();
setIsCreateDialogOpen(false);
setName("");
setDescription("");
} catch (error) {
- toast.error("Failed to create environment");
+ toast.error(
+ `Failed to create environment: ${error instanceof Error ? error.message : error}`,
+ );
}
};
@@ -120,7 +111,7 @@ export const AdvancedEnvironmentSelector = ({
await updateEnvironment.mutateAsync({
environmentId: selectedEnvironment.environmentId,
name: name.trim(),
- description: description.trim() || null,
+ description: description.trim() || undefined,
});
toast.success("Environment updated successfully");
@@ -130,7 +121,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
- toast.error("Failed to update environment");
+ toast.error(
+ `Failed to update environment: ${error instanceof Error ? error.message : error}`,
+ );
}
};
@@ -147,15 +140,18 @@ export const AdvancedEnvironmentSelector = ({
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
- // Redirect to production if we deleted the current environment
+ // Redirect to first available environment if we deleted the current environment
if (selectedEnvironment.environmentId === currentEnvironmentId) {
- const productionEnv = environments?.find(
- (env) => env.name === "production",
+ const firstEnv = environments?.find(
+ (env) => env.environmentId !== selectedEnvironment.environmentId,
);
- if (productionEnv) {
+ if (firstEnv) {
router.push(
- `/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
+ `/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
);
+ } else {
+ // No other environments, redirect to project page
+ router.push(`/dashboard/project/${projectId}`);
}
}
} catch (error) {
@@ -168,7 +164,7 @@ export const AdvancedEnvironmentSelector = ({
const result = await duplicateEnvironment.mutateAsync({
environmentId: environment.environmentId,
name: `${environment.name}-copy`,
- description: environment.description,
+ description: environment.description || undefined,
});
toast.success("Environment duplicated successfully");
@@ -246,22 +242,8 @@ export const AdvancedEnvironmentSelector = ({
)}
-
- {/* Action buttons for non-production environments */}
- {/*
- {
- e.stopPropagation();
- }}
- >
-
-
- */}
- {environment.name !== "production" && (
-
+
+ {!environment.isDefault && (
-
- {canDeleteEnvironments && (
-
{
- e.stopPropagation();
- openDeleteDialog(environment);
- }}
- >
-
-
- )}
-
- )}
+ )}
+ {canDeleteEnvironments && !environment.isDefault && (
+
{
+ e.stopPropagation();
+ openDeleteDialog(environment);
+ }}
+ >
+
+
+ )}
+
);
})}
@@ -349,9 +330,9 @@ export const AdvancedEnvironmentSelector = ({
- {createEnvironment.isLoading ? "Creating..." : "Create"}
+ {createEnvironment.isPending ? "Creating..." : "Create"}
@@ -402,9 +383,9 @@ export const AdvancedEnvironmentSelector = ({
- {updateEnvironment.isLoading ? "Updating..." : "Update"}
+ {updateEnvironment.isPending ? "Updating..." : "Update"}
@@ -442,12 +423,12 @@ export const AdvancedEnvironmentSelector = ({
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={
- deleteEnvironment.isLoading ||
+ deleteEnvironment.isPending ||
haveServices ||
!selectedEnvironment
}
>
- {deleteEnvironment.isLoading ? "Deleting..." : "Delete"}
+ {deleteEnvironment.isPending ? "Deleting..." : "Delete"}
diff --git a/apps/dokploy/components/dashboard/project/ai/step-two.tsx b/apps/dokploy/components/dashboard/project/ai/step-two.tsx
index 09484bc57..e13ff40ad 100644
--- a/apps/dokploy/components/dashboard/project/ai/step-two.tsx
+++ b/apps/dokploy/components/dashboard/project/ai/step-two.tsx
@@ -28,7 +28,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
const suggestions = templateInfo.suggestions || [];
const selectedVariant = templateInfo.details;
- const { mutateAsync, isLoading, error, isError } =
+ const { mutateAsync, isPending, error, isError } =
api.ai.suggest.useMutation();
useEffect(() => {
@@ -184,7 +184,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
);
}
- if (isLoading) {
+ if (isPending) {
return (
diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx
index 3455f34cf..f1418117a 100644
--- a/apps/dokploy/components/dashboard/project/duplicate-project.tsx
+++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx
@@ -25,17 +25,17 @@ import {
import { api } from "@/utils/api";
export type Services = {
- appName: string;
serverId?: string | null;
name: string;
type:
- | "mariadb"
| "application"
- | "postgres"
- | "mysql"
+ | "compose"
+ | "libsql"
+ | "mariadb"
| "mongo"
- | "redis"
- | "compose";
+ | "mysql"
+ | "postgres"
+ | "redis";
description?: string | null;
id: string;
createdAt: string;
@@ -76,7 +76,7 @@ export const DuplicateProject = ({
selectedServiceIds.includes(service.id),
);
- const { mutateAsync: duplicateProject, isLoading } =
+ const { mutateAsync: duplicateProject, isPending } =
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
@@ -321,20 +321,20 @@ export const DuplicateProject = ({
setOpen(false)}
- disabled={isLoading}
+ disabled={isPending}
>
Cancel
- {isLoading ? (
+ {isPending ? (
<>
{duplicateType === "new-project"
diff --git a/apps/dokploy/components/dashboard/project/environment-variables.tsx b/apps/dokploy/components/dashboard/project/environment-variables.tsx
index e833fa779..b4ed3f8cb 100644
--- a/apps/dokploy/components/dashboard/project/environment-variables.tsx
+++ b/apps/dokploy/components/dashboard/project/environment-variables.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Terminal } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -39,9 +39,12 @@ interface Props {
}
export const EnvironmentVariables = ({ environmentId, children }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canRead = permissions?.environmentEnvVars.read ?? false;
+ const canWrite = permissions?.environmentEnvVars.write ?? false;
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.environment.update.useMutation();
const { data } = api.environment.one.useQuery(
{
@@ -85,7 +88,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -95,7 +98,11 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
- }, [form, onSubmit, isLoading, isOpen]);
+ }, [form, onSubmit, isPending, isOpen]);
+
+ if (!canRead) {
+ return null;
+ }
return (
@@ -141,6 +148,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
)}
/>
-
-
- Update
-
-
+ {canWrite && (
+
+
+ Update
+
+
+ )}
diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx
index 09fd36f84..309e41dab 100644
--- a/apps/dokploy/components/dashboard/projects/handle-project.tsx
+++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx
@@ -1,4 +1,5 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
+
import { PlusIcon, SquarePen } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
@@ -6,6 +7,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
+import { TagSelector } from "@/components/shared/tag-selector";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -61,6 +63,7 @@ interface Props {
export const HandleProject = ({ projectId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
+ const [selectedTagIds, setSelectedTagIds] = useState([]);
const { mutateAsync, error, isError } = projectId
? api.project.update.useMutation()
@@ -74,13 +77,17 @@ export const HandleProject = ({ projectId }: Props) => {
enabled: !!projectId,
},
);
+
+ const { data: availableTags = [] } = api.tag.all.useQuery();
+ const bulkAssignMutation = api.tag.bulkAssign.useMutation();
+
const router = useRouter();
const form = useForm({
defaultValues: {
description: "",
name: "",
},
- resolver: zodResolver(AddProjectSchema),
+ resolver: standardSchemaResolver(AddProjectSchema),
});
useEffect(() => {
@@ -88,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => {
description: data?.description ?? "",
name: data?.name ?? "",
});
+ // Load existing tags when editing a project
+ if (data?.projectTags) {
+ const tagIds = data.projectTags.map((pt) => pt.tagId);
+ setSelectedTagIds(tagIds);
+ } else {
+ setSelectedTagIds([]);
+ }
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddProject) => {
@@ -97,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => {
projectId: projectId || "",
})
.then(async (data) => {
+ // Assign tags to the project (both create and update)
+ const projectIdToUse =
+ projectId ||
+ (data && "project" in data ? data.project.projectId : undefined);
+
+ if (projectIdToUse) {
+ try {
+ await bulkAssignMutation.mutateAsync({
+ projectId: projectIdToUse,
+ tagIds: selectedTagIds,
+ });
+ } catch (error) {
+ toast.error("Failed to assign tags to project");
+ }
+ }
+
await utils.project.all.invalidate();
toast.success(projectId ? "Project Updated" : "Project Created");
setIsOpen(false);
if (!projectId) {
- const projectIdToUse =
- data && "project" in data ? data.project.projectId : undefined;
const environmentIdToUse =
data && "environment" in data
? data.environment.environmentId
@@ -189,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => {
)}
/>
+
+
+ Tags
+ ({
+ id: tag.tagId,
+ name: tag.name,
+ color: tag.color ?? undefined,
+ }))}
+ selectedTags={selectedTagIds}
+ onTagsChange={setSelectedTagIds}
+ placeholder="Select tags..."
+ />
+
diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx
index cb6245f08..46e5d1f54 100644
--- a/apps/dokploy/components/dashboard/projects/project-environment.tsx
+++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { FileIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -39,9 +39,12 @@ interface Props {
}
export const ProjectEnvironment = ({ projectId, children }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canRead = permissions?.projectEnvVars.read ?? false;
+ const canWrite = permissions?.projectEnvVars.write ?? false;
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.project.update.useMutation();
const { data } = api.project.one.useQuery(
{
@@ -84,7 +87,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -94,7 +97,11 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
- }, [form, onSubmit, isLoading, isOpen]);
+ }, [form, onSubmit, isPending, isOpen]);
+
+ if (!canRead) {
+ return null;
+ }
return (
@@ -139,6 +146,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
)}
/>
-
-
- Update
-
-
+ {canWrite && (
+
+
+ Update
+
+
+ )}
diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx
index 783c5bb32..1d3055e5e 100644
--- a/apps/dokploy/components/dashboard/projects/show.tsx
+++ b/apps/dokploy/components/dashboard/projects/show.tsx
@@ -2,7 +2,6 @@ import {
AlertTriangle,
ArrowUpDown,
BookIcon,
- ExternalLinkIcon,
FolderInput,
Loader2,
MoreHorizontalIcon,
@@ -10,11 +9,14 @@ import {
TrashIcon,
} from "lucide-react";
import Link from "next/link";
+import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
-import { StatusTooltip } from "@/components/shared/status-tooltip";
+import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
+import { TagBadge } from "@/components/shared/tag-badge";
+import { TagFilter } from "@/components/shared/tag-filter";
import {
AlertDialog,
AlertDialogAction,
@@ -38,13 +40,10 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
- DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import {
Select,
SelectContent,
@@ -53,15 +52,25 @@ import {
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
+import { useDebounce } from "@/utils/hooks/use-debounce";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => {
const utils = api.useUtils();
- const { data, isLoading } = api.project.all.useQuery();
+ const router = useRouter();
+ const { data: isCloud } = api.settings.isCloud.useQuery();
+ const { data, isPending } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
+ const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
- const [searchQuery, setSearchQuery] = useState("");
+ const { data: availableTags } = api.tag.all.useQuery();
+
+ const [searchQuery, setSearchQuery] = useState(
+ router.isReady && typeof router.query.q === "string" ? router.query.q : "",
+ );
+ const debouncedSearchQuery = useDebounce(searchQuery, 500);
+
const [sortBy, setSortBy] = useState
(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("projectsSort") || "createdAt-desc";
@@ -69,20 +78,77 @@ export const ShowProjects = () => {
return "createdAt-desc";
});
+ const [selectedTagIds, setSelectedTagIds] = useState(() => {
+ if (typeof window !== "undefined") {
+ const saved = localStorage.getItem("projectsTagFilter");
+ return saved ? JSON.parse(saved) : [];
+ }
+ return [];
+ });
+
useEffect(() => {
localStorage.setItem("projectsSort", sortBy);
}, [sortBy]);
+ useEffect(() => {
+ localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds));
+ }, [selectedTagIds]);
+
+ useEffect(() => {
+ if (!availableTags) return;
+ const validIds = new Set(availableTags.map((t) => t.tagId));
+ setSelectedTagIds((prev) => {
+ const filtered = prev.filter((id) => validIds.has(id));
+ return filtered.length === prev.length ? prev : filtered;
+ });
+ }, [availableTags]);
+
+ useEffect(() => {
+ if (!router.isReady) return;
+ const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
+ if (urlQuery !== searchQuery) {
+ setSearchQuery(urlQuery);
+ }
+ }, [router.isReady, router.query.q]);
+
+ useEffect(() => {
+ if (!router.isReady) return;
+ const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
+ if (debouncedSearchQuery === urlQuery) return;
+
+ const newQuery = { ...router.query };
+ if (debouncedSearchQuery) {
+ newQuery.q = debouncedSearchQuery;
+ } else {
+ delete newQuery.q;
+ }
+ router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
+ shallow: true,
+ });
+ }, [debouncedSearchQuery]);
+
const filteredProjects = useMemo(() => {
if (!data) return [];
- // First filter by search query
- const filtered = data.filter(
+ let filtered = data.filter(
(project) =>
- project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
+ project.name
+ .toLowerCase()
+ .includes(debouncedSearchQuery.toLowerCase()) ||
+ project.description
+ ?.toLowerCase()
+ .includes(debouncedSearchQuery.toLowerCase()),
);
+ // Filter by selected tags (OR logic: show projects with ANY selected tag)
+ if (selectedTagIds.length > 0) {
+ filtered = filtered.filter((project) =>
+ project.projectTags?.some((pt) =>
+ selectedTagIds.includes(pt.tag.tagId),
+ ),
+ );
+ }
+
// Then sort the filtered results
const [field, direction] = sortBy.split("-");
return [...filtered].sort((a, b) => {
@@ -128,7 +194,7 @@ export const ShowProjects = () => {
}
return direction === "asc" ? comparison : -comparison;
});
- }, [data, searchQuery, sortBy]);
+ }, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
return (
<>
@@ -148,8 +214,7 @@ export const ShowProjects = () => {
Create and manage your projects
-
- {(auth?.role === "owner" || auth?.canCreateProjects) && (
+ {permissions?.project.create && (
@@ -157,7 +222,7 @@ export const ShowProjects = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -175,29 +240,44 @@ export const ShowProjects = () => {
-
-
-
-
-
-
-
- Name (A-Z)
- Name (Z-A)
-
- Newest first
-
-
- Oldest first
-
-
- Most services
-
-
- Least services
-
-
-
+
+
({
+ id: tag.tagId,
+ name: tag.name,
+ color: tag.color || undefined,
+ })) || []
+ }
+ selectedTags={selectedTagIds}
+ onTagsChange={setSelectedTagIds}
+ />
+
+
+
+
+
+
+
+ Name (A-Z)
+
+ Name (Z-A)
+
+
+ Newest first
+
+
+ Oldest first
+
+
+ Most services
+
+
+ Least services
+
+
+
+
{filteredProjects?.length === 0 && (
@@ -214,36 +294,36 @@ export const ShowProjects = () => {
.map(
(env) =>
env.applications.length === 0 &&
+ env.compose.length === 0 &&
+ env.libsql.length === 0 &&
env.mariadb.length === 0 &&
env.mongo.length === 0 &&
env.mysql.length === 0 &&
env.postgres.length === 0 &&
- env.redis.length === 0 &&
- env.applications.length === 0 &&
- env.compose.length === 0,
+ env.redis.length === 0,
)
.every(Boolean);
const totalServices = project?.environments
.map(
(env) =>
+ env.applications.length +
+ env.compose.length +
+ env.libsql.length +
env.mariadb.length +
env.mongo.length +
env.mysql.length +
env.postgres.length +
- env.redis.length +
- env.applications.length +
- env.compose.length,
+ env.redis.length,
)
.reduce((acc, curr) => acc + curr, 0);
- const haveServicesWithDomains = project?.environments
- .map(
- (env) =>
- env.applications.length > 0 ||
- env.compose.length > 0,
- )
- .some(Boolean);
+ // Find default environment from accessible environments, or fall back to first accessible environment
+ const accessibleEnvironment =
+ project?.environments.find((env) => env.isDefault) ||
+ project?.environments?.[0];
+
+ const hasNoEnvironments = !accessibleEnvironment;
return (
{
className="w-full lg:max-w-md"
>
{
+ if (hasNoEnvironments) {
+ e.preventDefault();
+ }
+ }}
>
- {haveServicesWithDomains ? (
-
-
-
-
-
-
- e.stopPropagation()}
- >
- {project.environments.some(
- (env) => env.applications.length > 0,
- ) && (
-
-
- Applications
-
- {project.environments.map((env) =>
- env.applications.map((app) => (
-
-
-
-
- {app.name}
-
-
-
- {app.domains.map((domain) => (
-
-
-
- {domain.host}
-
-
-
-
- ))}
-
-
- )),
- )}
-
- )}
- {project.environments.some(
- (env) => env.compose.length > 0,
- ) && (
-
-
- Compose
-
- {project.environments.map((env) =>
- env.compose.map((comp) => (
-
-
-
-
- {comp.name}
-
-
-
- {comp.domains.map((domain) => (
-
-
-
- {domain.host}
-
-
-
-
- ))}
-
-
- )),
- )}
-
- )}
-
-
- ) : null}
-
-
+
+
@@ -368,9 +353,32 @@ export const ShowProjects = () => {
-
+
{project.description}
+
+ {project.projectTags &&
+ project.projectTags.length > 0 && (
+
+ {project.projectTags.map((pt) => (
+
+ ))}
+
+ )}
+
+ {hasNoEnvironments && (
+
+
+
+ You have access to this project but no
+ environments are available
+
+
+ )}
@@ -408,8 +416,7 @@ export const ShowProjects = () => {
e.stopPropagation()}
>
- {(auth?.role === "owner" ||
- auth?.canDeleteProjects) && (
+ {permissions?.project.delete && (
{
-
+
Created
diff --git a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx
index 8edd92389..ebc01200a 100644
--- a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx
+++ b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -48,11 +48,11 @@ interface Props {
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.redis.one.useQuery({ redisId });
- const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
+ const { mutateAsync, isPending } = api.redis.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
- const form = useForm
({
+ const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
toast.success("External Port updated");
await refetch();
})
- .catch(() => {
- toast.error("Error saving the external port");
+ .catch((error: Error) => {
+ toast.error(error?.message || "Error saving the external port");
});
};
@@ -134,7 +134,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
@@ -154,7 +154,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
)}
-
+
Save
diff --git a/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx b/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx
index de70cc558..0018ddcde 100644
--- a/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx
+++ b/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx
@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralRedis = ({ redisId }: Props) => {
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.redis.one.useQuery(
{
redisId,
@@ -28,12 +30,12 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
{ enabled: !!redisId },
);
- const { mutateAsync: reload, isLoading: isReloading } =
+ const { mutateAsync: reload, isPending: isReloading } =
api.redis.reload.useMutation();
- const { mutateAsync: start, isLoading: isStarting } =
+ const { mutateAsync: start, isPending: isStarting } =
api.redis.start.useMutation();
- const { mutateAsync: stop, isLoading: isStopping } =
+ const { mutateAsync: stop, isPending: isStopping } =
api.redis.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
@@ -72,153 +74,158 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
-
-
-
-
- Deploy
-
-
-
-
- Downloads and sets up the Redis database
-
-
-
-
-
- {
- await reload({
- redisId: redisId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Redis reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading Redis");
- });
- }}
- >
-
-
-
-
-
- Reload
-
-
-
-
- Restart the Redis service without rebuilding
-
-
-
-
-
- {data?.applicationStatus === "idle" ? (
+ {canDeploy && (
{
- await start({
- redisId: redisId,
- })
- .then(() => {
- toast.success("Redis started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting Redis");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
+
+ Deploy
-
- Start the Redis database (requires a previous
- successful setup)
-
-
-
-
-
-
- ) : (
- {
- await stop({
- redisId: redisId,
- })
- .then(() => {
- toast.success("Redis stopped successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error stopping Redis");
- });
- }}
- >
-
-
-
-
-
- Stop
-
-
-
-
- Stop the currently running Redis database
+ Downloads and sets up the Redis database
)}
+ {canDeploy && (
+ {
+ await reload({
+ redisId: redisId,
+ appName: data?.appName || "",
+ })
+ .then(() => {
+ toast.success("Redis reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading Redis");
+ });
+ }}
+ >
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the Redis service without rebuilding
+
+
+
+
+
+ )}
+ {canDeploy &&
+ (data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ redisId: redisId,
+ })
+ .then(() => {
+ toast.success("Redis started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting Redis");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the Redis database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ redisId: redisId,
+ })
+ .then(() => {
+ toast.success("Redis stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping Redis");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running Redis database
+
+
+
+
+
+ ))}
{
+ const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
- const { mutateAsync, error, isError, isLoading } =
+ const { mutateAsync, error, isError, isPending } =
api.redis.update.useMutation();
const { data } = api.redis.one.useQuery(
{
@@ -79,6 +80,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
utils.redis.one.invalidate({
redisId: redisId,
});
+ setIsOpen(false);
})
.catch(() => {
toast.error("Error updating Redis");
@@ -87,7 +89,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
};
return (
-
+
{
/>
diff --git a/apps/dokploy/components/dashboard/requests/columns.tsx b/apps/dokploy/components/dashboard/requests/columns.tsx
index 3648261fb..997074fde 100644
--- a/apps/dokploy/components/dashboard/requests/columns.tsx
+++ b/apps/dokploy/components/dashboard/requests/columns.tsx
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
import type { LogEntry } from "./show-requests";
export const getStatusColor = (status: number) => {
+ if (status === 0) {
+ return "secondary";
+ }
if (status >= 100 && status < 200) {
return "outline";
}
@@ -21,6 +24,24 @@ export const getStatusColor = (status: number) => {
return "destructive";
};
+const formatStatusLabel = (status: number) => {
+ if (status === 0) {
+ return "N/A";
+ }
+ return status;
+};
+
+const formatDuration = (nanos: number) => {
+ const ms = nanos / 1000000;
+ if (ms < 1) {
+ return `${(nanos / 1000).toFixed(2)} µs`;
+ }
+ if (ms < 1000) {
+ return `${ms.toFixed(2)} ms`;
+ }
+ return `${(ms / 1000).toFixed(2)} s`;
+};
+
export const columns: ColumnDef[] = [
{
accessorKey: "level",
@@ -59,10 +80,10 @@ export const columns: ColumnDef[] = [
- Status: {log.OriginStatus}
+ Status: {formatStatusLabel(log.OriginStatus)}
- Exec Time: {`${log.Duration / 1000000000}s`}
+ Exec Time: {formatDuration(log.Duration)}
IP: {log.ClientAddr}
diff --git a/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx
index 2a5db2a94..c760c8175 100644
--- a/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx
+++ b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx
@@ -49,51 +49,65 @@ export const RequestDistributionChart = ({
);
return (
-
-
-
-
-
- new Date(value).toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit",
- })
- }
- />
-
- }
- labelFormatter={(value) =>
- new Date(value).toLocaleString([], {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- })
- }
- />
-
-
-
-
+
+
+
+
+
+
+ new Date(value).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ }
+ />
+
+ }
+ labelFormatter={(value) =>
+ new Date(value).toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ }
+ />
+
+
+
+
+
);
};
diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx
index 45a531324..e804b065b 100644
--- a/apps/dokploy/components/dashboard/requests/requests-table.tsx
+++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx
@@ -152,7 +152,15 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
return JSON.stringify(value, null, 2);
}
if (key === "Duration" || key === "OriginDuration" || key === "Overhead") {
- return `${value / 1000000000} s`;
+ const nanos = Number(value);
+ const ms = nanos / 1000000;
+ if (ms < 1) {
+ return `${(nanos / 1000).toFixed(2)} µs`;
+ }
+ if (ms < 1000) {
+ return `${ms.toFixed(2)} ms`;
+ }
+ return `${(ms / 1000).toFixed(2)} s`;
}
if (key === "level") {
return
{value} ;
@@ -161,7 +169,11 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
return
{value} ;
}
if (key === "DownstreamStatus" || key === "OriginStatus") {
- return
{value} ;
+ const num = Number(value);
+ if (num === 0) {
+ return
N/A ;
+ }
+ return
{value} ;
}
return value;
};
diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx
index ab602f463..cc4f1764a 100644
--- a/apps/dokploy/components/dashboard/requests/show-requests.tsx
+++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx
@@ -51,13 +51,38 @@ export const ShowRequests = () => {
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState
(null);
+
+ // Set default date range to last 3 days
+ const getDefaultDateRange = () => {
+ const to = new Date();
+ const from = new Date();
+ from.setDate(from.getDate() - 3);
+ return { from, to };
+ };
+
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
- }>({
- from: undefined,
- to: undefined,
- });
+ }>(getDefaultDateRange());
+
+ // Check if logs exist to determine if traefik has been reloaded
+ // Only fetch when active to minimize network calls
+ const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery(
+ {
+ page: {
+ pageIndex: 0,
+ pageSize: 1,
+ },
+ },
+ {
+ enabled: !!isActive,
+ refetchInterval: 5000, // Check every 5 seconds when active
+ },
+ );
+
+ // Determine if warning should be shown
+ // Show warning only if active but no logs exist yet
+ const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0;
useEffect(() => {
if (logCleanupStatus) {
@@ -79,16 +104,18 @@ export const ShowRequests = () => {
See all the incoming requests that pass trough Traefik
-
- When you activate, you need to reload traefik to apply the
- changes, you can reload traefik in{" "}
-
- Settings
-
-
+ {shouldShowWarning && (
+
+ When you activate, you need to reload traefik to apply the
+ changes, you can reload traefik in{" "}
+
+ Settings
+
+
+ )}
@@ -169,17 +196,13 @@ export const ShowRequests = () => {
{isActive ? (
<>
- {(dateRange.from || dateRange.to) && (
-
- setDateRange({ from: undefined, to: undefined })
- }
- className="px-3"
- >
- Clear dates
-
- )}
+
setDateRange(getDefaultDateRange())}
+ className="px-3"
+ >
+ Reset to Last 3 Days
+
{
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
- const { data: session } = authClient.useSession();
+ const { data: session } = api.user.session.useQuery();
const { data } = api.project.all.useQuery(undefined, {
enabled: !!session,
});
@@ -89,24 +88,26 @@ export const SearchCommand = () => {
{data?.map((project) => {
- const productionEnvironment = project.environments.find(
- (environment) => environment.name === "production",
- );
+ // Find default environment from accessible environments, or fall back to first accessible environment
+ const defaultEnvironment =
+ project.environments.find(
+ (environment) => environment.isDefault,
+ ) || project?.environments?.[0];
- if (!productionEnvironment) return null;
+ if (!defaultEnvironment) return null;
return (
{
router.push(
- `/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
+ `/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
);
setOpen(false);
}}
>
- {project.name} / {productionEnvironment!.name}
+ {project.name} / {defaultEnvironment.name}
);
})}
@@ -172,6 +173,14 @@ export const SearchCommand = () => {
>
Projects
+ {
+ router.push("/dashboard/deployments");
+ setOpen(false);
+ }}
+ >
+ Deployments
+
{!isCloud && (
<>
{
- const { data: aiConfigs, refetch, isLoading } = api.ai.getAll.useQuery();
- const { mutateAsync, isLoading: isRemoving } = api.ai.delete.useMutation();
+ const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
+ const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
return (
@@ -33,7 +33,7 @@ export const AiForm = () => {
{aiConfigs && aiConfigs?.length > 0 &&
}
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx b/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx
index 15c7ed6e0..c6db49b5d 100644
--- a/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx
+++ b/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { useState } from "react";
import { useForm } from "react-hook-form";
diff --git a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx
index efa68929f..2e90a9bb6 100644
--- a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx
+++ b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx
@@ -17,7 +17,7 @@ import { AddApiKey } from "./add-api-key";
export const ShowApiKeys = () => {
const { data, refetch } = api.user.get.useQuery();
- const { mutateAsync: deleteApiKey, isLoading: isLoadingDelete } =
+ const { mutateAsync: deleteApiKey, isPending: isLoadingDelete } =
api.user.deleteApiKey.useMutation();
return (
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx
new file mode 100644
index 000000000..67c15ee63
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx
@@ -0,0 +1,74 @@
+import { CreditCard, FileText } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/router";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import { ShowInvoices } from "./show-invoices";
+
+const navigationItems = [
+ {
+ name: "Subscription",
+ href: "/dashboard/settings/billing",
+ icon: CreditCard,
+ },
+ {
+ name: "Invoices",
+ href: "/dashboard/settings/invoices",
+ icon: FileText,
+ },
+];
+
+export const ShowBillingInvoices = () => {
+ const router = useRouter();
+
+ return (
+
+
+
+
+
+
+ Billing
+
+
+ Manage your subscription and invoices
+
+
+
+
+ {navigationItems.map((item) => {
+ const Icon = item.icon;
+ const isActive = router.pathname === item.href;
+ return (
+
+
+ {item.name}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index ac211a1c5..2f04620f3 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -4,12 +4,16 @@ import {
AlertTriangle,
CheckIcon,
CreditCard,
+ FileText,
Loader2,
MinusIcon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
-import { useState } from "react";
+import { useRouter } from "next/router";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -29,6 +33,7 @@ const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
+/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
export const calculatePrice = (count: number, isAnnual = false) => {
if (isAnnual) {
if (count <= 1) return 45.9;
@@ -37,25 +42,89 @@ export const calculatePrice = (count: number, isAnnual = false) => {
if (count <= 1) return 4.5;
return count * 3.5;
};
+
+/** Hobby: $4.50/mo per server; annual 20% off = $43.20/yr per server (4.5 * 12 * 0.8). */
+export const calculatePriceHobby = (count: number, isAnnual = false) => {
+ const perServerMonthly = 4.5;
+ const perServerAnnual = 43.2; // 4.5 * 12 * 0.8
+ return isAnnual ? count * perServerAnnual : count * perServerMonthly;
+};
+
+/** Startup: 3 servers included ($15/mo); extra servers $4.50/mo each. Annual 20% off. */
+export const STARTUP_SERVERS_INCLUDED = 3;
+export const calculatePriceStartup = (count: number, isAnnual = false) => {
+ const baseMonthly = 15;
+ const extraMonthly = 4.5;
+ const baseAnnual = 144; // 15 * 12 * 0.8
+ const extraAnnual = 43.2; // 4.5 * 12 * 0.8, consistent with Hobby annual
+ if (count <= STARTUP_SERVERS_INCLUDED)
+ return isAnnual ? baseAnnual : baseMonthly;
+ return isAnnual
+ ? baseAnnual + (count - STARTUP_SERVERS_INCLUDED) * extraAnnual
+ : baseMonthly + (count - STARTUP_SERVERS_INCLUDED) * extraMonthly;
+};
+
+const navigationItems = [
+ {
+ name: "Subscription",
+ href: "/dashboard/settings/billing",
+ icon: CreditCard,
+ },
+ {
+ name: "Invoices",
+ href: "/dashboard/settings/invoices",
+ icon: FileText,
+ },
+];
+
export const ShowBilling = () => {
+ const router = useRouter();
const { data: servers } = api.server.count.useQuery();
const { data: admin } = api.user.get.useQuery();
- const { data, isLoading } = api.stripe.getProducts.useQuery();
+ const { data, isPending } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
api.stripe.createCheckoutSession.useMutation();
const { mutateAsync: createCustomerPortalSession } =
api.stripe.createCustomerPortalSession.useMutation();
+ const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
+ api.stripe.upgradeSubscription.useMutation();
+ const utils = api.useUtils();
- const [serverQuantity, setServerQuantity] = useState(3);
+ const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
+ const [startupServerQuantity, setStartupServerQuantity] = useState(
+ STARTUP_SERVERS_INCLUDED,
+ );
const [isAnnual, setIsAnnual] = useState(false);
+ const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
+ null,
+ );
+ const [upgradeServerQty, setUpgradeServerQty] = useState(3);
+ /** Billing interval in the upgrade/update form; synced to current when data loads. */
+ const [updateFormAnnual, setUpdateFormAnnual] = useState(false);
- const handleCheckout = async (productId: string) => {
+ useEffect(() => {
+ if (data?.isAnnualCurrent !== undefined) {
+ setUpdateFormAnnual(data.isAnnualCurrent);
+ }
+ }, [data?.isAnnualCurrent]);
+
+ const handleCheckout = async (
+ tier: "legacy" | "hobby" | "startup",
+ productId: string,
+ ) => {
const stripe = await stripePromise;
+ const serverQuantity =
+ tier === "startup"
+ ? startupServerQuantity
+ : tier === "hobby"
+ ? hobbyServerQuantity
+ : hobbyServerQuantity;
if (data && data.subscriptions.length === 0) {
createCheckoutSession({
+ tier,
productId,
- serverQuantity: serverQuantity,
+ serverQuantity,
isAnnual,
}).then(async (session) => {
await stripe?.redirectToCheckout({
@@ -64,6 +133,8 @@ export const ShowBilling = () => {
});
}
};
+
+ const useNewPricing = data?.hobbyProductId && data?.startupProductId;
const products = data?.products.filter((product) => {
// @ts-ignore
const interval = product?.default_price?.recurring?.interval;
@@ -76,28 +147,41 @@ export const ShowBilling = () => {
return (
-
-
-
+
+
+
Billing
- Manage your subscription
+
+ Manage your subscription and invoices
+
-
-
-
setIsAnnual(e === "annual")}
- >
-
- Monthly
- Annual
-
-
+
+
+ {navigationItems.map((item) => {
+ const Icon = item.icon;
+ const isActive = router.pathname === item.href;
+ return (
+
+
+ {item.name}
+
+ );
+ })}
+
+
+
{admin?.user.stripeSubscriptionId && (
Servers Plan
@@ -119,6 +203,429 @@ export const ShowBilling = () => {
)}
)}
+ {/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
+ {useNewPricing &&
+ data?.currentPlan === "legacy" &&
+ data?.subscriptions?.length > 0 && (
+
+
Upgrade your plan
+
+ You’re on the legacy plan. Switch to Hobby or Startup
+ (same benefits). You can also choose annual billing (20%
+ off). Stripe will prorate the change.
+
+
+
+ Billing interval
+
+
+ setUpdateFormAnnual(false)}
+ >
+ Monthly
+
+ setUpdateFormAnnual(true)}
+ >
+ Annual (20% off)
+
+
+
+
New plan
+
+ setUpgradeTier("hobby")}
+ >
+ Hobby
+
+ setUpgradeTier("startup")}
+ >
+ Startup
+
+
+
+ {upgradeTier && (
+
+
+ Servers
+ {upgradeTier === "startup" &&
+ ` (min. ${STARTUP_SERVERS_INCLUDED})`}
+
+
+
+ setUpgradeServerQty((q) =>
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ q - 1,
+ ),
+ )
+ }
+ >
+
+
+
{
+ const v =
+ Number((e.target as HTMLInputElement).value) ||
+ 0;
+ setUpgradeServerQty(
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ v,
+ ),
+ );
+ }}
+ className="w-20 h-8"
+ />
+ setUpgradeServerQty((q) => q + 1)}
+ >
+
+
+
+
+ {upgradeTier === "hobby"
+ ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
+ : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
+
+
+
+ Current plan: Legacy
+
+
+ New plan:{" "}
+ {upgradeTier === "startup"
+ ? "Startup"
+ : "Hobby"}{" "}
+ · {upgradeServerQty} server
+ {upgradeServerQty !== 1 ? "s" : ""} · $
+ {upgradeTier === "hobby"
+ ? calculatePriceHobby(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)
+ : calculatePriceStartup(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)}
+ /{updateFormAnnual ? "yr" : "mo"} (
+ {updateFormAnnual ? "annual" : "monthly"})
+
+
+ Stripe will prorate the change.
+
+
+ }
+ type="default"
+ onClick={async () => {
+ if (!upgradeTier) return;
+ try {
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ toast.success("Plan upgraded successfully");
+ } catch {
+ toast.error("Error upgrading plan");
+ }
+ }}
+ >
+
+ {isUpgrading ? (
+ <>
+
+ Upgrading…
+ >
+ ) : (
+ "Upgrade plan"
+ )}
+
+
+
+ )}
+
+ )}
+ {/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
+ {useNewPricing &&
+ (data?.currentPlan === "hobby" ||
+ data?.currentPlan === "startup") &&
+ data?.subscriptions?.length > 0 && (
+
+
+ Change plan or number of servers
+
+
+ Your current plan:{" "}
+
+ {data?.currentPlan === "startup" ? "Startup" : "Hobby"}
+
+ {" · "}
+
+ {admin?.user.serversQuantity ?? 0} server
+ {(admin?.user.serversQuantity ?? 0) !== 1 ? "s" : ""}
+
+ {data?.currentPriceAmount != null && (
+ <>
+ {" · "}
+
+ ${data.currentPriceAmount.toFixed(2)}/
+ {data?.isAnnualCurrent ? "yr" : "mo"}
+
+ >
+ )}{" "}
+ ({data?.isAnnualCurrent ? "annual" : "monthly"} billing).
+
+
+ Add more servers, switch between Hobby and Startup, or
+ change to annual billing (20% off). Stripe will prorate
+ the change.
+
+
+
+ Billing interval
+
+
+ setUpdateFormAnnual(false)}
+ >
+ Monthly
+
+ setUpdateFormAnnual(true)}
+ >
+ Annual (20% off)
+
+
+
+
Plan
+
+ setUpgradeTier("hobby")}
+ >
+ Hobby
+
+ setUpgradeTier("startup")}
+ >
+ Startup
+
+
+
+ {upgradeTier && (
+
+
+ Servers
+ {upgradeTier === "startup" &&
+ ` (min. ${STARTUP_SERVERS_INCLUDED})`}
+
+
+
+ setUpgradeServerQty((q) =>
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ q - 1,
+ ),
+ )
+ }
+ >
+
+
+
{
+ const v =
+ Number((e.target as HTMLInputElement).value) ||
+ 0;
+ setUpgradeServerQty(
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ v,
+ ),
+ );
+ }}
+ className="w-20 h-8"
+ />
+ setUpgradeServerQty((q) => q + 1)}
+ >
+
+
+
+
+ {upgradeTier === "hobby"
+ ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
+ : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
+
+
+
+ Current plan:{" "}
+ {data?.currentPlan === "startup"
+ ? "Startup"
+ : "Hobby"}{" "}
+ · {admin?.user.serversQuantity ?? 0} server
+ {(admin?.user.serversQuantity ?? 0) !== 1
+ ? "s"
+ : ""}{" "}
+ ·{" "}
+ {data?.currentPriceAmount != null
+ ? `$${data.currentPriceAmount.toFixed(2)}/${data?.isAnnualCurrent ? "yr" : "mo"}`
+ : ""}{" "}
+ ({data?.isAnnualCurrent ? "annual" : "monthly"})
+
+
+ New plan:{" "}
+ {upgradeTier === "startup"
+ ? "Startup"
+ : "Hobby"}{" "}
+ · {upgradeServerQty} server
+ {upgradeServerQty !== 1 ? "s" : ""} · $
+ {upgradeTier === "hobby"
+ ? calculatePriceHobby(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)
+ : calculatePriceStartup(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)}
+ /{updateFormAnnual ? "yr" : "mo"} (
+ {updateFormAnnual ? "annual" : "monthly"})
+
+
+ Stripe will prorate the change.
+
+
+ }
+ type="default"
+ onClick={async () => {
+ if (!upgradeTier) return;
+ try {
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+
+ // add delay of 3 seconds
+ await new Promise((resolve) =>
+ setTimeout(resolve, 3000),
+ );
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ toast.success(
+ "Subscription updated successfully",
+ );
+ } catch {
+ toast.error("Error updating subscription");
+ }
+ }}
+ >
+
+ {isUpgrading ? (
+ <>
+
+ Updating…
+ >
+ ) : (
+ "Update subscription"
+ )}
+
+
+
+ )}
+
+ )}
Need Help? We are here to help you.
@@ -145,13 +652,357 @@ export const ShowBilling = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
+ ) : useNewPricing ? (
+ <>
+ setIsAnnual(e === "annual")}
+ >
+
+ Monthly
+ Annual (20% off)
+
+
+
+ {/* Hobby */}
+
+ {isAnnual && (
+
+ 20% off
+
+ )}
+
+ Hobby
+
+
+ Everything an individual developer needs
+
+
+
+ $
+ {calculatePriceHobby(
+ hobbyServerQuantity,
+ isAnnual,
+ ).toFixed(2)}
+ /{isAnnual ? "yr" : "mo"}
+
+
+ Add more servers as you'd like for{" "}
+ {isAnnual ? "$43.20/yr" : "$4.50/mo"}
+
+ {isAnnual && (
+
+ $
+ {(
+ calculatePriceHobby(hobbyServerQuantity, true) /
+ 12
+ ).toFixed(2)}
+ /mo
+
+ )}
+
+
+ {[
+ "Unlimited Deployments",
+ "Unlimited Databases",
+ "Unlimited Applications",
+ "1 Server Included",
+ "1 Organization",
+ "1 User",
+ "2 Environments",
+ "1 Volume Backup per Application",
+ "1 Backup per Database",
+ "1 Scheduled Job per Application",
+ "Community Support (Discord)",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+
+ Servers:
+
+
+ setHobbyServerQuantity((q) => Math.max(1, q - 1))
+ }
+ >
+
+
+
+ setHobbyServerQuantity(
+ Math.max(
+ 1,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || 1,
+ ),
+ )
+ }
+ className="text-center"
+ />
+ setHobbyServerQuantity((q) => q + 1)}
+ >
+
+
+
+
+ {admin?.user.stripeCustomerId && (
+ {
+ const session =
+ await createCustomerPortalSession();
+ window.open(session.url);
+ }}
+ >
+ Manage Subscription
+
+ )}
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
+ handleCheckout("hobby", data!.hobbyProductId!)
+ }
+ disabled={hobbyServerQuantity < 1}
+ >
+ Get Started
+
+ )}
+
+
+
+
+ {/* Startup - Recommended */}
+
+
+
+ Recommended
+
+ {isAnnual && (
+
+ 20% off
+
+ )}
+
+
+ Startup
+
+
+ Perfect for small to mid-size teams
+
+
+
+ $
+ {calculatePriceStartup(
+ startupServerQuantity,
+ isAnnual,
+ ).toFixed(2)}
+ /{isAnnual ? "yr" : "mo"}
+
+
+ Add more servers as you'd like for{" "}
+ {isAnnual ? "$43.20/yr" : "$4.50/mo"}
+
+ {isAnnual && (
+
+ $
+ {(
+ calculatePriceStartup(
+ startupServerQuantity,
+ true,
+ ) / 12
+ ).toFixed(2)}
+ /mo
+
+ )}
+
+
+
+
+ All the features of Hobby, plus…
+
+ {[
+ "3 Servers Included",
+ "3 Organizations",
+ "Unlimited Users",
+ "Unlimited Environments",
+ "Unlimited Volume Backups",
+ "Unlimited Database Backups",
+ "Unlimited Scheduled Jobs",
+ "Basic RBAC (Admin, Developer)",
+ "2FA",
+ "Email and Chat Support",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+
+ Servers (min. {STARTUP_SERVERS_INCLUDED} included)
+
+
+
+ setStartupServerQuantity((q) =>
+ Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
+ )
+ }
+ >
+
+
+
+ setStartupServerQuantity(
+ Math.max(
+ STARTUP_SERVERS_INCLUDED,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || STARTUP_SERVERS_INCLUDED,
+ ),
+ )
+ }
+ className="h-8 text-center"
+ />
+
+ setStartupServerQuantity((q) => q + 1)
+ }
+ >
+
+
+
+
+
+ {admin?.user.stripeCustomerId && (
+ {
+ const session =
+ await createCustomerPortalSession();
+ window.open(session.url);
+ }}
+ >
+ Manage Subscription
+
+ )}
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
+ handleCheckout(
+ "startup",
+ data!.startupProductId!,
+ )
+ }
+ disabled={
+ startupServerQuantity < STARTUP_SERVERS_INCLUDED
+ }
+ >
+ Get Started
+
+ )}
+
+
+
+
+ {/* Enterprise */}
+
+
+ Enterprise
+
+
+ For large organizations who want more control
+
+
+
+
+
+ All the features of Startup, plus…
+
+ {[
+ "Up to Unlimited Servers",
+ "Up to Unlimited Organizations",
+ "Fine-grained RBAC",
+ "Complete Hosting Flexibility",
+ "SSO / SAML (Azure, OKTA, etc)",
+ "Audit Logs",
+ "MSA/SLA",
+ "White Labeling",
+ "Priority Support and Services",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+ Contact Sales
+
+
+
+
+ >
) : (
<>
+ setIsAnnual(e === "annual")}
+ >
+
+ Monthly
+ Annual (20% off)
+
+
{products?.map((product) => {
const featured = true;
return (
@@ -174,7 +1025,7 @@ export const ShowBilling = () => {
${" "}
{calculatePrice(
- serverQuantity,
+ hobbyServerQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
@@ -183,7 +1034,10 @@ export const ShowBilling = () => {
${" "}
{(
- calculatePrice(serverQuantity, isAnnual) / 12
+ calculatePrice(
+ hobbyServerQuantity,
+ isAnnual,
+ ) / 12
).toFixed(2)}{" "}
/ Month USD
@@ -191,9 +1045,10 @@ export const ShowBilling = () => {
) : (
${" "}
- {calculatePrice(serverQuantity, isAnnual).toFixed(
- 2,
- )}{" "}
+ {calculatePrice(
+ hobbyServerQuantity,
+ isAnnual,
+ ).toFixed(2)}{" "}
USD
)}
@@ -236,26 +1091,28 @@ export const ShowBilling = () => {
- {serverQuantity} Servers
+ {hobbyServerQuantity} Servers
{
- if (serverQuantity <= 1) return;
+ if (hobbyServerQuantity <= 1) return;
- setServerQuantity(serverQuantity - 1);
+ setHobbyServerQuantity(
+ hobbyServerQuantity - 1,
+ );
}}
>
{
- setServerQuantity(
+ setHobbyServerQuantity(
e.target.value as unknown as number,
);
}}
@@ -264,21 +1121,15 @@ export const ShowBilling = () => {
{
- setServerQuantity(serverQuantity + 1);
+ setHobbyServerQuantity(
+ hobbyServerQuantity + 1,
+ );
}}
>
-
0
- ? "justify-between"
- : "justify-end",
- "flex flex-row items-center gap-2 mt-4",
- )}
- >
+
{admin?.user.stripeCustomerId && (
{
onClick={async () => {
const session =
await createCustomerPortalSession();
-
window.open(session.url);
}}
>
Manage Subscription
)}
-
- {data?.subscriptions?.length === 0 && (
-
- {
- handleCheckout(product.id);
- }}
- disabled={serverQuantity < 1}
- >
- Subscribe
-
-
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
{
+ handleCheckout("legacy", product.id);
+ }}
+ disabled={hobbyServerQuantity < 1}
+ >
+ Subscribe
+
)}
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx
new file mode 100644
index 000000000..b10e09596
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx
@@ -0,0 +1,137 @@
+import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
+import type Stripe from "stripe";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { api } from "@/utils/api";
+
+const formatDate = (timestamp: number | null) => {
+ if (!timestamp) return "-";
+ return new Date(timestamp * 1000).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+};
+
+const formatAmount = (amount: number, currency: string) => {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: currency.toUpperCase(),
+ }).format(amount / 100);
+};
+
+const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
+ const statusConfig: Record<
+ Stripe.Invoice.Status,
+ { label: string; variant: "default" | "secondary" | "destructive" }
+ > = {
+ paid: { label: "Paid", variant: "default" },
+ open: { label: "Open", variant: "secondary" },
+ draft: { label: "Draft", variant: "secondary" },
+ void: { label: "Void", variant: "destructive" },
+ uncollectible: { label: "Uncollectible", variant: "destructive" },
+ };
+
+ if (!status) {
+ return
Unknown ;
+ }
+
+ const config = statusConfig[status] || {
+ label: status,
+ variant: "secondary" as const,
+ };
+
+ return
{config.label} ;
+};
+
+export const ShowInvoices = () => {
+ const { data: invoices, isPending } = api.stripe.getInvoices.useQuery();
+
+ return (
+
+ {isPending ? (
+
+
+ Loading invoices...
+
+
+
+ ) : invoices && invoices.length > 0 ? (
+
+
+
+
+ Invoice
+ Date
+ Due Date
+ Amount
+ Status
+ Actions
+
+
+
+ {invoices.map((invoice) => (
+
+
+ {invoice.number || invoice.id.slice(0, 12)}
+
+ {formatDate(invoice.created)}
+ {formatDate(invoice.dueDate)}
+
+ {formatAmount(invoice.amountDue, invoice.currency)}
+
+ {getStatusBadge(invoice.status)}
+
+
+ {invoice.hostedInvoiceUrl && (
+
+ window.open(
+ invoice.hostedInvoiceUrl || "",
+ "_blank",
+ )
+ }
+ >
+
+
+ )}
+ {invoice.invoicePdf && (
+
+ window.open(invoice.invoicePdf || "", "_blank")
+ }
+ >
+
+
+ )}
+
+
+
+ ))}
+
+
+
+ ) : (
+
+
+
No invoices found
+
+ Your invoices will appear here once you have a subscription
+
+
+ )}
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx
index f87ca58c7..ca1407d7e 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx
@@ -12,7 +12,7 @@ export const ShowWelcomeDokploy = () => {
const { data } = api.user.get.useQuery();
const [open, setOpen] = useState(false);
- const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
+ const { data: isCloud, isPending } = api.settings.isCloud.useQuery();
if (!isCloud || data?.role !== "admin") {
return null;
@@ -20,14 +20,14 @@ export const ShowWelcomeDokploy = () => {
useEffect(() => {
if (
- !isLoading &&
+ !isPending &&
isCloud &&
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
data?.role === "owner"
) {
setOpen(true);
}
- }, [isCloud, isLoading]);
+ }, [isCloud, isPending]);
const handleClose = (isOpen: boolean) => {
if (data?.role === "owner") {
diff --git a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx
index 6f7ef6821..bc29a4c95 100644
--- a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx
+++ b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -62,7 +62,7 @@ export const AddCertificate = () => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
- const { mutateAsync, isError, error, isLoading } =
+ const { mutateAsync, isError, error, isPending } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
@@ -247,7 +247,7 @@ export const AddCertificate = () => {
diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
index 8356d89c6..76c54cdfa 100644
--- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
+++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx
@@ -15,9 +15,10 @@ import { AddCertificate } from "./add-certificate";
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
export const ShowCertificates = () => {
- const { mutateAsync, isLoading: isRemoving } =
+ const { mutateAsync, isPending: isRemoving } =
api.certificates.remove.useMutation();
- const { data, isLoading, refetch } = api.certificates.all.useQuery();
+ const { data, isPending, refetch } = api.certificates.all.useQuery();
+ const { data: permissions } = api.user.getPermissions.useQuery();
return (
@@ -40,7 +41,7 @@ export const ShowCertificates = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -53,7 +54,7 @@ export const ShowCertificates = () => {
You don't have any certificates created
-
+ {permissions?.certificate.create &&
}
) : (
@@ -101,47 +102,52 @@ export const ShowCertificates = () => {
-
-
{
- await mutateAsync({
- certificateId: certificate.certificateId,
- })
- .then(() => {
- toast.success(
- "Certificate deleted successfully",
- );
- refetch();
+ {permissions?.certificate.delete && (
+
+ {
+ await mutateAsync({
+ certificateId:
+ certificate.certificateId,
})
- .catch(() => {
- toast.error(
- "Error deleting certificate",
- );
- });
- }}
- >
- {
+ toast.success(
+ "Certificate deleted successfully",
+ );
+ refetch();
+ })
+ .catch(() => {
+ toast.error(
+ "Error deleting certificate",
+ );
+ });
+ }}
>
-
-
-
-
+
+
+
+
+
+ )}
);
})}
-
+ {permissions?.certificate.create && (
+
+ )}
)}
>
diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts
index e2aa59ef3..79b763a97 100644
--- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts
+++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts
@@ -14,13 +14,13 @@ export const extractExpirationDate = (certData: string): Date | null => {
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
- // biome-ignore lint/style/noParameterAssign:
+ // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
let len = der[pos++];
if (len & 0x80) {
const bytes = len & 0x7f;
len = 0;
for (let i = 0; i < bytes; i++) {
- // biome-ignore lint/style/noParameterAssign:
+ // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
len = (len << 8) + der[pos++];
}
}
diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx
index 36dda311c..a24e4dc2f 100644
--- a/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx
+++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx
@@ -15,7 +15,7 @@ interface Props {
}
export const AddManager = ({ serverId }: Props) => {
- const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
+ const { data, isPending, error, isError } = api.cluster.addManager.useQuery({
serverId,
});
@@ -27,7 +27,7 @@ export const AddManager = ({ serverId }: Props) => {
Add a new manager
{isError && {error?.message} }
- {isLoading ? (
+ {isPending ? (
) : (
<>
diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx
index b88fdd8e8..c7f580caa 100644
--- a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx
+++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx
@@ -48,7 +48,7 @@ interface Props {
}
export const ShowNodes = ({ serverId }: Props) => {
- const { data, isLoading, refetch } = api.cluster.getNodes.useQuery({
+ const { data, isPending, refetch } = api.cluster.getNodes.useQuery({
serverId,
});
const { data: registry } = api.registry.all.useQuery();
@@ -75,7 +75,7 @@ export const ShowNodes = ({ serverId }: Props) => {
)}
- {isLoading ? (
+ {isPending ? (
diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx
index c73e458ef..40a673265 100644
--- a/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx
+++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx
@@ -15,7 +15,7 @@ interface Props {
}
export const AddWorker = ({ serverId }: Props) => {
- const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
+ const { data, isPending, error, isError } = api.cluster.addWorker.useQuery({
serverId,
});
@@ -26,7 +26,7 @@ export const AddWorker = ({ serverId }: Props) => {
Add a new worker
{isError && {error?.message} }
- {isLoading ? (
+ {isPending ? (
) : (
<>
diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx
index 4321088f2..e22285c73 100644
--- a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx
+++ b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -42,12 +42,38 @@ const AddRegistrySchema = z.object({
username: z.string().min(1, {
message: "Username is required",
}),
- password: z.string().min(1, {
- message: "Password is required",
- }),
- registryUrl: z.string(),
+ password: z.string(),
+ registryUrl: z
+ .string()
+ .optional()
+ .refine(
+ (val) => {
+ // If empty or undefined, skip validation (field is optional)
+ if (!val || val.trim().length === 0) {
+ return true;
+ }
+ // Validate that it's a valid hostname (no protocol, no path, optional port)
+ // Valid formats: example.com, registry.example.com, [::1], example.com:5000
+ // Invalid: https://example.com, example.com/path
+ const trimmed = val.trim();
+ // Check for protocol or path - these are not allowed
+ if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
+ return false;
+ }
+ // Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
+ // Allow optional port at the end
+ const hostnameRegex =
+ /^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
+ return hostnameRegex.test(trimmed);
+ },
+ {
+ message:
+ "Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
+ },
+ ),
imagePrefix: z.string(),
serverId: z.string().optional(),
+ isEditing: z.boolean().optional(),
});
type AddRegistry = z.infer;
@@ -74,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
const { mutateAsync, error, isError } = registryId
? api.registry.update.useMutation()
: api.registry.create.useMutation();
- const { data: servers } = api.server.withSSHKey.useQuery();
+ const { data: deployServers } = api.server.withSSHKey.useQuery();
+ const { data: buildServers } = api.server.buildServers.useQuery();
+ const servers = [...(deployServers || []), ...(buildServers || [])];
const {
mutateAsync: testRegistry,
- isLoading,
+ isPending,
error: testRegistryError,
isError: testRegistryIsError,
} = api.registry.testRegistry.useMutation();
+ const {
+ mutateAsync: testRegistryById,
+ isPending: isPendingById,
+ error: testRegistryByIdError,
+ isError: testRegistryByIdIsError,
+ } = api.registry.testRegistryById.useMutation();
const form = useForm({
defaultValues: {
username: "",
@@ -89,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => {
imagePrefix: "",
registryName: "",
serverId: "",
+ isEditing: !!registryId,
},
- resolver: zodResolver(AddRegistrySchema),
+ resolver: zodResolver(
+ AddRegistrySchema.refine(
+ (data) => {
+ // When creating a new registry, password is required
+ if (
+ !data.isEditing &&
+ (!data.password || data.password.length === 0)
+ ) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Password is required",
+ path: ["password"],
+ },
+ ),
+ ),
});
const password = form.watch("password");
@@ -99,6 +151,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
const registryName = form.watch("registryName");
const imagePrefix = form.watch("imagePrefix");
const serverId = form.watch("serverId");
+ const selectedServer = servers?.find(
+ (server) => server.serverId === serverId,
+ );
useEffect(() => {
if (registry) {
@@ -108,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: registry.registryUrl,
imagePrefix: registry.imagePrefix || "",
registryName: registry.registryName,
+ isEditing: true,
});
} else {
form.reset({
@@ -116,21 +172,29 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: "",
imagePrefix: "",
serverId: "",
+ isEditing: false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
const onSubmit = async (data: AddRegistry) => {
- await mutateAsync({
- password: data.password,
+ const payload: any = {
registryName: data.registryName,
username: data.username,
- registryUrl: data.registryUrl,
+ registryUrl: data.registryUrl || "",
registryType: "cloud",
imagePrefix: data.imagePrefix,
serverId: data.serverId,
registryId: registryId || "",
- })
+ };
+
+ // Only include password if it's been provided (not empty)
+ // When editing, empty password means "keep the existing password"
+ if (data.password && data.password.length > 0) {
+ payload.password = data.password;
+ }
+
+ await mutateAsync(payload)
.then(async (_data) => {
await utils.registry.all.invalidate();
toast.success(registryId ? "Registry updated" : "Registry added");
@@ -168,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => {
Fill the next fields to add a external registry.
- {(isError || testRegistryIsError) && (
+ {(isError || testRegistryIsError || testRegistryByIdIsError) && (
- {testRegistryError?.message || error?.message || ""}
+ {testRegistryError?.message ||
+ testRegistryByIdError?.message ||
+ error?.message ||
+ ""}
)}
@@ -223,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => {
name="password"
render={({ field }) => (
- Password
+ Password{registryId && " (Optional)"}
+ {registryId && (
+
+ Leave blank to keep existing password. Enter new
+ password to test or update it.
+
+ )}
{
render={({ field }) => (
Registry URL
+
+ Enter only the hostname (e.g.,
+ aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
+
{
Server {!isCloud && "(Optional)"}
- Select a server to test the registry. this will run the
- following command on the server
+ {!isCloud ? (
+ <>
+ {serverId && serverId !== "none" && selectedServer ? (
+ <>
+ Authentication will be performed on{" "}
+ {selectedServer.name} . This
+ registry will be available on this server.
+ >
+ ) : (
+ <>
+ Choose where to authenticate with the registry. By
+ default, authentication occurs on the Dokploy
+ server. Select a specific server to authenticate
+ from that server instead.
+ >
+ )}
+ >
+ ) : (
+ <>
+ {serverId && serverId !== "none" && selectedServer ? (
+ <>
+ Authentication will be performed on{" "}
+ {selectedServer.name} . This
+ registry will be available on this server.
+ >
+ ) : (
+ <>
+ Select a server to authenticate with the registry.
+ The authentication will be performed from the
+ selected server.
+ >
+ )}
+ >
+ )}
{
+ {deployServers && deployServers.length > 0 && (
+
+ Deploy Servers
+ {deployServers.map((server) => (
+
+ {server.name}
+
+ ))}
+
+ )}
+ {buildServers && buildServers.length > 0 && (
+
+ Build Servers
+ {buildServers.map((server) => (
+
+ {server.name}
+
+ ))}
+
+ )}
- Servers
- {servers?.map((server) => (
-
- {server.name}
-
- ))}
None
@@ -321,8 +451,37 @@ export const HandleRegistry = ({ registryId }: Props) => {
{
+ // When editing with empty password, use the existing password from DB
+ if (registryId && (!password || password.length === 0)) {
+ await testRegistryById({
+ registryId: registryId || "",
+ ...(serverId && { serverId }),
+ })
+ .then((data) => {
+ if (data) {
+ toast.success("Registry Tested Successfully");
+ } else {
+ toast.error("Registry Test Failed");
+ }
+ })
+ .catch(() => {
+ toast.error("Error testing the registry");
+ });
+ return;
+ }
+
+ // When creating, password is required
+ if (!registryId && (!password || password.length === 0)) {
+ form.setError("password", {
+ type: "manual",
+ message: "Password is required",
+ });
+ return;
+ }
+
+ // When creating or editing with new password, validate and test with provided credentials
const validationResult = AddRegistrySchema.safeParse({
username,
password,
@@ -330,6 +489,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryName: "Dokploy Registry",
imagePrefix,
serverId,
+ isEditing: !!registryId,
});
if (!validationResult.success) {
@@ -345,7 +505,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
await testRegistry({
username: username,
password: password,
- registryUrl: registryUrl,
+ registryUrl: registryUrl || "",
registryName: registryName,
registryType: "cloud",
imagePrefix: imagePrefix,
diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx
index c5dd60622..86deb38a7 100644
--- a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx
+++ b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx
@@ -13,9 +13,10 @@ import { api } from "@/utils/api";
import { HandleRegistry } from "./handle-registry";
export const ShowRegistry = () => {
- const { mutateAsync, isLoading: isRemoving } =
+ const { mutateAsync, isPending: isRemoving } =
api.registry.remove.useMutation();
- const { data, isLoading, refetch } = api.registry.all.useQuery();
+ const { data, isPending, refetch } = api.registry.all.useQuery();
+ const { data: permissions } = api.user.getPermissions.useQuery();
return (
@@ -31,7 +32,7 @@ export const ShowRegistry = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -44,7 +45,7 @@ export const ShowRegistry = () => {
You don't have any registry configurations
-
+ {permissions?.registry.create && }
) : (
@@ -73,45 +74,49 @@ export const ShowRegistry = () => {
registryId={registry.registryId}
/>
- {
- await mutateAsync({
- registryId: registry.registryId,
- })
- .then(() => {
- toast.success(
- "Registry configuration deleted successfully",
- );
- refetch();
+ {permissions?.registry.delete && (
+ {
+ await mutateAsync({
+ registryId: registry.registryId,
})
- .catch(() => {
- toast.error(
- "Error deleting registry configuration",
- );
- });
- }}
- >
- {
+ toast.success(
+ "Registry configuration deleted successfully",
+ );
+ refetch();
+ })
+ .catch(() => {
+ toast.error(
+ "Error deleting registry configuration",
+ );
+ });
+ }}
>
-
-
-
+
+
+
+
+ )}
))}
-
-
-
+ {permissions?.registry.create && (
+
+
+
+ )}
)}
>
diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx
index d36d53af2..25a3a1048 100644
--- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx
+++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx
@@ -1,7 +1,7 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { PenBoxIcon, PlusIcon } from "lucide-react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
+import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -35,6 +35,10 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
+import {
+ ADDITIONAL_FLAG_ERROR,
+ ADDITIONAL_FLAG_REGEX,
+} from "@dokploy/server/db/validations/destination";
import { S3_PROVIDERS } from "./constants";
const addDestination = z.object({
@@ -46,6 +50,16 @@ const addDestination = z.object({
region: z.string(),
endpoint: z.string().min(1, "Endpoint is required"),
serverId: z.string().optional(),
+ additionalFlags: z
+ .array(
+ z.object({
+ value: z
+ .string()
+ .min(1, "Flag cannot be empty")
+ .regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR),
+ }),
+ )
+ .optional(),
});
type AddDestination = z.infer
;
@@ -60,7 +74,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
- const { mutateAsync, isError, error, isLoading } = destinationId
+ const { mutateAsync, isError, error, isPending } = destinationId
? api.destination.update.useMutation()
: api.destination.create.useMutation();
@@ -75,7 +89,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
);
const {
mutateAsync: testConnection,
- isLoading: isLoadingConnection,
+ isPending: isPendingConnection,
error: connectionError,
isError: isErrorConnection,
} = api.destination.testConnection.useMutation();
@@ -89,9 +103,16 @@ export const HandleDestinations = ({ destinationId }: Props) => {
region: "",
secretAccessKey: "",
endpoint: "",
+ additionalFlags: [],
},
resolver: zodResolver(addDestination),
});
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "additionalFlags",
+ });
+
useEffect(() => {
if (destination) {
form.reset({
@@ -102,6 +123,8 @@ export const HandleDestinations = ({ destinationId }: Props) => {
bucket: destination.bucket,
region: destination.region,
endpoint: destination.endpoint,
+ additionalFlags:
+ destination.additionalFlags?.map((f) => ({ value: f })) ?? [],
});
} else {
form.reset();
@@ -118,15 +141,22 @@ export const HandleDestinations = ({ destinationId }: Props) => {
region: data.region,
secretAccessKey: data.secretAccessKey,
destinationId: destinationId || "",
+ additionalFlags: data.additionalFlags?.map((f) => f.value) ?? [],
})
.then(async () => {
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
await utils.destination.all.invalidate();
+ if (destinationId) {
+ await utils.destination.one.invalidate({ destinationId });
+ }
setOpen(false);
})
- .catch(() => {
+ .catch((e) => {
toast.error(
`Error ${destinationId ? "Updating" : "Creating"} the Destination`,
+ {
+ description: e.message,
+ },
);
});
};
@@ -138,6 +168,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
"secretAccessKey",
"bucket",
"endpoint",
+ "additionalFlags",
]);
if (!result) {
@@ -176,6 +207,8 @@ export const HandleDestinations = ({ destinationId }: Props) => {
region,
secretAccessKey: secretKey,
serverId,
+ additionalFlags:
+ form.getValues("additionalFlags")?.map((f) => f.value) ?? [],
})
.then(() => {
toast.success("Connection Success");
@@ -355,6 +388,48 @@ export const HandleDestinations = ({ destinationId }: Props) => {
)}
/>
+
+
+
Additional Flags (Optional)
+
append({ value: "" })}
+ >
+
+ Add Flag
+
+
+ {fields.map((field, index) => (
+
(
+
+
+
+
+
+ remove(index)}
+ >
+
+
+
+
+
+ )}
+ />
+ ))}
+
{
{
await handleTestConnection(form.getValues("serverId"));
}}
@@ -417,7 +492,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
) : (
{
@@ -429,7 +504,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
)}
diff --git a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx
index f0ad39807..39741b932 100644
--- a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx
+++ b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx
@@ -13,9 +13,10 @@ import { api } from "@/utils/api";
import { HandleDestinations } from "./handle-destinations";
export const ShowDestinations = () => {
- const { data, isLoading, refetch } = api.destination.all.useQuery();
- const { mutateAsync, isLoading: isRemoving } =
+ const { data, isPending, refetch } = api.destination.all.useQuery();
+ const { mutateAsync, isPending: isRemoving } =
api.destination.remove.useMutation();
+ const { data: permissions } = api.user.getPermissions.useQuery();
return (
@@ -31,7 +32,7 @@ export const ShowDestinations = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -45,7 +46,7 @@ export const ShowDestinations = () => {
To create a backup it is required to set at least 1
provider.
-
+ {permissions?.destination.create && }
) : (
@@ -71,43 +72,49 @@ export const ShowDestinations = () => {
- {
- await mutateAsync({
- destinationId: destination.destinationId,
- })
- .then(() => {
- toast.success(
- "Destination deleted successfully",
- );
- refetch();
+ {permissions?.destination.delete && (
+ {
+ await mutateAsync({
+ destinationId: destination.destinationId,
})
- .catch(() => {
- toast.error("Error deleting destination");
- });
- }}
- >
- {
+ toast.success(
+ "Destination deleted successfully",
+ );
+ refetch();
+ })
+ .catch(() => {
+ toast.error(
+ "Error deleting destination",
+ );
+ });
+ }}
>
-
-
-
+
+
+
+
+ )}
))}
-
-
-
+ {permissions?.destination.create && (
+
+
+
+ )}
)}
>
diff --git a/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx
index c933a0b8c..51a2291c1 100644
--- a/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/git/bitbucket/add-bitbucket-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
diff --git a/apps/dokploy/components/dashboard/settings/git/bitbucket/edit-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/settings/git/bitbucket/edit-bitbucket-provider.tsx
index 3cccdff71..045b0196f 100644
--- a/apps/dokploy/components/dashboard/settings/git/bitbucket/edit-bitbucket-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/git/bitbucket/edit-bitbucket-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -58,7 +58,7 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } = api.bitbucket.update.useMutation();
- const { mutateAsync: testConnection, isLoading } =
+ const { mutateAsync: testConnection, isPending } =
api.bitbucket.testConnection.useMutation();
const form = useForm({
defaultValues: {
@@ -257,7 +257,7 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
{
await testConnection({
bitbucketId,
diff --git a/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx
index 4cb6bd50e..89307b1ce 100644
--- a/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
@@ -19,6 +19,7 @@ import {
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
@@ -39,6 +40,10 @@ const Schema = z.object({
giteaUrl: z.string().min(1, {
message: "Gitea URL is required",
}),
+ giteaInternalUrl: z
+ .union([z.string().url(), z.literal("")])
+ .optional()
+ .transform((v) => (v === "" ? undefined : v)),
clientId: z.string().min(1, {
message: "Client ID is required",
}),
@@ -63,13 +68,14 @@ export const AddGiteaProvider = () => {
const { mutateAsync, error, isError } = api.gitea.create.useMutation();
const webhookUrl = `${baseUrl}/api/providers/gitea/callback`;
- const form = useForm({
+ const form = useForm({
defaultValues: {
clientId: "",
clientSecret: "",
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
+ giteaInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -83,6 +89,7 @@ export const AddGiteaProvider = () => {
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
+ giteaInternalUrl: "",
});
}, [form, webhookUrl, isOpen]);
@@ -95,6 +102,7 @@ export const AddGiteaProvider = () => {
name: data.name,
redirectUri: data.redirectUri,
giteaUrl: data.giteaUrl,
+ giteaInternalUrl: data.giteaInternalUrl || undefined,
organizationName: data.organizationName,
})) as unknown as GiteaProviderResponse;
@@ -223,6 +231,29 @@ export const AddGiteaProvider = () => {
)}
/>
+ (
+
+ Internal URL (Optional)
+
+
+
+
+ Use when Gitea runs on the same instance as Dokploy.
+ Used for OAuth token exchange to reach Gitea via
+ internal network (e.g. Docker service name).
+
+
+
+ )}
+ />
+
(v === "" ? undefined : v)),
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
});
@@ -46,8 +51,8 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
isLoading,
refetch,
} = api.gitea.one.useQuery({ giteaId });
- const { mutateAsync, isLoading: isUpdating } = api.gitea.update.useMutation();
- const { mutateAsync: testConnection, isLoading: isTesting } =
+ const { mutateAsync, isPending: isUpdating } = api.gitea.update.useMutation();
+ const { mutateAsync: testConnection, isPending: isTesting } =
api.gitea.testConnection.useMutation();
const url = useUrl();
const utils = api.useUtils();
@@ -89,11 +94,12 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
}
}, [router.query, router.isReady, refetch]);
- const form = useForm>({
+ const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
giteaUrl: "https://gitea.com",
+ giteaInternalUrl: "",
clientId: "",
clientSecret: "",
},
@@ -104,6 +110,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
form.reset({
name: gitea.gitProvider?.name || "",
giteaUrl: gitea.giteaUrl || "https://gitea.com",
+ giteaInternalUrl: gitea.giteaInternalUrl || "",
clientId: gitea.clientId || "",
clientSecret: gitea.clientSecret || "",
});
@@ -116,6 +123,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
name: values.name,
giteaUrl: values.giteaUrl,
+ giteaInternalUrl: values.giteaInternalUrl ?? null,
clientId: values.clientId,
clientSecret: values.clientSecret,
})
@@ -224,6 +232,28 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
)}
/>
+ (
+
+ Internal URL (Optional)
+
+
+
+
+ Use when Gitea runs on the same instance as Dokploy. Used
+ for OAuth token exchange to reach Gitea via internal network
+ (e.g. Docker service name).
+
+
+
+ )}
+ />
{
const [isOpen, setIsOpen] = useState(false);
- const { data: activeOrganization } = authClient.useActiveOrganization();
- const { data: session } = authClient.useSession();
+ const { data: activeOrganization } = api.organization.active.useQuery();
+
+ const { data: session } = api.user.session.useQuery();
const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
@@ -30,7 +30,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
- redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`,
+ redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
url: origin,
hook_attributes: {
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
- }, [data?.id]);
+ }, [activeOrganization?.id, session?.user?.id]);
return (
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
diff --git a/apps/dokploy/components/dashboard/settings/git/github/edit-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/edit-github-provider.tsx
index 62463d0dc..7aeb565c1 100644
--- a/apps/dokploy/components/dashboard/settings/git/github/edit-github-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/git/github/edit-github-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -53,7 +53,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } = api.github.update.useMutation();
- const { mutateAsync: testConnection, isLoading } =
+ const { mutateAsync: testConnection, isPending } =
api.github.testConnection.useMutation();
const form = useForm({
defaultValues: {
@@ -151,7 +151,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
{
await testConnection({
githubId,
diff --git a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx
index f1369ade9..7c637f5ef 100644
--- a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx
+++ b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
@@ -19,6 +19,7 @@ import {
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
@@ -35,6 +36,10 @@ const Schema = z.object({
gitlabUrl: z.string().min(1, {
message: "GitLab URL is required",
}),
+ gitlabInternalUrl: z
+ .union([z.string().url(), z.literal("")])
+ .optional()
+ .transform((v) => (v === "" ? undefined : v)),
applicationId: z.string().min(1, {
message: "Application ID is required",
}),
@@ -58,7 +63,7 @@ export const AddGitlabProvider = () => {
const { mutateAsync, error, isError } = api.gitlab.create.useMutation();
const webhookUrl = `${url}/api/providers/gitlab/callback`;
- const form = useForm({
+ const form = useForm({
defaultValues: {
applicationId: "",
applicationSecret: "",
@@ -66,6 +71,7 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
+ gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -80,6 +86,7 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
+ gitlabInternalUrl: "",
});
}, [form, isOpen]);
@@ -92,6 +99,7 @@ export const AddGitlabProvider = () => {
name: data.name || "",
redirectUri: data.redirectUri || "",
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
+ gitlabInternalUrl: data.gitlabInternalUrl || undefined,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -192,6 +200,29 @@ export const AddGitlabProvider = () => {
)}
/>
+ (
+
+ Internal URL (Optional)
+
+
+
+
+ Use when GitLab runs on the same instance as Dokploy.
+ Used for OAuth token exchange to reach GitLab via
+ internal network (e.g. Docker service name).
+
+
+
+ )}
+ />
+
(v === "" ? undefined : v)),
groupName: z.string().optional(),
});
@@ -54,13 +59,14 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } = api.gitlab.update.useMutation();
- const { mutateAsync: testConnection, isLoading } =
+ const { mutateAsync: testConnection, isPending } =
api.gitlab.testConnection.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
groupName: "",
name: "",
gitlabUrl: "https://gitlab.com",
+ gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -72,6 +78,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: gitlab?.groupName || "",
name: gitlab?.gitProvider.name || "",
gitlabUrl: gitlab?.gitlabUrl || "",
+ gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
});
}, [form, isOpen]);
@@ -82,6 +89,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: data.groupName || "",
name: data.name || "",
gitlabUrl: data.gitlabUrl || "",
+ gitlabInternalUrl: data.gitlabInternalUrl ?? null,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -151,6 +159,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
)}
/>
+ (
+
+ Internal URL (Optional)
+
+
+
+
+ Use when GitLab runs on the same instance as Dokploy.
+ Used for OAuth token exchange to reach GitLab via
+ internal network (e.g. Docker service name).
+
+
+
+ )}
+ />
+
{
{
await testConnection({
gitlabId,
diff --git a/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx b/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx
index 5f99a2e97..a96bcf26c 100644
--- a/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx
+++ b/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx
@@ -36,8 +36,8 @@ import { AddGitlabProvider } from "./gitlab/add-gitlab-provider";
import { EditGitlabProvider } from "./gitlab/edit-gitlab-provider";
export const ShowGitProviders = () => {
- const { data, isLoading, refetch } = api.gitProvider.getAll.useQuery();
- const { mutateAsync, isLoading: isRemoving } =
+ const { data, isPending, refetch } = api.gitProvider.getAll.useQuery();
+ const { mutateAsync, isPending: isRemoving } =
api.gitProvider.remove.useMutation();
const url = useUrl();
@@ -48,7 +48,7 @@ export const ShowGitProviders = () => {
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
- const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
+ const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scopes=${encodeURIComponent(scope)}`;
return authUrl;
};
@@ -66,7 +66,7 @@ export const ShowGitProviders = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx
index 19716e0f3..d600d3a8e 100644
--- a/apps/dokploy/components/dashboard/settings/handle-ai.tsx
+++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx
@@ -1,12 +1,19 @@
"use client";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { PenBoxIcon, PlusIcon } from "lucide-react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -26,13 +33,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const Schema = z.object({
@@ -51,8 +57,9 @@ interface Props {
export const HandleAi = ({ aiId }: Props) => {
const utils = api.useUtils();
- const [error, setError] = useState
(null);
const [open, setOpen] = useState(false);
+ const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
+ const [modelSearch, setModelSearch] = useState("");
const { data, refetch } = api.ai.one.useQuery(
{
aiId: aiId || "",
@@ -61,7 +68,7 @@ export const HandleAi = ({ aiId }: Props) => {
enabled: !!aiId,
},
);
- const { mutateAsync, isLoading } = aiId
+ const { mutateAsync, isPending } = aiId
? api.ai.update.useMutation()
: api.ai.create.useMutation();
@@ -77,40 +84,36 @@ export const HandleAi = ({ aiId }: Props) => {
});
useEffect(() => {
- form.reset({
- name: data?.name ?? "",
- apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
- apiKey: data?.apiKey ?? "",
- model: data?.model ?? "",
- isEnabled: data?.isEnabled ?? true,
- });
+ if (data) {
+ form.reset({
+ name: data?.name ?? "",
+ apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
+ apiKey: data?.apiKey ?? "",
+ model: data?.model ?? "",
+ isEnabled: data?.isEnabled ?? true,
+ });
+ }
+ setModelSearch("");
+ setModelPopoverOpen(false);
}, [aiId, form, data]);
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
- const { data: models, isLoading: isLoadingServerModels } =
- api.ai.getModels.useQuery(
- {
- apiUrl: apiUrl ?? "",
- apiKey: apiKey ?? "",
- },
- {
- enabled: !!apiUrl && (isOllama || !!apiKey),
- onError: (error) => {
- setError(`Failed to fetch models: ${error.message}`);
- },
- },
- );
-
- useEffect(() => {
- const apiUrl = form.watch("apiUrl");
- const apiKey = form.watch("apiKey");
- if (apiUrl && apiKey) {
- form.setValue("model", "");
- }
- }, [form.watch("apiUrl"), form.watch("apiKey")]);
+ const {
+ data: models,
+ isPending: isLoadingServerModels,
+ error: modelsError,
+ } = api.ai.getModels.useQuery(
+ {
+ apiUrl: apiUrl ?? "",
+ apiKey: apiKey ?? "",
+ },
+ {
+ enabled: !!apiUrl && (isOllama || !!apiKey),
+ },
+ );
const onSubmit = async (data: Schema) => {
try {
@@ -131,7 +134,16 @@ export const HandleAi = ({ aiId }: Props) => {
};
return (
-
+ {
+ setOpen(isOpen);
+ if (!isOpen) {
+ setModelSearch("");
+ setModelPopoverOpen(false);
+ }
+ }}
+ >
{aiId ? (
{
- {error && {error} }
+ {modelsError && (
+ {modelsError.message}
+ )}
{
API URL
-
+ {
+ field.onChange(e);
+ // Reset model when user changes API URL
+ if (form.getValues("model")) {
+ form.setValue("model", "");
+ }
+ }}
+ />
The base URL for your AI provider's API
@@ -205,6 +229,13 @@ export const HandleAi = ({ aiId }: Props) => {
placeholder="sk-..."
autoComplete="one-time-code"
{...field}
+ onChange={(e) => {
+ field.onChange(e);
+ // Reset model when user changes API Key
+ if (form.getValues("model")) {
+ form.setValue("model", "");
+ }
+ }}
/>
@@ -232,30 +263,89 @@ export const HandleAi = ({ aiId }: Props) => {
(
-
- Model
-
-
-
-
-
-
-
- {models.map((model) => (
-
- {model.id}
-
- ))}
-
-
- Select an AI model to use
-
-
- )}
+ render={({ field }) => {
+ const selectedModel = models.find(
+ (m) => m.id === field.value,
+ );
+ const filteredModels = models.filter((model) =>
+ model.id.toLowerCase().includes(modelSearch.toLowerCase()),
+ );
+
+ // Ensure selected model is always in the filtered list
+ const displayModels =
+ field.value &&
+ !filteredModels.find((m) => m.id === field.value) &&
+ selectedModel
+ ? [selectedModel, ...filteredModels]
+ : filteredModels;
+
+ return (
+
+ Model
+
+
+
+
+ {field.value
+ ? (selectedModel?.id ?? field.value)
+ : "Select a model"}
+
+
+
+
+
+
+
+
+ No models found.
+ {displayModels.map((model) => {
+ const isSelected = field.value === model.id;
+ return (
+ {
+ field.onChange(model.id);
+ setModelPopoverOpen(false);
+ setModelSearch("");
+ }}
+ >
+
+ {model.id}
+
+ );
+ })}
+
+
+
+
+
+ Select an AI model to use
+
+
+
+ );
+ }}
/>
)}
@@ -283,7 +373,7 @@ export const HandleAi = ({ aiId }: Props) => {
/>
-
+
{aiId ? "Update" : "Create"}
diff --git a/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx
new file mode 100644
index 000000000..dcfa0b04f
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import { Link2, Loader2, Unlink } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { authClient } from "@/lib/auth-client";
+
+const LINKING_CALLBACK_URL = "/dashboard/settings/profile";
+
+const TRUSTED_PROVIDERS = ["google", "github"] as const;
+type SocialProvider = (typeof TRUSTED_PROVIDERS)[number];
+
+type AccountItem = {
+ providerId: string;
+ accountId?: string;
+};
+
+function providerLabel(providerId: string): string {
+ return providerId.charAt(0).toUpperCase() + providerId.slice(1);
+}
+
+export function LinkingAccount() {
+ const [accounts, setAccounts] = useState([]);
+ const [accountsLoading, setAccountsLoading] = useState(true);
+ const [linkingProvider, setLinkingProvider] = useState(
+ null,
+ );
+ const [unlinkingProviderId, setUnlinkingProviderId] = useState(
+ null,
+ );
+
+ const fetchAccounts = useCallback(async () => {
+ setAccountsLoading(true);
+ try {
+ const { data } = await authClient.listAccounts();
+ const list = Array.isArray(data)
+ ? data
+ : ((data && typeof data === "object" && "accounts" in data
+ ? (data as { accounts?: AccountItem[] }).accounts
+ : null) ?? []);
+ setAccounts(Array.isArray(list) ? list : []);
+ } catch {
+ setAccounts([]);
+ } finally {
+ setAccountsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchAccounts();
+ }, [fetchAccounts]);
+
+ const linkedProviderIds = new Set(accounts.map((a) => a.providerId));
+ const socialAccounts = accounts.filter((a) =>
+ TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider),
+ );
+
+ const handleLinkSocial = async (provider: SocialProvider) => {
+ setLinkingProvider(provider);
+ try {
+ const { error } = await authClient.linkSocial({
+ provider,
+ callbackURL: LINKING_CALLBACK_URL,
+ });
+ if (error) {
+ toast.error(error.message ?? "Failed to link account");
+ setLinkingProvider(null);
+ return;
+ }
+ } catch (err) {
+ toast.error(
+ "Failed to link account",
+ err instanceof Error ? { description: err.message } : undefined,
+ );
+ setLinkingProvider(null);
+ }
+ };
+
+ const handleUnlink = async (providerId: string, accountId?: string) => {
+ setUnlinkingProviderId(providerId);
+ try {
+ const { error } = await authClient.unlinkAccount({
+ providerId,
+ ...(accountId && { accountId }),
+ });
+ if (error) {
+ toast.error(error.message ?? "Failed to unlink account");
+ return;
+ }
+ toast.success("Account unlinked");
+ await fetchAccounts();
+ } catch (err) {
+ toast.error(
+ "Failed to unlink account",
+ err instanceof Error ? { description: err.message } : undefined,
+ );
+ } finally {
+ setUnlinkingProviderId(null);
+ }
+ };
+
+ const canUnlink = accounts.length > 1;
+
+ return (
+
+
+
+
+
+
+
+ Linking account
+
+
+ Link your Google or GitHub account to sign in with them.
+
+
+
+
+
+ {/* Linked accounts */}
+
+
Linked accounts
+ {accountsLoading ? (
+
+
+ Loading...
+
+ ) : socialAccounts.length === 0 ? (
+
+ No social accounts linked yet.
+
+ ) : (
+
+ {socialAccounts.map((acc) => (
+
+
+ {providerLabel(acc.providerId)}
+
+ {canUnlink && (
+
+ handleUnlink(acc.providerId, acc.accountId)
+ }
+ disabled={unlinkingProviderId === acc.providerId}
+ isLoading={unlinkingProviderId === acc.providerId}
+ >
+ {unlinkingProviderId === acc.providerId ? (
+
+ ) : (
+ <>
+
+ Unlink
+ >
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ Click a provider below to link it to your account. You will be
+ redirected to complete the flow.
+
+
+ {!linkedProviderIds.has("google") && (
+
handleLinkSocial("google")}
+ disabled={!!linkingProvider}
+ isLoading={linkingProvider === "google"}
+ >
+ {linkingProvider === "google" ? (
+
+ ) : (
+
+
+
+
+
+
+ )}
+ Link with Google
+
+ )}
+ {!linkedProviderIds.has("github") && (
+
handleLinkSocial("github")}
+ disabled={!!linkingProvider}
+ isLoading={linkingProvider === "github"}
+ >
+ {linkingProvider === "github" ? (
+
+ ) : (
+
+
+
+ )}
+ Link with GitHub
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx
index 8506446d3..4e4cec701 100644
--- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx
+++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx
@@ -1,5 +1,11 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import {
+ AlertTriangle,
+ Mail,
+ PenBoxIcon,
+ PlusIcon,
+ Trash2,
+} from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -8,8 +14,12 @@ import {
DiscordIcon,
GotifyIcon,
LarkIcon,
+ MattermostIcon,
NtfyIcon,
+ PushoverIcon,
+ ResendIcon,
SlackIcon,
+ TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { Button } from "@/components/ui/button";
@@ -45,6 +55,7 @@ const notificationBaseSchema = z.object({
appBuildError: z.boolean().default(false),
databaseBackup: z.boolean().default(false),
dokployBackup: z.boolean().default(false),
+ volumeBackup: z.boolean().default(false),
dokployRestart: z.boolean().default(false),
dockerCleanup: z.boolean().default(false),
serverThreshold: z.boolean().default(false),
@@ -90,6 +101,23 @@ export const notificationSchema = z.discriminatedUnion("type", [
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
+ z
+ .object({
+ type: z.literal("resend"),
+ apiKey: z.string().min(1, { message: "API Key is required" }),
+ fromAddress: z
+ .string()
+ .min(1, { message: "From Address is required" })
+ .email({ message: "Email is invalid" }),
+ toAddresses: z
+ .array(
+ z.string().min(1, { message: "Email is required" }).email({
+ message: "Email is invalid",
+ }),
+ )
+ .min(1, { message: "At least one email is required" }),
+ })
+ .merge(notificationBaseSchema),
z
.object({
type: z.literal("gotify"),
@@ -104,16 +132,55 @@ export const notificationSchema = z.discriminatedUnion("type", [
type: z.literal("ntfy"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
topic: z.string().min(1, { message: "Topic is required" }),
- accessToken: z.string().min(1, { message: "Access Token is required" }),
+ accessToken: z.string().optional(),
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
+ z
+ .object({
+ type: z.literal("mattermost"),
+ webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
+ channel: z.string().optional(),
+ username: z.string().optional(),
+ })
+ .merge(notificationBaseSchema),
+ z
+ .object({
+ type: z.literal("pushover"),
+ userKey: z.string().min(1, { message: "User Key is required" }),
+ apiToken: z.string().min(1, { message: "API Token is required" }),
+ priority: z.number().min(-2).max(2).default(0),
+ retry: z.number().min(30).nullish(),
+ expire: z.number().min(1).max(10800).nullish(),
+ })
+ .merge(notificationBaseSchema),
+ z
+ .object({
+ type: z.literal("custom"),
+ endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
+ headers: z
+ .array(
+ z.object({
+ key: z.string(),
+ value: z.string(),
+ }),
+ )
+ .optional()
+ .default([]),
+ })
+ .merge(notificationBaseSchema),
z
.object({
type: z.literal("lark"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
+ z
+ .object({
+ type: z.literal("teams"),
+ webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
+ })
+ .merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -133,10 +200,18 @@ export const notificationsMap = {
icon: ,
label: "Lark",
},
+ teams: {
+ icon: ,
+ label: "Microsoft Teams",
+ },
email: {
icon: ,
label: "Email",
},
+ resend: {
+ icon: ,
+ label: "Resend",
+ },
gotify: {
icon: ,
label: "Gotify",
@@ -145,6 +220,18 @@ export const notificationsMap = {
icon: ,
label: "ntfy",
},
+ mattermost: {
+ icon: ,
+ label: "Mattermost",
+ },
+ pushover: {
+ icon: ,
+ label: "Pushover",
+ },
+ custom: {
+ icon: ,
+ label: "Custom",
+ },
};
export type NotificationSchema = z.infer;
@@ -166,20 +253,36 @@ export const HandleNotifications = ({ notificationId }: Props) => {
enabled: !!notificationId,
},
);
- const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
+ const { mutateAsync: testSlackConnection, isPending: isLoadingSlack } =
api.notification.testSlackConnection.useMutation();
- const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } =
+ const { mutateAsync: testTelegramConnection, isPending: isLoadingTelegram } =
api.notification.testTelegramConnection.useMutation();
- const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } =
+ const { mutateAsync: testDiscordConnection, isPending: isLoadingDiscord } =
api.notification.testDiscordConnection.useMutation();
- const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
+ const { mutateAsync: testEmailConnection, isPending: isLoadingEmail } =
api.notification.testEmailConnection.useMutation();
- const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
+ const { mutateAsync: testResendConnection, isPending: isLoadingResend } =
+ api.notification.testResendConnection.useMutation();
+ const { mutateAsync: testGotifyConnection, isPending: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
- const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
+ const { mutateAsync: testNtfyConnection, isPending: isLoadingNtfy } =
api.notification.testNtfyConnection.useMutation();
- const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
+ const {
+ mutateAsync: testMattermostConnection,
+ isPending: isLoadingMattermost,
+ } = api.notification.testMattermostConnection.useMutation();
+ const { mutateAsync: testLarkConnection, isPending: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
+ const { mutateAsync: testTeamsConnection, isPending: isLoadingTeams } =
+ api.notification.testTeamsConnection.useMutation();
+ const { mutateAsync: testCustomConnection, isPending: isLoadingCustom } =
+ api.notification.testCustomConnection.useMutation();
+ const { mutateAsync: testPushoverConnection, isPending: isLoadingPushover } =
+ api.notification.testPushoverConnection.useMutation();
+
+ const customMutation = notificationId
+ ? api.notification.updateCustom.useMutation()
+ : api.notification.createCustom.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -192,17 +295,29 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const emailMutation = notificationId
? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation();
+ const resendMutation = notificationId
+ ? api.notification.updateResend.useMutation()
+ : api.notification.createResend.useMutation();
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
const ntfyMutation = notificationId
? api.notification.updateNtfy.useMutation()
: api.notification.createNtfy.useMutation();
+ const mattermostMutation = notificationId
+ ? api.notification.updateMattermost.useMutation()
+ : api.notification.createMattermost.useMutation();
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
+ const teamsMutation = notificationId
+ ? api.notification.updateTeams.useMutation()
+ : api.notification.createTeams.useMutation();
+ const pushoverMutation = notificationId
+ ? api.notification.updatePushover.useMutation()
+ : api.notification.createPushover.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
type: "slack",
webhookUrl: "",
@@ -218,8 +333,17 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: "toAddresses" as never,
});
+ const {
+ fields: headerFields,
+ append: appendHeader,
+ remove: removeHeader,
+ } = useFieldArray({
+ control: form.control,
+ name: "headers" as never,
+ });
+
useEffect(() => {
- if (type === "email" && fields.length === 0) {
+ if ((type === "email" || type === "resend") && fields.length === 0) {
append("");
}
}, [type, append, fields.length]);
@@ -233,6 +357,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
+ volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
webhookUrl: notification.slack?.webhookUrl,
channel: notification.slack?.channel || "",
@@ -247,6 +372,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
+ volumeBackup: notification.volumeBackup,
botToken: notification.telegram?.botToken,
messageThreadId: notification.telegram?.messageThreadId || "",
chatId: notification.telegram?.chatId,
@@ -262,9 +388,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
+ volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
- decoration: notification.discord?.decoration || undefined,
+ decoration: notification.discord?.decoration ?? undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -276,6 +403,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
+ volumeBackup: notification.volumeBackup,
type: notification.notificationType,
smtpServer: notification.email?.smtpServer,
smtpPort: notification.email?.smtpPort,
@@ -287,6 +415,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
+ } else if (notification.notificationType === "resend") {
+ form.reset({
+ appBuildError: notification.appBuildError,
+ appDeploy: notification.appDeploy,
+ dokployRestart: notification.dokployRestart,
+ databaseBackup: notification.databaseBackup,
+ volumeBackup: notification.volumeBackup,
+ type: notification.notificationType,
+ apiKey: notification.resend?.apiKey,
+ toAddresses: notification.resend?.toAddresses,
+ fromAddress: notification.resend?.fromAddress,
+ name: notification.name,
+ dockerCleanup: notification.dockerCleanup,
+ serverThreshold: notification.serverThreshold,
+ });
} else if (notification.notificationType === "gotify") {
form.reset({
appBuildError: notification.appBuildError,
@@ -294,9 +437,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
+ volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
- decoration: notification.gotify?.decoration || undefined,
+ decoration: notification.gotify?.decoration ?? undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
@@ -309,8 +453,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dokployBackup: notification.dokployBackup,
+ volumeBackup: notification.volumeBackup,
type: notification.notificationType,
- accessToken: notification.ntfy?.accessToken,
+ accessToken: notification.ntfy?.accessToken || "",
topic: notification.ntfy?.topic,
priority: notification.ntfy?.priority,
serverUrl: notification.ntfy?.serverUrl,
@@ -318,6 +463,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
+ } else if (notification.notificationType === "mattermost") {
+ form.reset({
+ appBuildError: notification.appBuildError,
+ appDeploy: notification.appDeploy,
+ dokployRestart: notification.dokployRestart,
+ databaseBackup: notification.databaseBackup,
+ volumeBackup: notification.volumeBackup,
+ type: notification.notificationType,
+ webhookUrl: notification.mattermost?.webhookUrl,
+ channel: notification.mattermost?.channel || "",
+ username: notification.mattermost?.username || "",
+ name: notification.name,
+ dockerCleanup: notification.dockerCleanup,
+ serverThreshold: notification.serverThreshold,
+ });
} else if (notification.notificationType === "lark") {
form.reset({
appBuildError: notification.appBuildError,
@@ -329,6 +489,58 @@ export const HandleNotifications = ({ notificationId }: Props) => {
webhookUrl: notification.lark?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
+ volumeBackup: notification.volumeBackup,
+ serverThreshold: notification.serverThreshold,
+ });
+ } else if (notification.notificationType === "teams") {
+ form.reset({
+ appBuildError: notification.appBuildError,
+ appDeploy: notification.appDeploy,
+ dokployRestart: notification.dokployRestart,
+ databaseBackup: notification.databaseBackup,
+ volumeBackup: notification.volumeBackup,
+ type: notification.notificationType,
+ webhookUrl: notification.teams?.webhookUrl,
+ name: notification.name,
+ dockerCleanup: notification.dockerCleanup,
+ serverThreshold: notification.serverThreshold,
+ });
+ } else if (notification.notificationType === "custom") {
+ form.reset({
+ appBuildError: notification.appBuildError,
+ appDeploy: notification.appDeploy,
+ dokployRestart: notification.dokployRestart,
+ databaseBackup: notification.databaseBackup,
+ type: notification.notificationType,
+ endpoint: notification.custom?.endpoint || "",
+ headers: notification.custom?.headers
+ ? Object.entries(notification.custom.headers).map(
+ ([key, value]) => ({
+ key,
+ value,
+ }),
+ )
+ : [],
+ name: notification.name,
+ volumeBackup: notification.volumeBackup,
+ dockerCleanup: notification.dockerCleanup,
+ serverThreshold: notification.serverThreshold,
+ });
+ } else if (notification.notificationType === "pushover") {
+ form.reset({
+ appBuildError: notification.appBuildError,
+ appDeploy: notification.appDeploy,
+ dokployRestart: notification.dokployRestart,
+ databaseBackup: notification.databaseBackup,
+ volumeBackup: notification.volumeBackup,
+ type: notification.notificationType,
+ userKey: notification.pushover?.userKey,
+ apiToken: notification.pushover?.apiToken,
+ priority: notification.pushover?.priority,
+ retry: notification.pushover?.retry ?? undefined,
+ expire: notification.pushover?.expire ?? undefined,
+ name: notification.name,
+ dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
}
@@ -342,9 +554,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
+ resend: resendMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
+ mattermost: mattermostMutation,
lark: larkMutation,
+ teams: teamsMutation,
+ custom: customMutation,
+ pushover: pushoverMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -354,6 +571,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart,
databaseBackup,
dokployBackup,
+ volumeBackup,
dockerCleanup,
serverThreshold,
} = data;
@@ -365,6 +583,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
+ volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
channel: data.channel,
name: data.name,
@@ -380,6 +599,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
+ volumeBackup: volumeBackup,
botToken: data.botToken,
messageThreadId: data.messageThreadId || "",
chatId: data.chatId,
@@ -396,6 +616,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
+ volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
decoration: data.decoration,
name: data.name,
@@ -411,6 +632,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
+ volumeBackup: volumeBackup,
smtpServer: data.smtpServer,
smtpPort: data.smtpPort,
username: data.username,
@@ -423,6 +645,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
emailId: notification?.emailId || "",
serverThreshold: serverThreshold,
});
+ } else if (data.type === "resend") {
+ promise = resendMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ volumeBackup: volumeBackup,
+ apiKey: data.apiKey,
+ fromAddress: data.fromAddress,
+ toAddresses: data.toAddresses,
+ name: data.name,
+ dockerCleanup: dockerCleanup,
+ notificationId: notificationId || "",
+ resendId: notification?.resendId || "",
+ serverThreshold: serverThreshold,
+ });
} else if (data.type === "gotify") {
promise = gotifyMutation.mutateAsync({
appBuildError: appBuildError,
@@ -430,6 +668,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
+ volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
appToken: data.appToken,
priority: data.priority,
@@ -446,8 +685,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
+ volumeBackup: volumeBackup,
serverUrl: data.serverUrl,
- accessToken: data.accessToken,
+ accessToken: data.accessToken || "",
topic: data.topic,
priority: data.priority,
name: data.name,
@@ -455,6 +695,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});
+ } else if (data.type === "mattermost") {
+ promise = mattermostMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ volumeBackup: volumeBackup,
+ webhookUrl: data.webhookUrl,
+ channel: data.channel || undefined,
+ username: data.username || undefined,
+ name: data.name,
+ dockerCleanup: dockerCleanup,
+ notificationId: notificationId || "",
+ mattermostId: notification?.mattermostId || "",
+ serverThreshold: serverThreshold,
+ });
} else if (data.type === "lark") {
promise = larkMutation.mutateAsync({
appBuildError: appBuildError,
@@ -462,6 +718,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
dokployBackup: dokployBackup,
+ volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
@@ -469,6 +726,69 @@ export const HandleNotifications = ({ notificationId }: Props) => {
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
+ } else if (data.type === "teams") {
+ promise = teamsMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ volumeBackup: volumeBackup,
+ webhookUrl: data.webhookUrl,
+ name: data.name,
+ dockerCleanup: dockerCleanup,
+ notificationId: notificationId || "",
+ teamsId: notification?.teamsId || "",
+ serverThreshold: serverThreshold,
+ });
+ } else if (data.type === "custom") {
+ // Convert headers array to object
+ const headersRecord =
+ data.headers && data.headers.length > 0
+ ? data.headers.reduce(
+ (acc, { key, value }) => {
+ if (key.trim()) acc[key] = value;
+ return acc;
+ },
+ {} as Record,
+ )
+ : undefined;
+
+ promise = customMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ volumeBackup: volumeBackup,
+ endpoint: data.endpoint,
+ headers: headersRecord,
+ name: data.name,
+ dockerCleanup: dockerCleanup,
+ serverThreshold: serverThreshold,
+ notificationId: notificationId || "",
+ customId: notification?.customId || "",
+ });
+ } else if (data.type === "pushover") {
+ if (data.priority === 2 && (data.retry == null || data.expire == null)) {
+ toast.error("Retry and expire are required for emergency priority (2)");
+ return;
+ }
+ promise = pushoverMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ volumeBackup: volumeBackup,
+ userKey: data.userKey,
+ apiToken: data.apiToken,
+ priority: data.priority,
+ retry: data.priority === 2 ? data.retry : undefined,
+ expire: data.priority === 2 ? data.expire : undefined,
+ name: data.name,
+ dockerCleanup: dockerCleanup,
+ serverThreshold: serverThreshold,
+ notificationId: notificationId || "",
+ pushoverId: notification?.pushoverId || "",
+ });
}
if (promise) {
@@ -483,6 +803,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
setVisible(false);
await utils.notification.all.invalidate();
+ if (notificationId) {
+ await utils.notification.one.invalidate({ notificationId });
+ }
})
.catch(() => {
toast.error(
@@ -891,6 +1214,96 @@ export const HandleNotifications = ({ notificationId }: Props) => {
>
)}
+ {type === "resend" && (
+ <>
+ (
+
+ API Key
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ From Address
+
+
+
+
+
+ )}
+ />
+
+
+
To Addresses
+
+ {fields.map((field, index) => (
+
+ (
+
+
+
+
+
+
+
+ )}
+ />
+ {
+ remove(index);
+ }}
+ >
+ Remove
+
+
+ ))}
+ {type === "resend" &&
+ "toAddresses" in form.formState.errors && (
+
+ {form.formState?.errors?.toAddresses?.root?.message}
+
+ )}
+
+
+ {
+ append("");
+ }}
+ >
+ Add
+
+ >
+ )}
+
{type === "gotify" && (
<>
{
+
+ Optional. Leave blank for public topics.
+
)}
@@ -1056,6 +1473,148 @@ export const HandleNotifications = ({ notificationId }: Props) => {
>
)}
+ {type === "mattermost" && (
+ <>
+ (
+
+ Webhook URL
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ Channel
+
+
+
+
+ Optional. Channel to post to (without #).
+
+
+
+ )}
+ />
+
+ (
+
+ Username
+
+
+
+
+ Optional. Display name for the webhook.
+
+
+
+ )}
+ />
+ >
+ )}
+
+ {type === "custom" && (
+
+
(
+
+ Webhook URL
+
+
+
+
+ The URL where POST requests will be sent with
+ notification data.
+
+
+
+ )}
+ />
+
+
+
+ Headers
+
+ Optional. Custom headers for your POST request (e.g.,
+ Authorization, Content-Type).
+
+
+
+
+
+
appendHeader({ key: "", value: "" })}
+ className="w-full"
+ >
+
+ Add header
+
+
+
+ )}
+
{type === "lark" && (
<>
{
/>
>
)}
+
+ {type === "teams" && (
+ <>
+ (
+
+ Webhook URL
+
+
+
+
+ Incoming Webhook URL from a Teams channel. Add an
+ Incoming Webhook in your channel settings to get the
+ URL.
+
+
+
+ )}
+ />
+ >
+ )}
+ {type === "pushover" && (
+ <>
+ (
+
+ User Key
+
+
+
+
+
+ )}
+ />
+ (
+
+ API Token
+
+
+
+
+
+ )}
+ />
+ (
+
+ Priority
+
+ {
+ const value = e.target.value;
+ if (value === "" || value === "-") {
+ field.onChange(0);
+ } else {
+ const priority = Number.parseInt(value);
+ if (
+ !Number.isNaN(priority) &&
+ priority >= -2 &&
+ priority <= 2
+ ) {
+ field.onChange(priority);
+ }
+ }
+ }}
+ type="number"
+ min={-2}
+ max={2}
+ />
+
+
+ Message priority (-2 to 2, default: 0, emergency: 2)
+
+
+
+ )}
+ />
+ {form.watch("priority") === 2 && (
+ <>
+ (
+
+ Retry (seconds)
+
+ {
+ const value = e.target.value;
+ if (value === "") {
+ field.onChange(undefined);
+ } else {
+ const retry = Number.parseInt(value);
+ if (!Number.isNaN(retry)) {
+ field.onChange(retry);
+ }
+ }
+ }}
+ type="number"
+ min={30}
+ />
+
+
+ How often (in seconds) to retry. Minimum 30
+ seconds.
+
+
+
+ )}
+ />
+ (
+
+ Expire (seconds)
+
+ {
+ const value = e.target.value;
+ if (value === "") {
+ field.onChange(undefined);
+ } else {
+ const expire = Number.parseInt(value);
+ if (!Number.isNaN(expire)) {
+ field.onChange(expire);
+ }
+ }
+ }}
+ type="number"
+ min={1}
+ max={10800}
+ />
+
+
+ How long to keep retrying (max 10800 seconds / 3
+ hours).
+
+
+
+ )}
+ />
+ >
+ )}
+ >
+ )}
@@ -1155,6 +1881,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
Dokploy Backup
Trigger the action when a dokploy backup is created.
+ name="volumeBackup"
+ render={({ field }) => (
+
+
+ Volume Backup
+
+ Trigger the action when a volume backup is created.
@@ -1246,60 +1979,120 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
+ isLoadingResend ||
isLoadingGotify ||
isLoadingNtfy ||
- isLoadingLark
+ isLoadingMattermost ||
+ isLoadingLark ||
+ isLoadingTeams ||
+ isLoadingCustom ||
+ isLoadingPushover
}
variant="secondary"
+ type="button"
onClick={async () => {
+ const isValid = await form.trigger();
+ if (!isValid) return;
+
+ const data = form.getValues();
+
try {
- if (type === "slack") {
+ if (data.type === "slack") {
await testSlackConnection({
- webhookUrl: form.getValues("webhookUrl"),
- channel: form.getValues("channel"),
+ webhookUrl: data.webhookUrl,
+ channel: data.channel,
});
- } else if (type === "telegram") {
+ } else if (data.type === "telegram") {
await testTelegramConnection({
- botToken: form.getValues("botToken"),
- chatId: form.getValues("chatId"),
- messageThreadId: form.getValues("messageThreadId") || "",
+ botToken: data.botToken,
+ chatId: data.chatId,
+ messageThreadId: data.messageThreadId || "",
});
- } else if (type === "discord") {
+ } else if (data.type === "discord") {
await testDiscordConnection({
- webhookUrl: form.getValues("webhookUrl"),
- decoration: form.getValues("decoration"),
+ webhookUrl: data.webhookUrl,
+ decoration: data.decoration,
});
- } else if (type === "email") {
+ } else if (data.type === "email") {
await testEmailConnection({
- smtpServer: form.getValues("smtpServer"),
- smtpPort: form.getValues("smtpPort"),
- username: form.getValues("username"),
- password: form.getValues("password"),
- toAddresses: form.getValues("toAddresses"),
- fromAddress: form.getValues("fromAddress"),
+ smtpServer: data.smtpServer,
+ smtpPort: data.smtpPort,
+ username: data.username,
+ password: data.password,
+ fromAddress: data.fromAddress,
+ toAddresses: data.toAddresses,
});
- } else if (type === "gotify") {
+ } else if (data.type === "resend") {
+ await testResendConnection({
+ apiKey: data.apiKey,
+ fromAddress: data.fromAddress,
+ toAddresses: data.toAddresses,
+ });
+ } else if (data.type === "gotify") {
await testGotifyConnection({
- serverUrl: form.getValues("serverUrl"),
- appToken: form.getValues("appToken"),
- priority: form.getValues("priority"),
- decoration: form.getValues("decoration"),
+ serverUrl: data.serverUrl,
+ appToken: data.appToken,
+ priority: data.priority ?? 0,
+ decoration: data.decoration,
});
- } else if (type === "ntfy") {
+ } else if (data.type === "ntfy") {
await testNtfyConnection({
- serverUrl: form.getValues("serverUrl"),
- topic: form.getValues("topic"),
- accessToken: form.getValues("accessToken"),
- priority: form.getValues("priority"),
+ serverUrl: data.serverUrl,
+ topic: data.topic,
+ accessToken: data.accessToken || "",
+ priority: data.priority ?? 0,
});
- } else if (type === "lark") {
+ } else if (data.type === "mattermost") {
+ await testMattermostConnection({
+ webhookUrl: data.webhookUrl,
+ channel: data.channel || undefined,
+ username: data.username || undefined,
+ });
+ } else if (data.type === "lark") {
await testLarkConnection({
- webhookUrl: form.getValues("webhookUrl"),
+ webhookUrl: data.webhookUrl,
+ });
+ } else if (data.type === "teams") {
+ await testTeamsConnection({
+ webhookUrl: data.webhookUrl,
+ });
+ } else if (data.type === "custom") {
+ const headersRecord =
+ data.headers && data.headers.length > 0
+ ? data.headers.reduce(
+ (acc, { key, value }) => {
+ if (key.trim()) acc[key] = value;
+ return acc;
+ },
+ {} as Record,
+ )
+ : undefined;
+ await testCustomConnection({
+ endpoint: data.endpoint,
+ headers: headersRecord,
+ });
+ } else if (data.type === "pushover") {
+ if (
+ data.priority === 2 &&
+ (data.retry == null || data.expire == null)
+ ) {
+ throw new Error(
+ "Retry and expire are required for emergency priority (2)",
+ );
+ }
+ await testPushoverConnection({
+ userKey: data.userKey,
+ apiToken: data.apiToken,
+ priority: data.priority ?? 0,
+ retry: data.priority === 2 ? data.retry : undefined,
+ expire: data.priority === 2 ? data.expire : undefined,
});
}
toast.success("Connection Success");
- } catch {
- toast.error("Error testing the provider");
+ } catch (error) {
+ toast.error(
+ `Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
}
}}
>
diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx
index 5b7a2220e..ccb28ee4c 100644
--- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx
+++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx
@@ -1,11 +1,14 @@
-import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
+import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
DiscordIcon,
GotifyIcon,
LarkIcon,
+ MattermostIcon,
NtfyIcon,
+ ResendIcon,
SlackIcon,
+ TeamsIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -21,9 +24,10 @@ import { api } from "@/utils/api";
import { HandleNotifications } from "./handle-notifications";
export const ShowNotifications = () => {
- const { data, isLoading, refetch } = api.notification.all.useQuery();
- const { mutateAsync, isLoading: isRemoving } =
+ const { data, isPending, refetch } = api.notification.all.useQuery();
+ const { mutateAsync, isPending: isRemoving } =
api.notification.remove.useMutation();
+ const { data: permissions } = api.user.getPermissions.useQuery();
return (
@@ -36,11 +40,11 @@ export const ShowNotifications = () => {
Add your providers to receive notifications, like Discord, Slack,
- Telegram, Email, Lark.
+ Telegram, Teams, Email, Resend, Lark.
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -54,7 +58,9 @@ export const ShowNotifications = () => {
To send notifications it is required to set at least 1
provider.
-
+ {permissions?.notification.create && (
+
+ )}
) : (
@@ -86,6 +92,11 @@ export const ShowNotifications = () => {
)}
+ {notification.notificationType === "resend" && (
+
+
+
+ )}
{notification.notificationType === "gotify" && (
@@ -96,11 +107,27 @@ export const ShowNotifications = () => {
)}
+ {notification.notificationType === "custom" && (
+
+ )}
{notification.notificationType === "lark" && (
)}
+ {notification.notificationType === "teams" && (
+
+
+
+ )}
+ {notification.notificationType ===
+ "mattermost" && (
+
+
+
+ )}
{notification.name}
@@ -109,45 +136,50 @@ export const ShowNotifications = () => {
notificationId={notification.notificationId}
/>
- {
- await mutateAsync({
- notificationId: notification.notificationId,
- })
- .then(() => {
- toast.success(
- "Notification deleted successfully",
- );
- refetch();
+ {permissions?.notification.delete && (
+ {
+ await mutateAsync({
+ notificationId:
+ notification.notificationId,
})
- .catch(() => {
- toast.error(
- "Error deleting notification",
- );
- });
- }}
- >
- {
+ toast.success(
+ "Notification deleted successfully",
+ );
+ refetch();
+ })
+ .catch(() => {
+ toast.error(
+ "Error deleting notification",
+ );
+ });
+ }}
>
-
-
-
+
+
+
+
+ )}
))}
-
-
-
+ {permissions?.notification.create && (
+
+
+
+ )}
)}
>
diff --git a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx
index 17220cd11..84a170434 100644
--- a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx
+++ b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import {
CopyIcon,
@@ -356,7 +356,7 @@ export const Configure2FA = () => {
{backupCodes.map((code, index) => (
{code}
diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx
index 656b27401..ff11d0322 100644
--- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx
+++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react";
import QRCode from "qrcode";
@@ -401,7 +401,7 @@ export const Enable2FA = () => {
{backupCodes.map((code, index) => (
{code}
diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx
index 583f3fefe..ceb90a560 100644
--- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx
+++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx
@@ -1,7 +1,6 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Loader2, User } from "lucide-react";
-import { useTranslation } from "next-i18next";
-import { useEffect, useMemo, useState } from "react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { Loader2, Palette, User } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -27,6 +26,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
+import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
@@ -40,7 +40,8 @@ const profileSchema = z.object({
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
- name: z.string().optional(),
+ firstName: z.string().optional(),
+ lastName: z.string().optional(),
allowImpersonation: z.boolean().optional().default(false),
});
@@ -62,17 +63,17 @@ const randomImages = [
];
export const ProfileForm = () => {
- const { data, refetch, isLoading } = api.user.get.useQuery();
+ const { data, refetch, isPending } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const {
mutateAsync,
- isLoading: isUpdating,
+ isPending: isUpdating,
isError,
error,
} = api.user.update.useMutation();
- const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState(null);
+ const colorInputRef = useRef(null);
const availableAvatars = useMemo(() => {
if (gravatarHash === null) return randomImages;
@@ -81,14 +82,15 @@ export const ProfileForm = () => {
]);
}, [gravatarHash]);
- const form = useForm({
+ const form = useForm({
defaultValues: {
email: data?.user?.email || "",
password: "",
image: data?.user?.image || "",
currentPassword: "",
allowImpersonation: data?.user?.allowImpersonation || false,
- name: data?.user?.name || "",
+ firstName: data?.user?.firstName || "",
+ lastName: data?.user?.lastName || "",
},
resolver: zodResolver(profileSchema),
});
@@ -102,7 +104,8 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
allowImpersonation: data?.user?.allowImpersonation,
- name: data?.user?.name || "",
+ firstName: data?.user?.firstName || "",
+ lastName: data?.user?.lastName || "",
},
{
keepValues: true,
@@ -126,7 +129,8 @@ export const ProfileForm = () => {
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
- name: values.name || undefined,
+ firstName: values.firstName || undefined,
+ lastName: values.lastName || undefined,
});
await refetch();
toast.success("Profile Updated");
@@ -135,7 +139,8 @@ export const ProfileForm = () => {
password: "",
image: values.image,
currentPassword: "",
- name: values.name || "",
+ firstName: values.firstName || "",
+ lastName: values.lastName || "",
});
} catch (error) {
toast.error("Error updating the profile");
@@ -150,10 +155,10 @@ export const ProfileForm = () => {
- {t("settings.profile.title")}
+ Account
- {t("settings.profile.description")}
+ Change the details of your profile here.
@@ -162,7 +167,7 @@ export const ProfileForm = () => {
{isError && {error?.message} }
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -177,12 +182,25 @@ export const ProfileForm = () => {
(
- Name
+ First Name
-
+
+
+
+
+ )}
+ />
+ (
+
+ Last Name
+
+
@@ -193,12 +211,9 @@ export const ProfileForm = () => {
name="email"
render={({ field }) => (
- {t("settings.profile.email")}
+ Email
-
+
@@ -213,7 +228,7 @@ export const ProfileForm = () => {
@@ -227,13 +242,11 @@ export const ProfileForm = () => {
name="password"
render={({ field }) => (
-
- {t("settings.profile.password")}
-
+ Password
@@ -248,24 +261,14 @@ export const ProfileForm = () => {
name="image"
render={({ field }) => (
-
- {t("settings.profile.avatar")}
-
+ Avatar
{
field.onChange(e);
}}
- defaultValue={
- field.value?.startsWith("data:")
- ? "upload"
- : field.value
- }
- value={
- field.value?.startsWith("data:")
- ? "upload"
- : field.value
- }
+ defaultValue={getAvatarType(field.value)}
+ value={getAvatarType(field.value)}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
@@ -280,7 +283,7 @@ export const ProfileForm = () => {
{getFallbackAvatarInitials(
- data?.user?.name,
+ `${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
)}
@@ -352,6 +355,40 @@ export const ProfileForm = () => {
/>
+
+
+
+
+
+
+ colorInputRef.current?.click()
+ }
+ >
+ {!isSolidColorAvatar(field.value) && (
+
+ )}
+
+
+
+
{availableAvatars.map((image) => (
@@ -408,7 +445,7 @@ export const ProfileForm = () => {
- {t("settings.common.save")}
+ Save
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx
index 2bafe7e64..7d63de210 100644
--- a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx
@@ -1,4 +1,3 @@
-import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
import { Button } from "@/components/ui/button";
@@ -17,24 +16,23 @@ import { TerminalModal } from "../../web-server/terminal-modal";
import { GPUSupportModal } from "../gpu-support-modal";
export const ShowDokployActions = () => {
- const { t } = useTranslation("settings");
- const { mutateAsync: reloadServer, isLoading } =
+ const { mutateAsync: reloadServer, isPending } =
api.settings.reloadServer.useMutation();
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
+ const { mutateAsync: cleanAllDeploymentQueue } =
+ api.settings.cleanAllDeploymentQueue.useMutation();
return (
-
-
- {t("settings.server.webServer.server.label")}
+
+
+ Server
-
- {t("settings.server.webServer.actions")}
-
+ Actions
{
}}
className="cursor-pointer"
>
- {t("settings.server.webServer.reload")}
+ Reload
- {t("settings.common.enterTerminal")}
+ Terminal
e.preventDefault()}
>
- {t("settings.server.webServer.watchLogs")}
+ View Logs
@@ -68,7 +66,7 @@ export const ShowDokployActions = () => {
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
- {t("settings.server.webServer.updateServerIp")}
+ Update Server IP
@@ -87,6 +85,21 @@ export const ShowDokployActions = () => {
Clean Redis
+ {
+ await cleanAllDeploymentQueue()
+ .then(() => {
+ toast.success("Deployment queue cleaned");
+ })
+ .catch(() => {
+ toast.error("Error cleaning deployment queue");
+ });
+ }}
+ >
+ Clean all deployment queue
+
+
{
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx
index 41156d35b..334d25b20 100644
--- a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx
@@ -1,4 +1,6 @@
+import { Activity } from "lucide-react";
import { useState } from "react";
+import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props {
serverId: string;
+ asButton?: boolean;
}
-export const ShowServerActions = ({ serverId }: Props) => {
+export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
-
+ {asButton ? (
+
+
+
+
+
+ ) : (
e.preventDefault()}
+ onSelect={(e) => {
+ e.preventDefault();
+ setIsOpen(true);
+ }}
>
View Actions
-
+ )}
Web server settings
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx
index 41c8ae5c5..2e69dfd23 100644
--- a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx
@@ -1,4 +1,3 @@
-import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -16,61 +15,63 @@ interface Props {
serverId?: string;
}
export const ShowStorageActions = ({ serverId }: Props) => {
- const { t } = useTranslation("settings");
- const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
+ const { mutateAsync: cleanAll, isPending: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
const {
mutateAsync: cleanDockerBuilder,
- isLoading: cleanDockerBuilderIsLoading,
+ isPending: cleanDockerBuilderIsPending,
} = api.settings.cleanDockerBuilder.useMutation();
const { mutateAsync: cleanMonitoring } =
api.settings.cleanMonitoring.useMutation();
const {
mutateAsync: cleanUnusedImages,
- isLoading: cleanUnusedImagesIsLoading,
+ isPending: cleanUnusedImagesIsPending,
} = api.settings.cleanUnusedImages.useMutation();
const {
mutateAsync: cleanUnusedVolumes,
- isLoading: cleanUnusedVolumesIsLoading,
+ isPending: cleanUnusedVolumesIsPending,
} = api.settings.cleanUnusedVolumes.useMutation();
const {
mutateAsync: cleanStoppedContainers,
- isLoading: cleanStoppedContainersIsLoading,
+ isPending: cleanStoppedContainersIsPending,
} = api.settings.cleanStoppedContainers.useMutation();
+ const { mutateAsync: cleanPatchRepos, isPending: cleanPatchReposIsLoading } =
+ api.patch.cleanPatchRepos.useMutation();
+
return (
- {t("settings.server.webServer.storage.label")}
+ Space
-
- {t("settings.server.webServer.actions")}
-
+ Actions
{
});
}}
>
-
- {t("settings.server.webServer.storage.cleanUnusedImages")}
-
+ Clean unused images
{
});
}}
>
-
- {t("settings.server.webServer.storage.cleanUnusedVolumes")}
-
+ Clean unused volumes
{
});
}}
>
-
- {t("settings.server.webServer.storage.cleanStoppedContainers")}
-
+ Clean stopped containers
+
+
+ {
+ await cleanPatchRepos({
+ serverId: serverId,
+ })
+ .then(async () => {
+ toast.success("Cleaned Patch Caches");
+ })
+ .catch(() => {
+ toast.error("Error cleaning Patch Caches");
+ });
+ }}
+ >
+ Clean Patch Caches
{
});
}}
>
-
- {t("settings.server.webServer.storage.cleanDockerBuilder")}
-
+ Clean Docker Builder & System
{!serverId && (
{
});
}}
>
-
- {t("settings.server.webServer.storage.cleanMonitoring")}
-
+ Clean Monitoring
)}
@@ -173,14 +181,14 @@ export const ShowStorageActions = ({ serverId }: Props) => {
serverId: serverId,
})
.then(async () => {
- toast.success("Cleaned all");
+ toast.success("Cleaning in progress... Please wait");
})
.catch(() => {
toast.error("Error cleaning all");
});
}}
>
- {t("settings.server.webServer.storage.cleanAll")}
+ Clean all
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx
index d9573ca74..65957a881 100644
--- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx
@@ -1,5 +1,6 @@
-import { useTranslation } from "next-i18next";
import { toast } from "sonner";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -10,6 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
@@ -19,11 +21,10 @@ interface Props {
serverId?: string;
}
export const ShowTraefikActions = ({ serverId }: Props) => {
- const { t } = useTranslation("settings");
- const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
+ const { mutateAsync: reloadTraefik, isPending: reloadTraefikIsLoading } =
api.settings.reloadTraefik.useMutation();
- const { mutateAsync: toggleDashboard, isLoading: toggleDashboardIsLoading } =
+ const { mutateAsync: toggleDashboard, isPending: toggleDashboardIsLoading } =
api.settings.toggleDashboard.useMutation();
const { data: haveTraefikDashboardPortEnabled, refetch: refetchDashboard } =
@@ -31,38 +32,71 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
serverId,
});
+ const {
+ execute: executeWithHealthCheck,
+ isExecuting: isHealthCheckExecuting,
+ } = useHealthCheckAfterMutation({
+ initialDelay: 5000,
+ pollInterval: 4000,
+ successMessage: "Traefik dashboard updated successfully",
+ onSuccess: () => {
+ refetchDashboard();
+ },
+ });
+
+ const {
+ execute: executeReloadWithHealthCheck,
+ isExecuting: isReloadHealthCheckExecuting,
+ } = useHealthCheckAfterMutation({
+ initialDelay: 5000,
+ pollInterval: 4000,
+ successMessage: "Traefik Reloaded",
+ });
+
return (
- {t("settings.server.webServer.traefik.label")}
+ Traefik
-
- {t("settings.server.webServer.actions")}
-
+ Actions
{
- await reloadTraefik({
- serverId: serverId,
- })
- .then(async () => {
- toast.success("Traefik Reloaded");
- })
- .catch(() => {});
+ try {
+ await executeReloadWithHealthCheck(() =>
+ reloadTraefik({ serverId }),
+ );
+ } catch (error) {
+ const errorMessage =
+ (error as Error)?.message ||
+ "Failed to reload Traefik. Please try again.";
+ toast.error(errorMessage);
+ }
}}
className="cursor-pointer"
+ disabled={isReloadHealthCheckExecuting}
>
- {t("settings.server.webServer.reload")}
+ Reload
{
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
- {t("settings.server.webServer.watchLogs")}
+ View Logs
@@ -81,36 +115,64 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
- {t("settings.server.webServer.traefik.modifyEnv")}
+ Modify Environment
-
+
+ The Traefik container will be recreated from scratch. This
+ means the container will be deleted and created again, which
+ may cause downtime in your applications.
+
+
+ Are you sure you want to{" "}
+ {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
+ Traefik dashboard?
+
+
+ }
onClick={async () => {
- await toggleDashboard({
- enableDashboard: !haveTraefikDashboardPortEnabled,
- serverId: serverId,
- })
- .then(async () => {
- toast.success(
- `${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
- );
- refetchDashboard();
- })
- .catch(() => {});
+ try {
+ await executeWithHealthCheck(() =>
+ toggleDashboard({
+ enableDashboard: !haveTraefikDashboardPortEnabled,
+ serverId: serverId,
+ }),
+ );
+ } catch (error) {
+ const errorMessage =
+ (error as Error)?.message ||
+ "Failed to toggle dashboard. Please check if port 8080 is available.";
+ toast.error(errorMessage);
+ }
}}
- className="w-full cursor-pointer space-x-3"
+ disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
+ type="default"
>
-
- {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
-
-
+ e.preventDefault()}
+ className="w-full cursor-pointer space-x-3"
+ >
+
+ {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
+ Dashboard
+
+
+
e.preventDefault()}
className="cursor-pointer"
>
- {t("settings.server.webServer.traefik.managePorts")}
+ Additional Port Mappings
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx
index 4021ddaf5..97cf3f6be 100644
--- a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx
@@ -7,9 +7,12 @@ interface Props {
serverId?: string;
}
export const ToggleDockerCleanup = ({ serverId }: Props) => {
- const { data, refetch } = api.user.get.useQuery(undefined, {
- enabled: !serverId,
- });
+ const { data, refetch } = api.settings.getWebServerSettings.useQuery(
+ undefined,
+ {
+ enabled: !serverId,
+ },
+ );
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
{
@@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
const enabled = serverId
? server?.enableDockerCleanup
- : data?.user.enableDockerCleanup;
+ : data?.enableDockerCleanup;
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
@@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
try {
await mutateAsync({
enableDockerCleanup: checked,
- serverId: serverId,
+ ...(serverId && { serverId }),
+ } as {
+ enableDockerCleanup: boolean;
+ serverId?: string;
});
if (serverId) {
await refetchServer();
diff --git a/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx
index a21f76cb3..92a2294ee 100644
--- a/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { FileTerminal } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -49,7 +49,7 @@ export const EditScript = ({ serverId }: Props) => {
},
);
- const { mutateAsync, isLoading } = api.server.update.useMutation();
+ const { mutateAsync, isPending } = api.server.update.useMutation();
const { data: defaultCommand } = api.server.getDefaultCommand.useQuery(
{
@@ -155,7 +155,7 @@ echo "Hello world"
Reset
diff --git a/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx b/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx
index a9eb229f1..560284604 100644
--- a/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx
@@ -105,7 +105,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
disabled={isLoading || serverId === undefined || isChecking}
>
{isLoading
- ? "Enabling GPU..."
+ ? "Loading..."
: gpuStatus?.swarmEnabled
? "Reconfigure GPU"
: "Enable GPU"}
diff --git a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx
index cdbe8a95b..44d73fb98 100644
--- a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx
@@ -1,7 +1,6 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { PlusIcon } from "lucide-react";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { Pencil, PlusIcon } from "lucide-react";
import Link from "next/link";
-import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -21,6 +20,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
@@ -52,17 +52,17 @@ const Schema = z.object({
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
+ serverType: z.enum(["deploy", "build"]).default("deploy"),
});
type Schema = z.infer;
interface Props {
serverId?: string;
+ asButton?: boolean;
}
-export const HandleServers = ({ serverId }: Props) => {
- const { t } = useTranslation("settings");
-
+export const HandleServers = ({ serverId, asButton = false }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -78,10 +78,10 @@ export const HandleServers = ({ serverId }: Props) => {
);
const { data: sshKeys } = api.sshKey.all.useQuery();
- const { mutateAsync, error, isLoading, isError } = serverId
+ const { mutateAsync, error, isPending, isError } = serverId
? api.server.update.useMutation()
: api.server.create.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
description: "",
name: "",
@@ -89,6 +89,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: 22,
username: "root",
sshKeyId: "",
+ serverType: "deploy",
},
resolver: zodResolver(Schema),
});
@@ -101,6 +102,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data?.port || 22,
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
+ serverType: data?.serverType || "deploy",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -116,6 +118,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
+ serverType: data.serverType || "deploy",
serverId: serverId || "",
})
.then(async (_data) => {
@@ -133,21 +136,32 @@ export const HandleServers = ({ serverId }: Props) => {
return (
-
- {serverId ? (
+ {serverId ? (
+ asButton ? (
+
+
+
+
+
+ ) : (
e.preventDefault()}
+ onSelect={(e) => {
+ e.preventDefault();
+ setIsOpen(true);
+ }}
>
Edit Server
- ) : (
+ )
+ ) : (
+
Create Server
- )}
-
+
+ )}
{serverId ? "Edit" : "Create"} Server
@@ -158,9 +172,8 @@ export const HandleServers = ({ serverId }: Props) => {
- You will need to purchase or rent a Virtual Private Server (VPS) to
- proceed, we recommend to use one of these providers since has been
- heavily tested.
+ You may need to purchase or rent a Virtual Private Server (VPS) to
+ proceed. We recommend using one of these heavily tested providers:
@@ -266,6 +279,50 @@ export const HandleServers = ({ serverId }: Props) => {
)}
/>
+ {
+ const serverTypeValue = form.watch("serverType");
+ return (
+
+ Server Type
+
+
+
+
+
+
+ Deploy Server
+ Build Server
+ Server Type
+
+
+
+
+ {serverTypeValue === "deploy" && (
+
+ Deploy servers are used to run your applications,
+ databases, and services. They handle the deployment and
+ execution of your projects.
+
+ )}
+ {serverTypeValue === "build" && (
+
+ Build servers are dedicated to building your
+ applications. They handle the compilation and build
+ process, offloading this work from your deployment
+ servers. Build servers won't appear in deployment
+ options.
+
+ )}
+
+ );
+ }}
+ />
{
name="ipAddress"
render={({ field }) => (
- {t("settings.terminal.ipAddress")}
+ IP Address
@@ -319,7 +376,7 @@ export const HandleServers = ({ serverId }: Props) => {
name="port"
render={({ field }) => (
- {t("settings.terminal.port")}
+ Port
{
name="username"
render={({ field }) => (
- {t("settings.terminal.username")}
+ Username
-
+
+ Use "root" or a non-root user with passwordless
+ sudo access.
+
)}
@@ -362,7 +422,7 @@ export const HandleServers = ({ serverId }: Props) => {
{
const [isRefreshing, setIsRefreshing] = useState(false);
- const { data, refetch, error, isLoading, isError } =
+ const { data, refetch, error, isPending, isError } =
api.server.security.useQuery(
{ serverId },
{
@@ -68,7 +68,7 @@ export const SecurityAudit = ({ serverId }: Props) => {
Ubuntu/Debian OS support is currently supported (Experimental)
- {isLoading ? (
+ {isPending ? (
Checking Server configuration
diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx
index fb7b23f78..701593436 100644
--- a/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Eye, EyeOff, LayoutDashboardIcon, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -80,7 +80,7 @@ const Schema = z.object({
type Schema = z.infer
;
export const SetupMonitoring = ({ serverId }: Props) => {
- const { data } = serverId
+ const { data: serverData } = serverId
? api.server.one.useQuery(
{
serverId: serverId || "",
@@ -89,11 +89,18 @@ export const SetupMonitoring = ({ serverId }: Props) => {
enabled: !!serverId,
},
)
- : api.user.getServerMetrics.useQuery();
+ : { data: null };
+
+ const { data: webServerSettings } =
+ api.settings.getWebServerSettings.useQuery(undefined, {
+ enabled: !serverId,
+ });
+
+ const data = serverId ? serverData : webServerSettings;
const url = useUrl();
- const { data: projects } = api.project.all.useQuery();
+ const { data: projects } = api.project.allForPermissions.useQuery();
const extractServicesFromProjects = () => {
if (!projects) return [];
diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx
index 2049b6c6b..0d4d3a44f 100644
--- a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx
@@ -1,5 +1,5 @@
import copy from "copy-to-clipboard";
-import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
+import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -22,7 +22,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -36,9 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
+ asButton?: boolean;
}
-export const SetupServer = ({ serverId }: Props) => {
+export const SetupServer = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
const [activeLog, setActiveLog] = useState(null);
const { data: isCloud } = api.settings.isCloud.useQuery();
+ const isBuildServer = server?.serverType === "build";
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState([]);
const [isDeploying, setIsDeploying] = useState(false);
@@ -80,14 +81,23 @@ export const SetupServer = ({ serverId }: Props) => {
return (
-
-
+
+
+
+
+ ) : (
+ e.preventDefault()}
+ size="sm"
+ onClick={() => {
+ setIsOpen(true);
+ }}
>
- Setup Server
-
-
+ Setup Server
+
+ )}
@@ -108,26 +118,36 @@ export const SetupServer = ({ serverId }: Props) => {
) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {!isBuildServer && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
)}
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
index 191aab9ce..859098394 100644
--- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx
@@ -1,8 +1,18 @@
import { format } from "date-fns";
-import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
+import {
+ Clock,
+ Key,
+ KeyIcon,
+ Loader2,
+ MoreHorizontal,
+ Network,
+ ServerIcon,
+ Terminal,
+ Trash2,
+ User,
+} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
-import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -18,20 +28,15 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
- DropdownMenuItem,
DropdownMenuLabel,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
- Table,
- TableBody,
- TableCaption,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal";
@@ -46,20 +51,20 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => {
- const { t } = useTranslation("settings");
const router = useRouter();
const query = router.query;
- const { data, refetch, isLoading } = api.server.all.useQuery();
+ const { data, refetch, isPending } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: canCreateMoreServers } =
api.stripe.canCreateMoreServers.useQuery();
+ const { data: permissions } = api.user.getPermissions.useQuery();
return (
{query?.success && isCloud &&
}
-
+
@@ -82,7 +87,7 @@ export const ShowServers = () => {
)}
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -111,188 +116,47 @@ export const ShowServers = () => {
Start adding servers to deploy your applications
remotely.
-
+ {permissions?.server.create && }
) : (
-
-
-
-
- See all servers
-
-
-
-
- Name
- {isCloud && (
-
- Status
-
- )}
-
- IP Address
-
-
- Port
-
-
- Username
-
-
- SSH Key
-
-
- Created
-
-
- Actions
-
-
-
-
- {data?.map((server) => {
- const canDelete = server.totalSum === 0;
- const isActive = server.serverStatus === "active";
- return (
-
-
- {server.name}
-
- {isCloud && (
-
-
- {server.serverStatus}
-
-
- )}
-
- {server.ipAddress}
-
-
- {server.port}
-
-
- {server.username}
-
-
-
- {server.sshKeyId ? "Yes" : "No"}
-
-
-
-
- {format(
- new Date(server.createdAt),
- "PPpp",
- )}
-
-
-
-
-
-
-
-
- Open menu
-
-
-
-
-
-
- Actions
-
-
- {isActive && (
- <>
- {server.sshKeyId && (
-
-
- {t(
- "settings.common.enterTerminal",
- )}
-
-
- )}
-
-
-
-
- {server.sshKeyId && (
-
- )}
- >
- )}
-
-
- You can not delete this server
- because it has active services.
-
- You have active services
- associated with this server,
- please delete them first.
-
-
- )
- }
- onClick={async () => {
- await mutateAsync({
- serverId: server.serverId,
- })
- .then(() => {
- refetch();
- toast.success(
- `Server ${server.name} deleted successfully`,
- );
- })
- .catch((err) => {
- toast.error(err.message);
- });
- }}
- >
- e.preventDefault()}
- >
- Delete Server
-
-
-
- {isActive && server.sshKeyId && (
- <>
-
+
+
+ {data?.map((server) => {
+ const canDelete = server.totalSum === 0;
+ const isActive = server.serverStatus === "active";
+ const isBuildServer = server.serverType === "build";
+ return (
+
+
+
+
+
+
+ {server.name}
+
+
+ {isActive &&
+ server.sshKeyId &&
+ !isBuildServer && (
+
+
+
+
+ More options
+
+
+
+
+
- Extra
+ Advanced
-
@@ -308,35 +172,280 @@ export const ShowServers = () => {
}
/>
)}
-
-
- >
- )}
-
-
-
-
- );
- })}
-
-
+
+
+ )}
+
+
+
+ {isCloud && (
+ <>
+ {server.serverStatus === "active" ? (
+
+ {server.serverStatus}
+
+ ) : (
+
+
+
+
+ {server.serverStatus}
+
+
+
+
+
+ This server is deactivated due
+ to lack of payment. Please pay
+ your invoice to reactivate it.
+ If you think this is an error,
+ please contact support.
+
+
+
+ )}
+ >
+ )}
+
+ {server.serverType}
+
+
+
+
+
+
+
+
+ IP:
+
+
+ {server.ipAddress}
+
+
+ Port:
+
+
+ {server.port}
+
+
+
+
+
+ User:
+
+
+ {server.username}
+
+
+
+
+
+ SSH Key:
+
+
+ {server.sshKeyId ? "Yes" : "No"}
+
+
+
+
+
+ Created{" "}
+ {format(
+ new Date(server.createdAt),
+ "PPp",
+ )}
+
+
-
- {data && data?.length > 0 && (
-
-
-
- )}
+ {/* Compact Actions */}
+ {isActive && (
+
+
+
+
+
+
+
+
+
+ Setup Server
+
+
+ Configure and initialize your
+ server with Docker, Traefik, and
+ other essential services
+
+
+
+
+
+
+
+ {server.sshKeyId && (
+
+
+
+
+
+
+
+
+
+
+
+ Terminal
+
+
+ )}
+
+
+
+
+
+
+
+
+ Edit Server
+
+
+
+ {server.sshKeyId && !isBuildServer && (
+
+
+
+
+
+
+
+ Web Server Actions
+
+
+ )}
+
+
+
+ {permissions?.server.delete && (
+
+
+
+
+ You can not delete this
+ server because it has
+ active services.
+
+ You have active
+ services associated
+ with this server,
+ please delete them
+ first.
+
+
+ )
+ }
+ onClick={async () => {
+ await mutateAsync({
+ serverId: server.serverId,
+ })
+ .then(() => {
+ refetch();
+ toast.success(
+ `Server ${server.name} deleted successfully`,
+ );
+ })
+ .catch((err) => {
+ toast.error(
+ err.message,
+ );
+ });
+ }}
+ >
+
+
+
+
+
+
+
+
+ {canDelete
+ ? "Delete Server"
+ : "Cannot delete - has active services"}
+
+
+
+ )}
+
+
+ )}
+
+
+ );
+ })}
+
+ {permissions?.server.create && (
+
+ {data && data?.length > 0 && (
+
+
+
+ )}
+
+ )}
)}
>
diff --git a/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx b/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx
index c09753f3e..39d5de09f 100644
--- a/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx
@@ -18,13 +18,20 @@ interface Props {
export const ValidateServer = ({ serverId }: Props) => {
const [isRefreshing, setIsRefreshing] = useState(false);
- const { data, refetch, error, isLoading, isError } =
+ const { data, refetch, error, isPending, isError } =
api.server.validate.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
+ const { data: server } = api.server.one.useQuery(
+ { serverId },
+ {
+ enabled: !!serverId,
+ },
+ );
+ const isBuildServer = server?.serverType === "build";
const _utils = api.useUtils();
return (
@@ -63,7 +70,7 @@ export const ValidateServer = ({ serverId }: Props) => {
- {isLoading ? (
+ {isPending ? (
Checking Server configuration
@@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {
Status
- Shows the server configuration status
+ {isBuildServer
+ ? "Shows the build server configuration status"
+ : "Shows the server configuration status"}
{
: undefined
}
/>
-
+ {!isBuildServer && (
+
+ )}
{
}
/>
-
+ {!isBuildServer && (
+ <>
+
+
+ >
+ )}
{
}
/>
+
diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx
index 0141aca08..41aac01d3 100644
--- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
+ serverType: "deploy",
})
.then(async (_data) => {
toast.success("Server Created");
diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx
index ad386cc49..5924bba50 100644
--- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx
@@ -12,7 +12,7 @@ import { api } from "@/utils/api";
export const CreateSSHKey = () => {
const { data, refetch } = api.sshKey.all.useQuery();
const generateMutation = api.sshKey.generate.useMutation();
- const { mutateAsync, isLoading } = api.sshKey.create.useMutation();
+ const { mutateAsync, isPending } = api.sshKey.create.useMutation();
const hasCreatedKey = useRef(false);
const [selectedOption, setSelectedOption] = useState<"manual" | "provider">(
"manual",
@@ -24,7 +24,7 @@ export const CreateSSHKey = () => {
useEffect(() => {
const createKey = async () => {
- if (!data || cloudSSHKey || hasCreatedKey.current || isLoading) {
+ if (!data || cloudSSHKey || hasCreatedKey.current || isPending) {
return;
}
@@ -55,7 +55,7 @@ export const CreateSSHKey = () => {
- {isLoading || !cloudSSHKey ? (
+ {isPending || !cloudSSHKey ? (
{
etc.)
diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx
index 03a2810b9..debdfcbb0 100644
--- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx
@@ -27,7 +27,7 @@ export const Verify = () => {
const [serverId, setServerId] = useState(
servers?.[0]?.serverId || "",
);
- const { data, refetch, error, isLoading, isError } =
+ const { data, refetch, error, isPending, isError } =
api.server.validate.useQuery(
{ serverId },
{
@@ -91,7 +91,7 @@ export const Verify = () => {
- {isLoading ? (
+ {isPending ? (
Checking Server configuration
diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx
index 3c1004143..004f79f74 100644
--- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx
+++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx
@@ -307,9 +307,9 @@ export const WelcomeSuscription = () => {
- {features.map((feature, index) => (
+ {features.map((feature) => (
{feature.icon}
diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx
index 73c807685..68e7d764e 100644
--- a/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx
+++ b/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DownloadIcon, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -48,7 +48,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
},
);
- const { mutateAsync, isError, error, isLoading } = sshKeyId
+ const { mutateAsync, isError, error, isPending } = sshKeyId
? api.sshKey.update.useMutation()
: api.sshKey.create.useMutation();
@@ -164,7 +164,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
onGenerateSSHKey({
@@ -177,7 +177,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
onGenerateSSHKey({
@@ -298,7 +298,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
)}
-
+
{sshKeyId ? "Update" : "Create"}
diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx
index 0a29f2d8f..86ea0a2ea 100644
--- a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx
+++ b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx
@@ -14,9 +14,10 @@ import { api } from "@/utils/api";
import { HandleSSHKeys } from "./handle-ssh-keys";
export const ShowDestinations = () => {
- const { data, isLoading, refetch } = api.sshKey.all.useQuery();
- const { mutateAsync, isLoading: isRemoving } =
+ const { data, isPending, refetch } = api.sshKey.all.useQuery();
+ const { mutateAsync, isPending: isRemoving } =
api.sshKey.remove.useMutation();
+ const { data: permissions } = api.user.getPermissions.useQuery();
return (
@@ -33,7 +34,7 @@ export const ShowDestinations = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -46,7 +47,7 @@ export const ShowDestinations = () => {
You don't have any SSH keys
-
+ {permissions?.sshKeys.create && }
) : (
@@ -84,43 +85,47 @@ export const ShowDestinations = () => {
- {
- await mutateAsync({
- sshKeyId: sshKey.sshKeyId,
- })
- .then(() => {
- toast.success(
- "SSH Key deleted successfully",
- );
- refetch();
+ {permissions?.sshKeys.delete && (
+ {
+ await mutateAsync({
+ sshKeyId: sshKey.sshKeyId,
})
- .catch(() => {
- toast.error("Error deleting SSH Key");
- });
- }}
- >
- {
+ toast.success(
+ "SSH Key deleted successfully",
+ );
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error deleting SSH Key");
+ });
+ }}
>
-
-
-
+
+
+
+
+ )}
))}
-
-
-
+ {permissions?.sshKeys.create && (
+
+
+
+ )}
)}
>
diff --git a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx
new file mode 100644
index 000000000..343e0f93b
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx
@@ -0,0 +1,239 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Palette, PenBoxIcon, PlusIcon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { TagBadge } from "@/components/shared/tag-badge";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { api } from "@/utils/api";
+
+const TagSchema = z.object({
+ name: z
+ .string()
+ .min(1, "Tag name is required")
+ .max(50, "Tag name must be less than 50 characters")
+ .refine(
+ (name) => {
+ const trimmedName = name.trim();
+ const validNameRegex =
+ /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u;
+ return validNameRegex.test(trimmedName);
+ },
+ {
+ message:
+ "Tag name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.",
+ },
+ )
+ .transform((name) => name.trim()),
+ color: z.string().optional(),
+});
+
+type Tag = z.infer;
+
+interface HandleTagProps {
+ tagId?: string;
+}
+
+export const HandleTag = ({ tagId }: HandleTagProps) => {
+ const utils = api.useUtils();
+ const [isOpen, setIsOpen] = useState(false);
+ const colorInputRef = useRef(null);
+
+ const { mutateAsync, error, isError } = tagId
+ ? api.tag.update.useMutation()
+ : api.tag.create.useMutation();
+
+ const { data: tag } = api.tag.one.useQuery(
+ {
+ tagId: tagId || "",
+ },
+ {
+ enabled: !!tagId,
+ },
+ );
+
+ const form = useForm({
+ defaultValues: {
+ name: "",
+ color: "#3b82f6",
+ },
+ resolver: zodResolver(TagSchema),
+ });
+
+ useEffect(() => {
+ if (tag) {
+ form.reset({
+ name: tag.name ?? "",
+ color: tag.color ?? "#3b82f6",
+ });
+ } else {
+ form.reset({
+ name: "",
+ color: "#3b82f6",
+ });
+ }
+ }, [form, form.reset, tag]);
+
+ const onSubmit = async (data: Tag) => {
+ await mutateAsync({
+ name: data.name,
+ color: data.color,
+ tagId: tagId || "",
+ })
+ .then(async () => {
+ await utils.tag.all.invalidate();
+ toast.success(tagId ? "Tag Updated" : "Tag Created");
+ setIsOpen(false);
+ form.reset();
+ })
+ .catch(() => {
+ toast.error(tagId ? "Error updating tag" : "Error creating tag");
+ });
+ };
+
+ const colorValue = form.watch("color");
+
+ return (
+
+
+ {tagId ? (
+
+
+
+ ) : (
+
+
+ Create Tag
+
+ )}
+
+
+
+ {tagId ? "Update" : "Create"} Tag
+
+ {tagId
+ ? "Update the tag name and color"
+ : "Create a new tag to organize your projects"}
+
+
+ {isError && {error?.message} }
+
+
+ (
+
+ Name
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ Color (Optional)
+
+
+
colorInputRef.current?.click()}
+ >
+
+ {!field.value && (
+
+ )}
+
+
+
+
+ {
+ const value = e.target.value;
+ if (value.startsWith("#") || value === "") {
+ field.onChange(value);
+ }
+ }}
+ />
+
+ Choose a color to easily identify this tag
+
+
+
+
+
+
+ )}
+ />
+
+ {colorValue && (
+
+ Preview:
+
+
+ )}
+
+
+
+
+
+ {tagId ? "Update" : "Create"}
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx
new file mode 100644
index 000000000..07eb9a7f3
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx
@@ -0,0 +1,124 @@
+import { Loader2, TagIcon, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+import { DialogAction } from "@/components/shared/dialog-action";
+import { TagBadge } from "@/components/shared/tag-badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { api } from "@/utils/api";
+import { HandleTag } from "./handle-tag";
+
+export const TagManager = () => {
+ const utils = api.useUtils();
+ const { data: tags, isPending } = api.tag.all.useQuery();
+ const { mutateAsync: deleteTag, isPending: isRemoving } =
+ api.tag.remove.useMutation();
+ const { data: permissions } = api.user.getPermissions.useQuery();
+
+ return (
+
+
+
+
+
+
+ Tags
+
+
+ Create and manage tags to organize your projects
+
+
+
+ {isPending ? (
+
+ Loading...
+
+
+ ) : (
+ <>
+ {!tags || tags.length === 0 ? (
+
+
+
+ No tags yet. Create your first tag to start organizing
+ projects.
+
+ {permissions?.tag.create && }
+
+ ) : (
+
+
+ {tags.map((tag) => (
+
+
+
+
+ {tag.color && (
+
+ {tag.color}
+
+ )}
+
+
+ {permissions?.tag.update && (
+
+ )}
+ {permissions?.tag.delete && (
+ {
+ await deleteTag({
+ tagId: tag.tagId,
+ })
+ .then(async () => {
+ await utils.tag.all.invalidate();
+ toast.success(
+ "Tag deleted successfully",
+ );
+ })
+ .catch(() => {
+ toast.error("Error deleting tag");
+ });
+ }}
+ >
+
+
+
+
+ )}
+
+
+
+ ))}
+
+
+ {permissions?.tag.create && (
+
+
+
+ )}
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx
index 6e0384554..f9dce77c9 100644
--- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx
+++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -32,7 +32,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const addInvitation = z.object({
@@ -40,7 +39,7 @@ const addInvitation = z.object({
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
- role: z.enum(["member", "admin"]),
+ role: z.string().min(1, "Role is required"),
notificationId: z.string().optional(),
});
@@ -49,13 +48,14 @@ type AddInvitation = z.infer;
export const AddInvitation = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
- const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
+ const { mutateAsync: inviteMember, isPending: isInviting } =
+ api.organization.inviteMember.useMutation();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
+ const { data: customRoles } = api.customRole.all.useQuery();
const [error, setError] = useState(null);
- const { data: activeOrganization } = authClient.useActiveOrganization();
const form = useForm({
defaultValues: {
@@ -70,19 +70,15 @@ export const AddInvitation = () => {
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddInvitation) => {
- setIsLoading(true);
- const result = await authClient.organization.inviteMember({
- email: data.email.toLowerCase(),
- role: data.role,
- organizationId: activeOrganization?.id,
- });
+ try {
+ const result = await inviteMember({
+ email: data.email.toLowerCase(),
+ role: data.role,
+ });
- if (result.error) {
- setError(result.error.message || "");
- } else {
if (!isCloud && data.notificationId) {
await sendInvitation({
- invitationId: result.data.id,
+ invitationId: result!.id,
notificationId: data.notificationId || "",
})
.then(() => {
@@ -96,10 +92,11 @@ export const AddInvitation = () => {
}
setError(null);
setOpen(false);
+ } catch (error: any) {
+ setError(error.message || "Failed to create invitation");
}
utils.organization.allInvitations.invalidate();
- setIsLoading(false);
};
return (
@@ -158,6 +155,12 @@ export const AddInvitation = () => {
Member
+ Admin
+ {customRoles?.map((role) => (
+
+ {role.role}
+
+ ))}
@@ -211,7 +214,7 @@ export const AddInvitation = () => {
)}
diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx
index fb4d01547..fdcf51ad7 100644
--- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx
+++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx
@@ -1,5 +1,4 @@
-import type { findEnvironmentById } from "@dokploy/server/index";
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -27,12 +26,14 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
-import { api } from "@/utils/api";
+import { api, type RouterOutputs } from "@/utils/api";
-type Environment = Omit<
- Awaited>,
- "project"
->;
+/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
+type ProjectForPermissions =
+ RouterOutputs["project"]["allForPermissions"][number];
+type EnvironmentForPermissions = ProjectForPermissions["environments"][number];
+
+type Environment = EnvironmentForPermissions;
export type Services = {
appName: string;
@@ -45,7 +46,8 @@ export type Services = {
| "mysql"
| "mongo"
| "redis"
- | "compose";
+ | "compose"
+ | "libsql";
description?: string | null;
id: string;
createdAt: string;
@@ -53,17 +55,16 @@ export type Services = {
};
export const extractServices = (data: Environment | undefined) => {
- const applications: Services[] =
- data?.applications.map((item) => ({
- appName: item.appName,
- name: item.name,
- type: "application",
- id: item.applicationId,
- createdAt: item.createdAt,
- status: item.applicationStatus,
- description: item.description,
- serverId: item.serverId,
- })) || [];
+ const applications: Services[] = (data?.applications?.map((item) => ({
+ appName: item.appName,
+ name: item.name,
+ type: "application",
+ id: item.applicationId,
+ createdAt: item.createdAt,
+ status: item.applicationStatus,
+ description: item.description,
+ serverId: item.serverId,
+ })) ?? []) as Services[];
const mariadb: Services[] =
data?.mariadb.map((item) => ({
@@ -125,14 +126,25 @@ export const extractServices = (data: Environment | undefined) => {
serverId: item.serverId,
})) || [];
- const compose: Services[] =
- data?.compose.map((item) => ({
+ const compose: Services[] = (data?.compose?.map((item) => ({
+ appName: item.appName,
+ name: item.name,
+ type: "compose",
+ id: item.composeId,
+ createdAt: item.createdAt,
+ status: item.composeStatus,
+ description: item.description,
+ serverId: item.serverId,
+ })) ?? []) as Services[];
+
+ const libsql: Services[] =
+ data?.libsql?.map((item) => ({
appName: item.appName,
name: item.name,
- type: "compose",
- id: item.composeId,
+ type: "libsql" as const,
+ id: item.libsqlId,
createdAt: item.createdAt,
- status: item.composeStatus,
+ status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
@@ -144,6 +156,7 @@ export const extractServices = (data: Environment | undefined) => {
...postgres,
...mariadb,
...compose,
+ ...libsql,
);
applications.sort((a, b) => {
@@ -174,11 +187,15 @@ type AddPermissions = z.infer;
interface Props {
userId: string;
+ role?: string;
}
-export const AddUserPermissions = ({ userId }: Props) => {
+export const AddUserPermissions = ({ userId, role }: Props) => {
+ const isCustomRole = !!role && !["owner", "admin", "member"].includes(role);
const [isOpen, setIsOpen] = useState(false);
- const { data: projects } = api.project.all.useQuery();
+ const { data: projects } = api.project.allForPermissions.useQuery(undefined, {
+ enabled: isOpen,
+ });
const { data, refetch } = api.user.one.useQuery(
{
@@ -189,10 +206,10 @@ export const AddUserPermissions = ({ userId }: Props) => {
},
);
- const { mutateAsync, isError, error, isLoading } =
+ const { mutateAsync, isError, error, isPending } =
api.user.assignPermissions.useMutation();
- const form = useForm({
+ const form = useForm({
defaultValues: {
accessedProjects: [],
accessedEnvironments: [],
@@ -283,226 +300,237 @@ export const AddUserPermissions = ({ userId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
- (
-
-
- Create Projects
-
- Allow the user to create projects
-
-
-
-
-
-
- )}
- />
- (
-
-
- Delete Projects
-
- Allow the user to delete projects
-
-
-
-
-
-
- )}
- />
- (
-
-
- Create Services
-
- Allow the user to create services
-
-
-
-
-
-
- )}
- />
- (
-
-
- Delete Services
-
- Allow the user to delete services
-
-
-
-
-
-
- )}
- />
- (
-
-
- Create Environments
-
- Allow the user to create environments
-
-
-
-
-
-
- )}
- />
- (
-
-
- Delete Environments
-
- Allow the user to delete environments
-
-
-
-
-
-
- )}
- />
- (
-
-
- Access to Traefik Files
-
- Allow the user to access to the Traefik Tab Files
-
-
-
-
-
-
- )}
- />
- (
-
-
- Access to Docker
-
- Allow the user to access to the Docker Tab
-
-
-
-
-
-
- )}
- />
- (
-
-
- Access to API/CLI
-
- Allow the user to access to the API/CLI
-
-
-
-
-
-
- )}
- />
- (
-
-
- Access to SSH Keys
-
- Allow to users to access to the SSH Keys section
-
-
-
-
-
-
- )}
- />
- (
-
-
- Access to Git Providers
-
- Allow to users to access to the Git Providers section
-
-
-
-
-
-
- )}
- />
+ {isCustomRole && (
+
+ This user has a custom role assigned. Capabilities are defined
+ by the role. You can still manage which projects, environments,
+ and services they can access below.
+
+ )}
+ {!isCustomRole && (
+ <>
+ (
+
+
+ Create Projects
+
+ Allow the user to create projects
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Delete Projects
+
+ Allow the user to delete projects
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Create Services
+
+ Allow the user to create services
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Delete Services
+
+ Allow the user to delete services
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Create Environments
+
+ Allow the user to create environments
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Delete Environments
+
+ Allow the user to delete environments
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Access to Traefik Files
+
+ Allow the user to access to the Traefik Tab Files
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Access to Docker
+
+ Allow the user to access to the Docker Tab
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Access to API/CLI
+
+ Allow the user to access to the API/CLI
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Access to SSH Keys
+
+ Allow to users to access to the SSH Keys section
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Access to Git Providers
+
+ Allow to users to access to the Git Providers section
+
+
+
+
+
+
+ )}
+ />
+ >
+ )}
{
/>
diff --git a/apps/dokploy/components/dashboard/settings/users/change-role.tsx b/apps/dokploy/components/dashboard/settings/users/change-role.tsx
new file mode 100644
index 000000000..2178284b1
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/users/change-role.tsx
@@ -0,0 +1,178 @@
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+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 {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { api } from "@/utils/api";
+
+const changeRoleSchema = z.object({
+ role: z.string().min(1),
+});
+
+type ChangeRoleSchema = z.infer;
+
+interface Props {
+ memberId: string;
+ currentRole: string;
+ userEmail: string;
+}
+
+export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const utils = api.useUtils();
+
+ const { data: customRoles } = api.customRole.all.useQuery(undefined, {
+ enabled: isOpen,
+ });
+
+ const { mutateAsync, isError, error, isPending } =
+ api.organization.updateMemberRole.useMutation();
+
+ const form = useForm({
+ defaultValues: {
+ role: currentRole,
+ },
+ resolver: zodResolver(changeRoleSchema),
+ });
+
+ useEffect(() => {
+ if (isOpen) {
+ form.reset({
+ role: currentRole,
+ });
+ }
+ }, [form, currentRole, isOpen]);
+
+ const onSubmit = async (data: ChangeRoleSchema) => {
+ await mutateAsync({
+ memberId,
+ role: data.role,
+ })
+ .then(async () => {
+ toast.success("Role updated successfully");
+ await utils.user.all.invalidate();
+ setIsOpen(false);
+ })
+ .catch((error) => {
+ toast.error(error?.message || "Error updating role");
+ });
+ };
+
+ return (
+
+
+ e.preventDefault()}
+ >
+ Change Role
+
+
+
+
+ Change User Role
+
+ Change the role for {userEmail}
+
+
+ {isError && {error?.message} }
+
+
+
+ (
+
+ Role
+
+
+
+
+
+
+
+ Admin
+ Member
+ {customRoles?.map((customRole) => (
+
+ {customRole.role}
+
+ ))}
+
+
+
+ Admin: Can manage users and settings.
+
+ Member: Limited permissions, can be
+ customized.
+ {customRoles && customRoles.length > 0 && (
+ <>
+
+ Custom roles: Enterprise-defined
+ permissions.
+ >
+ )}
+
+
+ Note: Owner role is intransferible.
+
+
+
+
+ )}
+ />
+
+
+
+
+
+ Update Role
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx
index 6e56f4c70..b6e95cb75 100644
--- a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx
+++ b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx
@@ -32,7 +32,7 @@ import { api } from "@/utils/api";
import { AddInvitation } from "./add-invitation";
export const ShowInvitations = () => {
- const { data, isLoading, refetch } =
+ const { data, isPending, refetch } =
api.organization.allInvitations.useQuery();
const { mutateAsync: removeInvitation } =
@@ -52,7 +52,7 @@ export const ShowInvitations = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx
index 51d8704a3..75aa839f9 100644
--- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx
+++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx
@@ -1,6 +1,7 @@
import { format } from "date-fns";
import { Loader2, MoreHorizontal, Users } from "lucide-react";
import { toast } from "sonner";
+import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -21,7 +22,6 @@ import {
import {
Table,
TableBody,
- TableCaption,
TableCell,
TableHead,
TableHeader,
@@ -30,12 +30,25 @@ import {
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { AddUserPermissions } from "./add-permissions";
+import { ChangeRole } from "./change-role";
export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
- const { data, isLoading, refetch } = api.user.all.useQuery();
+ const { data, isPending, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation();
+ const { data: permissions } = api.user.getPermissions.useQuery();
+ const { data: hasValidLicense } =
+ api.licenseKey.haveValidLicenseKey.useQuery();
+
const utils = api.useUtils();
+ const { data: session } = api.user.session.useQuery();
+
+ const FREE_ROLES = ["owner", "admin", "member"];
+ const membersWithCustomRoles = data?.filter(
+ (member) => !FREE_ROLES.includes(member.role),
+ );
+ const hasCustomRolesWithoutLicense =
+ !hasValidLicense && (membersWithCustomRoles?.length ?? 0) > 0;
return (
@@ -51,7 +64,7 @@ export const ShowUsers = () => {
- {isLoading ? (
+ {isPending ? (
Loading...
@@ -67,8 +80,19 @@ export const ShowUsers = () => {
) : (
+ {hasCustomRolesWithoutLicense && (
+
+ You have{" "}
+ {membersWithCustomRoles?.length === 1
+ ? "1 user"
+ : `${membersWithCustomRoles?.length} users`}{" "}
+ assigned to custom roles. Custom roles will not work
+ without a valid Enterprise license. Please activate your
+ license or change these users to a free role (Admin or
+ Member).
+
+ )}
- See all users
Email
@@ -83,10 +107,60 @@ export const ShowUsers = () => {
{data?.map((member) => {
+ const currentUserRole = data?.find(
+ (m) => m.user.id === session?.user?.id,
+ )?.role;
+
+ // Owner never has "Edit Permissions" (they're absolute owner)
+ // Other users can edit permissions if target is not themselves and target is a member/custom role
+ const isStaticAdminOrOwner =
+ member.role === "owner" || member.role === "admin";
+ const canEditPermissions =
+ !isStaticAdminOrOwner &&
+ member.user.id !== session?.user?.id;
+
+ // Can change role based on hierarchy:
+ // - Owner: Can change anyone's role (except themselves and other owners)
+ // - Admin: Can only change member/custom roles (not other admins or owners)
+ // - Owner role is intransferible
+ const canChangeRole =
+ member.role !== "owner" &&
+ member.user.id !== session?.user?.id &&
+ (currentUserRole === "owner" ||
+ (currentUserRole === "admin" &&
+ member.role !== "admin"));
+
+ const canDeleteMember =
+ permissions?.member.delete ?? false;
+
+ // Self-hosted: "Delete User" removes the user entirely
+ // Cloud: "Unlink User" removes from the organization only
+ const canRemove =
+ member.role !== "owner" &&
+ member.user.id !== session?.user?.id &&
+ (currentUserRole === "owner" ||
+ (currentUserRole === "admin" &&
+ member.role !== "admin") ||
+ (canDeleteMember && !isStaticAdminOrOwner));
+
+ const canDelete = canRemove && !isCloud;
+ const canUnlink = canRemove && !!isCloud;
+
+ const hasAnyAction =
+ canEditPermissions ||
+ canChangeRole ||
+ canDelete ||
+ canUnlink;
+
return (
{member.user.email}
+ {member.user.id === session?.user?.id && (
+
+ (You)
+
+ )}
{
-
-
-
- Open menu
-
-
-
-
-
- Actions
-
+ {hasAnyAction ? (
+
+
+
+
+ Open menu
+
+
+
+
+
+
+ Actions
+
- {member.role !== "owner" && (
-
- )}
+ {canChangeRole && (
+
+ )}
- {member.role !== "owner" && (
- <>
- {!isCloud && (
- {
- await mutateAsync({
- userId: member.user.id,
+ {canEditPermissions && (
+
+ )}
+
+ {canDelete && (
+ {
+ await mutateAsync({
+ userId: member.user.id,
+ })
+ .then(() => {
+ toast.success(
+ "User deleted successfully",
+ );
+ refetch();
})
- .then(() => {
- toast.success(
- "User deleted successfully",
- );
- refetch();
- })
- .catch(() => {
- toast.error(
- "Error deleting destination",
- );
- });
- }}
+ .catch((err) => {
+ toast.error(
+ err?.message ||
+ "Error deleting user",
+ );
+ });
+ }}
+ >
+ e.preventDefault()}
>
-
- e.preventDefault()
- }
- >
- Delete User
-
-
- )}
+ Delete User
+
+
+ )}
+ {canUnlink && (
{
},
);
- console.log(orgCount);
-
if (orgCount === 1) {
await mutateAsync({
userId: member.user.id,
@@ -227,10 +309,21 @@ export const ShowUsers = () => {
Unlink User
- >
- )}
-
-
+ )}
+
+
+ ) : (
+
+
+ No actions available
+
+
+
+ )}
);
diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx
index 51cb7af3e..29c7be5eb 100644
--- a/apps/dokploy/components/dashboard/settings/web-domain.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx
@@ -1,6 +1,5 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { GlobeIcon } from "lucide-react";
-import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -36,7 +35,7 @@ import { api } from "@/utils/api";
const addServerDomain = z
.object({
- domain: z.string(),
+ domain: z.string().trim().toLowerCase(),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
@@ -49,7 +48,11 @@ const addServerDomain = z
message: "Required",
});
}
- if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
+ if (
+ data.https &&
+ data.certificateType === "letsencrypt" &&
+ !data.letsEncryptEmail
+ ) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
@@ -62,9 +65,8 @@ const addServerDomain = z
type AddServerDomain = z.infer;
export const WebDomain = () => {
- const { t } = useTranslation("settings");
- const { data, refetch } = api.user.get.useQuery();
- const { mutateAsync, isLoading } =
+ const { data, refetch } = api.settings.getWebServerSettings.useQuery();
+ const { mutateAsync, isPending } =
api.settings.assignDomainServer.useMutation();
const form = useForm({
@@ -78,15 +80,15 @@ export const WebDomain = () => {
});
const https = form.watch("https");
const domain = form.watch("domain") || "";
- const host = data?.user?.host || "";
+ const host = data?.host || "";
const hasChanged = domain !== host;
useEffect(() => {
if (data) {
form.reset({
- domain: data?.user?.host || "",
- certificateType: data?.user?.certificateType,
- letsEncryptEmail: data?.user?.letsEncryptEmail || "",
- https: data?.user?.https || false,
+ domain: data?.host || "",
+ certificateType: data?.certificateType || "none",
+ letsEncryptEmail: data?.letsEncryptEmail || "",
+ https: data?.https || false,
});
}
}, [form, form.reset, data]);
@@ -115,10 +117,10 @@ export const WebDomain = () => {
- {t("settings.server.domain.title")}
+ Server Domain
- {t("settings.server.domain.description")}
+ Add a domain to your server application.
@@ -147,9 +149,7 @@ export const WebDomain = () => {
render={({ field }) => {
return (
-
- {t("settings.server.domain.form.domain")}
-
+ Domain
{
render={({ field }) => {
return (
-
- {t("settings.server.domain.form.letsEncryptEmail")}
-
+ Let's Encrypt Email
{
render={({ field }) => {
return (
-
- {t("settings.server.domain.form.certificate.label")}
-
+ Certificate Provider
-
+
-
- {t(
- "settings.server.domain.form.certificateOptions.none",
- )}
-
+ None
- {t(
- "settings.server.domain.form.certificateOptions.letsencrypt",
- )}
+ Let's Encrypt
@@ -249,8 +235,8 @@ export const WebDomain = () => {
)}
-
- {t("settings.common.save")}
+
+ Save
diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx
index 2a2ce4ab1..d9df975e7 100644
--- a/apps/dokploy/components/dashboard/settings/web-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server.tsx
@@ -1,5 +1,4 @@
import { ServerIcon } from "lucide-react";
-import { useTranslation } from "next-i18next";
import {
Card,
CardContent,
@@ -15,8 +14,8 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
- const { t } = useTranslation("settings");
- const { data } = api.user.get.useQuery();
+ const { data: webServerSettings } =
+ api.settings.getWebServerSettings.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
@@ -28,18 +27,16 @@ export const WebServer = () => {
- {t("settings.server.webServer.title")}
+ Web Server
-
- {t("settings.server.webServer.description")}
-
+ Reload or clean the web server.
{/*
- {t("settings.server.webServer.title")}
+ Web Server
- {t("settings.server.webServer.description")}
+ Reload or clean the web server.
*/}
@@ -53,7 +50,7 @@ export const WebServer = () => {
- Server IP: {data?.user.serverIp}
+ Server IP: {webServerSettings?.serverIp}
Version: {dokployVersion}
diff --git a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx
index 30471bcba..599dba57b 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx
@@ -48,7 +48,7 @@ export const DockerTerminalModal = ({
serverId,
appType,
}: Props) => {
- const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
+ const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
@@ -101,7 +101,7 @@ export const DockerTerminalModal = ({
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx
index f1f7bce32..3f63fbc90 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -23,6 +23,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
+import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
const schema = z.object({
@@ -43,9 +44,17 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
serverId,
});
- const { mutateAsync, isLoading, error, isError } =
+ const { mutateAsync, isPending, error, isError } =
api.settings.writeTraefikEnv.useMutation();
+ const {
+ execute: executeWithHealthCheck,
+ isExecuting: isHealthCheckExecuting,
+ } = useHealthCheckAfterMutation({
+ initialDelay: 5000,
+ successMessage: "Traefik Env Updated",
+ });
+
const form = useForm
({
defaultValues: {
env: data || "",
@@ -63,22 +72,22 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: Schema) => {
- await mutateAsync({
- env: data.env,
- serverId,
- })
- .then(async () => {
- toast.success("Traefik Env Updated");
- })
- .catch(() => {
- toast.error("Error updating the Traefik env");
- });
+ try {
+ await executeWithHealthCheck(() =>
+ mutateAsync({
+ env: data.env,
+ serverId,
+ }),
+ );
+ } catch {
+ toast.error("Error updating the Traefik env");
+ }
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && !canEdit) {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && !canEdit) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -88,7 +97,7 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
- }, [form, onSubmit, isLoading, canEdit]);
+ }, [form, onSubmit, isPending, canEdit]);
return (
@@ -154,8 +163,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
diff --git a/apps/dokploy/components/dashboard/settings/web-server/local-server-config.tsx b/apps/dokploy/components/dashboard/settings/web-server/local-server-config.tsx
index cb0218c5a..a79effae4 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/local-server-config.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/local-server-config.tsx
@@ -1,6 +1,5 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Settings } from "lucide-react";
-import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
@@ -52,8 +51,6 @@ interface Props {
}
const LocalServerConfig = ({ onSave }: Props) => {
- const { t } = useTranslation("settings");
-
const form = useForm({
defaultValues: getLocalServerData(),
resolver: zodResolver(Schema),
@@ -77,9 +74,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
-
- {t("settings.terminal.connectionSettings")}
-
+ Connection settings
@@ -96,7 +91,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
name="port"
render={({ field }) => (
- {t("settings.terminal.port")}
+ Port
{
name="username"
render={({ field }) => (
- {t("settings.terminal.username")}
+ Username
@@ -142,7 +137,7 @@ const LocalServerConfig = ({ onSave }: Props) => {
className="ml-auto"
disabled={!form.formState.isDirty}
>
- {t("settings.common.save")}
+ Save
diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
index 282f1fddd..6f42c804b 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
@@ -1,6 +1,5 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
-import { useTranslation } from "next-i18next";
import type React from "react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
@@ -35,6 +34,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
interface Props {
@@ -55,7 +55,6 @@ const TraefikPortsSchema = z.object({
type TraefikPortsForm = z.infer;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
- const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm({
@@ -75,12 +74,20 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
serverId,
});
- const { mutateAsync: updatePorts, isLoading } =
- api.settings.updateTraefikPorts.useMutation({
- onSuccess: () => {
- refetchPorts();
- },
- });
+ const { mutateAsync: updatePorts, isPending } =
+ api.settings.updateTraefikPorts.useMutation();
+
+ const {
+ execute: executeWithHealthCheck,
+ isExecuting: isHealthCheckExecuting,
+ } = useHealthCheckAfterMutation({
+ initialDelay: 5000,
+ successMessage: "Ports updated successfully",
+ onSuccess: () => {
+ refetchPorts();
+ setOpen(false);
+ },
+ });
useEffect(() => {
if (currentPorts) {
@@ -99,13 +106,16 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const onSubmit = async (data: TraefikPortsForm) => {
try {
- await updatePorts({
- serverId,
- additionalPorts: data.ports,
- });
- toast.success(t("settings.server.webServer.traefik.portsUpdated"));
+ await executeWithHealthCheck(() =>
+ updatePorts({
+ serverId,
+ additionalPorts: data.ports,
+ }),
+ );
setOpen(false);
- } catch {}
+ } catch (error) {
+ toast.error((error as Error).message || "Error updating Traefik ports");
+ }
};
return (
@@ -117,14 +127,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
- {t("settings.server.webServer.traefik.managePorts")}
+ Additional Port Mappings
Save
diff --git a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx
index 9aac25820..9b9d6fb7f 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx
@@ -47,7 +47,7 @@ export const ShowModalLogs = ({
serverId,
type = "swarm",
}: Props) => {
- const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery(
+ const { data, isPending } = api.docker.getContainersByAppLabel.useQuery(
{
appName,
serverId,
@@ -76,7 +76,7 @@ export const ShowModalLogs = ({
Select a container to view logs
- {isLoading ? (
+ {isPending ? (
Loading...
diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx
index 58e4c9d4e..2647e1dc0 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx
@@ -24,10 +24,16 @@ const getTerminalKey = () => {
interface Props {
children?: React.ReactNode;
serverId: string;
+ asButton?: boolean;
}
-export const TerminalModal = ({ children, serverId }: Props) => {
+export const TerminalModal = ({
+ children,
+ serverId,
+ asButton = false,
+}: Props) => {
const [terminalKey, setTerminalKey] = useState
(getTerminalKey());
+ const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local";
const { data } = api.server.one.useQuery(
@@ -43,15 +49,20 @@ export const TerminalModal = ({ children, serverId }: Props) => {
};
return (
-
-
+
+ {asButton ? (
+ {children}
+ ) : (
e.preventDefault()}
+ onSelect={(e) => {
+ e.preventDefault();
+ setIsOpen(true);
+ }}
>
{children}
-
+ )}
event.preventDefault()}
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server-ip.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server-ip.tsx
index 3c49873de..9a3210e28 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/update-server-ip.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/update-server-ip.tsx
@@ -1,4 +1,4 @@
-import { zodResolver } from "@hookform/resolvers/zod";
+import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -46,15 +46,15 @@ interface Props {
export const UpdateServerIp = ({ children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
- const { data } = api.user.get.useQuery();
+ const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { data: ip } = api.server.publicIp.useQuery();
- const { mutateAsync, isLoading, error, isError } =
- api.user.update.useMutation();
+ const { mutateAsync, isPending, error, isError } =
+ api.settings.updateServerIp.useMutation();
const form = useForm({
defaultValues: {
- serverIp: data?.user.serverIp || "",
+ serverIp: data?.serverIp || "",
},
resolver: zodResolver(schema),
});
@@ -62,13 +62,11 @@ export const UpdateServerIp = ({ children }: Props) => {
useEffect(() => {
if (data) {
form.reset({
- serverIp: data.user.serverIp || "",
+ serverIp: data.serverIp || "",
});
}
}, [form, form.reset, data]);
- const utils = api.useUtils();
-
const setCurrentIp = () => {
if (!ip) return;
form.setValue("serverIp", ip);
@@ -80,7 +78,7 @@ export const UpdateServerIp = ({ children }: Props) => {
})
.then(async () => {
toast.success("Server IP Updated");
- await utils.user.get.invalidate();
+ await refetch();
setIsOpen(false);
})
.catch(() => {
@@ -145,8 +143,8 @@ export const UpdateServerIp = ({ children }: Props) => {
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx
index e3b9fac95..f7e25f6e6 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx
@@ -45,7 +45,7 @@ export const UpdateServer = ({
const [isUpdateAvailable, setIsUpdateAvailable] = useState(
!!updateData?.updateAvailable,
);
- const { mutateAsync: getUpdateData, isLoading } =
+ const { mutateAsync: getUpdateData, isPending } =
api.settings.getUpdateData.useMutation();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
@@ -135,7 +135,9 @@ export const UpdateServer = ({
- {dokployVersion} | {releaseTag}
+ {dokployVersion}{" "}
+ {(releaseTag === "canary" || releaseTag === "feature") &&
+ `(${releaseTag})`}
)}
@@ -194,7 +196,7 @@ export const UpdateServer = ({
)}
{/* Up to date state */}
- {hasCheckedUpdate && !isUpdateAvailable && !isLoading && (
+ {hasCheckedUpdate && !isUpdateAvailable && !isPending && (
@@ -213,7 +215,7 @@ export const UpdateServer = ({
)}
- {hasCheckedUpdate && isLoading && (
+ {hasCheckedUpdate && isPending && (
@@ -250,7 +252,7 @@ export const UpdateServer = ({
)}
-
+
@@ -264,9 +266,9 @@ export const UpdateServer = ({
- {isLoading ? (
+ {isPending ? (
<>
Checking for updates
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
index 00771d328..abeba47c4 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
@@ -1,4 +1,11 @@
-import { HardDriveDownload, Loader2 } from "lucide-react";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ HardDriveDownload,
+ Loader2,
+ RefreshCw,
+ XCircle,
+} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
@@ -15,11 +22,70 @@ import {
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
+type ServiceStatus = {
+ status: "healthy" | "unhealthy";
+ message?: string;
+};
+
+type HealthResult = {
+ postgres: ServiceStatus;
+ redis: ServiceStatus;
+ traefik: ServiceStatus;
+};
+
+type ModalState = "idle" | "checking" | "results" | "updating";
+
+const ServiceStatusItem = ({
+ name,
+ service,
+}: {
+ name: string;
+ service: ServiceStatus;
+}) => (
+
+ {service.status === "healthy" ? (
+
+ ) : (
+
+ )}
+ {name}
+ {service.status === "unhealthy" && service.message && (
+ — {service.message}
+ )}
+
+);
+
export const UpdateWebServer = () => {
- const [updating, setUpdating] = useState(false);
+ const [modalState, setModalState] = useState("idle");
const [open, setOpen] = useState(false);
+ const [healthResult, setHealthResult] = useState(null);
const { mutateAsync: updateServer } = api.settings.updateServer.useMutation();
+ const { refetch: checkHealth } =
+ api.settings.checkInfrastructureHealth.useQuery(undefined, {
+ enabled: false,
+ });
+
+ const handleVerify = async () => {
+ setModalState("checking");
+ setHealthResult(null);
+
+ try {
+ const result = await checkHealth();
+ if (result.data) {
+ setHealthResult(result.data);
+ }
+ } catch {
+ // checkHealth failed entirely
+ }
+ setModalState("results");
+ };
+
+ const allHealthy =
+ healthResult &&
+ healthResult.postgres.status === "healthy" &&
+ healthResult.redis.status === "healthy" &&
+ healthResult.traefik.status === "healthy";
const checkIsUpdateFinished = async () => {
try {
@@ -33,28 +99,24 @@ export const UpdateWebServer = () => {
);
setTimeout(() => {
- // Allow seeing the toast before reloading
window.location.reload();
}, 2000);
} catch {
- // Delay each request
await new Promise((resolve) => setTimeout(resolve, 2000));
- // Keep running until it returns 200
void checkIsUpdateFinished();
}
};
const handleConfirm = async () => {
try {
- setUpdating(true);
+ setModalState("updating");
await updateServer();
- // Give some time for docker service restart before starting to check status
await new Promise((resolve) => setTimeout(resolve, 8000));
await checkIsUpdateFinished();
} catch (error) {
- setUpdating(false);
+ setModalState("results");
console.error("Error updating server:", error);
toast.error(
"An error occurred while updating the server, please try again.",
@@ -62,6 +124,14 @@ export const UpdateWebServer = () => {
}
};
+ const handleClose = () => {
+ if (modalState !== "updating") {
+ setOpen(false);
+ setModalState("idle");
+ setHealthResult(null);
+ }
+ };
+
return (
@@ -81,36 +151,111 @@ export const UpdateWebServer = () => {
- {updating
- ? "Server update in progress"
- : "Are you absolutely sure?"}
+ {modalState === "idle" && "Are you absolutely sure?"}
+ {modalState === "checking" && "Verifying Services..."}
+ {modalState === "results" &&
+ (allHealthy ? "Ready to Update" : "Service Issues Detected")}
+ {modalState === "updating" && "Server update in progress"}
-
- {updating ? (
-