From 7c10610a5ac175c806c0a8e5904ba1e8145d3198 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Thu, 9 Apr 2026 11:40:02 -0600 Subject: [PATCH] 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(