diff --git a/apps/dokploy/components/dashboard/project/monitoring/project-monitoring.tsx b/apps/dokploy/components/dashboard/project/monitoring/project-monitoring.tsx new file mode 100644 index 000000000..a86a94ecf --- /dev/null +++ b/apps/dokploy/components/dashboard/project/monitoring/project-monitoring.tsx @@ -0,0 +1,410 @@ +import { + Activity, + ChevronsUpDown, + ExternalLink, + Loader2, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { CompactContainerMonitoring } from "@/components/dashboard/monitoring/free/container/compact-container-monitoring"; +import { CompactPaidContainerMonitoring } from "@/components/dashboard/monitoring/paid/container/compact-paid-container-monitoring"; +import { + LibsqlIcon, + MariadbIcon, + MongodbIcon, + MysqlIcon, + PostgresqlIcon, + RedisIcon, +} from "@/components/icons/data-tools-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +export type MonitoringService = { + serverId?: string | null; + serverName?: string | null; + serverIp?: string | null; + metricsConfig?: any; + name: string; + appName?: string | null; + replicas?: number; + composeType?: "docker-compose" | "stack"; + type: + | "mariadb" + | "application" + | "postgres" + | "mysql" + | "mongo" + | "redis" + | "compose" + | "libsql"; + id: string; +}; + +interface Props { + projectId: string; + environmentId: string; + services: MonitoringService[]; +} + +const serviceTypeIcon = (type: MonitoringService["type"]) => { + switch (type) { + case "postgres": + return ; + case "mysql": + return ; + case "mariadb": + return ; + case "mongo": + return ; + case "redis": + return ; + case "libsql": + return ; + default: + return ; + } +}; + +const serviceHref = ( + projectId: string, + environmentId: string, + service: MonitoringService, +) => + `/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}?tab=monitoring`; + +const isMonitorable = (service: MonitoringService, isCloud?: boolean) => + (!!service.serverId && !!isCloud) || !service.serverId; + +const getStorageKey = (environmentId: string) => + `project-monitoring-selection:${environmentId}`; + +export const ProjectMonitoring = ({ + projectId, + environmentId, + services, +}: Props) => { + const { data: isCloud } = api.settings.isCloud.useQuery(); + const monitorableServices = useMemo( + () => services.filter((service) => isMonitorable(service, isCloud)), + [services, isCloud], + ); + + const [selectedIds, setSelectedIds] = useState([]); + const [showReplicas, setShowReplicas] = useState(false); + const [selectorOpen, setSelectorOpen] = useState(false); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + try { + const raw = localStorage.getItem(getStorageKey(environmentId)); + if (raw) { + const parsed = JSON.parse(raw) as { + selectedIds?: string[]; + showReplicas?: boolean; + }; + const validIds = (parsed.selectedIds || []).filter((id) => + monitorableServices.some((service) => service.id === id), + ); + setSelectedIds( + validIds.length > 0 + ? validIds + : monitorableServices.slice(0, 3).map((service) => service.id), + ); + setShowReplicas(!!parsed.showReplicas); + } else { + setSelectedIds( + monitorableServices.slice(0, 3).map((service) => service.id), + ); + } + } catch { + setSelectedIds( + monitorableServices.slice(0, 3).map((service) => service.id), + ); + } + setHydrated(true); + }, [environmentId, monitorableServices]); + + useEffect(() => { + if (!hydrated) return; + localStorage.setItem( + getStorageKey(environmentId), + JSON.stringify({ selectedIds, showReplicas }), + ); + }, [selectedIds, showReplicas, environmentId, hydrated]); + + const selectedServices = useMemo( + () => + monitorableServices.filter((service) => selectedIds.includes(service.id)), + [monitorableServices, selectedIds], + ); + + const toggleService = (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id], + ); + }; + + const selectAll = () => { + setSelectedIds(monitorableServices.map((service) => service.id)); + }; + + const clearAll = () => { + setSelectedIds([]); + }; + + if (!hydrated) { + return ( +
+ + Loading monitoring... +
+ ); + } + + return ( +
+
+
+

+ + Project Monitoring +

+

+ Watch metrics for services in this environment. Select only what you + need — charts stay collapsed until you expand them to keep the view + light. +

+
+ +
+
+ + +
+ + + + + + + + +
+ + +
+ No services found. + + {monitorableServices.map((service) => { + const checked = selectedIds.includes(service.id); + return ( + toggleService(service.id)} + className="gap-2" + > + + {serviceTypeIcon(service.type)} + {service.name} + + {service.type} + + + ); + })} + +
+
+
+
+
+ + {monitorableServices.length === 0 ? ( + + No monitorable services in this environment. Monitoring is available + for services running on the Dokploy server + {isCloud ? " or on remote servers (cloud)." : "."} + + ) : selectedServices.length === 0 ? ( + + Select one or more services to start monitoring. + + ) : ( +
+ {selectedServices.map((service) => ( + + ))} +
+ )} +
+ ); +}; + +const ServiceMonitoringPanel = ({ + projectId, + environmentId, + service, + showReplicas, + isCloud, +}: { + projectId: string; + environmentId: string; + service: MonitoringService; + showReplicas: boolean; + isCloud: boolean; +}) => { + const usePaid = !!(service.serverId && isCloud); + const appName = service.appName || ""; + const appType = + service.type === "compose" + ? service.composeType || "docker-compose" + : "application"; + + const { data: containers, isPending } = + api.docker.getContainersByAppNameMatch.useQuery( + { + appName, + appType: service.type === "compose" ? appType : undefined, + serverId: service.serverId || undefined, + }, + { + enabled: showReplicas && !!appName && !usePaid, + }, + ); + + const paidBaseUrl = service.serverIp + ? `http://${service.serverIp}:${service.metricsConfig?.server?.port || 4500}` + : ""; + const paidToken = service.metricsConfig?.server?.token || ""; + + return ( +
+
+
+ {serviceTypeIcon(service.type)} +
+

{service.name}

+

+ {appName} + {service.serverName ? ` · ${service.serverName}` : ""} + {service.type !== "compose" && (service.replicas ?? 1) > 1 + ? ` · ${service.replicas} replicas` + : ""} +

+
+ + {service.type} + +
+ +
+ +
+ {!appName ? ( +

+ This service has no app name configured. +

+ ) : usePaid ? ( + paidBaseUrl && paidToken ? ( + + ) : ( +

+ Monitoring is not configured on this remote server. +

+ ) + ) : showReplicas ? ( + isPending ? ( +
+ + Loading containers... +
+ ) : containers && containers.length > 0 ? ( + containers.map((container) => ( + + )) + ) : ( + <> +

+ No running replica containers found. Showing service-level + metrics. +

+ + + ) + ) : ( + + )} +
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index a386620ea..23f597e50 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -882,6 +882,16 @@ const EnvironmentPage = (
+ {permissions?.monitoring.read && ( + + )} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/monitoring.tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/monitoring.tsx new file mode 100644 index 000000000..51514255e --- /dev/null +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/monitoring.tsx @@ -0,0 +1,173 @@ +import type { GetServerSidePropsContext, InferGetServerSidePropsType } from "next"; +import Head from "next/head"; +import Link from "next/link"; +import type { ReactElement } from "react"; +import { validateRequest } from "@dokploy/server/lib/auth"; +import { hasPermission } from "@dokploy/server/services/permission"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import { ArrowLeft, FolderInput } from "lucide-react"; +import superjson from "superjson"; +import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector"; +import { extractServicesFromEnvironment } from "@/components/dashboard/project/extract-services"; +import { ProjectMonitoring } from "@/components/dashboard/project/monitoring/project-monitoring"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { appRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; +import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; + +const ProjectMonitoringPage = ( + props: InferGetServerSidePropsType, +) => { + const { projectId, environmentId } = props; + const { config: whitelabeling } = useWhitelabeling(); + const appName = whitelabeling?.appName || "Dokploy"; + + const { data: currentEnvironment } = api.environment.one.useQuery({ + environmentId, + }); + + const services = extractServicesFromEnvironment(currentEnvironment); + + return ( +
+ + + Monitoring | {currentEnvironment?.name} |{" "} + {currentEnvironment?.project?.name} | {appName} + + + +
+
+ + + +

+ {currentEnvironment?.project?.name} +

+ +
+ + Aggregated container metrics for this environment + +
+ +
+ + + +
+
+
+ ); +}; + +export default ProjectMonitoringPage; + +ProjectMonitoringPage.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ projectId: string; environmentId: string }>, +) { + const { params, req, res } = ctx; + const { user, session } = await validateRequest(req); + + if (!user) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + + const canView = await hasPermission( + { + user: { id: user.id }, + session: { activeOrganizationId: session?.activeOrganizationId || "" }, + }, + { monitoring: ["read"] }, + ); + + if (!canView) { + return { + redirect: { + permanent: false, + destination: "/dashboard/home", + }, + }; + } + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session as any, + user: user as any, + }, + transformer: superjson, + }); + + if ( + typeof params?.projectId === "string" && + typeof params?.environmentId === "string" + ) { + try { + await helpers.project.one.fetch({ + projectId: params.projectId, + }); + await helpers.environment.one.fetch({ + environmentId: params.environmentId, + }); + await helpers.settings.isCloud.prefetch(); + + return { + props: { + trpcState: helpers.dehydrate(), + projectId: params.projectId, + environmentId: params.environmentId, + }, + }; + } catch { + return { + redirect: { + permanent: false, + destination: "/dashboard/projects", + }, + }; + } + } + + return { + redirect: { + permanent: false, + destination: "/", + }, + }; +}