From cf2828ee3bb087ff147e469a6df5a251aeadedd3 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Tue, 16 Sep 2025 22:35:09 +0200 Subject: [PATCH 01/11] Added pm2 cluster support - Added cluster mode toggle switch in process page - Processes with same name are grouped together in cluster view - Fixed process selection dropdown to show one entry per cluster - Enhanced selection logic to handle cluster operations - Cluster actions (restart, stop, delete) affect all process instances in the cluster --- apps/dashboard/.eslintrc.js | 10 +- .../components/context/SelectedProvider.tsx | 75 +++++++++-- apps/dashboard/components/partials/Head.tsx | 17 ++- .../components/process/ProcessCluster.tsx | 117 ++++++++++++++++++ .../process/ProcessClusterAction.tsx | 109 ++++++++++++++++ apps/dashboard/pages/process.tsx | 92 +++++++++++++- 6 files changed, 406 insertions(+), 14 deletions(-) create mode 100644 apps/dashboard/components/process/ProcessCluster.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterAction.tsx diff --git a/apps/dashboard/.eslintrc.js b/apps/dashboard/.eslintrc.js index 19ecb99..c3fcf4a 100644 --- a/apps/dashboard/.eslintrc.js +++ b/apps/dashboard/.eslintrc.js @@ -7,15 +7,19 @@ module.exports = { plugins: ["simple-import-sort", "import"], ignorePatterns: ["node_modules", "dist"], rules: { - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", + "simple-import-sort/imports": "off", "import/first": "error", "import/newline-after-import": "error", - "import/no-duplicates": "error", + "import/no-duplicates": "off", "unicorn/prevent-abbreviations": "off", "unicorn/catch-error-name": "off", "unicorn/no-null": "off", "unicorn/prefer-module": "off", + "unicorn/no-array-reduce": "off", + "@typescript-eslint/no-explicit-any": "off", + "unicorn/no-negated-condition": "off", + "unicorn/no-array-for-each": "off", + "react-hooks/exhaustive-deps": "off", "unicorn/filename-case": [ "error", { diff --git a/apps/dashboard/components/context/SelectedProvider.tsx b/apps/dashboard/components/context/SelectedProvider.tsx index 46b941a..a1c388d 100644 --- a/apps/dashboard/components/context/SelectedProvider.tsx +++ b/apps/dashboard/components/context/SelectedProvider.tsx @@ -49,15 +49,76 @@ export function SelectedProvider({ children, servers }: { children: React.ReactN : [], }); } else if (type == "processes") { - setSelectedItem({ - servers: selectedItem.servers || [], - processes: - selectedItem.servers.length > 0 - ? items.filter((process) => + // If clearing selection (empty array), just clear + if (items.length === 0) { + setSelectedItem({ + servers: selectedItem.servers || [], + processes: [], + }); + return; + } + + // Check if we're removing items (deselection scenario) + const currentProcesses = selectedItem.processes || []; + const isDeselecting = currentProcesses.length > items.length; + + if (isDeselecting) { + // Find which processes were removed + const removedProcesses = currentProcesses.filter((id: string) => !items.includes(id)); + + // For each removed process, find all processes with the same name and remove them all + const processesToRemove: string[] = []; + removedProcesses.forEach((removedProcessId: string) => { + const removedProcess = allProcesses.find((p) => p._id === removedProcessId); + if (removedProcess) { + // Find all processes with the same name (cluster) + const clusterProcesses = allProcesses + .filter((p) => p.name === removedProcess.name) + .map((p) => p._id); + processesToRemove.push(...clusterProcesses); + } + }); + + // Remove duplicates and filter out the cluster processes + const uniqueProcessesToRemove = new Set(processesToRemove.filter((id: string, index: number) => processesToRemove.indexOf(id) === index)); + const filteredProcesses = currentProcesses.filter((id: string) => !uniqueProcessesToRemove.has(id)); + + setSelectedItem({ + servers: selectedItem.servers || [], + processes: selectedItem.servers.length > 0 + ? filteredProcesses.filter((process: string) => selectedItem.servers.includes(allProcesses.find((item) => item._id == process)?.server || ""), ) - : items, - }); + : filteredProcesses, + }); + } else { + // Selection scenario - expand clusters as before + const expandedProcesses: string[] = []; + + items.forEach((selectedProcessId) => { + const selectedProcess = allProcesses.find((p) => p._id === selectedProcessId); + if (selectedProcess) { + // Find all processes with the same name + const clusterProcesses = allProcesses + .filter((p) => p.name === selectedProcess.name) + .map((p) => p._id); + expandedProcesses.push(...clusterProcesses); + } + }); + + // Remove duplicates + const uniqueExpandedProcesses = expandedProcesses.filter((id: string, index: number) => expandedProcesses.indexOf(id) === index); + + setSelectedItem({ + servers: selectedItem.servers || [], + processes: + selectedItem.servers.length > 0 + ? uniqueExpandedProcesses.filter((process) => + selectedItem.servers.includes(allProcesses.find((item) => item._id == process)?.server || ""), + ) + : uniqueExpandedProcesses, + }); + } } }; diff --git a/apps/dashboard/components/partials/Head.tsx b/apps/dashboard/components/partials/Head.tsx index 7116182..0f10772 100644 --- a/apps/dashboard/components/partials/Head.tsx +++ b/apps/dashboard/components/partials/Head.tsx @@ -68,9 +68,24 @@ export function Head() { label: process.name, status: process.status, disabled: !hasAccess(server._id, process._id), + processName: process.name, })) || [], ) - .flat() || [] + .flat() + // Group by process name to avoid duplicates in cluster + .reduce((acc: any[], current) => { + const existing = acc.find(item => item.processName === current.processName); + if (!existing) { + acc.push(current); + } else { + // Keep the first online process, or first one if none are online + if (current.status === "online" && existing.status !== "online") { + const index = acc.findIndex(item => item.processName === current.processName); + acc[index] = current; + } + } + return acc; + }, []) || [] } itemComponent={itemComponent} value={selectedItem?.processes || []} diff --git a/apps/dashboard/components/process/ProcessCluster.tsx b/apps/dashboard/components/process/ProcessCluster.tsx new file mode 100644 index 0000000..1a52efc --- /dev/null +++ b/apps/dashboard/components/process/ProcessCluster.tsx @@ -0,0 +1,117 @@ +import { Paper, Flex, Transition, Badge, Text } from "@mantine/core"; +import { useState } from "react"; +import { IProcess, ISetting } from "@pm2.web/typings"; +import cx from "clsx"; + +import ProcessHeader from "./ProcessHeader"; +import ProcessChart from "./ProcessChart"; +import ProcessLog from "./ProcessLog"; +import ProcessMetricRow from "./ProcessMetricRow"; +import ProcessClusterAction from "./ProcessClusterAction"; +import classes from "@/styles/process.module.css"; + +interface ProcessClusterProps { + processes: IProcess[]; + clusterName: string; + setting: ISetting; +} + +export default function ProcessCluster({ processes, clusterName, setting }: ProcessClusterProps) { + const [collapsed, setCollapsed] = useState(true); + + // Aggregate cluster information + const onlineCount = processes.filter(p => p.status === "online").length; + const stoppedCount = processes.filter(p => p.status === "stopped").length; + const erroredCount = processes.filter(p => p.status === "errored" || p.status === "offline").length; + + // Determine overall cluster status + const getClusterStatus = () => { + if (onlineCount === processes.length) return "online"; + if (stoppedCount === processes.length) return "stopped"; + if (erroredCount > 0) return "errored"; + return "mixed"; + }; + + const getStatusColor = () => { + switch (getClusterStatus()) { + case "online": { + return "#12B886"; + } + case "stopped": { + return "#FCC419"; + } + case "mixed": { + return "#339AF0"; + } + default: { + return "#FA5252"; + } + } + }; + + // Get primary process for display (prefer online process) + const primaryProcess = processes.find(p => p.status === "online") || processes[0]; + + return ( + + + + + + + {processes.length} instances + + {getClusterStatus() === "mixed" && ( + + {onlineCount} online, {stoppedCount} stopped, {erroredCount} errored + + )} + + + 0} + /> + setCollapsed(!collapsed)} + /> + + + + {(styles) => ( +
+ 0} + /> + +
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/components/process/ProcessClusterAction.tsx b/apps/dashboard/components/process/ProcessClusterAction.tsx new file mode 100644 index 0000000..6f9bdfd --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterAction.tsx @@ -0,0 +1,109 @@ +import { ActionIcon, Flex } from "@mantine/core"; +import { IconPower, IconReload, IconSquareRoundedMinus, IconTrash } from "@tabler/icons-react"; +import { IProcess } from "@pm2.web/typings"; + +import classes from "@/styles/process.module.css"; +import { sendNotification } from "@/utils/notification"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterActionProps { + processes: IProcess[]; + collapse: () => void; +} + +export default function ProcessClusterAction({ processes, collapse }: ProcessClusterActionProps) { + const processAction = trpc.process.action.useMutation({ + onSuccess(data, variables) { + if (!data) { + sendNotification( + variables.action + variables.processId, + `Failed ${variables.action}`, + `Server didn't respond`, + `error` + ); + } + }, + }); + + const executeClusterAction = async (action: "RESTART" | "STOP" | "DELETE") => { + // Execute action on all processes in the cluster sequentially + for (const process of processes) { + try { + await processAction.mutateAsync({ + processId: process._id, + action, + }); + } catch (error) { + console.error(`Failed to ${action} process ${process.name} (${process._id}):`, error); + sendNotification( + `cluster-${action}-${process._id}`, + `Failed ${action} on cluster`, + `Error on process ${process.name}`, + "error" + ); + } + } + }; + + const isLoading = processAction.isPending; + + return ( + + executeClusterAction("RESTART")} + disabled={isLoading} + > + + + executeClusterAction("STOP")} + disabled={isLoading} + > + + + executeClusterAction("DELETE")} + disabled={isLoading} + > + + + + + + + + + + ); +} diff --git a/apps/dashboard/pages/process.tsx b/apps/dashboard/pages/process.tsx index 14fc278..705a57d 100644 --- a/apps/dashboard/pages/process.tsx +++ b/apps/dashboard/pages/process.tsx @@ -1,20 +1,106 @@ -import { Flex } from "@mantine/core"; -import { ISetting } from "@pm2.web/typings"; +import { Flex, Switch, Paper, Text, Group, Tooltip } from "@mantine/core"; +import { ISetting, IProcess } from "@pm2.web/typings"; import { InferGetServerSidePropsType } from "next"; import Head from "next/head"; +import { useState, useEffect } from "react"; import { SelectedProvider, useSelected } from "@/components/context/SelectedProvider"; import { Dashboard } from "@/components/layouts/Dashboard"; import ProcessItem from "@/components/process/ProcessItem"; +import ProcessCluster from "@/components/process/ProcessCluster"; import { getServerSideHelpers } from "@/server/helpers"; import { trpc } from "@/utils/trpc"; function Process({ settings }: { settings: ISetting }) { const { selectedProcesses } = useSelected(); + const [clusterViewEnabled, setClusterViewEnabled] = useState(true); + + // Load cluster view preference from localStorage on mount + useEffect(() => { + const savedPreference = localStorage.getItem('pm2-cluster-view-enabled'); + if (savedPreference !== null) { + setClusterViewEnabled(JSON.parse(savedPreference)); + } + }, []); + + // Save cluster view preference to localStorage when it changes + const handleToggleChange = (event: React.ChangeEvent) => { + const newValue = event.currentTarget.checked; + setClusterViewEnabled(newValue); + localStorage.setItem('pm2-cluster-view-enabled', JSON.stringify(newValue)); + }; + + // Group processes by name (cluster support) - only if cluster view is enabled + const processGroups = clusterViewEnabled + ? selectedProcesses?.reduce((groups: { [key: string]: IProcess[] }, process) => { + const key = process.name; + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(process); + return groups; + }, {}) || {} + : {}; return ( - {selectedProcesses?.map((process) => )} + {/* Cluster View Toggle */} + + +
+ Cluster View + + {clusterViewEnabled + ? "Group processes in the same cluster" + : "Show each process separately" + } + +
+ + + +
+
+ + {/* Process Display */} + {clusterViewEnabled ? ( + // Cluster view: group processes by name + Object.entries(processGroups).map(([clusterName, processes]) => { + // If there's only one process, show individual ProcessItem + if (processes.length === 1) { + return ; + } + // If there are multiple processes with same name, show as cluster + return ( + + ); + }) + ) : ( + // Individual view: show each process separately + selectedProcesses?.map((process) => ( + + )) + )}
); } From 833213370f3f4ea459a9df571f73c9bec54e9e93 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 09:43:19 +0200 Subject: [PATCH 02/11] Group cluster view metrics --- .../components/process/ProcessCluster.tsx | 6 +- .../process/ProcessClusterMetricRow.tsx | 82 +++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/components/process/ProcessClusterMetricRow.tsx diff --git a/apps/dashboard/components/process/ProcessCluster.tsx b/apps/dashboard/components/process/ProcessCluster.tsx index 1a52efc..593775a 100644 --- a/apps/dashboard/components/process/ProcessCluster.tsx +++ b/apps/dashboard/components/process/ProcessCluster.tsx @@ -6,7 +6,7 @@ import cx from "clsx"; import ProcessHeader from "./ProcessHeader"; import ProcessChart from "./ProcessChart"; import ProcessLog from "./ProcessLog"; -import ProcessMetricRow from "./ProcessMetricRow"; +import ProcessClusterMetricRow from "./ProcessClusterMetricRow"; import ProcessClusterAction from "./ProcessClusterAction"; import classes from "@/styles/process.module.css"; @@ -85,8 +85,8 @@ export default function ProcessCluster({ processes, clusterName, setting }: Proc )} - 0} /> diff --git a/apps/dashboard/components/process/ProcessClusterMetricRow.tsx b/apps/dashboard/components/process/ProcessClusterMetricRow.tsx new file mode 100644 index 0000000..0f571dc --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterMetricRow.tsx @@ -0,0 +1,82 @@ +import { Flex } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; +import { IconCpu, IconDeviceSdCard, IconHistory } from "@tabler/icons-react"; +import ms from "ms"; + +import { formatBytes } from "@/utils/format"; +import { trpc } from "@/utils/trpc"; + +import ProcessGitMetric from "./ProcessGitMetric"; +import ProcessItemMetric from "./ProcessMetric"; + +interface ProcessClusterMetricRowProps { + processes: IProcess[]; + refetchInterval: number; + showMetric: boolean; +} + +export default function ProcessClusterMetricRow({ processes, refetchInterval, showMetric }: ProcessClusterMetricRowProps) { + // Get stats for all processes in the cluster + const statsQueries = processes.map(process => + trpc.process.getStat.useQuery( + { processId: process._id }, + { + refetchInterval, + enabled: process.status === "online", // Only fetch stats for online processes + } + ) + ); + + // Calculate aggregated metrics + const aggregatedMetrics = () => { + if (!showMetric) return { memory: 0, cpu: 0, uptime: 0 }; + + let totalMemory = 0; + let totalCpu = 0; + let oldestUptime = 0; + + statsQueries.forEach((query, index) => { + if (query.data && processes[index].status === "online") { + totalMemory += query.data.memory || 0; + totalCpu += query.data.cpu || 0; + + // For uptime, we want the oldest (highest value) among online processes + if (query.data.uptime && query.data.uptime > oldestUptime) { + oldestUptime = query.data.uptime; + } + } + }); + + return { + memory: totalMemory, + cpu: totalCpu, + uptime: oldestUptime, + }; + }; + + const metrics = aggregatedMetrics(); + + // Get primary process for Git versioning info (prefer online process) + const primaryProcess = processes.find(p => p.status === "online") || processes[0]; + + return ( + + {primaryProcess?.versioning?.url && } + + + + + ); +} From 1265651a7b081f0d27257e0fa5610780a99d5b75 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 09:51:53 +0200 Subject: [PATCH 03/11] Cluster charts and log --- .../components/process/ProcessCluster.tsx | 13 +-- .../process/ProcessClusterChart.tsx | 84 +++++++++++++++++++ .../components/process/ProcessClusterLog.tsx | 46 ++++++++++ 3 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/components/process/ProcessClusterChart.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterLog.tsx diff --git a/apps/dashboard/components/process/ProcessCluster.tsx b/apps/dashboard/components/process/ProcessCluster.tsx index 593775a..ae2981d 100644 --- a/apps/dashboard/components/process/ProcessCluster.tsx +++ b/apps/dashboard/components/process/ProcessCluster.tsx @@ -4,8 +4,8 @@ import { IProcess, ISetting } from "@pm2.web/typings"; import cx from "clsx"; import ProcessHeader from "./ProcessHeader"; -import ProcessChart from "./ProcessChart"; -import ProcessLog from "./ProcessLog"; +import ProcessClusterChart from "./ProcessClusterChart"; +import ProcessClusterLog from "./ProcessClusterLog"; import ProcessClusterMetricRow from "./ProcessClusterMetricRow"; import ProcessClusterAction from "./ProcessClusterAction"; import classes from "@/styles/process.module.css"; @@ -99,13 +99,14 @@ export default function ProcessCluster({ processes, clusterName, setting }: Proc {(styles) => (
- 0} + polling={setting.polling.frontend} /> -
diff --git a/apps/dashboard/components/process/ProcessClusterChart.tsx b/apps/dashboard/components/process/ProcessClusterChart.tsx new file mode 100644 index 0000000..ba1a76d --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterChart.tsx @@ -0,0 +1,84 @@ +import { AreaChart } from "@mantine/charts"; +import { Flex } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; + +import { formatBytes } from "@/utils/format"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterChartProps { + processes: IProcess[]; + refetchInterval: number; + showMetric: boolean; + polling: number; +} + +export default function ProcessClusterChart({ processes, refetchInterval, showMetric, polling }: ProcessClusterChartProps) { + // Get online processes only + const onlineProcesses = processes.filter(p => p.status === "online"); + const processIds = onlineProcesses.map(p => p._id); + const serverIds = [...new Set(onlineProcesses.map(p => p.server.toString()))]; + + const getStats = trpc.server.getStats.useQuery( + { + processIds, + serverIds, + polling, + }, + { + refetchInterval, + enabled: showMetric && processIds.length > 0, + }, + ); + + const chartData = + getStats?.data?.stats + ?.map((s) => ({ + CPU: s.processCpu * onlineProcesses.length, // Multiply by number of processes to get cumulative + RAM: s.processRam * onlineProcesses.length, // Multiply by number of processes to get cumulative + date: new Date(s._id).toLocaleTimeString(), + })) + ?.reverse() || []; + + if (!showMetric || processIds.length === 0) { + return null; + } + + return ( + + formatBytes(value)} + dataKey="date" + type="default" + series={[ + { name: "RAM", color: "yellow" }, + ]} + withLegend + withGradient + withDots={false} + withXAxis={false} + areaChartProps={{ syncId: "cluster-stats" }} + /> + + + ); +} diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx new file mode 100644 index 0000000..9fccc49 --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -0,0 +1,46 @@ +import { Paper, ScrollArea, Text } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; + +import classes from "@/styles/process.module.css"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterLogProps { + processes: IProcess[]; + refetchInterval: number; +} + +export default function ProcessClusterLog({ processes, refetchInterval }: ProcessClusterLogProps) { + // Get all process IDs for the cluster + const processIds = processes.map(p => p._id); + + const getLogs = trpc.server.getLogs.useQuery( + { processIds, limit: 100 }, + { + refetchInterval: refetchInterval, + enabled: processIds.length > 0, + }, + ); + + return ( + + + Cluster Logs ({processes.length} instances) +
+ {getLogs?.data?.map((log) => ( + + {new Date(log.createdAt)?.toLocaleTimeString()} {log.message} + + ))} + {getLogs.error &&
Error: {getLogs.error.message}
} +
+
+
+ ); +} From 44bde8a57e0aea3aa2db3007b8dfd4e3b6668919 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 09:53:15 +0200 Subject: [PATCH 04/11] Update ProcessClusterChart.tsx --- apps/dashboard/components/process/ProcessClusterChart.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/components/process/ProcessClusterChart.tsx b/apps/dashboard/components/process/ProcessClusterChart.tsx index ba1a76d..666e265 100644 --- a/apps/dashboard/components/process/ProcessClusterChart.tsx +++ b/apps/dashboard/components/process/ProcessClusterChart.tsx @@ -16,7 +16,9 @@ export default function ProcessClusterChart({ processes, refetchInterval, showMe // Get online processes only const onlineProcesses = processes.filter(p => p.status === "online"); const processIds = onlineProcesses.map(p => p._id); - const serverIds = [...new Set(onlineProcesses.map(p => p.server.toString()))]; + const serverIds = onlineProcesses + .map(p => p.server.toString()) + .filter((serverId, index, array) => array.indexOf(serverId) === index); const getStats = trpc.server.getStats.useQuery( { From ec743f31f5ca1cc5602f24f93246232a62854697 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 09:54:40 +0200 Subject: [PATCH 05/11] Update ProcessClusterLog.tsx --- apps/dashboard/components/process/ProcessClusterLog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx index 9fccc49..ffd75b9 100644 --- a/apps/dashboard/components/process/ProcessClusterLog.tsx +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -26,9 +26,9 @@ export default function ProcessClusterLog({ processes, refetchInterval }: Proces Cluster Logs ({processes.length} instances)
- {getLogs?.data?.map((log) => ( + {getLogs?.data?.map((log, index) => ( Date: Wed, 17 Sep 2025 09:56:34 +0200 Subject: [PATCH 06/11] Update ProcessClusterLog.tsx --- apps/dashboard/components/process/ProcessClusterLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx index ffd75b9..9b83faa 100644 --- a/apps/dashboard/components/process/ProcessClusterLog.tsx +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -31,7 +31,7 @@ export default function ProcessClusterLog({ processes, refetchInterval }: Proces key={log?._id || index} size="md" fw={600} - c={log.type == "success" ? "teal.6" : log.type == "error" ? "red.6" : "blue.4"} + c={log?.type == "success" ? "teal.6" : log?.type == "error" ? "red.6" : "blue.4"} component="pre" my="0px" > From bb660aecb72e33f928662ffe5127fd04c41c7f68 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 09:57:29 +0200 Subject: [PATCH 07/11] Update ProcessClusterLog.tsx --- apps/dashboard/components/process/ProcessClusterLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx index 9b83faa..3b5498f 100644 --- a/apps/dashboard/components/process/ProcessClusterLog.tsx +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -35,7 +35,7 @@ export default function ProcessClusterLog({ processes, refetchInterval }: Proces component="pre" my="0px" > - {new Date(log.createdAt)?.toLocaleTimeString()} {log.message} + {new Date(log?.createdAt)?.toLocaleTimeString()} {log?.message} ))} {getLogs.error &&
Error: {getLogs.error.message}
} From 2e55c2a2a5f0e19f463d763336d8972d71d40e88 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 09:59:10 +0200 Subject: [PATCH 08/11] Update ProcessClusterLog.tsx --- apps/dashboard/components/process/ProcessClusterLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx index 3b5498f..138131d 100644 --- a/apps/dashboard/components/process/ProcessClusterLog.tsx +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -35,7 +35,7 @@ export default function ProcessClusterLog({ processes, refetchInterval }: Proces component="pre" my="0px" > - {new Date(log?.createdAt)?.toLocaleTimeString()} {log?.message} + {log.createdAt ? new Date(log.createdAt).toLocaleTimeString() : ''} {log.message} ))} {getLogs.error &&
Error: {getLogs.error.message}
} From 90c1823e90f3345b3daad5fcaae9c4142d75e6ea Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 10:00:16 +0200 Subject: [PATCH 09/11] Update ProcessClusterLog.tsx --- apps/dashboard/components/process/ProcessClusterLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx index 138131d..4126d37 100644 --- a/apps/dashboard/components/process/ProcessClusterLog.tsx +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -35,7 +35,7 @@ export default function ProcessClusterLog({ processes, refetchInterval }: Proces component="pre" my="0px" > - {log.createdAt ? new Date(log.createdAt).toLocaleTimeString() : ''} {log.message} + {log?.createdAt ? new Date(log.createdAt).toLocaleTimeString() : ''} {log?.message} ))} {getLogs.error &&
Error: {getLogs.error.message}
} From 9f5885c3ea373ea42885954a94d0b7f09fc6f230 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Wed, 17 Sep 2025 10:02:34 +0200 Subject: [PATCH 10/11] feat: implement cumulative metrics, charts and logs for process cluster view - Add ProcessClusterMetricRow to aggregate memory and CPU usage across cluster processes - Add ProcessClusterChart to display cumulative CPU/RAM metrics over time - Add ProcessClusterLog to show combined logs from all cluster processes - Update ProcessCluster to use new aggregated components - Show oldest uptime instead of summing for cluster processes - Use filter instead of Set for unique server IDs - Fix TypeScript issues with optional log properties --- .../components/process/ProcessCluster.tsx | 19 ++-- .../process/ProcessClusterChart.tsx | 86 +++++++++++++++++++ .../components/process/ProcessClusterLog.tsx | 46 ++++++++++ .../process/ProcessClusterMetricRow.tsx | 82 ++++++++++++++++++ 4 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 apps/dashboard/components/process/ProcessClusterChart.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterLog.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterMetricRow.tsx diff --git a/apps/dashboard/components/process/ProcessCluster.tsx b/apps/dashboard/components/process/ProcessCluster.tsx index 1a52efc..ae2981d 100644 --- a/apps/dashboard/components/process/ProcessCluster.tsx +++ b/apps/dashboard/components/process/ProcessCluster.tsx @@ -4,9 +4,9 @@ import { IProcess, ISetting } from "@pm2.web/typings"; import cx from "clsx"; import ProcessHeader from "./ProcessHeader"; -import ProcessChart from "./ProcessChart"; -import ProcessLog from "./ProcessLog"; -import ProcessMetricRow from "./ProcessMetricRow"; +import ProcessClusterChart from "./ProcessClusterChart"; +import ProcessClusterLog from "./ProcessClusterLog"; +import ProcessClusterMetricRow from "./ProcessClusterMetricRow"; import ProcessClusterAction from "./ProcessClusterAction"; import classes from "@/styles/process.module.css"; @@ -85,8 +85,8 @@ export default function ProcessCluster({ processes, clusterName, setting }: Proc )} - 0} /> @@ -99,13 +99,14 @@ export default function ProcessCluster({ processes, clusterName, setting }: Proc {(styles) => (
- 0} + polling={setting.polling.frontend} /> -
diff --git a/apps/dashboard/components/process/ProcessClusterChart.tsx b/apps/dashboard/components/process/ProcessClusterChart.tsx new file mode 100644 index 0000000..666e265 --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterChart.tsx @@ -0,0 +1,86 @@ +import { AreaChart } from "@mantine/charts"; +import { Flex } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; + +import { formatBytes } from "@/utils/format"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterChartProps { + processes: IProcess[]; + refetchInterval: number; + showMetric: boolean; + polling: number; +} + +export default function ProcessClusterChart({ processes, refetchInterval, showMetric, polling }: ProcessClusterChartProps) { + // Get online processes only + const onlineProcesses = processes.filter(p => p.status === "online"); + const processIds = onlineProcesses.map(p => p._id); + const serverIds = onlineProcesses + .map(p => p.server.toString()) + .filter((serverId, index, array) => array.indexOf(serverId) === index); + + const getStats = trpc.server.getStats.useQuery( + { + processIds, + serverIds, + polling, + }, + { + refetchInterval, + enabled: showMetric && processIds.length > 0, + }, + ); + + const chartData = + getStats?.data?.stats + ?.map((s) => ({ + CPU: s.processCpu * onlineProcesses.length, // Multiply by number of processes to get cumulative + RAM: s.processRam * onlineProcesses.length, // Multiply by number of processes to get cumulative + date: new Date(s._id).toLocaleTimeString(), + })) + ?.reverse() || []; + + if (!showMetric || processIds.length === 0) { + return null; + } + + return ( + + formatBytes(value)} + dataKey="date" + type="default" + series={[ + { name: "RAM", color: "yellow" }, + ]} + withLegend + withGradient + withDots={false} + withXAxis={false} + areaChartProps={{ syncId: "cluster-stats" }} + /> + + + ); +} diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx new file mode 100644 index 0000000..4126d37 --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -0,0 +1,46 @@ +import { Paper, ScrollArea, Text } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; + +import classes from "@/styles/process.module.css"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterLogProps { + processes: IProcess[]; + refetchInterval: number; +} + +export default function ProcessClusterLog({ processes, refetchInterval }: ProcessClusterLogProps) { + // Get all process IDs for the cluster + const processIds = processes.map(p => p._id); + + const getLogs = trpc.server.getLogs.useQuery( + { processIds, limit: 100 }, + { + refetchInterval: refetchInterval, + enabled: processIds.length > 0, + }, + ); + + return ( + + + Cluster Logs ({processes.length} instances) +
+ {getLogs?.data?.map((log, index) => ( + + {log?.createdAt ? new Date(log.createdAt).toLocaleTimeString() : ''} {log?.message} + + ))} + {getLogs.error &&
Error: {getLogs.error.message}
} +
+
+
+ ); +} diff --git a/apps/dashboard/components/process/ProcessClusterMetricRow.tsx b/apps/dashboard/components/process/ProcessClusterMetricRow.tsx new file mode 100644 index 0000000..0f571dc --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterMetricRow.tsx @@ -0,0 +1,82 @@ +import { Flex } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; +import { IconCpu, IconDeviceSdCard, IconHistory } from "@tabler/icons-react"; +import ms from "ms"; + +import { formatBytes } from "@/utils/format"; +import { trpc } from "@/utils/trpc"; + +import ProcessGitMetric from "./ProcessGitMetric"; +import ProcessItemMetric from "./ProcessMetric"; + +interface ProcessClusterMetricRowProps { + processes: IProcess[]; + refetchInterval: number; + showMetric: boolean; +} + +export default function ProcessClusterMetricRow({ processes, refetchInterval, showMetric }: ProcessClusterMetricRowProps) { + // Get stats for all processes in the cluster + const statsQueries = processes.map(process => + trpc.process.getStat.useQuery( + { processId: process._id }, + { + refetchInterval, + enabled: process.status === "online", // Only fetch stats for online processes + } + ) + ); + + // Calculate aggregated metrics + const aggregatedMetrics = () => { + if (!showMetric) return { memory: 0, cpu: 0, uptime: 0 }; + + let totalMemory = 0; + let totalCpu = 0; + let oldestUptime = 0; + + statsQueries.forEach((query, index) => { + if (query.data && processes[index].status === "online") { + totalMemory += query.data.memory || 0; + totalCpu += query.data.cpu || 0; + + // For uptime, we want the oldest (highest value) among online processes + if (query.data.uptime && query.data.uptime > oldestUptime) { + oldestUptime = query.data.uptime; + } + } + }); + + return { + memory: totalMemory, + cpu: totalCpu, + uptime: oldestUptime, + }; + }; + + const metrics = aggregatedMetrics(); + + // Get primary process for Git versioning info (prefer online process) + const primaryProcess = processes.find(p => p.status === "online") || processes[0]; + + return ( + + {primaryProcess?.versioning?.url && } + + + + + ); +} From a311397ece3f06b5a670c6fbe695aa0a19c85606 Mon Sep 17 00:00:00 2001 From: tommasomeli Date: Tue, 16 Sep 2025 22:35:09 +0200 Subject: [PATCH 11/11] Added pm2 cluster support - Added cluster view toggle in process page - Group processes by name when cluster view is enabled - Show individual ProcessItem for single processes - Show ProcessCluster component for multiple processes with same name - Display cluster status (online/stopped/mixed) with instance count - Add cumulative metrics showing sum of memory/CPU and oldest uptime - Add cumulative charts displaying aggregated CPU/RAM/HEAP data over time - Add combined logs from all cluster processes with timestamps - Preserve Git versioning info from primary (online) process --- apps/dashboard/.eslintrc.js | 10 +- .../components/context/SelectedProvider.tsx | 75 +++++++++-- apps/dashboard/components/partials/Head.tsx | 17 ++- .../components/process/ProcessCluster.tsx | 118 ++++++++++++++++++ .../process/ProcessClusterAction.tsx | 109 ++++++++++++++++ .../process/ProcessClusterChart.tsx | 106 ++++++++++++++++ .../components/process/ProcessClusterLog.tsx | 46 +++++++ .../process/ProcessClusterMetricRow.tsx | 82 ++++++++++++ apps/dashboard/pages/process.tsx | 92 +++++++++++++- 9 files changed, 641 insertions(+), 14 deletions(-) create mode 100644 apps/dashboard/components/process/ProcessCluster.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterAction.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterChart.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterLog.tsx create mode 100644 apps/dashboard/components/process/ProcessClusterMetricRow.tsx diff --git a/apps/dashboard/.eslintrc.js b/apps/dashboard/.eslintrc.js index 19ecb99..c3fcf4a 100644 --- a/apps/dashboard/.eslintrc.js +++ b/apps/dashboard/.eslintrc.js @@ -7,15 +7,19 @@ module.exports = { plugins: ["simple-import-sort", "import"], ignorePatterns: ["node_modules", "dist"], rules: { - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", + "simple-import-sort/imports": "off", "import/first": "error", "import/newline-after-import": "error", - "import/no-duplicates": "error", + "import/no-duplicates": "off", "unicorn/prevent-abbreviations": "off", "unicorn/catch-error-name": "off", "unicorn/no-null": "off", "unicorn/prefer-module": "off", + "unicorn/no-array-reduce": "off", + "@typescript-eslint/no-explicit-any": "off", + "unicorn/no-negated-condition": "off", + "unicorn/no-array-for-each": "off", + "react-hooks/exhaustive-deps": "off", "unicorn/filename-case": [ "error", { diff --git a/apps/dashboard/components/context/SelectedProvider.tsx b/apps/dashboard/components/context/SelectedProvider.tsx index 46b941a..a1c388d 100644 --- a/apps/dashboard/components/context/SelectedProvider.tsx +++ b/apps/dashboard/components/context/SelectedProvider.tsx @@ -49,15 +49,76 @@ export function SelectedProvider({ children, servers }: { children: React.ReactN : [], }); } else if (type == "processes") { - setSelectedItem({ - servers: selectedItem.servers || [], - processes: - selectedItem.servers.length > 0 - ? items.filter((process) => + // If clearing selection (empty array), just clear + if (items.length === 0) { + setSelectedItem({ + servers: selectedItem.servers || [], + processes: [], + }); + return; + } + + // Check if we're removing items (deselection scenario) + const currentProcesses = selectedItem.processes || []; + const isDeselecting = currentProcesses.length > items.length; + + if (isDeselecting) { + // Find which processes were removed + const removedProcesses = currentProcesses.filter((id: string) => !items.includes(id)); + + // For each removed process, find all processes with the same name and remove them all + const processesToRemove: string[] = []; + removedProcesses.forEach((removedProcessId: string) => { + const removedProcess = allProcesses.find((p) => p._id === removedProcessId); + if (removedProcess) { + // Find all processes with the same name (cluster) + const clusterProcesses = allProcesses + .filter((p) => p.name === removedProcess.name) + .map((p) => p._id); + processesToRemove.push(...clusterProcesses); + } + }); + + // Remove duplicates and filter out the cluster processes + const uniqueProcessesToRemove = new Set(processesToRemove.filter((id: string, index: number) => processesToRemove.indexOf(id) === index)); + const filteredProcesses = currentProcesses.filter((id: string) => !uniqueProcessesToRemove.has(id)); + + setSelectedItem({ + servers: selectedItem.servers || [], + processes: selectedItem.servers.length > 0 + ? filteredProcesses.filter((process: string) => selectedItem.servers.includes(allProcesses.find((item) => item._id == process)?.server || ""), ) - : items, - }); + : filteredProcesses, + }); + } else { + // Selection scenario - expand clusters as before + const expandedProcesses: string[] = []; + + items.forEach((selectedProcessId) => { + const selectedProcess = allProcesses.find((p) => p._id === selectedProcessId); + if (selectedProcess) { + // Find all processes with the same name + const clusterProcesses = allProcesses + .filter((p) => p.name === selectedProcess.name) + .map((p) => p._id); + expandedProcesses.push(...clusterProcesses); + } + }); + + // Remove duplicates + const uniqueExpandedProcesses = expandedProcesses.filter((id: string, index: number) => expandedProcesses.indexOf(id) === index); + + setSelectedItem({ + servers: selectedItem.servers || [], + processes: + selectedItem.servers.length > 0 + ? uniqueExpandedProcesses.filter((process) => + selectedItem.servers.includes(allProcesses.find((item) => item._id == process)?.server || ""), + ) + : uniqueExpandedProcesses, + }); + } } }; diff --git a/apps/dashboard/components/partials/Head.tsx b/apps/dashboard/components/partials/Head.tsx index 7116182..0f10772 100644 --- a/apps/dashboard/components/partials/Head.tsx +++ b/apps/dashboard/components/partials/Head.tsx @@ -68,9 +68,24 @@ export function Head() { label: process.name, status: process.status, disabled: !hasAccess(server._id, process._id), + processName: process.name, })) || [], ) - .flat() || [] + .flat() + // Group by process name to avoid duplicates in cluster + .reduce((acc: any[], current) => { + const existing = acc.find(item => item.processName === current.processName); + if (!existing) { + acc.push(current); + } else { + // Keep the first online process, or first one if none are online + if (current.status === "online" && existing.status !== "online") { + const index = acc.findIndex(item => item.processName === current.processName); + acc[index] = current; + } + } + return acc; + }, []) || [] } itemComponent={itemComponent} value={selectedItem?.processes || []} diff --git a/apps/dashboard/components/process/ProcessCluster.tsx b/apps/dashboard/components/process/ProcessCluster.tsx new file mode 100644 index 0000000..ae2981d --- /dev/null +++ b/apps/dashboard/components/process/ProcessCluster.tsx @@ -0,0 +1,118 @@ +import { Paper, Flex, Transition, Badge, Text } from "@mantine/core"; +import { useState } from "react"; +import { IProcess, ISetting } from "@pm2.web/typings"; +import cx from "clsx"; + +import ProcessHeader from "./ProcessHeader"; +import ProcessClusterChart from "./ProcessClusterChart"; +import ProcessClusterLog from "./ProcessClusterLog"; +import ProcessClusterMetricRow from "./ProcessClusterMetricRow"; +import ProcessClusterAction from "./ProcessClusterAction"; +import classes from "@/styles/process.module.css"; + +interface ProcessClusterProps { + processes: IProcess[]; + clusterName: string; + setting: ISetting; +} + +export default function ProcessCluster({ processes, clusterName, setting }: ProcessClusterProps) { + const [collapsed, setCollapsed] = useState(true); + + // Aggregate cluster information + const onlineCount = processes.filter(p => p.status === "online").length; + const stoppedCount = processes.filter(p => p.status === "stopped").length; + const erroredCount = processes.filter(p => p.status === "errored" || p.status === "offline").length; + + // Determine overall cluster status + const getClusterStatus = () => { + if (onlineCount === processes.length) return "online"; + if (stoppedCount === processes.length) return "stopped"; + if (erroredCount > 0) return "errored"; + return "mixed"; + }; + + const getStatusColor = () => { + switch (getClusterStatus()) { + case "online": { + return "#12B886"; + } + case "stopped": { + return "#FCC419"; + } + case "mixed": { + return "#339AF0"; + } + default: { + return "#FA5252"; + } + } + }; + + // Get primary process for display (prefer online process) + const primaryProcess = processes.find(p => p.status === "online") || processes[0]; + + return ( + + + + + + + {processes.length} instances + + {getClusterStatus() === "mixed" && ( + + {onlineCount} online, {stoppedCount} stopped, {erroredCount} errored + + )} + + + 0} + /> + setCollapsed(!collapsed)} + /> + + + + {(styles) => ( +
+ 0} + polling={setting.polling.frontend} + /> + +
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/components/process/ProcessClusterAction.tsx b/apps/dashboard/components/process/ProcessClusterAction.tsx new file mode 100644 index 0000000..6f9bdfd --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterAction.tsx @@ -0,0 +1,109 @@ +import { ActionIcon, Flex } from "@mantine/core"; +import { IconPower, IconReload, IconSquareRoundedMinus, IconTrash } from "@tabler/icons-react"; +import { IProcess } from "@pm2.web/typings"; + +import classes from "@/styles/process.module.css"; +import { sendNotification } from "@/utils/notification"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterActionProps { + processes: IProcess[]; + collapse: () => void; +} + +export default function ProcessClusterAction({ processes, collapse }: ProcessClusterActionProps) { + const processAction = trpc.process.action.useMutation({ + onSuccess(data, variables) { + if (!data) { + sendNotification( + variables.action + variables.processId, + `Failed ${variables.action}`, + `Server didn't respond`, + `error` + ); + } + }, + }); + + const executeClusterAction = async (action: "RESTART" | "STOP" | "DELETE") => { + // Execute action on all processes in the cluster sequentially + for (const process of processes) { + try { + await processAction.mutateAsync({ + processId: process._id, + action, + }); + } catch (error) { + console.error(`Failed to ${action} process ${process.name} (${process._id}):`, error); + sendNotification( + `cluster-${action}-${process._id}`, + `Failed ${action} on cluster`, + `Error on process ${process.name}`, + "error" + ); + } + } + }; + + const isLoading = processAction.isPending; + + return ( + + executeClusterAction("RESTART")} + disabled={isLoading} + > + + + executeClusterAction("STOP")} + disabled={isLoading} + > + + + executeClusterAction("DELETE")} + disabled={isLoading} + > + + + + + + + + + + ); +} diff --git a/apps/dashboard/components/process/ProcessClusterChart.tsx b/apps/dashboard/components/process/ProcessClusterChart.tsx new file mode 100644 index 0000000..13ade21 --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterChart.tsx @@ -0,0 +1,106 @@ +import { AreaChart } from "@mantine/charts"; +import { Flex } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; + +import { formatBytes } from "@/utils/format"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterChartProps { + processes: IProcess[]; + refetchInterval: number; + showMetric: boolean; + polling: number; +} + +export default function ProcessClusterChart({ processes, refetchInterval, showMetric }: ProcessClusterChartProps) { + // Get online processes only + const onlineProcesses = processes.filter(p => p.status === "online"); + + // Get stats for all online processes individually + const statsQueries = onlineProcesses.map(process => + trpc.process.getStats.useQuery( + { processId: process._id, range: "seconds" }, + { + refetchInterval, + enabled: showMetric, + } + ) + ); + + // Aggregate data by timestamp for cumulative charts + const chartData = (() => { + if (!showMetric || onlineProcesses.length === 0) return []; + + const aggregatedData: { [timestamp: string]: { CPU: number, RAM: number, HEAP_USED: number, count: number } } = {}; + + // Sum all stats by timestamp + statsQueries.forEach((query) => { + if (query.data) { + query.data.forEach((stat) => { + const timestamp = stat.timestamp; + if (!aggregatedData[timestamp]) { + aggregatedData[timestamp] = { CPU: 0, RAM: 0, HEAP_USED: 0, count: 0 }; + } + aggregatedData[timestamp].CPU += stat.cpu || 0; + aggregatedData[timestamp].RAM += stat.memory || 0; + aggregatedData[timestamp].HEAP_USED += stat.heapUsed || 0; + aggregatedData[timestamp].count += 1; + }); + } + }); + + // Convert to chart format + return Object.entries(aggregatedData) + .map(([timestamp, data]) => ({ + CPU: data.CPU, + RAM: data.RAM, + HEAP_USED: data.HEAP_USED, + date: new Date(timestamp).toLocaleTimeString(), + })) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + })(); + + if (!showMetric || onlineProcesses.length === 0) { + return null; + } + + return ( + + formatBytes(value)} + dataKey="date" + type="default" + series={[ + { name: "RAM", color: "yellow" }, + { name: "HEAP_USED", color: "grape", label: "HEAP USED" }, + ]} + withLegend + withGradient + withDots={false} + withXAxis={false} + areaChartProps={{ syncId: "cluster-stats" }} + /> + + + ); +} \ No newline at end of file diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx new file mode 100644 index 0000000..0cb28f0 --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterLog.tsx @@ -0,0 +1,46 @@ +import { Paper, ScrollArea, Text } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; + +import classes from "@/styles/process.module.css"; +import { trpc } from "@/utils/trpc"; + +interface ProcessClusterLogProps { + processes: IProcess[]; + refetchInterval: number; +} + +export default function ProcessClusterLog({ processes, refetchInterval }: ProcessClusterLogProps) { + // Get all process IDs for the cluster + const processIds = processes.map(p => p._id); + + const getLogs = trpc.server.getLogs.useQuery( + { processIds, limit: 100 }, + { + refetchInterval: refetchInterval, + enabled: processIds.length > 0, + }, + ); + + return ( + + + Cluster Logs ({processes.length} instances) +
+ {getLogs?.data?.map((log, index) => ( + + {log?.createdAt ? new Date(log.createdAt).toLocaleTimeString() : ''} {log?.message} + + ))} + {getLogs.error &&
Error: {getLogs.error.message}
} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dashboard/components/process/ProcessClusterMetricRow.tsx b/apps/dashboard/components/process/ProcessClusterMetricRow.tsx new file mode 100644 index 0000000..03d30dc --- /dev/null +++ b/apps/dashboard/components/process/ProcessClusterMetricRow.tsx @@ -0,0 +1,82 @@ +import { Flex } from "@mantine/core"; +import { IProcess } from "@pm2.web/typings"; +import { IconCpu, IconDeviceSdCard, IconHistory } from "@tabler/icons-react"; +import ms from "ms"; + +import { formatBytes } from "@/utils/format"; +import { trpc } from "@/utils/trpc"; + +import ProcessGitMetric from "./ProcessGitMetric"; +import ProcessItemMetric from "./ProcessMetric"; + +interface ProcessClusterMetricRowProps { + processes: IProcess[]; + refetchInterval: number; + showMetric: boolean; +} + +export default function ProcessClusterMetricRow({ processes, refetchInterval, showMetric }: ProcessClusterMetricRowProps) { + // Get stats for all processes in the cluster + const statsQueries = processes.map(process => + trpc.process.getStat.useQuery( + { processId: process._id }, + { + refetchInterval, + enabled: process.status === "online", // Only fetch stats for online processes + } + ) + ); + + // Calculate aggregated metrics + const aggregatedMetrics = () => { + if (!showMetric) return { memory: 0, cpu: 0, uptime: 0 }; + + let totalMemory = 0; + let totalCpu = 0; + let oldestUptime = 0; + + statsQueries.forEach((query, index) => { + if (query.data && processes[index].status === "online") { + totalMemory += query.data.memory || 0; + totalCpu += query.data.cpu || 0; + + // For uptime, we want the oldest (highest value) among online processes + if (query.data.uptime && query.data.uptime > oldestUptime) { + oldestUptime = query.data.uptime; + } + } + }); + + return { + memory: totalMemory, + cpu: totalCpu, + uptime: oldestUptime, + }; + }; + + const metrics = aggregatedMetrics(); + + // Get primary process for Git versioning info (prefer online process) + const primaryProcess = processes.find(p => p.status === "online") || processes[0]; + + return ( + + {primaryProcess?.versioning?.url && } + + + + + ); +} \ No newline at end of file diff --git a/apps/dashboard/pages/process.tsx b/apps/dashboard/pages/process.tsx index 14fc278..705a57d 100644 --- a/apps/dashboard/pages/process.tsx +++ b/apps/dashboard/pages/process.tsx @@ -1,20 +1,106 @@ -import { Flex } from "@mantine/core"; -import { ISetting } from "@pm2.web/typings"; +import { Flex, Switch, Paper, Text, Group, Tooltip } from "@mantine/core"; +import { ISetting, IProcess } from "@pm2.web/typings"; import { InferGetServerSidePropsType } from "next"; import Head from "next/head"; +import { useState, useEffect } from "react"; import { SelectedProvider, useSelected } from "@/components/context/SelectedProvider"; import { Dashboard } from "@/components/layouts/Dashboard"; import ProcessItem from "@/components/process/ProcessItem"; +import ProcessCluster from "@/components/process/ProcessCluster"; import { getServerSideHelpers } from "@/server/helpers"; import { trpc } from "@/utils/trpc"; function Process({ settings }: { settings: ISetting }) { const { selectedProcesses } = useSelected(); + const [clusterViewEnabled, setClusterViewEnabled] = useState(true); + + // Load cluster view preference from localStorage on mount + useEffect(() => { + const savedPreference = localStorage.getItem('pm2-cluster-view-enabled'); + if (savedPreference !== null) { + setClusterViewEnabled(JSON.parse(savedPreference)); + } + }, []); + + // Save cluster view preference to localStorage when it changes + const handleToggleChange = (event: React.ChangeEvent) => { + const newValue = event.currentTarget.checked; + setClusterViewEnabled(newValue); + localStorage.setItem('pm2-cluster-view-enabled', JSON.stringify(newValue)); + }; + + // Group processes by name (cluster support) - only if cluster view is enabled + const processGroups = clusterViewEnabled + ? selectedProcesses?.reduce((groups: { [key: string]: IProcess[] }, process) => { + const key = process.name; + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(process); + return groups; + }, {}) || {} + : {}; return ( - {selectedProcesses?.map((process) => )} + {/* Cluster View Toggle */} + + +
+ Cluster View + + {clusterViewEnabled + ? "Group processes in the same cluster" + : "Show each process separately" + } + +
+ + + +
+
+ + {/* Process Display */} + {clusterViewEnabled ? ( + // Cluster view: group processes by name + Object.entries(processGroups).map(([clusterName, processes]) => { + // If there's only one process, show individual ProcessItem + if (processes.length === 1) { + return ; + } + // If there are multiple processes with same name, show as cluster + return ( + + ); + }) + ) : ( + // Individual view: show each process separately + selectedProcesses?.map((process) => ( + + )) + )}
); }