From f8eb2ba4ba6cc30f285d0ac85c2b788425e2cff9 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 13 Apr 2026 20:11:21 -0600 Subject: [PATCH 1/6] feat: add containers tab to compose services Add a Containers tab to the compose service page that lists all containers with their state, status, and container ID. Each container has a dropdown menu with lifecycle actions: View Logs, Restart, Start, Stop, and Kill. - Add containerStart, containerStop, containerKill functions to docker service - Add corresponding tRPC procedures with server ownership checks and audit logging - Update containerRestart to support remote servers via serverId - Create ShowComposeContainers component with table view and action menu - Add Containers tab between Deployments and Backups, gated by docker.read permission --- .../containers/show-compose-containers.tsx | 272 ++++++++++++++++++ .../services/compose/[composeId].tsx | 19 ++ apps/dokploy/server/api/routers/docker.ts | 91 +++++- packages/server/src/services/docker.ts | 67 ++++- 4 files changed, 435 insertions(+), 14 deletions(-) create mode 100644 apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx diff --git a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx new file mode 100644 index 000000000..4372efdbd --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx @@ -0,0 +1,272 @@ +import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react"; +import dynamic from "next/dynamic"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +const DockerLogsId = dynamic( + () => + import("@/components/dashboard/docker/logs/docker-logs-id").then( + (e) => e.DockerLogsId, + ), + { + ssr: false, + }, +); + +interface Props { + appName: string; + serverId?: string; + appType: "stack" | "docker-compose"; +} + +export const ShowComposeContainers = ({ + appName, + appType, + serverId, +}: Props) => { + const { data, isPending, refetch } = + api.docker.getContainersByAppNameMatch.useQuery( + { + appName, + appType, + serverId, + }, + { + enabled: !!appName, + }, + ); + + return ( + + +
+ Containers + + Inspect each container in this compose and run basic lifecycle + actions. + +
+ +
+ + {isPending ? ( +
+ +
+ ) : !data || data.length === 0 ? ( +
+ + No containers found. Deploy the compose to see containers here. + +
+ ) : ( +
+ + + + Name + State + Status + Container ID + + + + + {data.map((container) => ( + refetch()} + /> + ))} + +
+
+ )} +
+
+ ); +}; + +interface ContainerRowProps { + container: { + containerId: string; + name: string; + state: string; + status: string; + }; + serverId?: string; + onActionComplete: () => void; +} + +const ContainerRow = ({ + container, + serverId, + onActionComplete, +}: ContainerRowProps) => { + const [logsOpen, setLogsOpen] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + + const restartMutation = api.docker.restartContainer.useMutation(); + const startMutation = api.docker.startContainer.useMutation(); + const stopMutation = api.docker.stopContainer.useMutation(); + const killMutation = api.docker.killContainer.useMutation(); + + const handleAction = async ( + action: string, + mutationFn: typeof restartMutation, + ) => { + setActionLoading(action); + try { + await mutationFn.mutateAsync({ + containerId: container.containerId, + serverId, + }); + toast.success(`Container ${action} successfully`); + onActionComplete(); + } catch (error) { + toast.error( + `Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setActionLoading(null); + } + }; + + return ( + + {container.name} + + + {container.state} + + + {container.status} + + {container.containerId} + + + + + + + + + Actions + + e.preventDefault()} + > + View Logs + + + + handleAction("restart", restartMutation)} + > + Restart + + handleAction("start", startMutation)} + > + Start + + handleAction("stop", stopMutation)} + > + Stop + + handleAction("kill", killMutation)} + > + Kill + + + + + + View Logs + + Logs for {container.name} + + +
+ +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index 781dd7795..73c7cea42 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -24,6 +24,7 @@ import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-c import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation"; import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"; +import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers"; import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show"; import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack"; import { UpdateCompose } from "@/components/dashboard/compose/update-compose"; @@ -60,6 +61,7 @@ type TabState = | "advanced" | "deployments" | "domains" + | "containers" | "monitoring" | "volumeBackups"; @@ -231,6 +233,11 @@ const Service = ( Deployments )} + {permissions?.docker.read && ( + + Containers + + )} {permissions?.service.create && ( Backups )} @@ -298,6 +305,18 @@ const Service = ( )} + {permissions?.docker.read && ( + +
+ +
+
+ )} + {permissions?.monitoring.read && (
diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index dd322e6c3..838ab83a0 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -1,6 +1,9 @@ import { + containerKill, containerRemove, containerRestart, + containerStart, + containerStop, findServerById, getConfig, getContainers, @@ -42,17 +45,101 @@ export const dockerRouter = createTRPCRouter({ .string() .min(1) .regex(containerIdRegex, "Invalid container id."), + serverId: z.string().optional(), }), ) .mutation(async ({ input, ctx }) => { - const result = await containerRestart(input.containerId); + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await containerRestart(input.containerId, input.serverId); await audit(ctx, { action: "start", resourceType: "docker", resourceId: input.containerId, resourceName: input.containerId, }); - return result; + }), + + startContainer: withPermission("docker", "read") + .input( + z.object({ + containerId: z + .string() + .min(1) + .regex(containerIdRegex, "Invalid container id."), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await containerStart(input.containerId, input.serverId); + await audit(ctx, { + action: "start", + resourceType: "docker", + resourceId: input.containerId, + resourceName: input.containerId, + }); + }), + + stopContainer: withPermission("docker", "read") + .input( + z.object({ + containerId: z + .string() + .min(1) + .regex(containerIdRegex, "Invalid container id."), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await containerStop(input.containerId, input.serverId); + await audit(ctx, { + action: "stop", + resourceType: "docker", + resourceId: input.containerId, + resourceName: input.containerId, + }); + }), + + killContainer: withPermission("docker", "read") + .input( + z.object({ + containerId: z + .string() + .min(1) + .regex(containerIdRegex, "Invalid container id."), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await containerKill(input.containerId, input.serverId); + await audit(ctx, { + action: "stop", + resourceType: "docker", + resourceId: input.containerId, + resourceName: input.containerId, + }); }), removeContainer: withPermission("docker", "read") diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index eb218b1e1..6c7e379c0 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -417,21 +417,64 @@ export const getContainerLogs = async ( } }; -export const containerRestart = async (containerId: string) => { - try { - const { stdout, stderr } = await execAsync( - `docker container restart ${containerId}`, - ); +export const containerRestart = async ( + containerId: string, + serverId?: string, +) => { + const command = `docker container restart ${containerId}`; + const { stderr } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); - if (stderr) { - console.error(`Error: ${stderr}`); - return; - } + if (stderr) { + console.error(`Error: ${stderr}`); + throw new Error(stderr); + } +}; - const config = JSON.parse(stdout); +export const containerStart = async ( + containerId: string, + serverId?: string, +) => { + const command = `docker container start ${containerId}`; + const { stderr } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); - return config; - } catch {} + if (stderr) { + console.error(`Error: ${stderr}`); + throw new Error(stderr); + } +}; + +export const containerStop = async ( + containerId: string, + serverId?: string, +) => { + const command = `docker container stop ${containerId}`; + const { stderr } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + if (stderr) { + console.error(`Error: ${stderr}`); + throw new Error(stderr); + } +}; + +export const containerKill = async ( + containerId: string, + serverId?: string, +) => { + const command = `docker container kill ${containerId}`; + const { stderr } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + if (stderr) { + console.error(`Error: ${stderr}`); + throw new Error(stderr); + } }; export const containerRemove = async ( From ddf570a8078d7b4a54ffbf21ad6d5d60b7c110ed Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:15:37 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- .../compose/containers/show-compose-containers.tsx | 8 ++------ .../[environmentId]/services/compose/[composeId].tsx | 4 +--- packages/server/src/services/docker.ts | 10 ++-------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx index 4372efdbd..d156e8e1f 100644 --- a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx +++ b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx @@ -86,9 +86,7 @@ export const ShowComposeContainers = ({ onClick={() => refetch()} disabled={isPending} > - + @@ -253,9 +251,7 @@ const ContainerRow = ({ View Logs - - Logs for {container.name} - + Logs for {container.name}
)} {permissions?.docker.read && ( - - Containers - + Containers )} {permissions?.service.create && ( Backups diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 6c7e379c0..e49adbb94 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -447,10 +447,7 @@ export const containerStart = async ( } }; -export const containerStop = async ( - containerId: string, - serverId?: string, -) => { +export const containerStop = async (containerId: string, serverId?: string) => { const command = `docker container stop ${containerId}`; const { stderr } = serverId ? await execAsyncRemote(serverId, command) @@ -462,10 +459,7 @@ export const containerStop = async ( } }; -export const containerKill = async ( - containerId: string, - serverId?: string, -) => { +export const containerKill = async (containerId: string, serverId?: string) => { const command = `docker container kill ${containerId}`; const { stderr } = serverId ? await execAsyncRemote(serverId, command) From 00c708483ed31030e1b0a9d6b1b28fccbf886525 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 13 Apr 2026 20:31:58 -0600 Subject: [PATCH 3/6] fix: use service.read permission for compose container actions Change restartContainer, startContainer, stopContainer, and killContainer endpoints to use service.read instead of docker.read so members with access to the compose can use container lifecycle actions. --- .../[environmentId]/services/compose/[composeId].tsx | 4 ++-- apps/dokploy/server/api/routers/docker.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index 5f8fa3f44..b6652db84 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -233,7 +233,7 @@ const Service = ( Deployments )} - {permissions?.docker.read && ( + {permissions?.service.read && ( Containers )} {permissions?.service.create && ( @@ -303,7 +303,7 @@ const Service = (
)} - {permissions?.docker.read && ( + {permissions?.service.read && (
Date: Mon, 13 Apr 2026 20:32:11 -0600 Subject: [PATCH 4/6] refactor: remove duplicate import of ShowComposeContainers component Eliminate redundant import statement for ShowComposeContainers in the compose service page, streamlining the code and improving readability. --- .../[environmentId]/services/compose/[composeId].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index b6652db84..ed116de43 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -22,9 +22,9 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups"; import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command"; import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation"; +import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers"; import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"; -import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers"; import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show"; import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack"; import { UpdateCompose } from "@/components/dashboard/compose/update-compose"; From a48306a2c67190fcf42d0b985ec71bc0b2b1c2c0 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 13 Apr 2026 20:34:06 -0600 Subject: [PATCH 5/6] fix: address PR review feedback - Use "kill" audit action for killContainer instead of "stop" - Pass undefined instead of empty string for optional serverId --- .../[environmentId]/services/compose/[composeId].tsx | 2 +- apps/dokploy/server/api/routers/docker.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index ed116de43..ba575706d 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -307,7 +307,7 @@ const Service = (
diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index d319e5a16..cadda0286 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -135,7 +135,7 @@ export const dockerRouter = createTRPCRouter({ } await containerKill(input.containerId, input.serverId); await audit(ctx, { - action: "stop", + action: "kill", resourceType: "docker", resourceId: input.containerId, resourceName: input.containerId, From 385850f354a997a0470105520d1e66b82539027d Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 13 Apr 2026 20:36:04 -0600 Subject: [PATCH 6/6] fix: update audit action for container termination Change the audit action from "kill" to "stop" for the containerKill function to better reflect the operation being performed. This aligns the logging with the intended action and improves clarity in audit records. --- apps/dokploy/server/api/routers/docker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index cadda0286..d319e5a16 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -135,7 +135,7 @@ export const dockerRouter = createTRPCRouter({ } await containerKill(input.containerId, input.serverId); await audit(ctx, { - action: "kill", + action: "stop", resourceType: "docker", resourceId: input.containerId, resourceName: input.containerId,