diff --git a/apps/dokploy/components/ui/context-menu.tsx b/apps/dokploy/components/ui/context-menu.tsx new file mode 100644 index 0000000000..2ff9f812eb --- /dev/null +++ b/apps/dokploy/components/ui/context-menu.tsx @@ -0,0 +1,198 @@ +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = "ContextMenuShortcut"; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index f7eff0aba3..eadbe989d1 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -67,6 +67,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index a91138f669..051415a748 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -12,6 +12,7 @@ import { Loader2, Play, PlusIcon, + RefreshCw, Search, ServerIcon, SquareTerminal, @@ -68,6 +69,14 @@ import { CommandInput, CommandItem, } from "@/components/ui/command"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Dialog, DialogContent, @@ -424,6 +433,7 @@ const EnvironmentPage = ( const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const [deleteVolumes, setDeleteVolumes] = useState(false); const [selectedServerId, setSelectedServerId] = useState("all"); + const [serviceToDelete, setServiceToDelete] = useState(null); const handleSelectAll = () => { if (selectedServices.length === filteredServices.length) { @@ -814,6 +824,110 @@ const EnvironmentPage = ( setIsBulkActionLoading(false); }; + const getServiceActions = (service: Services) => { + switch (service.type) { + case "application": + return applicationActions; + case "compose": + return composeActions; + case "postgres": + return postgresActions; + case "mysql": + return mysqlActions; + case "mariadb": + return mariadbActions; + case "redis": + return redisActions; + case "mongo": + return mongoActions; + default: + return null; + } + }; + + const getServiceIdKey = (service: Services) => { + switch (service.type) { + case "application": + return "applicationId"; + case "compose": + return "composeId"; + case "postgres": + return "postgresId"; + case "mysql": + return "mysqlId"; + case "mariadb": + return "mariadbId"; + case "redis": + return "redisId"; + case "mongo": + return "mongoId"; + default: + return null; + } + }; + + const handleServiceAction = async ( + service: Services, + action: "start" | "stop" | "deploy", + ) => { + const actions = getServiceActions(service); + const idKey = getServiceIdKey(service); + if (!actions || !idKey) return; + + const actionLabels = { + start: { loading: "Starting", success: "started", error: "starting" }, + stop: { loading: "Stopping", success: "stopped", error: "stopping" }, + deploy: { + loading: "Deploying", + success: "queued for deployment", + error: "deploying", + }, + }; + + const labels = actionLabels[action]; + + toast.promise( + (async () => { + await actions[action].mutateAsync({ + [idKey]: service.id, + } as any); + })(), + { + loading: `${labels.loading} ${service.name}...`, + success: () => { + utils.environment.one.invalidate({ environmentId }); + return `${service.name} ${labels.success} successfully`; + }, + error: (error) => + `Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ); + }; + + const handleServiceDelete = async (service: Services) => { + const actions = getServiceActions(service); + const idKey = getServiceIdKey(service); + if (!actions || !idKey) return; + + toast.promise( + (async () => { + await actions.delete.mutateAsync({ + [idKey]: service.id, + } as any); + })(), + { + loading: `Deleting ${service.name}...`, + success: () => { + utils.environment.one.invalidate({ environmentId }); + return `${service.name} deleted successfully`; + }, + error: (error) => + `Error deleting ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ); + setServiceToDelete(null); + }; + // Get unique servers from services const availableServers = useMemo(() => { if (!applications) return []; @@ -1472,110 +1586,156 @@ const EnvironmentPage = (
{filteredServices?.map((service) => ( - - - {service.serverId && ( -
- -
- )} -
- -
- -
- handleServiceSelect(service.id, e) - } + + + -
- -
-
+ + {service.serverId && ( +
+ +
+ )} +
+ +
- - -
-
- - {service.name} - - {service.description && ( - - {service.description} - - )} +
+ handleServiceSelect(service.id, e) + } + > +
+
+
- - {service.type === "postgres" && ( - - )} - {service.type === "redis" && ( - - )} - {service.type === "mariadb" && ( - - )} - {service.type === "mongo" && ( - - )} - {service.type === "mysql" && ( - - )} - {service.type === "application" && - (service.icon ? ( - // biome-ignore lint/performance/noImgElement: application icon is data URL - {service.name} - ) : ( - - ))} - {service.type === "compose" && ( - - )} - {service.type === "libsql" && ( - + + +
+
+ + {service.name} + + {service.description && ( + + {service.description} + + )} +
+ + + {service.type === "postgres" && ( + + )} + {service.type === "redis" && ( + + )} + {service.type === "mariadb" && ( + + )} + {service.type === "mongo" && ( + + )} + {service.type === "mysql" && ( + + )} + {service.type === "application" && + (service.icon ? ( + // biome-ignore lint/performance/noImgElement: application icon is data URL + {service.name} + ) : ( + + ))} + {service.type === "compose" && ( + + )} + {service.type === "libsql" && ( + + )} + +
+
+
+ +
+ {service.serverName && ( +
+ + + {service.serverName} + +
)} - -
- - - -
- {service.serverName && ( -
- - - {service.serverName} - + + Created +
- )} - - Created - -
-
- - +
+ + + + {service.type !== "libsql" && ( + + + {service.name} + + + + handleServiceAction(service, "start") + } + > + + Start + + + handleServiceAction(service, "deploy") + } + > + + Deploy + + + handleServiceAction(service, "stop") + } + > + + Stop + + + setServiceToDelete(service)} + > + + Delete + + + )} + ))}
@@ -1586,6 +1746,38 @@ const EnvironmentPage = (
+ + {/* Single Service Delete Dialog */} + !open && setServiceToDelete(null)} + > + + + Delete Service + + Are you sure you want to delete{" "} + {serviceToDelete?.name}? + This action cannot be undone. + + + + + + + + ); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f962c6752..02c06680b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.1.11 version: 1.1.12(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dialog': specifier: ^1.1.14 version: 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -2801,6 +2804,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.0.0': resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: @@ -10633,6 +10649,20 @@ snapshots: optionalDependencies: '@types/react': 18.3.5 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.5)(react@18.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.5)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.5)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-context@1.0.0(react@18.2.0)': dependencies: '@babel/runtime': 7.28.6