mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
Merge pull request #5041 from Dokploy/refactor/cluster-nodes-docker-tab
refactor: move cluster nodes into Docker dashboard as a Swarm sub-tab
This commit is contained in:
+1
-1
@@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
To use a cluster feature, you need to configure at least
|
||||
a registry first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Boxes,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircuitBoard,
|
||||
ExternalLink,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
Rocket,
|
||||
Server,
|
||||
@@ -71,6 +72,7 @@ function getServiceInfo(d: DeploymentRow) {
|
||||
return {
|
||||
type: "Application" as const,
|
||||
name: app.name,
|
||||
icon: app.icon,
|
||||
projectId: app.environment.project.projectId,
|
||||
environmentId: app.environment.environmentId,
|
||||
projectName: app.environment.project.name,
|
||||
@@ -83,6 +85,7 @@ function getServiceInfo(d: DeploymentRow) {
|
||||
return {
|
||||
type: "Compose" as const,
|
||||
name: comp.name,
|
||||
icon: comp.icon,
|
||||
projectId: comp.environment.project.projectId,
|
||||
environmentId: comp.environment.environmentId,
|
||||
projectName: comp.environment.project.name,
|
||||
@@ -175,10 +178,16 @@ export function ShowDeploymentsTable() {
|
||||
if (!info) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{info.type === "Application" ? (
|
||||
<Rocket className="size-4 text-muted-foreground shrink-0" />
|
||||
{info.icon ? (
|
||||
<img
|
||||
src={info.icon}
|
||||
alt={info.name}
|
||||
className="size-4 object-contain shrink-0"
|
||||
/>
|
||||
) : info.type === "Application" ? (
|
||||
<GlobeIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Boxes className="size-4 text-muted-foreground shrink-0" />
|
||||
<CircuitBoard className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{info.name}</span>
|
||||
|
||||
@@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
serverId,
|
||||
});
|
||||
const { data: registry } = api.registry.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
|
||||
|
||||
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
</CardTitle>
|
||||
<CardDescription>Add nodes to your cluster</CardDescription>
|
||||
</div>
|
||||
{haveAtLeastOneRegistry && (
|
||||
{haveAtLeastOneRegistry && permissions?.server.create && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<AddNode serverId={serverId} />
|
||||
</div>
|
||||
@@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<ShowNodeData data={node} />
|
||||
{!node?.ManagerStatus?.Leader && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteNode({
|
||||
nodeId: node.ID,
|
||||
serverId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Node deleted successfully",
|
||||
);
|
||||
{!node?.ManagerStatus?.Leader &&
|
||||
permissions?.server.delete && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteNode({
|
||||
nodeId: node.ID,
|
||||
serverId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting node");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Node deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting node");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DocLinks = () => (
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
|
||||
<li>
|
||||
Check the{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -130,7 +130,7 @@ export const ServicesError = ({
|
||||
<li>
|
||||
Network connectivity issues to a remote server — check{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
|
||||
Worker nodes need to pull images from a shared registry. Configure one
|
||||
in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -255,7 +255,7 @@ export const NoRunningContainers = ({
|
||||
<li>
|
||||
Images can't be pulled on worker nodes — verify your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
registry configuration
|
||||
|
||||
@@ -303,7 +303,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
|
||||
<p className="mt-2 text-xs">
|
||||
Manage nodes in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
|
||||
@@ -51,7 +51,9 @@ export default function SwarmMonitorCard({ serverId }: Props) {
|
||||
{!serverId && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.location.replace("/dashboard/settings/cluster")
|
||||
window.location.replace(
|
||||
"/dashboard/docker?tab=swarm&subtab=nodes",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -373,14 +373,6 @@ const MENU: Menu = {
|
||||
icon: ShieldCheck,
|
||||
isEnabled: ({ permissions }) => !!permissions?.certificate.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Cluster",
|
||||
url: "/dashboard/settings/cluster",
|
||||
icon: Boxes,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Notifications",
|
||||
|
||||
@@ -45,10 +45,12 @@ type ServiceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServiceType;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
type NamedService = {
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
type EnvironmentServiceCollections = {
|
||||
@@ -116,8 +118,20 @@ const getStringQueryParam = (value: string | string[] | undefined) =>
|
||||
const includesSearch = (value: string | null | undefined, search: string) =>
|
||||
value?.toLowerCase().includes(search.toLowerCase()) ?? false;
|
||||
|
||||
const getServiceIcon = (type: ServiceType, className = "size-4") => {
|
||||
const Icon = SERVICE_ICONS[type];
|
||||
const getServiceIcon = (
|
||||
service: Pick<ServiceItem, "type" | "icon">,
|
||||
className = "size-4",
|
||||
) => {
|
||||
if (service.icon) {
|
||||
return (
|
||||
<img
|
||||
src={service.icon}
|
||||
alt=""
|
||||
className={`${className} object-contain shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Icon = SERVICE_ICONS[service.type];
|
||||
return <Icon className={className} />;
|
||||
};
|
||||
|
||||
@@ -127,7 +141,7 @@ const countEnvironmentServices = (environment: ServiceCollections): number =>
|
||||
0,
|
||||
);
|
||||
|
||||
const mapServices = <T extends { name: string }>(
|
||||
const mapServices = <T extends NamedService>(
|
||||
items: readonly T[],
|
||||
getId: (item: T) => string,
|
||||
type: ServiceType,
|
||||
@@ -136,6 +150,7 @@ const mapServices = <T extends { name: string }>(
|
||||
id: getId(item),
|
||||
name: item.name,
|
||||
type,
|
||||
icon: item.icon ?? null,
|
||||
}));
|
||||
|
||||
const extractServicesFromEnvironment = (
|
||||
@@ -550,7 +565,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
aria-expanded={serviceOpen}
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
{getServiceIcon(currentService.type)}
|
||||
{getServiceIcon(currentService)}
|
||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||
{currentService.name}
|
||||
</span>
|
||||
@@ -589,7 +604,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-md bg-muted">
|
||||
{getServiceIcon(service.type)}
|
||||
{getServiceIcon(service)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
|
||||
@@ -7,6 +7,7 @@ import superjson from "superjson";
|
||||
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
|
||||
import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes";
|
||||
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
||||
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
|
||||
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
|
||||
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
@@ -17,6 +18,7 @@ import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DEFAULT_TAB = "containers";
|
||||
const DEFAULT_SWARM_TAB = "overview";
|
||||
|
||||
const Dashboard = () => {
|
||||
const router = useRouter();
|
||||
@@ -26,8 +28,13 @@ const Dashboard = () => {
|
||||
typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
|
||||
const activeTab = isCloud && queryTab === "networks" ? DEFAULT_TAB : queryTab;
|
||||
|
||||
const activeSwarmTab =
|
||||
typeof router.query.subtab === "string"
|
||||
? router.query.subtab
|
||||
: DEFAULT_SWARM_TAB;
|
||||
|
||||
const setTab = (value: string) => {
|
||||
const { tab: _current, ...query } = router.query;
|
||||
const { tab: _current, subtab: _subtab, ...query } = router.query;
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
@@ -38,6 +45,19 @@ const Dashboard = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const setSwarmTab = (value: string) => {
|
||||
const { subtab: _current, ...query } = router.query;
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query:
|
||||
value === DEFAULT_SWARM_TAB ? query : { ...query, subtab: value },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerFilter>
|
||||
{(serverId) => (
|
||||
@@ -52,10 +72,11 @@ const Dashboard = () => {
|
||||
<ShowContainers serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="swarm">
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs value={activeSwarmTab} onValueChange={setSwarmTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
<TabsTrigger value="nodes">Nodes</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<SwarmMonitorCard serverId={serverId} />
|
||||
@@ -67,6 +88,9 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="nodes">
|
||||
<ShowNodes serverId={serverId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
<TabsContent value="volumes">
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<ServerFilter>
|
||||
{(serverId) => (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowNodes serverId={serverId} />
|
||||
</div>
|
||||
)}
|
||||
</ServerFilter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Nodes">{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user || user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
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,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { getLocalServerIp } from "@/server/wss/terminal";
|
||||
import { createTRPCRouter, withPermission } from "../trpc";
|
||||
|
||||
export const clusterRouter = createTRPCRouter({
|
||||
getNodes: withPermission("server", "read")
|
||||
getNodes: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().optional(),
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createTRPCRouter, withPermission } from "../trpc";
|
||||
import { containerIdRegex } from "./docker";
|
||||
|
||||
export const swarmRouter = createTRPCRouter({
|
||||
getNodes: withPermission("server", "read")
|
||||
getNodes: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().optional(),
|
||||
@@ -30,7 +30,7 @@ export const swarmRouter = createTRPCRouter({
|
||||
}
|
||||
return await getSwarmNodes(input.serverId);
|
||||
}),
|
||||
getNodeInfo: withPermission("server", "read")
|
||||
getNodeInfo: withPermission("docker", "read")
|
||||
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
@@ -44,7 +44,7 @@ export const swarmRouter = createTRPCRouter({
|
||||
}
|
||||
return await getNodeInfo(input.nodeId, input.serverId);
|
||||
}),
|
||||
getNodeApps: withPermission("server", "read")
|
||||
getNodeApps: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().optional(),
|
||||
@@ -62,7 +62,7 @@ export const swarmRouter = createTRPCRouter({
|
||||
}
|
||||
return getNodeApplications(input.serverId);
|
||||
}),
|
||||
getAppInfos: withPermission("server", "read")
|
||||
getAppInfos: withPermission("docker", "read")
|
||||
.meta({
|
||||
openapi: {
|
||||
path: "/drop-deployment",
|
||||
@@ -93,7 +93,7 @@ export const swarmRouter = createTRPCRouter({
|
||||
}
|
||||
return await getApplicationInfo(input.appName, input.serverId);
|
||||
}),
|
||||
getContainerStats: withPermission("server", "read")
|
||||
getContainerStats: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().optional(),
|
||||
|
||||
@@ -802,7 +802,7 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => {
|
||||
|
||||
const centralizedDeploymentsWith = {
|
||||
application: {
|
||||
columns: { applicationId: true, name: true, appName: true },
|
||||
columns: { applicationId: true, name: true, appName: true, icon: true },
|
||||
with: {
|
||||
environment: {
|
||||
columns: { environmentId: true, name: true },
|
||||
@@ -821,7 +821,7 @@ const centralizedDeploymentsWith = {
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
columns: { composeId: true, name: true, appName: true },
|
||||
columns: { composeId: true, name: true, appName: true, icon: true },
|
||||
with: {
|
||||
environment: {
|
||||
columns: { environmentId: true, name: true },
|
||||
|
||||
Reference in New Issue
Block a user