Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/dashboard/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
75 changes: 68 additions & 7 deletions apps/dashboard/components/context/SelectedProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
};

Expand Down
17 changes: 16 additions & 1 deletion apps/dashboard/components/partials/Head.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || []}
Expand Down
118 changes: 118 additions & 0 deletions apps/dashboard/components/process/ProcessCluster.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Paper
key={`cluster-${clusterName}`}
radius="md"
p="xs"
shadow="sm"
className={cx(classes.processItem, {
[classes.opened]: !collapsed,
[classes.closed]: collapsed,
})}
>
<Flex direction={"column"}>
<Flex align={"center"} justify={"space-between"} wrap={"wrap"}>
<Flex align={"center"} gap={"sm"}>
<ProcessHeader
statusColor={getStatusColor()}
interpreter={primaryProcess.type}
name={clusterName}
/>
<Badge
size="sm"
variant="light"
color={getClusterStatus() === "online" ? "green" : getClusterStatus() === "stopped" ? "yellow" : "red"}
>
{processes.length} instances
</Badge>
{getClusterStatus() === "mixed" && (
<Text size="xs" c="dimmed">
{onlineCount} online, {stoppedCount} stopped, {erroredCount} errored
</Text>
)}
</Flex>
<Flex align={"center"} rowGap={"10px"} columnGap={"40px"} wrap={"wrap"} justify={"end"}>
<ProcessClusterMetricRow
processes={processes}
refetchInterval={setting.polling.frontend}
showMetric={onlineCount > 0}
/>
<ProcessClusterAction
processes={processes}
collapse={() => setCollapsed(!collapsed)}
/>
</Flex>
</Flex>
<Transition transition="scale-y" duration={500} mounted={!collapsed}>
{(styles) => (
<div style={{ ...styles }}>
<ProcessClusterChart
processes={processes}
refetchInterval={setting.polling.frontend}
showMetric={onlineCount > 0}
polling={setting.polling.frontend}
/>
<ProcessClusterLog
processes={processes}
refetchInterval={setting.polling.frontend}
/>
</div>
)}
</Transition>
</Flex>
</Paper>
);
}
109 changes: 109 additions & 0 deletions apps/dashboard/components/process/ProcessClusterAction.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex gap={"5px"}>
<ActionIcon
variant="light"
color="blue"
radius="sm"
size={"lg"}
loading={isLoading && processAction.variables?.action === "RESTART"}
onClick={() => executeClusterAction("RESTART")}
disabled={isLoading}
>
<IconReload size="1.4rem" />
</ActionIcon>
<ActionIcon
variant="light"
color="orange"
radius="sm"
size={"lg"}
loading={isLoading && processAction.variables?.action === "STOP"}
onClick={() => executeClusterAction("STOP")}
disabled={isLoading}
>
<IconPower size="1.4rem" />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
radius="sm"
size={"lg"}
loading={isLoading && processAction.variables?.action === "DELETE"}
onClick={() => executeClusterAction("DELETE")}
disabled={isLoading}
>
<IconTrash size="1.4rem" />
</ActionIcon>
<ActionIcon
className={classes.colorSchemeLight}
variant={"light"}
color={"dark.2"}
radius="sm"
size={"sm"}
mr={"-3px"}
onClick={collapse}
>
<IconSquareRoundedMinus size="1.1rem" />
</ActionIcon>
<ActionIcon
className={classes.colorSchemeDark}
variant={"light"}
color={"gray.5"}
radius="sm"
size={"sm"}
mr={"-3px"}
onClick={collapse}
>
<IconSquareRoundedMinus size="1.1rem" />
</ActionIcon>
</Flex>
);
}
Loading