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..98d44b9 --- /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" }} + /> + + + ); +} diff --git a/apps/dashboard/components/process/ProcessClusterLog.tsx b/apps/dashboard/components/process/ProcessClusterLog.tsx new file mode 100644 index 0000000..0f8584f --- /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..2719599 --- /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 && } + + + + + ); +} 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) => ( + + )) + )}
); }