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..8a6d76db8 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx @@ -0,0 +1,425 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { Activity, ArrowUpDown, 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]; +type EventRow = DockerEvent & { key: string }; + +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"; + +const getResource = (event: DockerEvent) => + event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-"; + +const getAttributesText = (event: DockerEvent) => { + const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter( + ([key]) => key !== "name", + ); + return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-"; +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +export const ShowDockerEvents = ({ serverId }: Props) => { + const [minutes, setMinutes] = useState(15); + const [search, setSearch] = useState(""); + const [sorting, setSorting] = useState([ + { id: "time", desc: true }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 15, + }); + + const { data, isLoading, isRefetching, refetch, error } = + api.docker.getEvents.useQuery({ + serverId, + minutes, + }); + + const events = useMemo( + () => + (data?.events ?? []).map((event, index) => ({ + ...event, + key: `${event.time}-${event.Action}-${index}`, + })), + [data], + ); + + 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]); + + const columns = useMemo[]>( + () => [ + { + id: "time", + accessorFn: (event) => event.time ?? 0, + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.time + ? new Date(row.original.time * 1000).toLocaleTimeString() + : "-"} + + ), + }, + { + id: "type", + accessorFn: (event) => event.Type ?? "", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.Type ?? "unknown"} + + ), + }, + { + id: "action", + accessorFn: (event) => event.Action ?? "", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.Action ?? "-"} + + ), + }, + { + id: "resource", + accessorFn: (event) => getResource(event), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const resource = getResource(row.original); + return ( +
+ {resource} +
+ ); + }, + }, + { + id: "attributes", + accessorFn: (event) => getAttributesText(event), + header: "Attributes", + enableSorting: false, + cell: ({ row }) => { + const attributesText = getAttributesText(row.original); + return ( +
+ {attributesText} +
+ ); + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredEvents, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ +
+
+ + + Docker Events + + + Events reported by the Docker daemon, equivalent to running + "docker events". + +
+ +
+
+ + {error && ( +

{error.message}

+ )} +
+ { + setSearch(e.target.value); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + className="max-w-xs" + /> + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {isLoading ? ( + + +
+ Loading events... + +
+
+
+ ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No events found in the selected time range. + + + )} +
+
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ Go to + { + if (e.key !== "Enter") return; + const value = Number( + (e.target as HTMLInputElement).value, + ); + if (!Number.isFinite(value)) return; + const page = Math.min( + Math.max(value, 1), + table.getPageCount(), + ); + table.setPageIndex(page - 1); + }} + onBlur={(e) => { + const value = Number(e.target.value); + if (!Number.isFinite(value)) return; + const page = Math.min( + Math.max(value, 1), + table.getPageCount(), + ); + table.setPageIndex(page - 1); + }} + className="w-16 h-8" + /> +
+
+ + +
+
+ )} +
+
+
+
+ ); +}; 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(), + }; +};