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..d156e8e1f --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx @@ -0,0 +1,268 @@ +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..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 @@ -22,6 +22,7 @@ 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 { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show"; @@ -60,6 +61,7 @@ type TabState = | "advanced" | "deployments" | "domains" + | "containers" | "monitoring" | "volumeBackups"; @@ -231,6 +233,9 @@ const Service = ( Deployments )} + {permissions?.service.read && ( + Containers + )} {permissions?.service.create && ( Backups )} @@ -298,6 +303,18 @@ const Service = ( )} + {permissions?.service.read && ( + +
+ +
+
+ )} + {permissions?.monitoring.read && (
diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index dd322e6c3..d319e5a16 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, @@ -35,24 +38,108 @@ export const dockerRouter = createTRPCRouter({ return await getContainers(input.serverId); }), - restartContainer: withPermission("docker", "read") + restartContainer: withPermission("service", "read") .input( z.object({ containerId: z .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("service", "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("service", "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("service", "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..e49adbb94 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -417,21 +417,58 @@ 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 (