Skip to content

Commit 1a8fd83

Browse files
authored
Merge pull request #4218 from Dokploy/feat/compose-containers-tab
feat: add containers tab to compose services
2 parents 3cefa43 + 385850f commit 1a8fd83

4 files changed

Lines changed: 424 additions & 15 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
2+
import dynamic from "next/dynamic";
3+
import { useState } from "react";
4+
import { toast } from "sonner";
5+
import { Badge } from "@/components/ui/badge";
6+
import { Button } from "@/components/ui/button";
7+
import {
8+
Card,
9+
CardContent,
10+
CardDescription,
11+
CardHeader,
12+
CardTitle,
13+
} from "@/components/ui/card";
14+
import {
15+
Dialog,
16+
DialogContent,
17+
DialogDescription,
18+
DialogHeader,
19+
DialogTitle,
20+
DialogTrigger,
21+
} from "@/components/ui/dialog";
22+
import {
23+
DropdownMenu,
24+
DropdownMenuContent,
25+
DropdownMenuItem,
26+
DropdownMenuLabel,
27+
DropdownMenuSeparator,
28+
DropdownMenuTrigger,
29+
} from "@/components/ui/dropdown-menu";
30+
import {
31+
Table,
32+
TableBody,
33+
TableCell,
34+
TableHead,
35+
TableHeader,
36+
TableRow,
37+
} from "@/components/ui/table";
38+
import { api } from "@/utils/api";
39+
40+
const DockerLogsId = dynamic(
41+
() =>
42+
import("@/components/dashboard/docker/logs/docker-logs-id").then(
43+
(e) => e.DockerLogsId,
44+
),
45+
{
46+
ssr: false,
47+
},
48+
);
49+
50+
interface Props {
51+
appName: string;
52+
serverId?: string;
53+
appType: "stack" | "docker-compose";
54+
}
55+
56+
export const ShowComposeContainers = ({
57+
appName,
58+
appType,
59+
serverId,
60+
}: Props) => {
61+
const { data, isPending, refetch } =
62+
api.docker.getContainersByAppNameMatch.useQuery(
63+
{
64+
appName,
65+
appType,
66+
serverId,
67+
},
68+
{
69+
enabled: !!appName,
70+
},
71+
);
72+
73+
return (
74+
<Card className="bg-background">
75+
<CardHeader className="flex flex-row items-center justify-between">
76+
<div>
77+
<CardTitle className="text-xl">Containers</CardTitle>
78+
<CardDescription>
79+
Inspect each container in this compose and run basic lifecycle
80+
actions.
81+
</CardDescription>
82+
</div>
83+
<Button
84+
variant="outline"
85+
size="icon"
86+
onClick={() => refetch()}
87+
disabled={isPending}
88+
>
89+
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
90+
</Button>
91+
</CardHeader>
92+
<CardContent>
93+
{isPending ? (
94+
<div className="flex items-center justify-center h-[20vh]">
95+
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
96+
</div>
97+
) : !data || data.length === 0 ? (
98+
<div className="flex items-center justify-center h-[20vh]">
99+
<span className="text-muted-foreground">
100+
No containers found. Deploy the compose to see containers here.
101+
</span>
102+
</div>
103+
) : (
104+
<div className="rounded-md border">
105+
<Table>
106+
<TableHeader>
107+
<TableRow>
108+
<TableHead>Name</TableHead>
109+
<TableHead>State</TableHead>
110+
<TableHead>Status</TableHead>
111+
<TableHead>Container ID</TableHead>
112+
<TableHead className="text-right" />
113+
</TableRow>
114+
</TableHeader>
115+
<TableBody>
116+
{data.map((container) => (
117+
<ContainerRow
118+
key={container.containerId}
119+
container={container}
120+
serverId={serverId}
121+
onActionComplete={() => refetch()}
122+
/>
123+
))}
124+
</TableBody>
125+
</Table>
126+
</div>
127+
)}
128+
</CardContent>
129+
</Card>
130+
);
131+
};
132+
133+
interface ContainerRowProps {
134+
container: {
135+
containerId: string;
136+
name: string;
137+
state: string;
138+
status: string;
139+
};
140+
serverId?: string;
141+
onActionComplete: () => void;
142+
}
143+
144+
const ContainerRow = ({
145+
container,
146+
serverId,
147+
onActionComplete,
148+
}: ContainerRowProps) => {
149+
const [logsOpen, setLogsOpen] = useState(false);
150+
const [actionLoading, setActionLoading] = useState<string | null>(null);
151+
152+
const restartMutation = api.docker.restartContainer.useMutation();
153+
const startMutation = api.docker.startContainer.useMutation();
154+
const stopMutation = api.docker.stopContainer.useMutation();
155+
const killMutation = api.docker.killContainer.useMutation();
156+
157+
const handleAction = async (
158+
action: string,
159+
mutationFn: typeof restartMutation,
160+
) => {
161+
setActionLoading(action);
162+
try {
163+
await mutationFn.mutateAsync({
164+
containerId: container.containerId,
165+
serverId,
166+
});
167+
toast.success(`Container ${action} successfully`);
168+
onActionComplete();
169+
} catch (error) {
170+
toast.error(
171+
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
172+
);
173+
} finally {
174+
setActionLoading(null);
175+
}
176+
};
177+
178+
return (
179+
<TableRow>
180+
<TableCell className="font-medium">{container.name}</TableCell>
181+
<TableCell>
182+
<Badge
183+
variant={
184+
container.state === "running"
185+
? "default"
186+
: container.state === "exited"
187+
? "secondary"
188+
: "destructive"
189+
}
190+
>
191+
{container.state}
192+
</Badge>
193+
</TableCell>
194+
<TableCell>{container.status}</TableCell>
195+
<TableCell className="font-mono text-sm text-muted-foreground">
196+
{container.containerId}
197+
</TableCell>
198+
<TableCell className="text-right">
199+
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
200+
<DropdownMenu>
201+
<DropdownMenuTrigger asChild>
202+
<Button variant="ghost" className="h-8 w-8 p-0">
203+
{actionLoading ? (
204+
<Loader2 className="h-4 w-4 animate-spin" />
205+
) : (
206+
<MoreHorizontal className="h-4 w-4" />
207+
)}
208+
</Button>
209+
</DropdownMenuTrigger>
210+
<DropdownMenuContent align="end">
211+
<DropdownMenuLabel>Actions</DropdownMenuLabel>
212+
<DialogTrigger asChild>
213+
<DropdownMenuItem
214+
className="cursor-pointer"
215+
onSelect={(e) => e.preventDefault()}
216+
>
217+
View Logs
218+
</DropdownMenuItem>
219+
</DialogTrigger>
220+
<DropdownMenuSeparator />
221+
<DropdownMenuItem
222+
className="cursor-pointer"
223+
disabled={actionLoading !== null}
224+
onClick={() => handleAction("restart", restartMutation)}
225+
>
226+
Restart
227+
</DropdownMenuItem>
228+
<DropdownMenuItem
229+
className="cursor-pointer"
230+
disabled={actionLoading !== null}
231+
onClick={() => handleAction("start", startMutation)}
232+
>
233+
Start
234+
</DropdownMenuItem>
235+
<DropdownMenuItem
236+
className="cursor-pointer"
237+
disabled={actionLoading !== null}
238+
onClick={() => handleAction("stop", stopMutation)}
239+
>
240+
Stop
241+
</DropdownMenuItem>
242+
<DropdownMenuItem
243+
className="cursor-pointer text-red-500 focus:text-red-600"
244+
disabled={actionLoading !== null}
245+
onClick={() => handleAction("kill", killMutation)}
246+
>
247+
Kill
248+
</DropdownMenuItem>
249+
</DropdownMenuContent>
250+
</DropdownMenu>
251+
<DialogContent className="sm:max-w-7xl">
252+
<DialogHeader>
253+
<DialogTitle>View Logs</DialogTitle>
254+
<DialogDescription>Logs for {container.name}</DialogDescription>
255+
</DialogHeader>
256+
<div className="flex flex-col gap-4 pt-2.5">
257+
<DockerLogsId
258+
containerId={container.containerId}
259+
serverId={serverId}
260+
runType="native"
261+
/>
262+
</div>
263+
</DialogContent>
264+
</Dialog>
265+
</TableCell>
266+
</TableRow>
267+
);
268+
};

apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
2222
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
2323
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
2424
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
25+
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
2526
import { DeleteService } from "@/components/dashboard/compose/delete-service";
2627
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
2728
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
@@ -60,6 +61,7 @@ type TabState =
6061
| "advanced"
6162
| "deployments"
6263
| "domains"
64+
| "containers"
6365
| "monitoring"
6466
| "volumeBackups";
6567

@@ -231,6 +233,9 @@ const Service = (
231233
Deployments
232234
</TabsTrigger>
233235
)}
236+
{permissions?.service.read && (
237+
<TabsTrigger value="containers">Containers</TabsTrigger>
238+
)}
234239
{permissions?.service.create && (
235240
<TabsTrigger value="backups">Backups</TabsTrigger>
236241
)}
@@ -298,6 +303,18 @@ const Service = (
298303
</div>
299304
</TabsContent>
300305
)}
306+
{permissions?.service.read && (
307+
<TabsContent value="containers">
308+
<div className="flex flex-col gap-4 pt-2.5">
309+
<ShowComposeContainers
310+
serverId={data?.serverId || undefined}
311+
appName={data?.appName || ""}
312+
appType={data?.composeType || "docker-compose"}
313+
/>
314+
</div>
315+
</TabsContent>
316+
)}
317+
301318
{permissions?.monitoring.read && (
302319
<TabsContent value="monitoring">
303320
<div className="pt-2.5">

0 commit comments

Comments
 (0)