mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
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.
This commit is contained in:
parent
a60b0af419
commit
daba2d7fbc
@ -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<string, BadgeVariant> = {
|
||||||
|
container: "blue",
|
||||||
|
image: "green",
|
||||||
|
volume: "yellow",
|
||||||
|
network: "orange",
|
||||||
|
service: "blue",
|
||||||
|
node: "orange",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_VARIANTS: Record<string, BadgeVariant> = {
|
||||||
|
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 (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Activity className="size-6 text-muted-foreground self-center" />
|
||||||
|
Docker Events
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Events reported by the Docker daemon, equivalent to running
|
||||||
|
"docker events".
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-4 mr-1 ${isRefetching ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 py-8 border-t">
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error.message}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by type, action or name..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={String(minutes)}
|
||||||
|
onValueChange={(value) => setMinutes(Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RANGE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={String(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[110px]">Time</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Type</TableHead>
|
||||||
|
<TableHead className="w-[110px]">Action</TableHead>
|
||||||
|
<TableHead>Resource</TableHead>
|
||||||
|
<TableHead>Attributes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
|
<span>Loading events...</span>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : 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 (
|
||||||
|
<TableRow
|
||||||
|
key={`${event.time}-${event.Action}-${index}`}
|
||||||
|
>
|
||||||
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{event.time
|
||||||
|
? new Date(event.time * 1000).toLocaleTimeString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getTypeVariant(event.Type)}>
|
||||||
|
{event.Type ?? "unknown"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getActionVariant(event.Action)}>
|
||||||
|
{event.Action ?? "-"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-[220px] truncate font-mono text-xs"
|
||||||
|
title={resource}
|
||||||
|
>
|
||||||
|
{resource}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-[360px] truncate text-xs text-muted-foreground"
|
||||||
|
title={attributesText}
|
||||||
|
>
|
||||||
|
{attributesText}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No events found in the selected time range.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,6 +4,7 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { ShowDockerEvents } from "@/components/dashboard/docker/events/show-docker-events";
|
||||||
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
|
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
|
||||||
import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes";
|
import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes";
|
||||||
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
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 { Card } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const DEFAULT_TAB = "containers";
|
const DEFAULT_TAB = "containers";
|
||||||
const DEFAULT_SWARM_TAB = "overview";
|
const DEFAULT_SWARM_TAB = "overview";
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
|
|
||||||
const queryTab =
|
const activeTab =
|
||||||
typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
|
typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
|
||||||
const activeTab = isCloud && queryTab === "networks" ? DEFAULT_TAB : queryTab;
|
|
||||||
|
|
||||||
const activeSwarmTab =
|
const activeSwarmTab =
|
||||||
typeof router.query.subtab === "string"
|
typeof router.query.subtab === "string"
|
||||||
@ -66,7 +64,8 @@ const Dashboard = () => {
|
|||||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||||
<TabsTrigger value="swarm">Swarm</TabsTrigger>
|
<TabsTrigger value="swarm">Swarm</TabsTrigger>
|
||||||
<TabsTrigger value="volumes">Volumes</TabsTrigger>
|
<TabsTrigger value="volumes">Volumes</TabsTrigger>
|
||||||
{!isCloud && <TabsTrigger value="networks">Networks</TabsTrigger>}
|
<TabsTrigger value="networks">Networks</TabsTrigger>
|
||||||
|
<TabsTrigger value="events">Events</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="containers">
|
<TabsContent value="containers">
|
||||||
<ShowContainers serverId={serverId} />
|
<ShowContainers serverId={serverId} />
|
||||||
@ -96,11 +95,12 @@ const Dashboard = () => {
|
|||||||
<TabsContent value="volumes">
|
<TabsContent value="volumes">
|
||||||
<ShowVolumes serverId={serverId} />
|
<ShowVolumes serverId={serverId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{!isCloud && (
|
<TabsContent value="networks">
|
||||||
<TabsContent value="networks">
|
<ShowNetworks serverId={serverId} />
|
||||||
<ShowNetworks serverId={serverId} />
|
</TabsContent>
|
||||||
</TabsContent>
|
<TabsContent value="events">
|
||||||
)}
|
<ShowDockerEvents serverId={serverId} />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</ServerFilter>
|
</ServerFilter>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
getContainers,
|
getContainers,
|
||||||
getContainersByAppLabel,
|
getContainersByAppLabel,
|
||||||
getContainersByAppNameMatch,
|
getContainersByAppNameMatch,
|
||||||
|
getDockerEvents,
|
||||||
getServiceContainersByAppName,
|
getServiceContainersByAppName,
|
||||||
getStackContainersByAppName,
|
getStackContainersByAppName,
|
||||||
listContainerFiles,
|
listContainerFiles,
|
||||||
@ -424,4 +425,21 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
resourceName: `${input.containerId}:${input.path}`,
|
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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -871,3 +871,57 @@ export const deleteContainerFile = async (
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface DockerEvent {
|
||||||
|
Type?: string;
|
||||||
|
Action?: string;
|
||||||
|
Actor?: {
|
||||||
|
ID?: string;
|
||||||
|
Attributes?: Record<string, string>;
|
||||||
|
};
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user