From 076262e47981699bad7cd296afee78f7225eade0 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 7 Mar 2026 17:44:01 -0600 Subject: [PATCH 01/60] feat: add maxAliasCount option to parse function for improved Docker Compose file handling --- packages/server/src/utils/docker/compose.ts | 4 +++- packages/server/src/utils/docker/domain.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/server/src/utils/docker/compose.ts b/packages/server/src/utils/docker/compose.ts index a78b416ec..2e2011b03 100644 --- a/packages/server/src/utils/docker/compose.ts +++ b/packages/server/src/utils/docker/compose.ts @@ -18,7 +18,9 @@ export const randomizeComposeFile = async ( ) => { const compose = await findComposeById(composeId); const composeFile = compose.composeFile; - const composeData = parse(composeFile) as ComposeSpecification; + const composeData = parse(composeFile, { + maxAliasCount: 10000, + }) as ComposeSpecification; const randomSuffix = suffix || generateRandomHash(); diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 230453e56..a9a425a93 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -63,7 +63,9 @@ export const loadDockerCompose = async ( if (existsSync(path)) { const yamlStr = readFileSync(path, "utf8"); - const parsedConfig = parse(yamlStr) as ComposeSpecification; + const parsedConfig = parse(yamlStr, { + maxAliasCount: 10000, + }) as ComposeSpecification; return parsedConfig; } return null; @@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async ( return null; } if (!stdout) return null; - const parsedConfig = parse(stdout) as ComposeSpecification; + const parsedConfig = parse(stdout, { + maxAliasCount: 10000, + }) as ComposeSpecification; return parsedConfig; } catch { return null; From a8467e80e86b9b69917c4dac62d3af0e32737ca2 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 7 Mar 2026 18:02:25 -0600 Subject: [PATCH 02/60] refactor: replace authClient with api.user.session.useQuery in multiple components for improved session management --- .../components/dashboard/search-command.tsx | 3 +-- .../git/github/add-github-provider.tsx | 8 ++++---- .../dashboard/settings/users/show-users.tsx | 3 +-- apps/dokploy/components/layouts/side.tsx | 2 +- .../pages/api/providers/github/setup.ts | 19 +++++++++++++------ apps/dokploy/server/api/routers/user.ts | 10 ++++++++++ 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index bbd612d92..b98099b5f 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -23,7 +23,6 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; -import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { StatusTooltip } from "../shared/status-tooltip"; @@ -56,7 +55,7 @@ export const SearchCommand = () => { 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, }); diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index d29b3a345..60fe2d343 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -12,14 +12,14 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; -import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; export const AddGithubProvider = () => { const [isOpen, setIsOpen] = useState(false); const { data: activeOrganization } = api.organization.active.useQuery(); - const { data: session } = authClient.useSession(); + const { data: session } = api.user.session.useQuery(); + console.log(session); const { data } = api.user.get.useQuery(); const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); @@ -99,8 +99,8 @@ export const AddGithubProvider = () => {
diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 0245739f8..56f029664 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -26,7 +26,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { AddUserPermissions } from "./add-permissions"; import { ChangeRole } from "./change-role"; @@ -37,7 +36,7 @@ export const ShowUsers = () => { const { mutateAsync } = api.user.remove.useMutation(); const utils = api.useUtils(); - const { data: session } = authClient.useSession(); + const { data: session } = api.user.session.useQuery(); return (
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index f34c33a31..6dea37f5b 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -546,7 +546,7 @@ function SidebarLogo() { const { state } = useSidebar(); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: user } = api.user.get.useQuery(); - const { data: session } = authClient.useSession(); + const { data: session } = api.user.session.useQuery(); const { data: organizations, refetch, diff --git a/apps/dokploy/pages/api/providers/github/setup.ts b/apps/dokploy/pages/api/providers/github/setup.ts index c09d6fba5..663939c5e 100644 --- a/apps/dokploy/pages/api/providers/github/setup.ts +++ b/apps/dokploy/pages/api/providers/github/setup.ts @@ -10,22 +10,29 @@ type Query = { state: string; installation_id: string; setup_action: string; - userId: string; }; export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { - const { code, state, installation_id, userId }: Query = req.query as Query; + const { code, state, installation_id }: Query = req.query as Query; if (!code) { return res.status(400).json({ error: "Missing code parameter" }); } - const [action, value] = state?.split(":"); - // Value could be the organizationId or the githubProviderId + const [action, ...rest] = state?.split(":"); + // For gh_init: rest[0] = organizationId, rest[1] = userId + // For gh_setup: rest[0] = githubProviderId if (action === "gh_init") { + const organizationId = rest[0]; + const userId = rest[1] || (req.query.userId as string); + + if (!userId) { + return res.status(400).json({ error: "Missing userId parameter" }); + } + const octokit = new Octokit({}); const { data } = await octokit.request( "POST /app-manifests/{code}/conversions", @@ -44,7 +51,7 @@ export default async function handler( githubWebhookSecret: data.webhook_secret, githubPrivateKey: data.pem, }, - value as string, + organizationId as string, userId, ); } else if (action === "gh_setup") { @@ -53,7 +60,7 @@ export default async function handler( .set({ githubInstallationId: installation_id, }) - .where(eq(github.githubId, value as string)) + .where(eq(github.githubId, rest[0] as string)) .returning(); } diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 3f217ceed..f67b62925 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -101,6 +101,16 @@ export const userRouter = createTRPCRouter({ return memberResult; }), + session: protectedProcedure.query(async ({ ctx }) => { + return { + user: { + id: ctx.user.id, + }, + session: { + activeOrganizationId: ctx.session.activeOrganizationId, + }, + }; + }), get: protectedProcedure.query(async ({ ctx }) => { const memberResult = await db.query.member.findFirst({ where: and( From 21821295e3d7f17b7d97567b78dfbc27dcc6a8d3 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 7 Mar 2026 18:10:35 -0600 Subject: [PATCH 03/60] chore: remove console.log for session in AddGithubProvider component to clean up code --- .../dashboard/settings/git/github/add-github-provider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index 60fe2d343..f2ba167ff 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -19,7 +19,6 @@ export const AddGithubProvider = () => { const { data: activeOrganization } = api.organization.active.useQuery(); const { data: session } = api.user.session.useQuery(); - console.log(session); const { data } = api.user.get.useQuery(); const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); From 735c9952d8bca5b357ad033d091e7698c54c6eae Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 7 Mar 2026 18:14:30 -0600 Subject: [PATCH 04/60] chore: import authClient in show-users component for enhanced authentication handling --- apps/dokploy/components/dashboard/settings/users/show-users.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 56f029664..f4bc9b897 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -26,6 +26,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { AddUserPermissions } from "./add-permissions"; import { ChangeRole } from "./change-role"; From 922b4d58f18994a65641641b2abaa69c50e35383 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 7 Mar 2026 23:32:41 -0600 Subject: [PATCH 05/60] refactor: enhance backup functionality by incorporating appName and serviceName for S3 bucket paths --- packages/server/src/utils/backups/compose.ts | 7 ++++--- packages/server/src/utils/backups/index.ts | 17 ++++++++++++++- packages/server/src/utils/backups/mariadb.ts | 4 ++-- packages/server/src/utils/backups/mongo.ts | 4 ++-- packages/server/src/utils/backups/mysql.ts | 4 ++-- packages/server/src/utils/backups/postgres.ts | 4 ++-- .../server/src/utils/volume-backups/backup.ts | 21 ++++++++++++++++++- .../server/src/utils/volume-backups/utils.ts | 5 +++-- 8 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 28124f809..34f6d2a9b 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -14,13 +14,14 @@ export const runComposeBackup = async ( compose: Compose, backup: BackupSchedule, ) => { - const { environmentId, name } = compose; + const { environmentId, name, appName } = compose; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); - const { prefix, databaseType } = backup; + const { prefix, databaseType, serviceName } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${backup.appName}/${normalizeS3Path(prefix)}${backupFileName}`; + const s3AppName = serviceName ? `${appName}_${serviceName}` : appName; + const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "Compose Backup", diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index c747c8656..71eeda7ea 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -106,6 +106,20 @@ export const initCronJobs = async () => { } }; +const getServiceAppName = (backup: BackupSchedule): string => { + if (backup.compose?.appName) { + return backup.serviceName + ? `${backup.compose.appName}_${backup.serviceName}` + : backup.compose.appName; + } + const serviceAppName = + backup.postgres?.appName || + backup.mysql?.appName || + backup.mariadb?.appName || + backup.mongo?.appName; + return serviceAppName || backup.appName; +}; + export const keepLatestNBackups = async ( backup: BackupSchedule, serverId?: string | null, @@ -116,7 +130,8 @@ export const keepLatestNBackups = async ( try { const rcloneFlags = getS3Credentials(backup.destination); - const backupFilesPath = `:s3:${backup.destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}`; + const appName = getServiceAppName(backup); + const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`; // --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`; diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 292b08cc8..089b3cb04 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -14,13 +14,13 @@ export const runMariadbBackup = async ( mariadb: Mariadb, backup: BackupSchedule, ) => { - const { environmentId, name } = mariadb; + const { environmentId, name, appName } = mariadb; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${backup.appName}/${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "MariaDB Backup", diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 10a434560..d1b04e68b 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { - const { environmentId, name } = mongo; + const { environmentId, name, appName } = mongo; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${backup.appName}/${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "MongoDB Backup", diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index 26c3105a4..461a17bf9 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { - const { environmentId, name } = mysql; + const { environmentId, name, appName } = mysql; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${backup.appName}/${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "MySQL Backup", diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index de493f0bd..3371b0cf9 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -14,7 +14,7 @@ export const runPostgresBackup = async ( postgres: Postgres, backup: BackupSchedule, ) => { - const { name, environmentId } = postgres; + const { name, environmentId, appName } = postgres; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); @@ -26,7 +26,7 @@ export const runPostgresBackup = async ( const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${backup.appName}/${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index dd1575563..e192fd698 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -4,6 +4,24 @@ import { findComposeById } from "@dokploy/server/services/compose"; import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups"; import { getS3Credentials, normalizeS3Path } from "../backups/utils"; +export const getVolumeServiceAppName = ( + volumeBackup: Awaited>, +): string => { + if (volumeBackup.compose?.appName) { + return volumeBackup.serviceName + ? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}` + : volumeBackup.compose.appName; + } + const serviceAppName = + volumeBackup.application?.appName || + volumeBackup.postgres?.appName || + volumeBackup.mysql?.appName || + volumeBackup.mariadb?.appName || + volumeBackup.mongo?.appName || + volumeBackup.redis?.appName; + return serviceAppName || volumeBackup.appName; +}; + export const backupVolume = async ( volumeBackup: Awaited>, ) => { @@ -12,8 +30,9 @@ export const backupVolume = async ( volumeBackup.application?.serverId || volumeBackup.compose?.serverId; const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId); const destination = volumeBackup.destination; + const s3AppName = getVolumeServiceAppName(volumeBackup); const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`; - const bucketDestination = `${volumeBackup.appName}/${normalizeS3Path(prefix || "")}${backupFileName}`; + const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`; const rcloneFlags = getS3Credentials(volumeBackup.destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName); diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index dc2a4504f..6a51e765d 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -12,7 +12,7 @@ import { import { scheduledJobs, scheduleJob } from "node-schedule"; import { getS3Credentials, normalizeS3Path } from "../backups/utils"; import { sendVolumeBackupNotifications } from "../notifications/volume-backup"; -import { backupVolume } from "./backup"; +import { backupVolume, getVolumeServiceAppName } from "./backup"; // Helper functions to extract project info from volume backup const getProjectName = ( @@ -81,7 +81,8 @@ const cleanupOldVolumeBackups = async ( try { const rcloneFlags = getS3Credentials(destination); - const backupFilesPath = `:s3:${destination.bucket}/${volumeBackup.appName}/${normalizeS3Path(prefix || "")}`; + const s3AppName = getVolumeServiceAppName(volumeBackup); + const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`; const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`; const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`; const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`; From b419294b0968b2799587a45896d0ee0770429c57 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 7 Mar 2026 23:38:58 -0600 Subject: [PATCH 06/60] fix: add --drop option to mongorestore command for improved data restoration https://github.com/Dokploy/dokploy/issues/2713 --- packages/server/src/utils/restore/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index 23052e642..7300ca479 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -30,7 +30,7 @@ export const getMongoRestoreCommand = ( databaseUser: string, databasePassword: string, ) => { - return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`; + return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`; }; export const getComposeSearchCommand = ( From b4319c7ea25941a805fd0887c2d3598f3485ee2b Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:46:55 -0600 Subject: [PATCH 07/60] Bump version from v0.28.4 to v0.28.5 --- apps/dokploy/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 7ed8a2e6a..1acc5d4bb 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.28.4", + "version": "v0.28.5", "private": true, "license": "Apache-2.0", "type": "module", From 75a4e8e8ef34569e1c5c60f23706ce5db0eb0a17 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 8 Mar 2026 02:52:46 -0600 Subject: [PATCH 08/60] fix: update success message for service deployment to reflect queued status --- .../project/[projectId]/environment/[environmentId].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index 07a7396e2..de1b94b72 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -777,7 +777,7 @@ const EnvironmentPage = ( } if (success > 0) { toast.success( - `${success} service${success !== 1 ? "s" : ""} deployed successfully`, + `${success} service${success !== 1 ? "s" : ""} queued for deployment`, ); } if (failed > 0) { From ce82e2322b8e721cee0988f7c7fa953acf7e7b09 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 8 Mar 2026 03:08:38 -0600 Subject: [PATCH 09/60] fix: improve port conflict detection by enhancing error messages and adding host-level service checks --- apps/dokploy/server/api/routers/settings.ts | 6 ++-- packages/server/src/services/settings.ts | 39 ++++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index fee7f2f5d..30cb522ba 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -149,12 +149,12 @@ export const settingsRouter = createTRPCRouter({ // Check if port 8080 is already in use before enabling dashboard const portCheck = await checkPortInUse(8080, input.serverId); if (portCheck.isInUse) { - const conflictingContainer = portCheck.conflictingContainer - ? ` by container "${portCheck.conflictingContainer}"` + const conflictInfo = portCheck.conflictingContainer + ? ` by ${portCheck.conflictingContainer}` : ""; throw new TRPCError({ code: "CONFLICT", - message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`, + message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`, }); } newPorts.push({ diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index f3603a8f0..07aaf690c 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -413,17 +413,38 @@ export const checkPortInUse = async ( serverId?: string, ): Promise<{ isInUse: boolean; conflictingContainer?: string }> => { try { - const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; - const { stdout } = serverId - ? await execAsyncRemote(serverId, command) - : await execAsync(command); + // Check if port is in use by a Docker container + const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; + const { stdout: dockerOut } = serverId + ? await execAsyncRemote(serverId, dockerCommand) + : await execAsync(dockerCommand); - const container = stdout.trim(); + const container = dockerOut.trim(); - return { - isInUse: !!container, - conflictingContainer: container || undefined, - }; + if (container) { + return { + isInUse: true, + conflictingContainer: `container "${container}"`, + }; + } + + // Check if port is in use by a host-level service (non-Docker) + // Dokploy runs inside a container, so we spawn an ephemeral container + // with --net=host to share the host's network stack and use nc -z to + // check if something is listening on the port + const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`; + const { stdout: hostOut } = serverId + ? await execAsyncRemote(serverId, hostCommand) + : await execAsync(hostCommand); + + if (hostOut.includes("in_use")) { + return { + isInUse: true, + conflictingContainer: "a host-level service", + }; + } + + return { isInUse: false }; } catch (error) { console.error("Error checking port availability:", error); return { isInUse: false }; From c00aa6acbf585b125badf4a992abaf19350f96db Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 8 Mar 2026 16:16:45 -0600 Subject: [PATCH 10/60] fix: enhance container metrics query to support wildcard matching for container names --- apps/monitoring/database/containers.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/monitoring/database/containers.go b/apps/monitoring/database/containers.go index 6dad7f5ea..43ff468a6 100644 --- a/apps/monitoring/database/containers.go +++ b/apps/monitoring/database/containers.go @@ -54,13 +54,13 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta WITH recent_metrics AS ( SELECT metrics_json FROM container_metrics - WHERE container_name = ? + WHERE container_name = ? OR container_name LIKE ? ORDER BY timestamp DESC LIMIT ? ) SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC ` - rows, err := db.Query(query, containerName, limit) + rows, err := db.Query(query, containerName, containerName+".%", limit) if err != nil { return nil, err } @@ -90,12 +90,12 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e WITH recent_metrics AS ( SELECT metrics_json FROM container_metrics - WHERE container_name = ? + WHERE container_name = ? OR container_name LIKE ? ORDER BY timestamp DESC ) SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC ` - rows, err := db.Query(query, containerName) + rows, err := db.Query(query, containerName, containerName+".%") if err != nil { return nil, err } From 2102840bb969356b85c3c8643366bf5b6701fdff Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 8 Mar 2026 23:48:51 -0600 Subject: [PATCH 11/60] fix: add error handling to trusted origins retrieval in admin service --- packages/server/src/services/admin.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/server/src/services/admin.ts b/packages/server/src/services/admin.ts index 2721777df..f0c8cb0eb 100644 --- a/packages/server/src/services/admin.ts +++ b/packages/server/src/services/admin.ts @@ -135,15 +135,25 @@ export const getTrustedOrigins = async () => { if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) { return trustedOriginsCache.data; } - const trustedOrigins = await runQuery(); - trustedOriginsCache = { - data: trustedOrigins, - expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS, - }; - return trustedOrigins; + try { + const trustedOrigins = await runQuery(); + trustedOriginsCache = { + data: trustedOrigins, + expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS, + }; + return trustedOrigins; + } catch (error) { + console.error("Failed to fetch trusted origins:", error); + return trustedOriginsCache?.data ?? []; + } } - return runQuery(); + try { + return await runQuery(); + } catch (error) { + console.error("Failed to fetch trusted origins:", error); + return []; + } }; export const getTrustedProviders = async () => { From 4330d7bd99f07b88ccf81d1d4a8a64a13907f710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vrba?= Date: Mon, 9 Mar 2026 09:25:41 +0100 Subject: [PATCH 12/60] feat(deployments): Add option to copy webhook url by clicking on it --- .../deployments/show-deployments.tsx | 831 +++++++++--------- 1 file changed, 423 insertions(+), 408 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 61841e294..0cc096f5c 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,12 +1,12 @@ import { - ChevronDown, - ChevronUp, - Clock, - Loader2, - RefreshCcw, - RocketIcon, - Settings, - Trash2, + ChevronDown, + ChevronUp, + Clock, Copy, + Loader2, + RefreshCcw, + RocketIcon, + Settings, + Trash2, } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -17,11 +17,11 @@ import { StatusTooltip } from "@/components/shared/status-tooltip"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { api, type RouterOutputs } from "@/utils/api"; import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; @@ -30,441 +30,456 @@ import { ClearDeployments } from "./clear-deployments"; import { KillBuild } from "./kill-build"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; +import copy from "copy-to-clipboard"; interface Props { - id: string; - type: - | "application" - | "compose" - | "schedule" - | "server" - | "backup" - | "previewDeployment" - | "volumeBackup"; - refreshToken?: string; - serverId?: string; + id: string; + type: + | "application" + | "compose" + | "schedule" + | "server" + | "backup" + | "previewDeployment" + | "volumeBackup"; + refreshToken?: string; + serverId?: string; } export const formatDuration = (seconds: number) => { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds}s`; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; }; export const ShowDeployments = ({ - id, - type, - refreshToken, - serverId, + id, + type, + refreshToken, + serverId, }: Props) => { - const [activeLog, setActiveLog] = useState< - RouterOutputs["deployment"]["all"][number] | null - >(null); - const { data: deployments, isPending: isLoadingDeployments } = - api.deployment.allByType.useQuery( - { - id, - type, - }, - { - enabled: !!id, - refetchInterval: 1000, - }, - ); + const [activeLog, setActiveLog] = useState< + RouterOutputs["deployment"]["all"][number] | null + >(null); + const { data: deployments, isPending: isLoadingDeployments } = + api.deployment.allByType.useQuery( + { + id, + type, + }, + { + enabled: !!id, + refetchInterval: 1000, + }, + ); - const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); - const { mutateAsync: rollback, isPending: isRollingBack } = - api.rollback.rollback.useMutation(); - const { mutateAsync: killProcess, isPending: isKillingProcess } = - api.deployment.killProcess.useMutation(); - const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = - api.deployment.removeDeployment.useMutation(); + const { mutateAsync: rollback, isPending: isRollingBack } = + api.rollback.rollback.useMutation(); + const { mutateAsync: killProcess, isPending: isKillingProcess } = + api.deployment.killProcess.useMutation(); + const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = + api.deployment.removeDeployment.useMutation(); - // Cancel deployment mutations - const { - mutateAsync: cancelApplicationDeployment, - isPending: isCancellingApp, - } = api.application.cancelDeployment.useMutation(); - const { - mutateAsync: cancelComposeDeployment, - isPending: isCancellingCompose, - } = api.compose.cancelDeployment.useMutation(); + // Cancel deployment mutations + const { + mutateAsync: cancelApplicationDeployment, + isPending: isCancellingApp, + } = api.application.cancelDeployment.useMutation(); + const { + mutateAsync: cancelComposeDeployment, + isPending: isCancellingCompose, + } = api.compose.cancelDeployment.useMutation(); - const [url, setUrl] = React.useState(""); - const [expandedDescriptions, setExpandedDescriptions] = useState>( - new Set(), - ); + const [url, setUrl] = React.useState(""); + const [expandedDescriptions, setExpandedDescriptions] = useState>( + new Set(), + ); - const MAX_DESCRIPTION_LENGTH = 200; + const MAX_DESCRIPTION_LENGTH = 200; - const truncateDescription = (description: string): string => { - if (description.length <= MAX_DESCRIPTION_LENGTH) { - return description; - } - const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH); - const lastSpace = truncated.lastIndexOf(" "); - if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) { - return `${truncated.slice(0, lastSpace)}...`; - } - return `${truncated}...`; - }; + const truncateDescription = (description: string): string => { + if (description.length <= MAX_DESCRIPTION_LENGTH) { + return description; + } + const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH); + const lastSpace = truncated.lastIndexOf(" "); + if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) { + return `${truncated.slice(0, lastSpace)}...`; + } + return `${truncated}...`; + }; - // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment - const stuckDeployment = useMemo(() => { - if (!isCloud || !deployments || deployments.length === 0) return null; + // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment + const stuckDeployment = useMemo(() => { + if (!isCloud || !deployments || deployments.length === 0) return null; - const now = Date.now(); - const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds + const now = Date.now(); + const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds - // Get the most recent deployment (first in the list since they're sorted by date) - const mostRecentDeployment = deployments[0]; + // Get the most recent deployment (first in the list since they're sorted by date) + const mostRecentDeployment = deployments[0]; - if ( - !mostRecentDeployment || - mostRecentDeployment.status !== "running" || - !mostRecentDeployment.startedAt - ) { - return null; - } + if ( + !mostRecentDeployment || + mostRecentDeployment.status !== "running" || + !mostRecentDeployment.startedAt + ) { + return null; + } - const startTime = new Date(mostRecentDeployment.startedAt).getTime(); - const elapsed = now - startTime; + const startTime = new Date(mostRecentDeployment.startedAt).getTime(); + const elapsed = now - startTime; - return elapsed > NINE_MINUTES ? mostRecentDeployment : null; - }, [isCloud, deployments]); - useEffect(() => { - setUrl(document.location.origin); - }, []); + return elapsed > NINE_MINUTES ? mostRecentDeployment : null; + }, [isCloud, deployments]); + useEffect(() => { + setUrl(document.location.origin); + }, []); - return ( - - -
- Deployments - - See the last 10 deployments for this {type} - -
-
- {(type === "application" || type === "compose") && ( - - )} - {(type === "application" || type === "compose") && ( - - )} - {(type === "application" || type === "compose") && ( - - )} - {type === "application" && ( - - - - )} -
-
- - {stuckDeployment && (type === "application" || type === "compose") && ( - -
-
-
- Build appears to be stuck -
-

- Hey! Looks like the build has been running for more than 10 - minutes. Would you like to cancel this deployment? -

-
- -
-
- )} - {refreshToken && ( -
+ return ( + + +
+ Deployments + + See the last 10 deployments for this {type} + +
+
+ {(type === "application" || type === "compose") && ( + + )} + {(type === "application" || type === "compose") && ( + + )} + {(type === "application" || type === "compose") && ( + + )} + {type === "application" && ( + + + + )} +
+
+ + {stuckDeployment && (type === "application" || type === "compose") && ( + +
+
+
+ Build appears to be stuck +
+

+ Hey! Looks like the build has been running for more than 10 + minutes. Would you like to cancel this deployment? +

+
+ +
+
+ )} + {refreshToken && ( +
If you want to re-deploy this application use this URL in the config of your git provider or docker -
- Webhook URL: -
- - {`${url}/api/deploy${ - type === "compose" ? "/compose" : "" - }/${refreshToken}`} - - {(type === "application" || type === "compose") && ( - - )} -
-
-
- )} +
+ Webhook URL: +
+ { + copy(`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`); + toast.success("Copied to clipboard."); + }} + > + {`${url}/api/deploy${ + type === "compose" ? "/compose" : "" + }/${refreshToken}`} + + + {(type === "application" || type === "compose") && ( + + )} +
+
+
+ )} - {isLoadingDeployments ? ( -
- - + {isLoadingDeployments ? ( +
+ + Loading deployments... -
- ) : deployments?.length === 0 ? ( -
- - +
+ ) : deployments?.length === 0 ? ( +
+ + No deployments found -
- ) : ( -
- {deployments?.map((deployment, index) => { - const titleText = deployment?.title?.trim() || ""; - const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH; - const isExpanded = expandedDescriptions.has( - deployment.deploymentId, - ); - const canDelete = - deployment.status === "done" || deployment.status === "error"; +
+ ) : ( +
+ {deployments?.map((deployment, index) => { + const titleText = deployment?.title?.trim() || ""; + const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH; + const isExpanded = expandedDescriptions.has( + deployment.deploymentId, + ); + const canDelete = + deployment.status === "done" || deployment.status === "error"; - return ( -
-
- + return ( +
+
+ {index + 1}. {deployment.status} - + -
- +
+ {isExpanded || !needsTruncation - ? titleText - : truncateDescription(titleText)} + ? titleText + : truncateDescription(titleText)} - {needsTruncation && ( - - )} - {/* Hash (from description) - shown in compact form */} - {deployment.description?.trim() && ( - + {needsTruncation && ( + + )} + {/* Hash (from description) - shown in compact form */} + {deployment.description?.trim() && ( + {deployment.description} - )} -
-
-
-
- - {deployment.startedAt && deployment.finishedAt && ( - - - {formatDuration( - Math.floor( - (new Date(deployment.finishedAt).getTime() - - new Date(deployment.startedAt).getTime()) / - 1000, - ), - )} - - )} -
+ )} +
+
+
+
+ + {deployment.startedAt && deployment.finishedAt && ( + + + {formatDuration( + Math.floor( + (new Date(deployment.finishedAt).getTime() - + new Date(deployment.startedAt).getTime()) / + 1000, + ), + )} + + )} +
-
- {deployment.pid && deployment.status === "running" && ( - { - await killProcess({ - deploymentId: deployment.deploymentId, - }) - .then(() => { - toast.success("Process killed successfully"); - }) - .catch(() => { - toast.error("Error killing process"); - }); - }} - > - - - )} - +
+ {deployment.pid && deployment.status === "running" && ( + { + await killProcess({ + deploymentId: deployment.deploymentId, + }) + .then(() => { + toast.success("Process killed successfully"); + }) + .catch(() => { + toast.error("Error killing process"); + }); + }} + > + + + )} + - {canDelete && ( - { - try { - await removeDeployment({ - deploymentId: deployment.deploymentId, - }); - toast.success("Deployment deleted successfully"); - } catch (error) { - toast.error("Error deleting deployment"); - } - }} - > - - - )} + {canDelete && ( + { + try { + await removeDeployment({ + deploymentId: deployment.deploymentId, + }); + toast.success("Deployment deleted successfully"); + } catch (error) { + toast.error("Error deleting deployment"); + } + }} + > + + + )} - {deployment?.rollback && - deployment.status === "done" && - type === "application" && ( - -

- Are you sure you want to rollback to this - deployment? -

- - Please wait a few seconds while the image is - pulled from the registry. Your application - should be running shortly. - -
- } - type="default" - onClick={async () => { - await rollback({ - rollbackId: deployment.rollback.rollbackId, - }) - .then(() => { - toast.success( - "Rollback initiated successfully", - ); - }) - .catch(() => { - toast.error("Error initiating rollback"); - }); - }} - > - - - )} -
-
-
- ); - })} -
- )} - setActiveLog(null)} - logPath={activeLog?.logPath || ""} - errorMessage={activeLog?.errorMessage || ""} - /> - - - ); + {deployment?.rollback && + deployment.status === "done" && + type === "application" && ( + +

+ Are you sure you want to rollback to this + deployment? +

+ + Please wait a few seconds while the image is + pulled from the registry. Your application + should be running shortly. + +
+ } + type="default" + onClick={async () => { + await rollback({ + rollbackId: deployment.rollback.rollbackId, + }) + .then(() => { + toast.success( + "Rollback initiated successfully", + ); + }) + .catch(() => { + toast.error("Error initiating rollback"); + }); + }} + > + + + )} +
+
+
+ ); + })} + + )} + setActiveLog(null)} + logPath={activeLog?.logPath || ""} + errorMessage={activeLog?.errorMessage || ""} + /> + + + ); }; From d8c7c1eaf44779b37bca3452b19c0dfed352ea9d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:28:35 +0000 Subject: [PATCH 13/60] [autofix.ci] apply automated fixes --- .../deployments/show-deployments.tsx | 841 +++++++++--------- 1 file changed, 419 insertions(+), 422 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 0cc096f5c..fe17697ff 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,12 +1,13 @@ import { - ChevronDown, - ChevronUp, - Clock, Copy, - Loader2, - RefreshCcw, - RocketIcon, - Settings, - Trash2, + ChevronDown, + ChevronUp, + Clock, + Copy, + Loader2, + RefreshCcw, + RocketIcon, + Settings, + Trash2, } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -17,11 +18,11 @@ import { StatusTooltip } from "@/components/shared/status-tooltip"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { api, type RouterOutputs } from "@/utils/api"; import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; @@ -33,453 +34,449 @@ import { ShowDeployment } from "./show-deployment"; import copy from "copy-to-clipboard"; interface Props { - id: string; - type: - | "application" - | "compose" - | "schedule" - | "server" - | "backup" - | "previewDeployment" - | "volumeBackup"; - refreshToken?: string; - serverId?: string; + id: string; + type: + | "application" + | "compose" + | "schedule" + | "server" + | "backup" + | "previewDeployment" + | "volumeBackup"; + refreshToken?: string; + serverId?: string; } export const formatDuration = (seconds: number) => { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds}s`; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; }; export const ShowDeployments = ({ - id, - type, - refreshToken, - serverId, + id, + type, + refreshToken, + serverId, }: Props) => { - const [activeLog, setActiveLog] = useState< - RouterOutputs["deployment"]["all"][number] | null - >(null); - const { data: deployments, isPending: isLoadingDeployments } = - api.deployment.allByType.useQuery( - { - id, - type, - }, - { - enabled: !!id, - refetchInterval: 1000, - }, - ); + const [activeLog, setActiveLog] = useState< + RouterOutputs["deployment"]["all"][number] | null + >(null); + const { data: deployments, isPending: isLoadingDeployments } = + api.deployment.allByType.useQuery( + { + id, + type, + }, + { + enabled: !!id, + refetchInterval: 1000, + }, + ); - const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); - const { mutateAsync: rollback, isPending: isRollingBack } = - api.rollback.rollback.useMutation(); - const { mutateAsync: killProcess, isPending: isKillingProcess } = - api.deployment.killProcess.useMutation(); - const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = - api.deployment.removeDeployment.useMutation(); + const { mutateAsync: rollback, isPending: isRollingBack } = + api.rollback.rollback.useMutation(); + const { mutateAsync: killProcess, isPending: isKillingProcess } = + api.deployment.killProcess.useMutation(); + const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = + api.deployment.removeDeployment.useMutation(); - // Cancel deployment mutations - const { - mutateAsync: cancelApplicationDeployment, - isPending: isCancellingApp, - } = api.application.cancelDeployment.useMutation(); - const { - mutateAsync: cancelComposeDeployment, - isPending: isCancellingCompose, - } = api.compose.cancelDeployment.useMutation(); + // Cancel deployment mutations + const { + mutateAsync: cancelApplicationDeployment, + isPending: isCancellingApp, + } = api.application.cancelDeployment.useMutation(); + const { + mutateAsync: cancelComposeDeployment, + isPending: isCancellingCompose, + } = api.compose.cancelDeployment.useMutation(); - const [url, setUrl] = React.useState(""); - const [expandedDescriptions, setExpandedDescriptions] = useState>( - new Set(), - ); + const [url, setUrl] = React.useState(""); + const [expandedDescriptions, setExpandedDescriptions] = useState>( + new Set(), + ); - const MAX_DESCRIPTION_LENGTH = 200; + const MAX_DESCRIPTION_LENGTH = 200; - const truncateDescription = (description: string): string => { - if (description.length <= MAX_DESCRIPTION_LENGTH) { - return description; - } - const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH); - const lastSpace = truncated.lastIndexOf(" "); - if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) { - return `${truncated.slice(0, lastSpace)}...`; - } - return `${truncated}...`; - }; + const truncateDescription = (description: string): string => { + if (description.length <= MAX_DESCRIPTION_LENGTH) { + return description; + } + const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH); + const lastSpace = truncated.lastIndexOf(" "); + if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) { + return `${truncated.slice(0, lastSpace)}...`; + } + return `${truncated}...`; + }; - // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment - const stuckDeployment = useMemo(() => { - if (!isCloud || !deployments || deployments.length === 0) return null; + // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment + const stuckDeployment = useMemo(() => { + if (!isCloud || !deployments || deployments.length === 0) return null; - const now = Date.now(); - const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds + const now = Date.now(); + const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds - // Get the most recent deployment (first in the list since they're sorted by date) - const mostRecentDeployment = deployments[0]; + // Get the most recent deployment (first in the list since they're sorted by date) + const mostRecentDeployment = deployments[0]; - if ( - !mostRecentDeployment || - mostRecentDeployment.status !== "running" || - !mostRecentDeployment.startedAt - ) { - return null; - } + if ( + !mostRecentDeployment || + mostRecentDeployment.status !== "running" || + !mostRecentDeployment.startedAt + ) { + return null; + } - const startTime = new Date(mostRecentDeployment.startedAt).getTime(); - const elapsed = now - startTime; + const startTime = new Date(mostRecentDeployment.startedAt).getTime(); + const elapsed = now - startTime; - return elapsed > NINE_MINUTES ? mostRecentDeployment : null; - }, [isCloud, deployments]); - useEffect(() => { - setUrl(document.location.origin); - }, []); + return elapsed > NINE_MINUTES ? mostRecentDeployment : null; + }, [isCloud, deployments]); + useEffect(() => { + setUrl(document.location.origin); + }, []); - return ( - - -
- Deployments - - See the last 10 deployments for this {type} - -
-
- {(type === "application" || type === "compose") && ( - - )} - {(type === "application" || type === "compose") && ( - - )} - {(type === "application" || type === "compose") && ( - - )} - {type === "application" && ( - - - - )} -
-
- - {stuckDeployment && (type === "application" || type === "compose") && ( - -
-
-
- Build appears to be stuck -
-

- Hey! Looks like the build has been running for more than 10 - minutes. Would you like to cancel this deployment? -

-
- -
-
- )} - {refreshToken && ( -
+ return ( + + +
+ Deployments + + See the last 10 deployments for this {type} + +
+
+ {(type === "application" || type === "compose") && ( + + )} + {(type === "application" || type === "compose") && ( + + )} + {(type === "application" || type === "compose") && ( + + )} + {type === "application" && ( + + + + )} +
+
+ + {stuckDeployment && (type === "application" || type === "compose") && ( + +
+
+
+ Build appears to be stuck +
+

+ Hey! Looks like the build has been running for more than 10 + minutes. Would you like to cancel this deployment? +

+
+ +
+
+ )} + {refreshToken && ( +
If you want to re-deploy this application use this URL in the config of your git provider or docker -
- Webhook URL: -
- { - copy(`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`); - toast.success("Copied to clipboard."); - }} - > - {`${url}/api/deploy${ - type === "compose" ? "/compose" : "" - }/${refreshToken}`} - - - {(type === "application" || type === "compose") && ( - - )} -
-
-
- )} +
+ Webhook URL: +
+ { + copy( + `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, + ); + toast.success("Copied to clipboard."); + }} + > + {`${url}/api/deploy${ + type === "compose" ? "/compose" : "" + }/${refreshToken}`} + + + {(type === "application" || type === "compose") && ( + + )} +
+
+
+ )} - {isLoadingDeployments ? ( -
- - + {isLoadingDeployments ? ( +
+ + Loading deployments... -
- ) : deployments?.length === 0 ? ( -
- - +
+ ) : deployments?.length === 0 ? ( +
+ + No deployments found -
- ) : ( -
- {deployments?.map((deployment, index) => { - const titleText = deployment?.title?.trim() || ""; - const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH; - const isExpanded = expandedDescriptions.has( - deployment.deploymentId, - ); - const canDelete = - deployment.status === "done" || deployment.status === "error"; +
+ ) : ( +
+ {deployments?.map((deployment, index) => { + const titleText = deployment?.title?.trim() || ""; + const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH; + const isExpanded = expandedDescriptions.has( + deployment.deploymentId, + ); + const canDelete = + deployment.status === "done" || deployment.status === "error"; - return ( -
-
- + return ( +
+
+ {index + 1}. {deployment.status} - + -
- +
+ {isExpanded || !needsTruncation - ? titleText - : truncateDescription(titleText)} + ? titleText + : truncateDescription(titleText)} - {needsTruncation && ( - - )} - {/* Hash (from description) - shown in compact form */} - {deployment.description?.trim() && ( - + {needsTruncation && ( + + )} + {/* Hash (from description) - shown in compact form */} + {deployment.description?.trim() && ( + {deployment.description} - )} -
-
-
-
- - {deployment.startedAt && deployment.finishedAt && ( - - - {formatDuration( - Math.floor( - (new Date(deployment.finishedAt).getTime() - - new Date(deployment.startedAt).getTime()) / - 1000, - ), - )} - - )} -
+ )} +
+
+
+
+ + {deployment.startedAt && deployment.finishedAt && ( + + + {formatDuration( + Math.floor( + (new Date(deployment.finishedAt).getTime() - + new Date(deployment.startedAt).getTime()) / + 1000, + ), + )} + + )} +
-
- {deployment.pid && deployment.status === "running" && ( - { - await killProcess({ - deploymentId: deployment.deploymentId, - }) - .then(() => { - toast.success("Process killed successfully"); - }) - .catch(() => { - toast.error("Error killing process"); - }); - }} - > - - - )} - +
+ {deployment.pid && deployment.status === "running" && ( + { + await killProcess({ + deploymentId: deployment.deploymentId, + }) + .then(() => { + toast.success("Process killed successfully"); + }) + .catch(() => { + toast.error("Error killing process"); + }); + }} + > + + + )} + - {canDelete && ( - { - try { - await removeDeployment({ - deploymentId: deployment.deploymentId, - }); - toast.success("Deployment deleted successfully"); - } catch (error) { - toast.error("Error deleting deployment"); - } - }} - > - - - )} + {canDelete && ( + { + try { + await removeDeployment({ + deploymentId: deployment.deploymentId, + }); + toast.success("Deployment deleted successfully"); + } catch (error) { + toast.error("Error deleting deployment"); + } + }} + > + + + )} - {deployment?.rollback && - deployment.status === "done" && - type === "application" && ( - -

- Are you sure you want to rollback to this - deployment? -

- - Please wait a few seconds while the image is - pulled from the registry. Your application - should be running shortly. - -
- } - type="default" - onClick={async () => { - await rollback({ - rollbackId: deployment.rollback.rollbackId, - }) - .then(() => { - toast.success( - "Rollback initiated successfully", - ); - }) - .catch(() => { - toast.error("Error initiating rollback"); - }); - }} - > - - - )} -
-
-
- ); - })} -
- )} - setActiveLog(null)} - logPath={activeLog?.logPath || ""} - errorMessage={activeLog?.errorMessage || ""} - /> - - - ); + {deployment?.rollback && + deployment.status === "done" && + type === "application" && ( + +

+ Are you sure you want to rollback to this + deployment? +

+ + Please wait a few seconds while the image is + pulled from the registry. Your application + should be running shortly. + +
+ } + type="default" + onClick={async () => { + await rollback({ + rollbackId: deployment.rollback.rollbackId, + }) + .then(() => { + toast.success( + "Rollback initiated successfully", + ); + }) + .catch(() => { + toast.error("Error initiating rollback"); + }); + }} + > + + + )} +
+
+ + ); + })} + + )} + setActiveLog(null)} + logPath={activeLog?.logPath || ""} + errorMessage={activeLog?.errorMessage || ""} + /> +
+
+ ); }; From f1d4543d5e4d0dedd4290fa7c45bf55e257f792b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vrba?= Date: Mon, 9 Mar 2026 09:33:30 +0100 Subject: [PATCH 14/60] Code review fixes --- .../dashboard/application/deployments/show-deployments.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index fe17697ff..67f1c0e87 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -227,7 +227,7 @@ export const ShowDeployments = ({ Webhook URL:
{ copy( From b9ca6ea9dbf89fab0d607cb7a4a2a2f0c6541f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vrba?= Date: Mon, 9 Mar 2026 09:38:00 +0100 Subject: [PATCH 15/60] Code review fixes --- .../dashboard/application/deployments/show-deployments.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 67f1c0e87..f82c9589b 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -227,6 +227,8 @@ export const ShowDeployments = ({ Webhook URL:
{ From 3e4a1b92ebbc69e0b999e890fe511fb3a83bdcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vrba?= Date: Mon, 9 Mar 2026 09:48:37 +0100 Subject: [PATCH 16/60] Code review fixes --- .../deployments/show-deployments.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index f82c9589b..6de8c1cb0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import copy from "copy-to-clipboard"; import { AlertBlock } from "@/components/shared/alert-block"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -31,7 +32,6 @@ import { ClearDeployments } from "./clear-deployments"; import { KillBuild } from "./kill-build"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; -import copy from "copy-to-clipboard"; interface Props { id: string; @@ -99,6 +99,11 @@ export const ShowDeployments = ({ new Set(), ); + const webhookUrl = useMemo( + () => `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, + [url, refreshToken, type] + ); + const MAX_DESCRIPTION_LENGTH = 200; const truncateDescription = (description: string): string => { @@ -231,17 +236,20 @@ export const ShowDeployments = ({ tabIndex={0} className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all" variant="outline" + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + copy(webhookUrl); + toast.success("Copied to clipboard."); + } + }} onClick={() => { - copy( - `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, - ); + copy(webhookUrl); toast.success("Copied to clipboard."); }} > - {`${url}/api/deploy${ - type === "compose" ? "/compose" : "" - }/${refreshToken}`} - + {webhookUrl} + {(type === "application" || type === "compose") && ( From 6866e2b63abc2bbeaf68231a1348c34b386666cc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:49:06 +0000 Subject: [PATCH 17/60] [autofix.ci] apply automated fixes --- .../dashboard/application/deployments/show-deployments.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 6de8c1cb0..c50cd1607 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -100,8 +100,9 @@ export const ShowDeployments = ({ ); const webhookUrl = useMemo( - () => `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, - [url, refreshToken, type] + () => + `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, + [url, refreshToken, type], ); const MAX_DESCRIPTION_LENGTH = 200; From de201d0b0af2056a995fc3c3b0fb4ac814a336b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Vrba?= Date: Mon, 9 Mar 2026 09:59:36 +0100 Subject: [PATCH 18/60] Add aria-label to webhook URL badge --- .../dashboard/application/deployments/show-deployments.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index c50cd1607..3cecef1ec 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -235,6 +235,7 @@ export const ShowDeployments = ({ { From b84bc9b7c6a4603b7f3364fb357e2da29b890ad9 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 10 Mar 2026 00:27:58 -0600 Subject: [PATCH 19/60] feat: implement whitelabeling features including settings, preview, and provider components --- .../server/update-server-config.test.ts | 15 + .../impersonation/impersonation-bar.tsx | 7 +- .../components/layouts/onboarding-layout.tsx | 20 +- apps/dokploy/components/layouts/side.tsx | 76 +- .../whitelabeling/whitelabeling-preview.tsx | 85 + .../whitelabeling/whitelabeling-provider.tsx | 93 + .../whitelabeling/whitelabeling-settings.tsx | 536 ++ .../dokploy/components/shared/code-editor.tsx | 5 +- apps/dokploy/drizzle/0148_strong_karma.sql | 1 + apps/dokploy/drizzle/0149_omniscient_bug.sql | 1 + apps/dokploy/drizzle/meta/0148_snapshot.json | 7467 +++++++++++++++++ apps/dokploy/drizzle/meta/0149_snapshot.json | 7467 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 14 + apps/dokploy/package.json | 15 +- apps/dokploy/pages/_app.tsx | 2 + apps/dokploy/pages/_error.tsx | 56 +- .../environment/[environmentId].tsx | 6 +- .../services/application/[applicationId].tsx | 6 +- .../services/compose/[composeId].tsx | 5 +- .../services/mariadb/[mariadbId].tsx | 5 +- .../services/mongo/[mongoId].tsx | 6 +- .../services/mysql/[mysqlId].tsx | 5 +- .../services/postgres/[postgresId].tsx | 6 +- .../services/redis/[redisId].tsx | 6 +- .../dashboard/settings/whitelabeling.tsx | 81 + apps/dokploy/pages/index.tsx | 11 +- apps/dokploy/pages/invitation.tsx | 17 +- apps/dokploy/pages/register.tsx | 17 +- apps/dokploy/pages/reset-password.tsx | 11 +- apps/dokploy/pages/send-reset-password.tsx | 12 +- apps/dokploy/server/api/root.ts | 2 + .../api/routers/proprietary/whitelabeling.ts | 88 + apps/dokploy/server/api/routers/user.ts | 5 +- apps/dokploy/utils/hooks/use-whitelabeling.ts | 13 + .../src/db/schema/web-server-settings.ts | 61 +- pnpm-lock.yaml | 23 + 36 files changed, 16148 insertions(+), 98 deletions(-) create mode 100644 apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx create mode 100644 apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx create mode 100644 apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx create mode 100644 apps/dokploy/drizzle/0148_strong_karma.sql create mode 100644 apps/dokploy/drizzle/0149_omniscient_bug.sql create mode 100644 apps/dokploy/drizzle/meta/0148_snapshot.json create mode 100644 apps/dokploy/drizzle/meta/0149_snapshot.json create mode 100644 apps/dokploy/pages/dashboard/settings/whitelabeling.tsx create mode 100644 apps/dokploy/server/api/routers/proprietary/whitelabeling.ts create mode 100644 apps/dokploy/utils/hooks/use-whitelabeling.ts diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index b422279ca..eb99242c3 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -48,6 +48,21 @@ const baseSettings: WebServerSettings = { urlCallback: "", }, }, + whitelabelingConfig: { + appName: null, + appDescription: null, + logoUrl: null, + faviconUrl: null, + primaryColor: null, + customCss: null, + loginLogoUrl: null, + supportUrl: null, + docsUrl: null, + errorPageTitle: null, + errorPageDescription: null, + metaTitle: null, + footerText: null, + }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index f77983996..02f7f59f1 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 { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; type User = typeof authClient.$Infer.Session.user; export const ImpersonationBar = () => { + const { config: whitelabeling } = useWhitelabelingPublic(); const [users, setUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [isImpersonating, setIsImpersonating] = useState(false); @@ -180,7 +182,10 @@ export const ImpersonationBar = () => { )} >
- + {!isImpersonating ? (
diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index fff5413e0..c76c920fd 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import type React from "react"; import { cn } from "@/lib/utils"; +import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; import { GithubIcon } from "../icons/data-tools-icons"; import { Logo } from "../shared/logo"; import { Button } from "../ui/button"; @@ -9,23 +10,28 @@ interface Props { children: React.ReactNode; } export const OnboardingLayout = ({ children }: Props) => { + const { config: whitelabeling } = useWhitelabelingPublic(); + const appName = whitelabeling?.appName || "Dokploy"; + const appDescription = + whitelabeling?.appDescription || + "\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D"; + const logoUrl = + whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined; + return (
- - Dokploy + + {appName}
-

- “The Open Source alternative to Netlify, Vercel, - Heroku.” -

+

{appDescription}

diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 6dea37f5b..487e5e2ee 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -23,6 +23,7 @@ import { Loader2, LogIn, type LucideIcon, + Palette, Package, PieChart, Rocket, @@ -422,6 +423,15 @@ const MENU: Menu = { isEnabled: ({ auth }) => !!(auth?.role === "owner" || auth?.role === "admin"), }, + { + isSingle: true, + title: "Whitelabeling", + url: "/dashboard/settings/whitelabeling", + icon: Palette, + // Only enabled for owners in non-cloud environments (enterprise) + isEnabled: ({ auth, isCloud }) => + !!(auth?.role === "owner" && !isCloud), + }, ], help: [ @@ -445,38 +455,33 @@ const MENU: Menu = { function createMenuForAuthUser(opts: { auth?: AuthQueryOutput; isCloud: boolean; + whitelabeling?: { + docsUrl?: string | null; + supportUrl?: string | null; + } | null; }): Menu { + const filterEnabled = boolean }>(items: readonly T[]): T[] => + items.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ auth: opts.auth, isCloud: opts.isCloud }), + ) as T[]; + + // Apply whitelabeling URL overrides to help items + const helpItems = filterEnabled(MENU.help).map((item) => { + if (opts.whitelabeling?.docsUrl && item.name === "Documentation") { + return { ...item, url: opts.whitelabeling.docsUrl }; + } + if (opts.whitelabeling?.supportUrl && item.name === "Support") { + return { ...item, url: opts.whitelabeling.supportUrl }; + } + return item; + }); + return { - // Filter the home items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - home: MENU.home.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }), - ), - // Filter the settings items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - settings: MENU.settings.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }), - ), - // Filter the help items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - help: MENU.help.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }), - ), + home: filterEnabled(MENU.home), + settings: filterEnabled(MENU.settings), + help: helpItems, }; } @@ -885,6 +890,10 @@ export default function Page({ children }: Props) { const pathname = usePathname(); const { data: auth } = api.user.get.useQuery(); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); + const { data: whitelabeling } = api.whitelabeling.getPublic.useQuery( + undefined, + { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false }, + ); const includesProjects = pathname?.includes("/dashboard/project"); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -893,7 +902,7 @@ export default function Page({ children }: Props) { home: filteredHome, settings: filteredSettings, help, - } = createMenuForAuthUser({ auth, isCloud: !!isCloud }); + } = createMenuForAuthUser({ auth, isCloud: !!isCloud, whitelabeling }); const activeItem = findActiveNavItem( [...filteredHome, ...filteredSettings], @@ -1141,6 +1150,11 @@ export default function Page({ children }: Props) { + {whitelabeling?.footerText && ( +
+ {whitelabeling.footerText} +
+ )} {dokployVersion && ( <>
diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx new file mode 100644 index 000000000..f87268400 --- /dev/null +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface WhitelabelingPreviewProps { + config: { + appName?: string; + logoUrl?: string; + primaryColor?: string; + footerText?: string; + }; +} + +export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) { + const appName = config.appName || "Dokploy"; + const primaryColor = config.primaryColor || "hsl(var(--primary))"; + + return ( + + + Live Preview + + A quick preview of how your branding changes will look. + + + +
+ {/* Simulated sidebar header */} +
+ {config.logoUrl ? ( + Preview Logo + ) : ( +
+ {appName.charAt(0).toUpperCase()} +
+ )} + {appName} +
+ + {/* Simulated content area */} +
+
+
+
+
+
+
+ Button +
+
+ Secondary +
+
+
+ + {/* Simulated footer */} + {config.footerText && ( +
+ {config.footerText} +
+ )} +
+ + + ); +} diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx new file mode 100644 index 000000000..651998cbf --- /dev/null +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx @@ -0,0 +1,93 @@ +"use client"; + +import Head from "next/head"; +import { api } from "@/utils/api"; + +export function WhitelabelingProvider() { + const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, { + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + if (!config) return null; + + return ( + <> + + {config.metaTitle && {config.metaTitle}} + {config.faviconUrl && } + + + {(config.customCss || config.primaryColor) && ( +