diff --git a/cyclops-ui/package.json b/cyclops-ui/package.json index 59d9e822..6b7d0dfd 100644 --- a/cyclops-ui/package.json +++ b/cyclops-ui/package.json @@ -9,11 +9,13 @@ "@ant-design/icons": "^5.3.3", "@microsoft/fetch-event-source": "^2.0.1", "@rmlio/matey": "^1.0.4", + "@types/dagre": "^0.7.53", "ace-builds": "^1.4.14", "antd": "^5.14.0", "axios": "^1.7.4", "babel-preset-react": "^6.24.1", "codeblock": "^0.4.0-pre.3", + "dagre": "^0.8.5", "date-fns": "^2.30.0", "draft-js": "^0.11.7", "fetch-jsonp": "^1.2.1", @@ -29,6 +31,7 @@ "react-router-dom": "^6.21.1", "react-scripts": "^5.0.1", "react-terminal": "^1.3.1", + "reactflow": "^11.11.4", "remark-gfm": "^4.0.1", "runtime-env-cra": "^0.2.4", "terser-webpack-plugin": "^5.3.10", diff --git a/cyclops-ui/src/components/k8s-resources/ResourceTree/ResourceTree.css b/cyclops-ui/src/components/k8s-resources/ResourceTree/ResourceTree.css new file mode 100644 index 00000000..6844c966 --- /dev/null +++ b/cyclops-ui/src/components/k8s-resources/ResourceTree/ResourceTree.css @@ -0,0 +1,70 @@ +.resource-tree-container { + height: 700px; + width: 100%; + background: #fafafa; + border: 1px solid #d9d9d9; + border-radius: 8px; +} + +.resource-tree-empty { + display: flex; + justify-content: center; + align-items: center; + height: 400px; + background: #fafafa; + border: 1px solid #d9d9d9; + border-radius: 8px; + color: #8c8c8c; + font-size: 14px; +} + +.resource-node { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + word-wrap: break-word; + height: 100%; +} + +.resource-node-kind { + font-weight: 600; + color: #262626; + margin-bottom: 4px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.resource-node-name { + color: #595959; + font-size: 11px; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* React Flow customization */ +.react-flow__node { + transition: all 0.2s ease; +} + +.react-flow__node:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.react-flow__edge-path { + stroke-width: 2.5px; +} + +.react-flow__controls { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.react-flow__minimap { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 4px; +} diff --git a/cyclops-ui/src/components/k8s-resources/ResourceTree/ResourceTree.tsx b/cyclops-ui/src/components/k8s-resources/ResourceTree/ResourceTree.tsx new file mode 100644 index 00000000..01a4b721 --- /dev/null +++ b/cyclops-ui/src/components/k8s-resources/ResourceTree/ResourceTree.tsx @@ -0,0 +1,1191 @@ +import React, { useMemo, useEffect, useState, useCallback } from "react"; +import ReactFlow, { + Node, + Edge, + Background, + Controls, + MiniMap, + Position, + NodeMouseHandler, +} from "reactflow"; +import dagre from "dagre"; +import { + Drawer, + Button, + Row, + Col, + Tooltip, + Modal, + Alert, + Spin, + Tag, + Descriptions, + Divider, + Checkbox, + Input, + Popover, + Space, + Badge, +} from "antd"; +import { + FileTextOutlined, + UndoOutlined, + DeleteOutlined, + CloseOutlined, + CopyOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, + InfoCircleOutlined, + FilterOutlined, + QuestionCircleOutlined, +} from "@ant-design/icons"; +import ReactAce from "react-ace"; +import "reactflow/dist/style.css"; +import "./ResourceTree.css"; +import { useResourceListActions } from "../ResourceList/ResourceListActionsContext"; +import { ResourceRef, resourceRefKey } from "../../../utils/resourceRef"; +import { Workload } from "../../../utils/k8s/workload"; +import { mapResponseError } from "../../../utils/api/errors"; +import { canRestart } from "../common/RestartButton"; +import { useTheme } from "../../theme/ThemeContext"; + +import Deployment from "../Deployment"; +import StatefulSet from "../StatefulSet"; +import DaemonSet from "../DaemonSet"; +import CronJob from "../CronJob"; +import Job from "../Job"; +import Service from "../Service"; +import ConfigMap from "../ConfigMap"; +import Secret from "../Secret"; +import PersistentVolumeClaim from "../PersistentVolumeClaim"; +import Role from "../Role"; +import ClusterRole from "../ClusterRole"; +import NetworkPolicy from "../NetworkPolicy"; + +interface Resource { + group: string; + version: string; + kind: string; + name: string; + namespace: string; + status?: string; + deleted?: boolean; + missing?: boolean; +} + +interface ContainerData { + name: string; + image: string; + env?: Record; + status?: { + status: string; + message?: string; + running: boolean; + }; +} + +interface PodData { + group?: string; + version?: string; + kind?: string; + name: string; + namespace: string; + status?: boolean; + podPhase?: string; + node?: string; + started?: string; + containers?: ContainerData[]; + initContainers?: ContainerData[]; +} + +interface WorkloadDetails { + pods?: PodData[]; + status?: string; +} + +interface ResourceTreeProps { + resources: Resource[]; + workloads: Map; + onResourceDelete: () => void; +} + +const nodeWidth = 180; +const nodeHeight = 60; + +const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + dagreGraph.setGraph({ rankdir: "TB", ranksep: 80, nodesep: 40 }); + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); + }); + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + + dagre.layout(dagreGraph); + + const layoutedNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - nodeWidth / 2, + y: nodeWithPosition.y - nodeHeight / 2, + }, + }; + }); + + return { nodes: layoutedNodes, edges }; +}; + +const getStatusColor = (status?: string): string => { + switch (status) { + case "healthy": + return "#52c41a"; + case "progressing": + return "#faad14"; + case "unhealthy": + return "#ff4d4f"; + default: + return "#d9d9d9"; + } +}; + +const isWorkload = (resource: Resource): boolean => { + return ( + (resource.group === "apps" && + resource.version === "v1" && + (resource.kind === "Deployment" || + resource.kind === "StatefulSet" || + resource.kind === "DaemonSet")) || + (resource.group === "batch" && + resource.version === "v1" && + resource.kind === "CronJob") + ); +}; + +const ResourceTree: React.FC = ({ + resources, + workloads, + onResourceDelete +}) => { + const { + fetchResource, + fetchResourceManifest, + restartResource, + deleteResource, + } = useResourceListActions(); + + const { mode } = useTheme(); + + const [workloadDetails, setWorkloadDetails] = useState>(new Map()); + const [loadingWorkloads, setLoadingWorkloads] = useState(true); + const [selectedResource, setSelectedResource] = useState(null); + const [selectedPod, setSelectedPod] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const [manifestModal, setManifestModal] = useState({ + on: false, + resource: { + group: "", + version: "", + kind: "", + name: "", + namespace: "", + }, + manifest: "", + }); + const [loadingManifest, setLoadingManifest] = useState(false); + const [showManagedFields, setShowManagedFields] = useState(false); + + const [deleteResourceModal, setDeleteResourceModal] = useState(false); + const [deleteResourceRef, setDeleteResourceRef] = useState({ + group: "", + version: "", + kind: "", + name: "", + namespace: "", + }); + const [deleteResourceVerify, setDeleteResourceVerify] = useState(""); + const [deletingResource, setDeletingResource] = useState(false); + + const [resourceFilter, setResourceFilter] = useState([]); + + const [error, setError] = useState({ + message: "", + description: "", + }); + + useEffect(() => { + let isMounted = true; + + // Fetch details for all workloads + const fetchWorkloadDetails = async () => { + // Don't refetch if drawer is open to prevent flickering + if (drawerOpen) { + return; + } + + setLoadingWorkloads(true); + const detailsMap = new Map(); + + for (const resource of resources) { + if (isWorkload(resource)) { + try { + const details = await fetchResource( + resource.group, + resource.version, + resource.kind, + resource.namespace, + resource.name + )(); + const key = `${resource.kind}-${resource.namespace}-${resource.name}`; + detailsMap.set(key, details); + } catch (error) { + console.error("Error fetching workload details:", error); + } + } + } + + if (isMounted) { + setWorkloadDetails(detailsMap); + setLoadingWorkloads(false); + } + }; + + fetchWorkloadDetails(); + + return () => { + isMounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resources, drawerOpen]); + + const availableKinds = useMemo(() => { + const kinds = new Set(); + resources.forEach(r => kinds.add(r.kind)); + workloadDetails.forEach((details) => { + if (details.pods) { + kinds.add("Pod"); + } + }); + return Array.from(kinds).sort(); + }, [resources, workloadDetails]); + + const filteredResources = useMemo(() => { + if (resourceFilter.length === 0) { + return resources; + } + return resources.filter(r => resourceFilter.includes(r.kind)); + }, [resources, resourceFilter]); + + const { nodes, edges } = useMemo(() => { + const flowNodes: Node[] = []; + const flowEdges: Edge[] = []; + + filteredResources.forEach((resource) => { + const nodeId = `${resource.kind}-${resource.namespace}-${resource.name}`; + + flowNodes.push({ + id: nodeId, + type: "default", + data: { + label: ( +
+
{resource.kind}
+
{resource.name}
+
+ ), + resource: resource, + }, + position: { x: 0, y: 0 }, + style: { + background: "#fff", + border: `2px solid ${getStatusColor(resource.status)}`, + borderRadius: "8px", + padding: "10px", + width: nodeWidth, + fontSize: "12px", + cursor: "pointer", + }, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + }); + + if (isWorkload(resource)) { + const key = `${resource.kind}-${resource.namespace}-${resource.name}`; + const details = workloadDetails.get(key); + + if (details?.pods && Array.isArray(details.pods)) { + details.pods.forEach((pod: PodData) => { + if (resourceFilter.length > 0 && !resourceFilter.includes("Pod")) { + return; + } + + const podNamespace = pod.namespace || resource.namespace; + const podId = `Pod-${podNamespace}-${pod.name}`; + + let podStatus = "unhealthy"; + if (pod.status === true || pod.podPhase === "Running") { + podStatus = "healthy"; + } else if (pod.podPhase === "Pending" || pod.podPhase === "ContainerCreating") { + podStatus = "progressing"; + } + + const podData: PodData = { + ...pod, + namespace: podNamespace, + }; + + flowNodes.push({ + id: podId, + type: "default", + data: { + label: ( +
+
Pod
+
{pod.name}
+
+ ), + pod: podData, + }, + position: { x: 0, y: 0 }, + style: { + background: "#fff", + border: `2px solid ${getStatusColor(podStatus)}`, + borderRadius: "8px", + padding: "10px", + width: nodeWidth, + fontSize: "12px", + cursor: "pointer", + }, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + }); + + flowEdges.push({ + id: `${nodeId}-${podId}`, + source: nodeId, + target: podId, + type: "smoothstep", + animated: false, + style: { + stroke: "#1890ff", + strokeWidth: 2, + }, + }); + }); + } + } + }); + + return getLayoutedElements(flowNodes, flowEdges); + }, [filteredResources, workloadDetails, resourceFilter]); + + const getWorkload = (ref: ResourceRef): Workload | undefined => { + let k = resourceRefKey(ref); + return workloads.get(k); + }; + + const onNodeClick: NodeMouseHandler = (event, node) => { + if (node.data.resource) { + setSelectedResource(node.data.resource); + setSelectedPod(null); + setDrawerOpen(true); + } else if (node.data.pod) { + const podData = { ...node.data.pod }; + if (!podData.namespace || podData.namespace === "") { + console.warn("Pod namespace is missing, using 'default'"); + podData.namespace = "default"; + } + setSelectedPod(podData); + setSelectedResource(null); + setDrawerOpen(true); + } + }; + + const fetchManifest = useCallback( + ( + group: string, + version: string, + kind: string, + namespace: string, + name: string, + showManagedFields: boolean, + ) => { + setLoadingManifest(true); + fetchResourceManifest( + group, + version, + kind, + namespace, + name, + showManagedFields, + ) + .then((res) => { + setManifestModal((prev) => ({ + ...prev, + manifest: res, + })); + setLoadingManifest(false); + }) + .catch((error) => { + const errorDetails = mapResponseError(error); + setError({ + message: "Failed to fetch manifest", + description: errorDetails.description || "Unable to retrieve resource manifest. Please try again.", + }); + setLoadingManifest(false); + }); + }, + [fetchResourceManifest] + ); + + const handleViewManifest = async (resource: Resource) => { + try { + setError({ message: "", description: "" }); + setShowManagedFields(false); + + setManifestModal({ + on: true, + resource: resource, + manifest: "", + }); + + fetchManifest( + resource.group, + resource.version, + resource.kind, + resource.namespace, + resource.name, + false, + ); + } catch (err: any) { + const errorDetails = mapResponseError(err); + setError({ + message: "Failed to open manifest", + description: errorDetails.description || "Unable to open manifest modal. Please try again.", + }); + } + }; + + const handleManagedFieldsChange = (checked: boolean) => { + setShowManagedFields(checked); + fetchManifest( + manifestModal.resource.group, + manifestModal.resource.version, + manifestModal.resource.kind, + manifestModal.resource.namespace, + manifestModal.resource.name, + checked, + ); + }; + + const handleRestartResource = async (resource: Resource) => { + try { + setError({ message: "", description: "" }); + await restartResource( + resource.group, + resource.version, + resource.kind, + resource.namespace, + resource.name + ); + window.location.reload(); + } catch (err: any) { + const errorDetails = mapResponseError(err); + setError({ + message: "Failed to restart resource", + description: errorDetails.description || "Unable to restart the resource. Please try again.", + }); + } + }; + + const confirmDeleteResource = (resource: Resource) => { + setDeleteResourceRef(resource); + setDeleteResourceVerify(""); + setDeleteResourceModal(true); + }; + + const handleDeleteResource = async () => { + if (deleteResourceVerify !== deleteResourceRef.name) { + setError({ + message: "Verification failed", + description: "Please type the resource name correctly to confirm deletion.", + }); + return; + } + + try { + setDeletingResource(true); + setError({ message: "", description: "" }); + + await deleteResource( + deleteResourceRef.group, + deleteResourceRef.version, + deleteResourceRef.kind, + deleteResourceRef.namespace, + deleteResourceRef.name + ); + + setDeleteResourceModal(false); + setDeleteResourceVerify(""); + setDrawerOpen(false); + onResourceDelete(); + } catch (err: any) { + const errorDetails = mapResponseError(err); + setError({ + message: "Failed to delete resource", + description: errorDetails.description || "Unable to delete the resource. Please try again.", + }); + } finally { + setDeletingResource(false); + } + }; + + const handleCancelDeleteResource = () => { + setDeleteResourceModal(false); + setDeleteResourceRef({ + group: "", + version: "", + kind: "", + name: "", + namespace: "", + }); + setDeleteResourceVerify(""); + }; + + const renderResourceDetails = (resource: Resource) => { + let resourceRef: ResourceRef = { + group: resource.group, + version: resource.version, + kind: resource.kind, + name: resource.name, + namespace: resource.namespace, + }; + + switch (true) { + case resource.group === "apps" && + resource.version === "v1" && + resource.kind === "Deployment": + return ( + + ); + case resource.group === "batch" && + resource.version === "v1" && + resource.kind === "CronJob": + return ( + + ); + case resource.group === "batch" && + resource.version === "v1" && + resource.kind === "Job": + return ; + case resource.group === "apps" && + resource.version === "v1" && + resource.kind === "DaemonSet": + return ( + + ); + case resource.group === "apps" && + resource.version === "v1" && + resource.kind === "StatefulSet": + return ( + + ); + case resource.group === "" && + resource.version === "v1" && + resource.kind === "Service": + return ; + case resource.group === "" && + resource.version === "v1" && + resource.kind === "ConfigMap": + return ( + + ); + case resource.group === "" && + resource.version === "v1" && + resource.kind === "Secret": + return ; + case resource.group === "" && + resource.version === "v1" && + resource.kind === "PersistentVolumeClaim": + return ( + + ); + case resource.group === "rbac.authorization.k8s.io" && + resource.version === "v1" && + resource.kind === "Role": + return ; + case resource.group === "rbac.authorization.k8s.io" && + resource.version === "v1" && + resource.kind === "ClusterRole": + return ; + case resource.group === "networking.k8s.io" && + resource.version === "v1" && + resource.kind === "NetworkPolicy": + return ( + + ); + default: + return ( + + {resource.group || "core"} + {resource.version} + {resource.kind} + {resource.namespace} + {resource.name} + + ); + } + }; + + const renderPodStatus = (pod: PodData) => { + if (pod.podPhase === "Running") { + return } color="success">Healthy; + } else if (pod.podPhase === "Pending") { + return } color="processing">Pending; + } else { + return } color="error">Unhealthy; + } + }; + + const renderFilterPopover = () => ( +
+
Filter by Resource Kind
+ ({ label: kind, value: kind }))} + value={resourceFilter} + onChange={(values) => setResourceFilter(values as string[])} + style={{ display: 'flex', flexDirection: 'column' }} + /> + {resourceFilter.length > 0 && ( + + )} +
+ ); + + const renderLegend = () => ( +
+
Resource Status Legend
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ Click on any resource to view details. Use the controls below to zoom and pan. +
+
+ ); + + if (resources.length === 0) { + return ( +
+ +

No resources to display

+
+ ); + } + + if (loadingWorkloads) { + return ( +
+ +

Loading resource tree...

+
+ ); + } + + return ( + <> + {error.message.length !== 0 && ( + { + setError({ + message: "", + description: "", + }); + }} + style={{ marginBottom: "20px" }} + /> + )} + + + + + + + + + + + + + + + + {nodes.length} resource{nodes.length !== 1 ? "s" : ""} displayed + + + +
+ + + + { + return node.style?.border?.toString().split(" ")[2] || "#d9d9d9"; + }} + /> + +
+ + + + + {selectedResource?.name || selectedPod?.name} + + + + {selectedResource && ( + {selectedResource.kind} + )} + {selectedPod && Pod} + + + } + placement="right" + onClose={() => { + setDrawerOpen(false); + setSelectedResource(null); + setSelectedPod(null); + }} + open={drawerOpen} + width="60%" + closeIcon={} + extra={ + <> + {selectedResource && ( + + + + + + + {canRestart( + selectedResource.group, + selectedResource.version, + selectedResource.kind + ) && ( + + + + + + )} + + + + + + + )} + {selectedPod && ( + + + + + + + + + + + + + )} + + } + > + {selectedResource && ( +
+ + + + { + navigator.clipboard.writeText(selectedResource.namespace); + }} + > + {selectedResource.namespace}{" "} + + + + + {selectedResource.kind} + {selectedResource.version} + {selectedResource.group && ( + {selectedResource.group} + )} + + + + + {renderResourceDetails(selectedResource)} +
+ )} + {selectedPod && ( +
+ + + {selectedPod.namespace || "default"} + + + {selectedPod.podPhase || "Unknown"} + + + {renderPodStatus(selectedPod)} + + {selectedPod.node && ( + + {selectedPod.node} + + )} + {selectedPod.started && ( + + {new Date(selectedPod.started).toLocaleString()} + + )} + + + {selectedPod.containers && selectedPod.containers.length > 0 && ( + <> + Containers ({selectedPod.containers.length}) + {selectedPod.containers.map((container, index) => ( +
+ + + + {container.image} + + + {container.status && ( + + + {container.status.status} + + {container.status.message && ( +
+ {container.status.message} +
+ )} +
+ )} +
+
+ ))} + + )} + + {selectedPod.initContainers && selectedPod.initContainers.length > 0 && ( + <> + Init Containers ({selectedPod.initContainers.length}) + {selectedPod.initContainers.map((container, index) => ( +
+ + + + {container.image} + + + +
+ ))} + + )} +
+ )} +
+ + + Resource Manifest + + handleManagedFieldsChange(e.target.checked)} + > + Show Managed Fields + + + + } + open={manifestModal.on} + onOk={() => { + setManifestModal({ + on: false, + resource: { + group: "", + version: "", + kind: "", + name: "", + namespace: "", + }, + manifest: "", + }); + setShowManagedFields(false); + }} + onCancel={() => { + setManifestModal({ + on: false, + resource: { + group: "", + version: "", + kind: "", + name: "", + namespace: "", + }, + manifest: "", + }); + setShowManagedFields(false); + }} + cancelButtonProps={{ style: { display: "none" } }} + width="70%" + > + {loadingManifest ? ( +
+ +
Loading manifest...
+
+ ) : ( +
+ + + + +
+ )} +
+ + + Delete{" "} + + {deleteResourceRef.kind}/{deleteResourceRef.name} + + + } + open={deleteResourceModal} + onCancel={handleCancelDeleteResource} + footer={ + + + + + + + + + } + > + + + {deleteResourceRef.kind || "Unknown"} + {deleteResourceRef.namespace || "default"} + {deleteResourceRef.name || "Unknown"} + +
+ Type {deleteResourceRef.name} to confirm: +
+ setDeleteResourceVerify(e.target.value)} + onPressEnter={() => { + if (deleteResourceVerify === deleteResourceRef.name) { + handleDeleteResource(); + } + }} + /> +
+ + ); +}; + +export default ResourceTree; diff --git a/cyclops-ui/src/components/shared/ModuleResourceDetails/ModuleResourceDetails.tsx b/cyclops-ui/src/components/shared/ModuleResourceDetails/ModuleResourceDetails.tsx index 24a40a55..62e602e9 100644 --- a/cyclops-ui/src/components/shared/ModuleResourceDetails/ModuleResourceDetails.tsx +++ b/cyclops-ui/src/components/shared/ModuleResourceDetails/ModuleResourceDetails.tsx @@ -19,12 +19,14 @@ import { } from "antd"; import "ace-builds/src-noconflict/ace"; import { + AppstoreOutlined, BookOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileTextOutlined, GithubOutlined, + PartitionOutlined, UndoOutlined, } from "@ant-design/icons"; import "./custom.css"; @@ -34,6 +36,7 @@ import ReactAce from "react-ace"; import { mapResponseError } from "../../../utils/api/errors"; import ResourceList from "../../k8s-resources/ResourceList/ResourceList"; +import ResourceTree from "../../k8s-resources/ResourceTree/ResourceTree"; import { Workload } from "../../../utils/k8s/workload"; import { @@ -209,6 +212,7 @@ export const ModuleResourceDetails = ({ const [resources, setResources] = useState([]); const [workloads, setWorkloads] = useState>(new Map()); + const [viewMode, setViewMode] = useState<"list" | "tree">("list"); function getWorkload(ref: ResourceRef): Workload | undefined { let k = resourceRefKey(ref); @@ -836,6 +840,33 @@ export const ModuleResourceDetails = ({ + + Resources + + + + + + + + + - { - setLoadResources(false); - fetchModuleResourcesCallback(); - }} - /> + {viewMode === "list" ? ( + { + setLoadResources(false); + fetchModuleResourcesCallback(); + }} + /> + ) : ( + { + setLoadResources(false); + fetchModuleResourcesCallback(); + }} + /> + )}