feat(servers): enhance server management UI with button options

- Added `asButton` prop to `HandleServers`, `SetupServer`, `ShowServerActions`, and `TerminalModal` components to allow rendering as buttons for improved UI flexibility.
- Updated the server management interface to use buttons for actions like editing and setting up servers, enhancing user experience.
- Introduced new icons for better visual representation of actions in the server management dashboard.
This commit is contained in:
Mauricio Siu
2025-12-15 15:17:56 -06:00
parent 5ebcbf86ea
commit eb4fbff1b2
5 changed files with 293 additions and 239 deletions
@@ -1,3 +1,4 @@
import { Activity } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
Dialog, Dialog,
@@ -6,6 +7,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowStorageActions } from "./show-storage-actions"; import { ShowStorageActions } from "./show-storage-actions";
import { ShowTraefikActions } from "./show-traefik-actions"; import { ShowTraefikActions } from "./show-traefik-actions";
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props { interface Props {
serverId: string; serverId: string;
asButton?: boolean;
} }
export const ShowServerActions = ({ serverId }: Props) => { export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer" className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
View Actions View Actions
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> )}
<DialogContent className="sm:max-w-xl"> <DialogContent className="sm:max-w-xl">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle> <DialogTitle className="text-xl">Web server settings</DialogTitle>
@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react"; import { PlusIcon, Pencil } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -59,9 +59,10 @@ type Schema = z.infer<typeof Schema>;
interface Props { interface Props {
serverId?: string; serverId?: string;
asButton?: boolean;
} }
export const HandleServers = ({ serverId }: Props) => { export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const utils = api.useUtils(); const utils = api.useUtils();
@@ -137,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {serverId ? (
{serverId ? ( asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
Edit Server Edit Server
</DropdownMenuItem> </DropdownMenuItem>
) : ( )
) : (
<DialogTrigger asChild>
<Button className="cursor-pointer space-x-3"> <Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Create Server Create Server
</Button> </Button>
)} </DialogTrigger>
</DialogTrigger> )}
<DialogContent className="sm:max-w-3xl "> <DialogContent className="sm:max-w-3xl ">
<DialogHeader> <DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle> <DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
@@ -1,5 +1,5 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react"; import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -36,9 +36,10 @@ import { ValidateServer } from "./validate-server";
interface Props { interface Props {
serverId: string; serverId: string;
asButton?: boolean;
} }
export const SetupServer = ({ serverId }: Props) => { export const SetupServer = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery( const { data: server } = api.server.one.useQuery(
{ {
@@ -81,14 +82,23 @@ export const SetupServer = ({ serverId }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
Setup Server Setup Server
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> )}
<DialogContent className="sm:max-w-4xl "> <DialogContent className="sm:max-w-4xl ">
<DialogHeader> <DialogHeader>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -1,5 +1,5 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react"; import { KeyIcon, Loader2, MoreHorizontal, ServerIcon, Clock, User, Key, Network, Terminal, Settings, Pencil, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
@@ -24,14 +24,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
Table, Tooltip,
TableBody, TooltipContent,
TableCaption, TooltipProvider,
TableCell, TooltipTrigger,
TableHead, } from "@/components/ui/tooltip";
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal"; import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal"; import { TerminalModal } from "../web-server/terminal-modal";
@@ -59,7 +56,7 @@ export const ShowServers = () => {
return ( return (
<div className="w-full"> <div className="w-full">
{query?.success && isCloud && <WelcomeSuscription />} {query?.success && isCloud && <WelcomeSuscription />}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto"> <Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md "> <div className="rounded-xl bg-background shadow-md ">
<CardHeader className=""> <CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2"> <CardTitle className="text-xl flex flex-row gap-2">
@@ -114,57 +111,70 @@ export const ShowServers = () => {
<HandleServers /> <HandleServers />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
<Table> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<TableCaption> {data?.map((server) => {
<div className="flex flex-col gap-4"> const canDelete = server.totalSum === 0;
See all servers const isActive = server.serverStatus === "active";
</div> const isBuildServer =
</TableCaption> server.serverType === "build";
<TableHeader> return (
<TableRow> <Card key={server.serverId} className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent">
<TableHead className="text-left">Name</TableHead> <CardHeader className="pb-3">
{isCloud && ( <div className="flex items-start justify-between">
<TableHead className="text-center"> <div className="flex items-center gap-2">
Status <ServerIcon className="size-5 text-muted-foreground" />
</TableHead> <CardTitle className="text-lg">
)} {server.name}
<TableHead className="text-center"> </CardTitle>
Type </div>
</TableHead> {isActive && server.sshKeyId && !isBuildServer && (
<TableHead className="text-center"> <DropdownMenu>
IP Address <DropdownMenuTrigger asChild>
</TableHead> <Button
<TableHead className="text-center"> variant="ghost"
Port className="h-8 w-8 p-0"
</TableHead> >
<TableHead className="text-center"> <span className="sr-only">
Username More options
</TableHead> </span>
<TableHead className="text-center"> <MoreHorizontal className="h-4 w-4" />
SSH Key </Button>
</TableHead> </DropdownMenuTrigger>
<TableHead className="text-center"> <DropdownMenuContent align="end">
Created <DropdownMenuLabel>
</TableHead> Advanced
<TableHead className="text-right"> </DropdownMenuLabel>
Actions <ShowTraefikFileSystemModal
</TableHead> serverId={server.serverId}
</TableRow> />
</TableHeader> <ShowDockerContainersModal
<TableBody> serverId={server.serverId}
{data?.map((server) => { />
const canDelete = server.totalSum === 0; {isCloud && (
const isActive = server.serverStatus === "active"; <ShowMonitoringModal
const isBuildServer = url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
server.serverType === "build"; token={
return ( server?.metricsConfig
<TableRow key={server.serverId}> ?.server?.token
<TableCell className="text-left"> }
{server.name} />
</TableCell> )}
{isCloud && ( <ShowSwarmOverviewModal
<TableHead className="text-center"> serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex gap-2 mt-2 flex-wrap">
{isCloud && (
<Badge <Badge
variant={ variant={
server.serverStatus === "active" server.serverStatus === "active"
@@ -174,9 +184,7 @@ export const ShowServers = () => {
> >
{server.serverStatus} {server.serverStatus}
</Badge> </Badge>
</TableHead> )}
)}
<TableCell className="text-center">
<Badge <Badge
variant={ variant={
isBuildServer ? "secondary" : "default" isBuildServer ? "secondary" : "default"
@@ -184,170 +192,173 @@ export const ShowServers = () => {
> >
{server.serverType} {server.serverType}
</Badge> </Badge>
</TableCell> </div>
<TableCell className="text-center"> </CardHeader>
<Badge>{server.ipAddress}</Badge> <CardContent className="space-y-3 flex-1 flex flex-col">
</TableCell> <div className="flex items-center gap-2 text-sm">
<TableCell className="text-center"> <Network className="size-4 text-muted-foreground" />
{server.port} <span className="text-muted-foreground">IP:</span>
</TableCell> <Badge variant="outline">{server.ipAddress}</Badge>
<TableCell className="text-center"> <span className="text-muted-foreground">Port:</span>
{server.username} <span className="font-medium">{server.port}</span>
</TableCell> </div>
<TableCell className="text-right"> <div className="flex items-center gap-2 text-sm">
<span className="text-sm text-muted-foreground"> <User className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">User:</span>
<span className="font-medium">{server.username}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Key className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">SSH Key:</span>
<span className="font-medium">
{server.sshKeyId ? "Yes" : "No"} {server.sshKeyId ? "Yes" : "No"}
</span> </span>
</TableCell> </div>
<TableCell className="text-right"> <div className="flex items-center gap-2 text-sm pt-2 border-t">
<span className="text-sm text-muted-foreground"> <Clock className="size-4 text-muted-foreground" />
{format( <span className="text-xs text-muted-foreground">
new Date(server.createdAt), Created {format(new Date(server.createdAt), "PPp")}
"PPpp",
)}
</span> </span>
</TableCell> </div>
<TableCell className="text-right flex justify-end"> {/* Compact Actions */}
<DropdownMenu> {isActive && (
<DropdownMenuTrigger asChild> <div className="flex items-center gap-2 pt-3 border-t mt-auto">
<Button <TooltipProvider>
variant="ghost" {server.sshKeyId && (
className="h-8 w-8 p-0" <Tooltip>
> <TooltipTrigger asChild>
<span className="sr-only"> <div>
Open menu <TerminalModal
</span> serverId={server.serverId}
<MoreHorizontal className="h-4 w-4" /> asButton={true}
</Button> >
</DropdownMenuTrigger> <Button
<DropdownMenuContent align="end"> variant="outline"
<DropdownMenuLabel> size="icon"
Actions className="h-9 w-9"
</DropdownMenuLabel> >
<Terminal className="h-4 w-4" />
{isActive && ( </Button>
<> </TerminalModal>
{server.sshKeyId && ( </div>
<TerminalModal </TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div>
<SetupServer
serverId={server.serverId} serverId={server.serverId}
> asButton={true}
<span> />
{t( </div>
"settings.common.enterTerminal", </TooltipTrigger>
)} <TooltipContent>
</span> <p>Setup Server</p>
</TerminalModal> </TooltipContent>
)} </Tooltip>
<SetupServer
serverId={server.serverId}
/>
<HandleServers <Tooltip>
serverId={server.serverId} <TooltipTrigger asChild>
/> <div>
<HandleServers
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Edit Server</p>
</TooltipContent>
</Tooltip>
{server.sshKeyId && {server.sshKeyId && !isBuildServer && (
!isBuildServer && ( <Tooltip>
<TooltipTrigger asChild>
<div>
<ShowServerActions <ShowServerActions
serverId={server.serverId} serverId={server.serverId}
asButton={true}
/> />
)}
</>
)}
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this server
because it has active services.
<AlertBlock type="warning">
You have active services
associated with this server,
please delete them first.
</AlertBlock>
</div> </div>
) </TooltipTrigger>
} <TooltipContent>
onClick={async () => { <p>Web Server Actions</p>
await mutateAsync({ </TooltipContent>
serverId: server.serverId, </Tooltip>
}) )}
.then(() => {
refetch(); <div className="flex-1" />
toast.success(
`Server ${server.name} deleted successfully`, <Tooltip>
); <TooltipTrigger asChild>
}) <div>
.catch((err) => { <DialogAction
toast.error(err.message); disabled={!canDelete}
}); title={
}} canDelete
> ? "Delete Server"
<DropdownMenuItem : "Server has active services"
className="w-full cursor-pointer text-red-500 hover:!text-red-600" }
onSelect={(e) => e.preventDefault()} description={
> canDelete ? (
Delete Server "This will delete the server and all associated data"
</DropdownMenuItem> ) : (
</DialogAction> <div className="flex flex-col gap-2">
You can not delete this server
because it has active services.
<AlertBlock type="warning">
You have active services
associated with this server,
please delete them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? 'text-destructive hover:text-destructive hover:bg-destructive/10' : 'text-muted-foreground hover:bg-muted'}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{canDelete ? 'Delete Server' : 'Cannot delete - has active services'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{isActive && <div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
server.sshKeyId &&
!isBuildServer && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
{data && data?.length > 0 && ( {data && data?.length > 0 && (
<div> <div>
<HandleServers /> <HandleServers />
@@ -24,10 +24,12 @@ const getTerminalKey = () => {
interface Props { interface Props {
children?: React.ReactNode; children?: React.ReactNode;
serverId: string; serverId: string;
asButton?: boolean;
} }
export const TerminalModal = ({ children, serverId }: Props) => { export const TerminalModal = ({ children, serverId, asButton = false }: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey()); const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local"; const isLocalServer = serverId === "local";
const { data } = api.server.one.useQuery( const { data } = api.server.one.useQuery(
@@ -43,15 +45,22 @@ export const TerminalModal = ({ children, serverId }: Props) => {
}; };
return ( return (
<Dialog> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {asButton ? (
<DialogTrigger asChild>
{children}
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer space-x-3" className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
{children} {children}
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> )}
<DialogContent <DialogContent
className="sm:max-w-7xl" className="sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()} onEscapeKeyDown={(event) => event.preventDefault()}