From fbde5be02ce317dfe0e99d2b4fef9053c1324693 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:20:44 +0000 Subject: [PATCH 01/12] [autofix.ci] apply automated fixes --- .../dashboard/docker/logs/analyze-logs.tsx | 21 +++++++++++-------- .../dashboard/docker/logs/docker-logs-id.tsx | 2 +- apps/dokploy/server/api/routers/ai.ts | 8 +++++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx index f330c3fe2..c26ce5bfe 100644 --- a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx @@ -32,14 +32,13 @@ export function AnalyzeLogs({ logs, context }: Props) { const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, { enabled: open, }); - const { mutate, isPending, data, reset } = - api.ai.analyzeLogs.useMutation({ - onError: (error) => { - toast.error("Analysis failed", { - description: error.message, - }); - }, - }); + const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({ + onError: (error) => { + toast.error("Analysis failed", { + description: error.message, + }); + }, + }); const handleAnalyze = () => { if (!aiId || logs.length === 0) return; @@ -119,7 +118,11 @@ export function AnalyzeLogs({ logs, context }: Props) { ) : ( <> - Analyze {logs.length > MAX_LOG_LINES ? `last ${MAX_LOG_LINES}` : logs.length} lines + Analyze{" "} + {logs.length > MAX_LOG_LINES + ? `last ${MAX_LOG_LINES}` + : logs.length}{" "} + lines )} 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 ace20b343..8d8842ac0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -378,7 +378,7 @@ export const DockerLogsId: React.FC = ({ Download logs - + {isPaused && ( diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 139227221..48bacfcba 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -253,7 +253,9 @@ ${input.logs}`, throw new TRPCError({ code: "BAD_REQUEST", message: - error instanceof Error ? error.message : `Analysis failed: ${error}`, + error instanceof Error + ? error.message + : `Analysis failed: ${error}`, }); } }), @@ -285,7 +287,9 @@ ${input.logs}`, throw new TRPCError({ code: "BAD_REQUEST", message: - error instanceof Error ? error.message : `Connection failed: ${error}`, + error instanceof Error + ? error.message + : `Connection failed: ${error}`, }); } }), From 8d8658a478b0bbe6e2ef796c797f3a0750247639 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 11:27:19 -0600 Subject: [PATCH 02/12] fix: update Z.AI API URL and enhance AI router access control - Corrected the API URL for Z.AI by removing the trailing slash. - Modified the AI router mutation to include context and added access control to ensure users can only access their organization's AI settings. These changes improve the accuracy of the API integration and enhance security by enforcing organizational access restrictions. --- apps/dokploy/components/dashboard/settings/handle-ai.tsx | 2 +- apps/dokploy/server/api/routers/ai.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx index db225bb58..18915609e 100644 --- a/apps/dokploy/components/dashboard/settings/handle-ai.tsx +++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx @@ -68,7 +68,7 @@ const AI_PROVIDERS = [ { name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" }, { name: "Ollama", apiUrl: "http://localhost:11434" }, { name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" }, - { name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4/" }, + { name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" }, { name: "MiniMax", apiUrl: "https://api.minimax.io/v1" }, ] as const; diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 48bacfcba..3a299235a 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -217,7 +217,7 @@ export const aiRouter = createTRPCRouter({ context: z.enum(["build", "runtime"]), }), ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { const aiSettings = await getAiSettingById(input.aiId); if (!aiSettings?.isEnabled) { @@ -227,6 +227,13 @@ export const aiRouter = createTRPCRouter({ }); } + if (aiSettings.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Access denied", + }); + } + const provider = selectAIProvider(aiSettings); const model = provider(aiSettings.model); From 7c10610a5ac175c806c0a8e5904ba1e8145d3198 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 11:40:02 -0600 Subject: [PATCH 03/12] feat: add readLogs procedure to multiple routers for container log retrieval - Implemented a new `readLogs` procedure across various routers (application, compose, libsql, mariadb, mongo, mysql, postgres, redis) to enable users to retrieve logs from containers. - Each procedure includes input validation for parameters such as `tail`, `since`, and `search`, ensuring robust access control and authorization checks. - Enhanced the `getContainerLogs` service to support fetching logs from both Docker containers and services, improving the logging capabilities of the application. This feature enhances observability and troubleshooting for users by providing direct access to container logs. --- .../dokploy/server/api/routers/application.ts | 38 ++++++++- apps/dokploy/server/api/routers/compose.ts | 42 +++++++++- apps/dokploy/server/api/routers/libsql.ts | 36 +++++++++ apps/dokploy/server/api/routers/mariadb.ts | 38 ++++++++- apps/dokploy/server/api/routers/mongo.ts | 36 +++++++++ apps/dokploy/server/api/routers/mysql.ts | 36 +++++++++ apps/dokploy/server/api/routers/postgres.ts | 36 +++++++++ apps/dokploy/server/api/routers/redis.ts | 36 +++++++++ packages/server/src/services/docker.ts | 81 +++++++++++++++++++ 9 files changed, 376 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index c00514715..228c98656 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -6,7 +6,9 @@ import { findEnvironmentById, findGitProviderById, findProjectById, + getAccessibleServerIds, getApplicationStats, + getContainerLogs, IS_CLOUD, mechanizeDockerContainer, readConfig, @@ -26,7 +28,6 @@ import { updateDeploymentStatus, writeConfig, writeConfigRemote, - getAccessibleServerIds, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -1101,4 +1102,39 @@ export const applicationRouter = createTRPCRouter({ total: countResult[0]?.count ?? 0, }; }), + + readLogs: protectedProcedure + .input( + apiFindOneApplication.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.applicationId, "read"); + const application = await findApplicationById(input.applicationId); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await getContainerLogs( + application.appName, + input.tail, + input.since, + input.search, + application.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index be5a836d7..dd632466b 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -16,7 +16,9 @@ import { findGitProviderById, findProjectById, findServerById, + getAccessibleServerIds, getComposeContainer, + getContainerLogs, getWebServerSettings, IS_CLOUD, loadServices, @@ -30,7 +32,6 @@ import { stopCompose, updateCompose, updateDeploymentStatus, - getAccessibleServerIds, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -1130,4 +1131,43 @@ export const composeRouter = createTRPCRouter({ total: countResult[0]?.count ?? 0, }; }), + + readLogs: protectedProcedure + .input( + apiFindCompose.extend({ + containerId: z + .string() + .min(1) + .regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container id."), + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.composeId, "read"); + const compose = await findComposeById(input.composeId); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this compose", + }); + } + return await getContainerLogs( + input.containerId, + input.tail, + input.since, + input.search, + compose.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/libsql.ts b/apps/dokploy/server/api/routers/libsql.ts index 65052097e..47798393e 100644 --- a/apps/dokploy/server/api/routers/libsql.ts +++ b/apps/dokploy/server/api/routers/libsql.ts @@ -6,6 +6,7 @@ import { findEnvironmentById, findLibsqlById, findProjectById, + getContainerLogs, IS_CLOUD, rebuildDatabase, removeLibsqlById, @@ -466,4 +467,39 @@ export const libsqlRouter = createTRPCRouter({ }); return true; }), + + readLogs: protectedProcedure + .input( + apiFindOneLibsql.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.libsqlId, "read"); + const libsql = await findLibsqlById(input.libsqlId); + if ( + libsql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this LibSQL", + }); + } + return await getContainerLogs( + libsql.appName, + input.tail, + input.since, + input.search, + libsql.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index a31554bec..a58a33a9c 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -9,6 +9,8 @@ import { findEnvironmentById, findMariadbById, findProjectById, + getAccessibleServerIds, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -19,7 +21,6 @@ import { stopService, stopServiceRemote, updateMariadbById, - getAccessibleServerIds, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { @@ -590,4 +591,39 @@ export const mariadbRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneMariaDB.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.mariadbId, "read"); + const mariadb = await findMariadbById(input.mariadbId); + if ( + mariadb.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MariaDB", + }); + } + return await getContainerLogs( + mariadb.appName, + input.tail, + input.since, + input.search, + mariadb.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index f0f5c593a..222917b9f 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -10,6 +10,7 @@ import { findMongoById, findProjectById, getAccessibleServerIds, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -601,4 +602,39 @@ export const mongoRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneMongo.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.mongoId, "read"); + const mongo = await findMongoById(input.mongoId); + if ( + mongo.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MongoDB", + }); + } + return await getContainerLogs( + mongo.appName, + input.tail, + input.since, + input.search, + mongo.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index f2a750c04..263fa53f0 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -9,6 +9,7 @@ import { findEnvironmentById, findMySqlById, findProjectById, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -604,4 +605,39 @@ export const mysqlRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneMySql.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.mysqlId, "read"); + const mysql = await findMySqlById(input.mysqlId); + if ( + mysql.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this MySQL", + }); + } + return await getContainerLogs( + mysql.appName, + input.tail, + input.since, + input.search, + mysql.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 0baeb3f0e..33d8fd3f4 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -9,6 +9,7 @@ import { findEnvironmentById, findPostgresById, findProjectById, + getContainerLogs, getMountPath, getServiceContainerCommand, IS_CLOUD, @@ -614,4 +615,39 @@ export const postgresRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOnePostgres.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.postgresId, "read"); + const postgres = await findPostgresById(input.postgresId); + if ( + postgres.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Postgres", + }); + } + return await getContainerLogs( + postgres.appName, + input.tail, + input.since, + input.search, + postgres.serverId, + ); + }), }); diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index 2d7b20674..a1e912e0b 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -8,6 +8,7 @@ import { findEnvironmentById, findProjectById, findRedisById, + getContainerLogs, getServiceContainerCommand, IS_CLOUD, rebuildDatabase, @@ -587,4 +588,39 @@ export const redisRouter = createTRPCRouter({ ]); return { items, total: countResult[0]?.count ?? 0 }; }), + + readLogs: protectedProcedure + .input( + apiFindOneRedis.extend({ + tail: z.number().int().min(1).max(10000).default(100), + since: z + .string() + .regex(/^(all|\d+[smhd])$/, "Invalid since format") + .default("all"), + search: z + .string() + .regex(/^[a-zA-Z0-9 ._-]{0,500}$/) + .optional(), + }), + ) + .query(async ({ input, ctx }) => { + await checkServiceAccess(ctx, input.redisId, "read"); + const redis = await findRedisById(input.redisId); + if ( + redis.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this Redis", + }); + } + return await getContainerLogs( + redis.appName, + input.tail, + input.since, + input.search, + redis.serverId, + ); + }), }); diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 1205e62c2..2b14d0b23 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -354,6 +354,87 @@ export const getContainersByAppLabel = async ( return []; }; +export const getContainerLogs = async ( + appName: string, + tail = 100, + since = "all", + search?: string, + serverId?: string | null, +): Promise => { + // First, find the real container ID by appName filter + const findCommand = `docker ps -q --filter "name=^${appName}" | head -1`; + const findResult = serverId + ? await execAsyncRemote(serverId, findCommand) + : await execAsync(findCommand); + + const containerId = findResult.stdout.trim(); + if (!containerId) { + // Fallback: try as a swarm service + const svcCommand = `docker service ls -q --filter "name=${appName}" | head -1`; + const svcResult = serverId + ? await execAsyncRemote(serverId, svcCommand) + : await execAsync(svcCommand); + + const serviceId = svcResult.stdout.trim(); + if (!serviceId) { + throw new Error(`No container or service found for: ${appName}`); + } + + // Use docker service logs for swarm + const sinceFlag = since === "all" ? "" : `--since ${since}`; + const baseCommand = `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${appName}`; + const escapedSearch = search?.replace(/'/g, "'\\''") ?? ""; + const command = search + ? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'` + : `${baseCommand} 2>&1`; + + try { + const result = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + return result.stdout; + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "stdout" in error && + typeof (error as { stdout: string }).stdout === "string" && + (error as { stdout: string }).stdout.length > 0 + ) { + return (error as { stdout: string }).stdout; + } + throw error; + } + } + + const sinceFlag = since === "all" ? "" : `--since ${since}`; + const baseCommand = `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${containerId}`; + + const escapedSearch = search?.replace(/'/g, "'\\''") ?? ""; + const command = search + ? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'` + : `${baseCommand} 2>&1`; + + try { + const result = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + return result.stdout; + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "stdout" in error && + typeof (error as { stdout: string }).stdout === "string" && + (error as { stdout: string }).stdout.length > 0 + ) { + return (error as { stdout: string }).stdout; + } + throw error; + } +}; + export const containerRestart = async (containerId: string) => { try { const { stdout, stderr } = await execAsync( From b8db120432c966810f88ee66cf638f0be7fd0c9d Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 11:41:01 -0600 Subject: [PATCH 04/12] refactor: enhance getContainerLogs function to support app name or ID - Updated the `getContainerLogs` function to accept either an application name or container ID, improving flexibility in log retrieval. - Simplified the command execution logic by consolidating the remote and local execution paths. - Added a new parameter to directly use container IDs, streamlining the process for users. These changes enhance the usability of the logging feature, allowing for more efficient access to container logs. --- apps/dokploy/server/api/routers/compose.ts | 1 + packages/server/src/services/docker.ts | 72 ++++++++-------------- 2 files changed, 28 insertions(+), 45 deletions(-) diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index dd632466b..d395bdffc 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -1168,6 +1168,7 @@ export const composeRouter = createTRPCRouter({ input.since, input.search, compose.serverId, + true, ); }), }); diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 2b14d0b23..eb218b1e1 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -355,60 +355,45 @@ export const getContainersByAppLabel = async ( }; export const getContainerLogs = async ( - appName: string, + appNameOrId: string, tail = 100, since = "all", search?: string, serverId?: string | null, + useContainerIdDirectly = false, ): Promise => { - // First, find the real container ID by appName filter - const findCommand = `docker ps -q --filter "name=^${appName}" | head -1`; - const findResult = serverId - ? await execAsyncRemote(serverId, findCommand) - : await execAsync(findCommand); + const exec = (cmd: string) => + serverId ? execAsyncRemote(serverId, cmd) : execAsync(cmd); - const containerId = findResult.stdout.trim(); - if (!containerId) { - // Fallback: try as a swarm service - const svcCommand = `docker service ls -q --filter "name=${appName}" | head -1`; - const svcResult = serverId - ? await execAsyncRemote(serverId, svcCommand) - : await execAsync(svcCommand); + let target = appNameOrId; + let isService = false; - const serviceId = svcResult.stdout.trim(); - if (!serviceId) { - throw new Error(`No container or service found for: ${appName}`); - } + if (!useContainerIdDirectly) { + // Find the real container ID by appName filter + const findResult = await exec( + `docker ps -q --filter "name=^${appNameOrId}" | head -1`, + ); + const containerId = findResult.stdout.trim(); - // Use docker service logs for swarm - const sinceFlag = since === "all" ? "" : `--since ${since}`; - const baseCommand = `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${appName}`; - const escapedSearch = search?.replace(/'/g, "'\\''") ?? ""; - const command = search - ? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'` - : `${baseCommand} 2>&1`; - - try { - const result = serverId - ? await execAsyncRemote(serverId, command) - : await execAsync(command); - return result.stdout; - } catch (error: unknown) { - if ( - error && - typeof error === "object" && - "stdout" in error && - typeof (error as { stdout: string }).stdout === "string" && - (error as { stdout: string }).stdout.length > 0 - ) { - return (error as { stdout: string }).stdout; + if (!containerId) { + // Fallback: try as a swarm service + const svcResult = await exec( + `docker service ls -q --filter "name=${appNameOrId}" | head -1`, + ); + const serviceId = svcResult.stdout.trim(); + if (!serviceId) { + throw new Error(`No container or service found for: ${appNameOrId}`); } - throw error; + isService = true; + } else { + target = containerId; } } const sinceFlag = since === "all" ? "" : `--since ${since}`; - const baseCommand = `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${containerId}`; + const baseCommand = isService + ? `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${target}` + : `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${target}`; const escapedSearch = search?.replace(/'/g, "'\\''") ?? ""; const command = search @@ -416,10 +401,7 @@ export const getContainerLogs = async ( : `${baseCommand} 2>&1`; try { - const result = serverId - ? await execAsyncRemote(serverId, command) - : await execAsync(command); - + const result = await exec(command); return result.stdout; } catch (error: unknown) { if ( From 6c3578a475f0479d2ed80b5253cd31c23308bb60 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 11:44:55 -0600 Subject: [PATCH 05/12] feat: enhance AnalyzeLogs component with AI provider configuration prompt - Updated the AnalyzeLogs component to display a message and button for configuring AI providers when none are available, improving user guidance. - Added a link to the settings page for easy access to AI provider configuration. - Integrated new icon for the configuration button to enhance UI clarity. These changes improve the user experience by ensuring users are informed about the need to set up AI providers for log analysis. --- .../dashboard/docker/logs/analyze-logs.tsx | 90 +++++++++++-------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx index c26ce5bfe..267735eac 100644 --- a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx @@ -1,5 +1,6 @@ "use client"; -import { Bot, Loader2, RotateCcw, X } from "lucide-react"; +import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react"; +import Link from "next/link"; import { useState } from "react"; import ReactMarkdown from "react-markdown"; import { toast } from "sonner"; @@ -91,42 +92,57 @@ export function AnalyzeLogs({ logs, context }: Props) {
{!data?.analysis ? ( - <> - - - + providers && providers.length === 0 ? ( +
+

+ No AI providers configured. Set up a provider to start + analyzing logs. +

+ +
+ ) : ( + <> + + + + ) ) : ( <>
From 825e6b654c11f302f4353e2166e566b6e2859c5a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 16:25:36 -0600 Subject: [PATCH 06/12] fix: prevent orphaned containers when deleting compose services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands were chained with && so if the project directory was missing, cd would fail and docker compose down would never execute — leaving containers and volumes running. Use semicolons to run each command independently, matching the existing stack deletion pattern. Closes #4064 --- packages/server/src/services/compose.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 5db6526a6..0cec3418b 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -440,17 +440,16 @@ export const removeCompose = async ( } } else { const command = ` - docker network disconnect ${compose.appName} dokploy-traefik; - cd ${projectPath} && env -i PATH="$PATH" docker compose -p ${compose.appName} down ${ + docker network disconnect ${compose.appName} dokploy-traefik; + env -i PATH="$PATH" docker compose -p ${compose.appName} down ${ deleteVolumes ? "--volumes" : "" - } && rm -rf ${projectPath}`; + }; + rm -rf ${projectPath}`; if (compose.serverId) { await execAsyncRemote(compose.serverId, command); } else { - await execAsync(command, { - cwd: projectPath, - }); + await execAsync(command); } } } catch (error) { From cb64482649cba0717a55da30c181ecf4bc45527c Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 17:06:09 -0600 Subject: [PATCH 07/12] fix: inject COMPOSE_PROJECT_NAME to prevent orphaned containers on redeploy When users set a custom docker compose command without the -p flag, Docker Compose defaults to using the directory name (code) as the project name. If the custom command is later removed, Dokploy uses -p appName, creating a new stack while the old one remains running. Injecting COMPOSE_PROJECT_NAME=appName into the .env ensures the project name is always consistent regardless of the command used. Closes #4019 --- packages/server/src/utils/builders/compose.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 316570626..8da1b6678 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -106,6 +106,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => { const envFilePath = join(dirname(composeFilePath), ".env"); let envContent = `APP_NAME=${appName}\n`; + envContent += `COMPOSE_PROJECT_NAME=${appName}\n`; envContent += env || ""; if (!envContent.includes("DOCKER_CONFIG")) { envContent += "\nDOCKER_CONFIG=/root/.docker"; From b079cbd427940d9370aa5b103fbc8eff6b16f2ae Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 17:25:04 -0600 Subject: [PATCH 08/12] fix: add runtime type guard for cpu.value in monitoring tab Closes #4062 --- .../free/container/show-free-container-monitoring.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 fd666255c..96cd41bdd 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 @@ -220,11 +220,11 @@ export const ContainerFreeMonitoring = ({
- Used: {currentData.cpu.value} + Used: {String(currentData.cpu.value ?? "0%")} Date: Thu, 9 Apr 2026 17:35:42 -0600 Subject: [PATCH 09/12] fix: swap stripPrefix and addPrefix middleware order in Traefik domain config When both stripPath and internalPath are configured, addPrefix was pushed before stripPrefix causing incorrect path rewriting (e.g. /app/v2/public/api instead of /app/v2/api). Traefik executes middlewares in array order, so stripPrefix must come first. Closes #4061 --- apps/dokploy/__test__/traefik/traefik.test.ts | 20 +++++++++++++++++++ packages/server/src/utils/traefik/domain.ts | 12 ++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index fe7d0ff9d..14d45f76c 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -424,6 +424,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () = expect(router.entryPoints).toEqual(["custom"]); }); +test("stripPath and internalPath together: stripprefix must come before addprefix", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + path: "/public", + stripPath: true, + internalPath: "/app/v2", + }, + "web", + ); + + const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1; + const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1; + + expect(stripIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeGreaterThanOrEqual(0); + expect(stripIndex).toBeLessThan(addIndex); +}); + test("Custom entrypoint with https and custom cert resolver", async () => { const router = await createRouterConfig( baseApp, diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts index c1745ddab..596758b33 100644 --- a/packages/server/src/utils/traefik/domain.ts +++ b/packages/server/src/utils/traefik/domain.ts @@ -151,16 +151,18 @@ export const createRouterConfig = async ( routerConfig.middlewares?.push("redirect-to-https"); } else { // Add path rewriting middleware if needed - if (internalPath && internalPath !== "/" && internalPath !== path) { - const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`; - routerConfig.middlewares?.push(pathMiddleware); - } - + // stripPrefix must come before addPrefix so Traefik strips the + // public path first, then prepends the internal path. if (stripPath && path && path !== "/") { const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`; routerConfig.middlewares?.push(stripMiddleware); } + if (internalPath && internalPath !== "/" && internalPath !== path) { + const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`; + routerConfig.middlewares?.push(pathMiddleware); + } + // redirects - skip for preview deployments as wildcard subdomains // should not inherit parent redirect rules (e.g., www-redirect) if (domain.domainType !== "preview") { From 9687ed0d831671f468706c0fec7b650899d5e080 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 11 Apr 2026 00:18:23 -0600 Subject: [PATCH 10/12] feat: add invoice notification settings and email notifications for payments - Introduced a new feature allowing users to enable or disable invoice email notifications in the billing settings. - Implemented email notifications for successful invoice payments and payment failures, enhancing user communication regarding billing. - Updated the database schema to include a new column for storing user preferences on invoice notifications. - Added corresponding email templates for invoice notifications and payment failure alerts. These changes improve user experience by keeping users informed about their billing status and actions required. --- .../settings/billing/show-billing.tsx | 83 +- .../welcome-stripe/welcome-subscription.tsx | 16 +- .../drizzle/0165_abnormal_greymalkin.sql | 1 + apps/dokploy/drizzle/meta/0165_snapshot.json | 8312 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + apps/dokploy/pages/api/stripe/webhook.ts | 14 +- apps/dokploy/server/api/routers/stripe.ts | 16 + .../server/utils/stripe-notifications.ts | 119 + packages/server/src/db/schema/user.ts | 3 + .../emails/emails/invoice-notification.tsx | 171 + .../src/emails/emails/payment-failed.tsx | 175 + .../server/src/utils/notifications/utils.ts | 2 + .../verification/send-verification-email.tsx | 3 + 13 files changed, 8911 insertions(+), 11 deletions(-) create mode 100644 apps/dokploy/drizzle/0165_abnormal_greymalkin.sql create mode 100644 apps/dokploy/drizzle/meta/0165_snapshot.json create mode 100644 apps/dokploy/server/utils/stripe-notifications.ts create mode 100644 packages/server/src/emails/emails/invoice-notification.tsx create mode 100644 packages/server/src/emails/emails/payment-failed.tsx diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index c740f8211..cbc363dcd 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js"; import clsx from "clsx"; import { AlertTriangle, + Bell, CheckIcon, CreditCard, FileText, @@ -25,7 +26,17 @@ import { CardTitle, } from "@/components/ui/card"; import { NumberInput } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; +import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; @@ -90,6 +101,8 @@ export const ShowBilling = () => { api.stripe.createCustomerPortalSession.useMutation(); const { mutateAsync: upgradeSubscription, isPending: isUpgrading } = api.stripe.upgradeSubscription.useMutation(); + const { mutateAsync: updateInvoiceNotifications } = + api.stripe.updateInvoiceNotifications.useMutation(); const utils = api.useUtils(); const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1); @@ -151,14 +164,68 @@ export const ShowBilling = () => {
- - - - Billing - - - Manage your subscription and invoices - + +
+ + + Billing + + + Manage your subscription and invoices + +
+ {(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && ( + + + + + + + Notification Settings + + Configure your billing email notifications. + + +
+
+ +

+ Receive email notifications for payments and failed + charges. +

+
+ { + await updateInvoiceNotifications({ + enabled: checked, + }) + .then(() => { + utils.user.get.invalidate(); + toast.success( + checked + ? "Invoice notifications enabled" + : "Invoice notifications disabled", + ); + }) + .catch(() => { + toast.error( + "Failed to update invoice notifications", + ); + }); + }} + /> +
+
+
+ )}