From daba2d7fbcdbc1f0c1d26dfa9a0d2f6bb65330dc Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Wed, 12 Aug 2026 04:07:59 -0600 Subject: [PATCH] feat: add Docker events tab to docker dashboard Adds a new Events tab to /dashboard/docker showing Docker daemon events (equivalent to `docker events`), polled via docker.getEvents with a time-range selector and refresh button. Also fixes a stale isCloud redirect that forced the Networks tab back to Containers even though it is now shown in cloud. --- .../docker/events/show-docker-events.tsx | 245 ++++++++++++++++++ apps/dokploy/pages/dashboard/docker.tsx | 20 +- apps/dokploy/server/api/routers/docker.ts | 18 ++ packages/server/src/services/docker.ts | 54 ++++ 4 files changed, 327 insertions(+), 10 deletions(-) create mode 100644 apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx diff --git a/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx b/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx new file mode 100644 index 000000000..7e5ef7dc2 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx @@ -0,0 +1,245 @@ +import { Activity, Loader2, RefreshCw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api, type RouterOutputs } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number]; + +const RANGE_OPTIONS = [ + { label: "Last 5 minutes", value: 5 }, + { label: "Last 15 minutes", value: 15 }, + { label: "Last hour", value: 60 }, + { label: "Last 6 hours", value: 360 }, + { label: "Last 24 hours", value: 1440 }, +]; + +type BadgeVariant = "blue" | "green" | "yellow" | "orange" | "red" | "blank"; + +const TYPE_VARIANTS: Record = { + container: "blue", + image: "green", + volume: "yellow", + network: "orange", + service: "blue", + node: "orange", +}; + +const ACTION_VARIANTS: Record = { + create: "green", + start: "green", + pull: "green", + connect: "green", + die: "red", + destroy: "red", + kill: "red", + stop: "red", + remove: "red", + disconnect: "red", + pause: "yellow", + unpause: "yellow", +}; + +const getTypeVariant = (type?: string): BadgeVariant => + (type && TYPE_VARIANTS[type]) || "blank"; + +const getActionVariant = (action?: string): BadgeVariant => + (action && ACTION_VARIANTS[action]) || "blank"; + +export const ShowDockerEvents = ({ serverId }: Props) => { + const [minutes, setMinutes] = useState(15); + const [search, setSearch] = useState(""); + + const { data, isLoading, isRefetching, refetch, error } = + api.docker.getEvents.useQuery({ + serverId, + minutes, + }); + + const events = data?.events ?? []; + + const filteredEvents = useMemo(() => { + if (!search.trim()) return events; + const query = search.toLowerCase(); + return events.filter((event) => { + return ( + event.Type?.toLowerCase().includes(query) || + event.Action?.toLowerCase().includes(query) || + event.Actor?.Attributes?.name?.toLowerCase().includes(query) || + event.Actor?.ID?.toLowerCase().includes(query) + ); + }); + }, [events, search]); + + return ( +
+ +
+ +
+
+ + + Docker Events + + + Events reported by the Docker daemon, equivalent to running + "docker events". + +
+ +
+
+ + {error && ( +

{error.message}

+ )} +
+ setSearch(e.target.value)} + className="max-w-xs" + /> + +
+
+ + + + Time + Type + Action + Resource + Attributes + + + + {isLoading ? ( + + +
+ Loading events... + +
+
+
+ ) : filteredEvents.length ? ( + filteredEvents.map((event: DockerEvent, index: number) => { + const attributes = Object.entries( + event.Actor?.Attributes ?? {}, + ).filter(([key]) => key !== "name"); + const attributesText = + attributes + .map(([key, value]) => `${key}=${value}`) + .join(" ") || "-"; + const resource = + event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-"; + + return ( + + + {event.time + ? new Date(event.time * 1000).toLocaleTimeString() + : "-"} + + + + {event.Type ?? "unknown"} + + + + + {event.Action ?? "-"} + + + + {resource} + + + {attributesText} + + + ); + }) + ) : ( + + + No events found in the selected time range. + + + )} +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/docker.tsx b/apps/dokploy/pages/dashboard/docker.tsx index c84e238a3..47bfc3201 100644 --- a/apps/dokploy/pages/dashboard/docker.tsx +++ b/apps/dokploy/pages/dashboard/docker.tsx @@ -4,6 +4,7 @@ import type { GetServerSidePropsContext } from "next"; import { useRouter } from "next/router"; import type { ReactElement } from "react"; import superjson from "superjson"; +import { ShowDockerEvents } from "@/components/dashboard/docker/events/show-docker-events"; import { ShowContainers } from "@/components/dashboard/docker/show/show-containers"; import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes"; import { ShowNetworks } from "@/components/dashboard/networks/show-networks"; @@ -15,18 +16,15 @@ import { ServerFilter } from "@/components/shared/server-filter"; import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { appRouter } from "@/server/api/root"; -import { api } from "@/utils/api"; const DEFAULT_TAB = "containers"; const DEFAULT_SWARM_TAB = "overview"; const Dashboard = () => { const router = useRouter(); - const { data: isCloud } = api.settings.isCloud.useQuery(); - const queryTab = + const activeTab = typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB; - const activeTab = isCloud && queryTab === "networks" ? DEFAULT_TAB : queryTab; const activeSwarmTab = typeof router.query.subtab === "string" @@ -66,7 +64,8 @@ const Dashboard = () => { Containers Swarm Volumes - {!isCloud && Networks} + Networks + Events @@ -96,11 +95,12 @@ const Dashboard = () => { - {!isCloud && ( - - - - )} + + + + + + )} diff --git a/apps/dokploy/server/api/routers/docker.ts b/apps/dokploy/server/api/routers/docker.ts index cc7fbc4c0..e025b3434 100644 --- a/apps/dokploy/server/api/routers/docker.ts +++ b/apps/dokploy/server/api/routers/docker.ts @@ -10,6 +10,7 @@ import { getContainers, getContainersByAppLabel, getContainersByAppNameMatch, + getDockerEvents, getServiceContainersByAppName, getStackContainersByAppName, listContainerFiles, @@ -424,4 +425,21 @@ export const dockerRouter = createTRPCRouter({ resourceName: `${input.containerId}:${input.path}`, }); }), + + getEvents: withPermission("docker", "read") + .input( + z.object({ + serverId: z.string().optional(), + minutes: z.number().min(1).max(1440).default(15), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getDockerEvents(input.serverId, input.minutes); + }), }); diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 9f9cf9100..65eb29041 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -871,3 +871,57 @@ export const deleteContainerFile = async ( throw error; } }; + +export interface DockerEvent { + Type?: string; + Action?: string; + Actor?: { + ID?: string; + Attributes?: Record; + }; + time?: number; +} + +export const getDockerEvents = async ( + serverId?: string | null, + minutes = 15, +) => { + const until = new Date(); + const since = new Date(until.getTime() - minutes * 60 * 1000); + const command = `docker events --since ${quote([since.toISOString()])} --until ${quote([until.toISOString()])} --format '{{json .}}'`; + + let stdout = ""; + let stderr = ""; + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + } + + const events: DockerEvent[] = stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as DockerEvent; + } catch { + return null; + } + }) + .filter((event): event is DockerEvent => event !== null) + .reverse(); + + return { + events, + fetchedAt: until.toISOString(), + }; +};