mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: added bulk actions for services start and stop and added service status for domain dropdown
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -176,8 +177,11 @@ export const ShowProjects = () => {
|
|||||||
<div key={app.applicationId}>
|
<div key={app.applicationId}>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-normal capitalize text-xs">
|
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
||||||
{app.name}
|
{app.name}
|
||||||
|
<StatusTooltip
|
||||||
|
status={app.applicationStatus}
|
||||||
|
/>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{app.domains.map((domain) => (
|
{app.domains.map((domain) => (
|
||||||
@@ -209,8 +213,11 @@ export const ShowProjects = () => {
|
|||||||
<div key={comp.composeId}>
|
<div key={comp.composeId}>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-normal capitalize text-xs">
|
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
||||||
{comp.name}
|
{comp.name}
|
||||||
|
<StatusTooltip
|
||||||
|
status={comp.composeStatus}
|
||||||
|
/>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{comp.domains.map((domain) => (
|
{comp.domains.map((domain) => (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { ProjectLayout } from "@/components/layouts/project-layout";
|
import { ProjectLayout } from "@/components/layouts/project-layout";
|
||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -50,22 +52,27 @@ import type { findProjectById } from "@dokploy/server";
|
|||||||
import { validateRequest } from "@dokploy/server";
|
import { validateRequest } from "@dokploy/server";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import {
|
import {
|
||||||
|
Ban,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronsUpDown,
|
||||||
CircuitBoard,
|
CircuitBoard,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Search,
|
Search,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
|
||||||
import type {
|
import type {
|
||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useMemo, useState, type ReactElement } from "react";
|
import { useMemo, useState, type ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export type Services = {
|
export type Services = {
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -201,7 +208,7 @@ const Project = (
|
|||||||
enabled: !!auth?.id && auth?.rol === "user",
|
enabled: !!auth?.id && auth?.rol === "user",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { data, isLoading } = api.project.one.useQuery({ projectId });
|
const { data, isLoading, refetch } = api.project.one.useQuery({ projectId });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const emptyServices =
|
const emptyServices =
|
||||||
@@ -228,6 +235,66 @@ const Project = (
|
|||||||
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||||||
const [openCombobox, setOpenCombobox] = useState(false);
|
const [openCombobox, setOpenCombobox] = useState(false);
|
||||||
|
const [selectedServices, setSelectedServices] = useState<string[]>([]);
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedServices.length === filteredServices.length) {
|
||||||
|
setSelectedServices([]);
|
||||||
|
} else {
|
||||||
|
setSelectedServices(filteredServices.map((service) => service.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedServices((prev) =>
|
||||||
|
prev.includes(serviceId)
|
||||||
|
? prev.filter((id) => id !== serviceId)
|
||||||
|
: [...prev, serviceId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const composeActions = {
|
||||||
|
start: api.compose.start.useMutation(),
|
||||||
|
stop: api.compose.stop.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkStart = async () => {
|
||||||
|
let success = 0;
|
||||||
|
for (const serviceId of selectedServices) {
|
||||||
|
try {
|
||||||
|
await composeActions.start.mutateAsync({ composeId: serviceId });
|
||||||
|
success++;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Error starting service ${serviceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (success > 0) {
|
||||||
|
toast.success(`${success} services started successfully`);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
setSelectedServices([]);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkStop = async () => {
|
||||||
|
let success = 0;
|
||||||
|
for (const serviceId of selectedServices) {
|
||||||
|
try {
|
||||||
|
await composeActions.stop.mutateAsync({ composeId: serviceId });
|
||||||
|
success++;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Error stopping service ${serviceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (success > 0) {
|
||||||
|
toast.success(`${success} services stopped successfully`);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
setSelectedServices([]);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const filteredServices = useMemo(() => {
|
const filteredServices = useMemo(() => {
|
||||||
if (!applications) return [];
|
if (!applications) return [];
|
||||||
@@ -309,78 +376,150 @@ const Project = (
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="w-full relative">
|
<div className="flex items-center gap-4">
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
placeholder="Filter services..."
|
<Checkbox
|
||||||
value={searchQuery}
|
checked={selectedServices.length > 0}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
className={cn(
|
||||||
className="pr-10"
|
"data-[state=checked]:bg-primary",
|
||||||
/>
|
selectedServices.length > 0 &&
|
||||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
selectedServices.length <
|
||||||
|
filteredServices.length &&
|
||||||
|
"bg-primary/50",
|
||||||
|
)}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Select All{" "}
|
||||||
|
{selectedServices.length > 0 &&
|
||||||
|
`(${selectedServices.length}/${filteredServices.length})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu
|
||||||
|
open={isDropdownOpen}
|
||||||
|
onOpenChange={setIsDropdownOpen}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={selectedServices.length === 0}
|
||||||
|
>
|
||||||
|
Bulk Actions
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DialogAction
|
||||||
|
title="Start Services"
|
||||||
|
description={`Are you sure you want to start ${selectedServices.length} services?`}
|
||||||
|
type="default"
|
||||||
|
onClick={handleBulkStart}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
<DialogAction
|
||||||
|
title="Stop Services"
|
||||||
|
description={`Are you sure you want to stop ${selectedServices.length} services?`}
|
||||||
|
type="destructive"
|
||||||
|
onClick={handleBulkStop}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start text-destructive"
|
||||||
|
>
|
||||||
|
<Ban className="mr-2 h-4 w-4" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
|
|
||||||
<PopoverTrigger asChild>
|
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center">
|
||||||
<Button
|
<div className="w-full relative">
|
||||||
variant="outline"
|
<Input
|
||||||
aria-expanded={openCombobox}
|
placeholder="Filter services..."
|
||||||
className="min-w-[200px] justify-between"
|
value={searchQuery}
|
||||||
>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
{selectedTypes.length === 0
|
className="pr-10"
|
||||||
? "Select types..."
|
/>
|
||||||
: `${selectedTypes.length} selected`}
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
</div>
|
||||||
</Button>
|
<Popover
|
||||||
</PopoverTrigger>
|
open={openCombobox}
|
||||||
<PopoverContent className="w-[200px] p-0">
|
onOpenChange={setOpenCombobox}
|
||||||
<Command>
|
>
|
||||||
<CommandInput placeholder="Search type..." />
|
<PopoverTrigger asChild>
|
||||||
<CommandEmpty>No type found.</CommandEmpty>
|
<Button
|
||||||
<CommandGroup>
|
variant="outline"
|
||||||
{serviceTypes.map((type) => (
|
aria-expanded={openCombobox}
|
||||||
|
className="min-w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
{selectedTypes.length === 0
|
||||||
|
? "Select types..."
|
||||||
|
: `${selectedTypes.length} selected`}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search type..." />
|
||||||
|
<CommandEmpty>No type found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{serviceTypes.map((type) => (
|
||||||
|
<CommandItem
|
||||||
|
key={type.value}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedTypes((prev) =>
|
||||||
|
prev.includes(type.value)
|
||||||
|
? prev.filter((t) => t !== type.value)
|
||||||
|
: [...prev, type.value],
|
||||||
|
);
|
||||||
|
setOpenCombobox(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedTypes.includes(type.value)
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{type.icon && (
|
||||||
|
<type.icon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{type.label}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={type.value}
|
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSelectedTypes((prev) =>
|
setSelectedTypes([]);
|
||||||
prev.includes(type.value)
|
|
||||||
? prev.filter((t) => t !== type.value)
|
|
||||||
: [...prev, type.value],
|
|
||||||
);
|
|
||||||
setOpenCombobox(false);
|
setOpenCombobox(false);
|
||||||
}}
|
}}
|
||||||
|
className="border-t"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row items-center">
|
||||||
<Check
|
<X className="mr-2 h-4 w-4" />
|
||||||
className={cn(
|
Clear filters
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedTypes.includes(type.value)
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{type.icon && (
|
|
||||||
<type.icon className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{type.label}
|
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
</CommandGroup>
|
||||||
<CommandItem
|
</Command>
|
||||||
onSelect={() => {
|
</PopoverContent>
|
||||||
setSelectedTypes([]);
|
</Popover>
|
||||||
setOpenCombobox(false);
|
</div>
|
||||||
}}
|
|
||||||
className="border-t"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<X className="mr-2 h-4 w-4" />
|
|
||||||
Clear filters
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full gap-8">
|
<div className="flex w-full gap-8">
|
||||||
@@ -418,6 +557,27 @@ const Project = (
|
|||||||
<StatusTooltip status={service.status} />
|
<StatusTooltip status={service.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||||
|
selectedServices.includes(service.id)
|
||||||
|
? "opacity-100 translate-y-0"
|
||||||
|
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
onClick={(e) =>
|
||||||
|
handleServiceSelect(service.id, e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedServices.includes(
|
||||||
|
service.id,
|
||||||
|
)}
|
||||||
|
className="data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||||
|
|||||||
Reference in New Issue
Block a user