+
+
{isUpdateAvailable ? (
) : (
)}
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
);
};
+
+export default UpdateServer;
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
index 47d38310..c1e5de70 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx
@@ -11,24 +11,53 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
+import { HardDriveDownload } from "lucide-react";
import { toast } from "sonner";
-export const UpdateWebServer = () => {
+interface Props {
+ isNavbar?: boolean;
+}
+
+export const UpdateWebServer = ({ isNavbar }: Props) => {
const { mutateAsync: updateServer, isLoading } =
api.settings.updateServer.useMutation();
+
+ const buttonLabel = isNavbar ? "Update available" : "Update Server";
+
+ const handleConfirm = async () => {
+ try {
+ await updateServer();
+ toast.success(
+ "The server has been updated. The page will be reloaded to reflect the changes...",
+ );
+ setTimeout(() => {
+ // Allow seeing the toast before reloading
+ window.location.reload();
+ }, 2000);
+ } catch (error) {
+ console.error("Error updating server:", error);
+ toast.error(
+ "An error occurred while updating the server, please try again.",
+ );
+ }
+ };
+
return (
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
Are you absolutely sure?
This action cannot be undone. This will update the web server to the
- new version.
+ new version. The page will be reloaded once the update is finished.
Cancel
- {
- await updateServer();
- toast.success("Please reload the browser to see the changes");
- }}
- >
- Confirm
-
+ Confirm
diff --git a/apps/dokploy/components/dashboard/swarm/applications/columns.tsx b/apps/dokploy/components/dashboard/swarm/applications/columns.tsx
new file mode 100644
index 00000000..1961cd99
--- /dev/null
+++ b/apps/dokploy/components/dashboard/swarm/applications/columns.tsx
@@ -0,0 +1,218 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { ArrowUpDown, MoreHorizontal } from "lucide-react";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+import { Badge } from "@/components/ui/badge";
+import { ShowNodeConfig } from "../details/show-node-config";
+// import { ShowContainerConfig } from "../config/show-container-config";
+// import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
+// import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
+// import type { Container } from "./show-containers";
+
+export interface ApplicationList {
+ ID: string;
+ Image: string;
+ Mode: string;
+ Name: string;
+ Ports: string;
+ Replicas: string;
+ CurrentState: string;
+ DesiredState: string;
+ Error: string;
+ Node: string;
+}
+
+export const columns: ColumnDef
[] = [
+ {
+ accessorKey: "ID",
+ accessorFn: (row) => row.ID,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("ID")}
;
+ },
+ },
+ {
+ accessorKey: "Name",
+ accessorFn: (row) => row.Name,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("Name")}
;
+ },
+ },
+ {
+ accessorKey: "Image",
+ accessorFn: (row) => row.Image,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("Image")}
;
+ },
+ },
+ {
+ accessorKey: "Mode",
+ accessorFn: (row) => row.Mode,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("Mode")}
;
+ },
+ },
+ {
+ accessorKey: "CurrentState",
+ accessorFn: (row) => row.CurrentState,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ const value = row.getValue("CurrentState") as string;
+ const valueStart = value.startsWith("Running")
+ ? "Running"
+ : value.startsWith("Shutdown")
+ ? "Shutdown"
+ : value;
+ return (
+
+
+ {value}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: "DesiredState",
+ accessorFn: (row) => row.DesiredState,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("DesiredState")}
;
+ },
+ },
+
+ {
+ accessorKey: "Replicas",
+ accessorFn: (row) => row.Replicas,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("Replicas")}
;
+ },
+ },
+
+ {
+ accessorKey: "Ports",
+ accessorFn: (row) => row.Ports,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("Ports")}
;
+ },
+ },
+ {
+ accessorKey: "Errors",
+ accessorFn: (row) => row.Error,
+ header: ({ column }) => {
+ return (
+
+ );
+ },
+ cell: ({ row }) => {
+ return {row.getValue("Errors")}
;
+ },
+ },
+];
diff --git a/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx b/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx
new file mode 100644
index 00000000..03915c19
--- /dev/null
+++ b/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import {
+ type ColumnDef,
+ type ColumnFiltersState,
+ type SortingState,
+ type VisibilityState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
+import { ChevronDown } from "lucide-react";
+import React from "react";
+
+interface DataTableProps {
+ columns: ColumnDef[];
+ data: TData[];
+}
+
+export function DataTable({
+ columns,
+ data,
+}: DataTableProps) {
+ const [sorting, setSorting] = React.useState([]);
+ const [columnFilters, setColumnFilters] = React.useState(
+ [],
+ );
+ const [columnVisibility, setColumnVisibility] =
+ React.useState({});
+ const [rowSelection, setRowSelection] = React.useState({});
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0, //initial page index
+ pageSize: 8, //default page size
+ });
+
+ const table = useReactTable({
+ data,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ },
+ });
+
+ return (
+
+
+
+
+ table.getColumn("Name")?.setFilterValue(event.target.value)
+ }
+ className="md:max-w-sm"
+ />
+
+
+
+
+
+ {table
+ .getAllColumns()
+ .filter((column) => column.getCanHide())
+ .map((column) => {
+ return (
+
+ column.toggleVisibility(!!value)
+ }
+ >
+ {column.id}
+
+ );
+ })}
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table?.getRowModel()?.rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+ {/* {isLoading ? (
+
+
+ Loading...
+
+
+ ) : (
+ <>No results.>
+ )} */}
+
+
+ )}
+
+
+
+ {data && data?.length > 0 && (
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx
new file mode 100644
index 00000000..132cb008
--- /dev/null
+++ b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx
@@ -0,0 +1,116 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { api } from "@/utils/api";
+import { Layers, Loader2 } from "lucide-react";
+import React from "react";
+import { columns } from "./columns";
+import { DataTable } from "./data-table";
+
+interface Props {
+ serverId?: string;
+}
+
+interface ApplicationList {
+ ID: string;
+ Image: string;
+ Mode: string;
+ Name: string;
+ Ports: string;
+ Replicas: string;
+ CurrentState: string;
+ DesiredState: string;
+ Error: string;
+ Node: string;
+}
+
+export const ShowNodeApplications = ({ serverId }: Props) => {
+ const { data: NodeApps, isLoading: NodeAppsLoading } =
+ api.swarm.getNodeApps.useQuery({ serverId });
+
+ let applicationList = "";
+
+ if (NodeApps && NodeApps.length > 0) {
+ applicationList = NodeApps.map((app) => app.Name).join(" ");
+ }
+
+ const { data: NodeAppDetails, isLoading: NodeAppDetailsLoading } =
+ api.swarm.getAppInfos.useQuery({ appName: applicationList, serverId });
+
+ if (NodeAppsLoading || NodeAppDetailsLoading) {
+ return (
+
+ );
+ }
+
+ if (!NodeApps || !NodeAppDetails) {
+ return (
+
+ No data found
+
+ );
+ }
+
+ const combinedData: ApplicationList[] = NodeApps.flatMap((app) => {
+ const appDetails =
+ NodeAppDetails?.filter((detail) =>
+ detail.Name.startsWith(`${app.Name}.`),
+ ) || [];
+
+ if (appDetails.length === 0) {
+ return [
+ {
+ ...app,
+ CurrentState: "N/A",
+ DesiredState: "N/A",
+ Error: "",
+ Node: "N/A",
+ Ports: app.Ports,
+ },
+ ];
+ }
+
+ return appDetails.map((detail) => ({
+ ...app,
+ CurrentState: detail.CurrentState,
+ DesiredState: detail.DesiredState,
+ Error: detail.Error,
+ Node: detail.Node,
+ Ports: detail.Ports || app.Ports,
+ }));
+ });
+
+ return (
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/swarm/details/details-card.tsx b/apps/dokploy/components/dashboard/swarm/details/details-card.tsx
new file mode 100644
index 00000000..a499f898
--- /dev/null
+++ b/apps/dokploy/components/dashboard/swarm/details/details-card.tsx
@@ -0,0 +1,140 @@
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { api } from "@/utils/api";
+import {
+ AlertCircle,
+ CheckCircle,
+ HelpCircle,
+ Loader2,
+ LoaderIcon,
+} from "lucide-react";
+import { ShowNodeApplications } from "../applications/show-applications";
+import { ShowNodeConfig } from "./show-node-config";
+
+export interface SwarmList {
+ ID: string;
+ Hostname: string;
+ Availability: string;
+ EngineVersion: string;
+ Status: string;
+ ManagerStatus: string;
+ TLSStatus: string;
+}
+
+interface Props {
+ node: SwarmList;
+ serverId?: string;
+}
+
+export function NodeCard({ node, serverId }: Props) {
+ const { data, isLoading } = api.swarm.getNodeInfo.useQuery({
+ nodeId: node.ID,
+ serverId,
+ });
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case "Ready":
+ return ;
+ case "Down":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+ {getStatusIcon(node.Status)}
+ {node.Hostname}
+
+
+ {node.ManagerStatus || "Worker"}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {getStatusIcon(node.Status)}
+ {node.Hostname}
+
+
+ {node.ManagerStatus || "Worker"}
+
+
+
+
+
+
+ Status:
+ {node.Status}
+
+
+ IP Address:
+ {isLoading ? (
+
+ ) : (
+ {data?.Status?.Addr}
+ )}
+
+
+ Availability:
+ {node.Availability}
+
+
+ Engine Version:
+ {node.EngineVersion}
+
+
+ CPU:
+ {isLoading ? (
+
+ ) : (
+
+ {(data?.Description?.Resources?.NanoCPUs / 1e9).toFixed(2)} GHz
+
+ )}
+
+
+ Memory:
+ {isLoading ? (
+
+ ) : (
+
+ {(
+ data?.Description?.Resources?.MemoryBytes /
+ 1024 ** 3
+ ).toFixed(2)}{" "}
+ GB
+
+ )}
+
+
+ TLS Status:
+ {node.TLSStatus}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx b/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx
new file mode 100644
index 00000000..a41c5a49
--- /dev/null
+++ b/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx
@@ -0,0 +1,56 @@
+import { CodeEditor } from "@/components/shared/code-editor";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { api } from "@/utils/api";
+import { Settings } from "lucide-react";
+
+interface Props {
+ nodeId: string;
+ serverId?: string;
+}
+
+export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
+ const { data, isLoading } = api.swarm.getNodeInfo.useQuery({
+ nodeId,
+ serverId,
+ });
+ return (
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx
new file mode 100644
index 00000000..e2453dd9
--- /dev/null
+++ b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx
@@ -0,0 +1,188 @@
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { api } from "@/utils/api";
+import {
+ AlertCircle,
+ CheckCircle,
+ HelpCircle,
+ Loader2,
+ Server,
+} from "lucide-react";
+import { NodeCard } from "./details/details-card";
+
+interface Props {
+ serverId?: string;
+}
+
+export default function SwarmMonitorCard({ serverId }: Props) {
+ const { data: nodes, isLoading } = api.swarm.getNodes.useQuery({
+ serverId,
+ });
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!nodes) {
+ return (
+
+
+
+ Failed to load data
+
+
+
+ );
+ }
+
+ const totalNodes = nodes.length;
+ const activeNodesCount = nodes.filter(
+ (node) => node.Status === "Ready",
+ ).length;
+ const managerNodesCount = nodes.filter(
+ (node) =>
+ node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
+ ).length;
+
+ const activeNodes = nodes.filter((node) => node.Status === "Ready");
+ const managerNodes = nodes.filter(
+ (node) =>
+ node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
+ );
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case "Ready":
+ return ;
+ case "Down":
+ return ;
+ case "Disconnected":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
Docker Swarm Overview
+ {!serverId && (
+
+ )}
+
+
+
+
+
+ Monitor
+
+
+
+
+
+ Total Nodes:
+ {totalNodes}
+
+
+
Active Nodes:
+
+
+
+
+ {activeNodesCount} / {totalNodes}
+
+
+
+
+ {activeNodes.map((node) => (
+
+ {getStatusIcon(node.Status)}
+ {node.Hostname}
+
+ ))}
+
+
+
+
+
+
+
Manager Nodes:
+
+
+
+
+ {managerNodesCount} / {totalNodes}
+
+
+
+
+ {managerNodes.map((node) => (
+
+ {getStatusIcon(node.Status)}
+ {node.Hostname}
+
+ ))}
+
+
+
+
+
+
+
+
Node Status:
+
+ {nodes.map((node) => (
+ -
+
+ {getStatusIcon(node.Status)}
+ {node.Hostname}
+
+
+ {node.ManagerStatus || "Worker"}
+
+
+ ))}
+
+
+
+
+
+ {nodes.map((node) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/dokploy/components/layouts/navbar.tsx b/apps/dokploy/components/layouts/navbar.tsx
index cead4683..0e52d701 100644
--- a/apps/dokploy/components/layouts/navbar.tsx
+++ b/apps/dokploy/components/layouts/navbar.tsx
@@ -12,11 +12,16 @@ import { api } from "@/utils/api";
import { HeartIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
+import { useEffect, useRef, useState } from "react";
+import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver";
import { Logo } from "../shared/logo";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { buttonVariants } from "../ui/button";
+const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
+
export const Navbar = () => {
+ const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const router = useRouter();
const { data } = api.auth.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -29,6 +34,59 @@ export const Navbar = () => {
},
);
const { mutateAsync } = api.auth.logout.useMutation();
+ const { mutateAsync: getUpdateData } =
+ api.settings.getUpdateData.useMutation();
+
+ const checkUpdatesIntervalRef = useRef(null);
+
+ useEffect(() => {
+ // Handling of automatic check for server updates
+ if (isCloud) {
+ return;
+ }
+
+ if (!localStorage.getItem("enableAutoCheckUpdates")) {
+ // Enable auto update checking by default if user didn't change it
+ localStorage.setItem("enableAutoCheckUpdates", "true");
+ }
+
+ const clearUpdatesInterval = () => {
+ if (checkUpdatesIntervalRef.current) {
+ clearInterval(checkUpdatesIntervalRef.current);
+ }
+ };
+
+ const checkUpdates = async () => {
+ try {
+ if (localStorage.getItem("enableAutoCheckUpdates") !== "true") {
+ return;
+ }
+
+ const { updateAvailable } = await getUpdateData();
+
+ if (updateAvailable) {
+ // Stop interval when update is available
+ clearUpdatesInterval();
+ setIsUpdateAvailable(true);
+ }
+ } catch (error) {
+ console.error("Error auto-checking for updates:", error);
+ }
+ };
+
+ checkUpdatesIntervalRef.current = setInterval(
+ checkUpdates,
+ AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000,
+ );
+
+ // Also check for updates on initial page load
+ checkUpdates();
+
+ return () => {
+ clearUpdatesInterval();
+ };
+ }, []);
+
return (
+ {isUpdateAvailable && (
+