feat: added bulk actions for services start and stop and added service status for domain dropdown

This commit is contained in:
vishalkadam47
2025-02-05 08:17:15 +05:30
parent 9d988c9a9b
commit bd809c8dca
2 changed files with 236 additions and 69 deletions

View File

@@ -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) => (

View File

@@ -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">