void;
+ disabled?: boolean;
+}) {
+ const sx = {
+ width: '32px',
+ height: '32px',
+ padding: 0,
+ minWidth: '32px',
+ borderRadius: '50%',
+ '> svg': {
+ width: '14px',
+ height: '14px',
+ },
+ fontSize: 'x-small',
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function GraphControls({ children }: { children?: React.ReactNode }) {
+ const { t } = useTranslation();
+ const minZoomReached = useStore(it => it.transform[2] <= it.minZoom);
+ const maxZoomReached = useStore(it => it.transform[2] >= it.maxZoom);
+ const { zoomIn, zoomOut, fitView } = useReactFlow();
+
+ return (
+
+ .MuiButtonGroup-grouped': {
+ minWidth: '32px',
+ },
+ }}
+ orientation="vertical"
+ aria-label="Vertical button group"
+ variant="contained"
+ >
+ zoomIn()}>
+
+
+ zoomOut()}
+ >
+
+
+
+ fitView()}>
+
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/GraphRenderer.tsx b/frontend/src/components/resourceMap/GraphRenderer.tsx
new file mode 100644
index 00000000000..7226e705a55
--- /dev/null
+++ b/frontend/src/components/resourceMap/GraphRenderer.tsx
@@ -0,0 +1,119 @@
+import { Box, Typography, useTheme } from '@mui/material';
+import {
+ Background,
+ BackgroundVariant,
+ ConnectionMode,
+ Controls,
+ Edge,
+ EdgeMouseHandler,
+ Node,
+ NodeMouseHandler,
+ OnMoveStart,
+ ReactFlow,
+} from '@xyflow/react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Loader } from '../common';
+import { KubeRelationEdge } from './edges/KubeRelationEdge';
+import { GraphControls } from './GraphControls';
+import { GroupNodeComponent } from './nodes/GroupNode';
+import { KubeGroupNodeComponent } from './nodes/KubeGroupNode';
+import { KubeObjectNodeComponent } from './nodes/KubeObjectNode';
+
+export const nodeTypes = {
+ kubeObject: KubeObjectNodeComponent,
+ kubeGroup: KubeGroupNodeComponent,
+ group: GroupNodeComponent,
+};
+
+const edgeTypes = {
+ kubeRelation: KubeRelationEdge,
+};
+
+export interface GraphRendererProps {
+ /** List of nodes to render */
+ nodes: Node[];
+ /** List of edges to render */
+ edges: Edge[];
+ /** Callback when a node is clicked */
+ onNodeClick?: NodeMouseHandler
;
+ /** Callback when an edge is clicked */
+ onEdgeClick?: EdgeMouseHandler;
+ /** Callback when the graph is started to be moved */
+ onMoveStart?: OnMoveStart;
+ /** Callback when the background is clicked */
+ onBackgroundClick?: () => void;
+ /** Additional components to render */
+ children?: React.ReactNode;
+ /** Additional actions for the controls panael */
+ controlActions?: React.ReactNode;
+ isLoading?: boolean;
+}
+
+const emptyArray: any[] = [];
+
+export function GraphRenderer({
+ nodes,
+ edges,
+ onNodeClick,
+ onEdgeClick,
+ onMoveStart,
+ onBackgroundClick,
+ children,
+ controlActions,
+ isLoading,
+}: GraphRendererProps) {
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ return (
+ {
+ if ((e.target as HTMLElement)?.className?.includes?.('react-flow__pane')) {
+ onBackgroundClick?.();
+ }
+ }}
+ minZoom={0.1}
+ maxZoom={2.0}
+ connectionMode={ConnectionMode.Loose}
+ >
+
+
+ {controlActions}
+
+ {isLoading && (
+
+
+
+ )}
+ {!isLoading && nodes.length === 0 && (
+
+ {t('No data to be shown. Try to change filters or select a different namespace.')}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/GraphView.css b/frontend/src/components/resourceMap/GraphView.css
new file mode 100644
index 00000000000..98a02814095
--- /dev/null
+++ b/frontend/src/components/resourceMap/GraphView.css
@@ -0,0 +1,47 @@
+:root {
+ --graph-animation-duration: 0.15s;
+}
+
+.react-flow__node,
+.react-flow__edge,
+.react-flow__edges path {
+ transition: all;
+ transition-duration: var(--graph-animation-duration);
+}
+
+.react-flow__edges {
+ z-index: 1;
+}
+
+.react-flow__node-group,
+.react-flow__node-group:focus,
+.react-flow__node-group:active {
+ border: unset;
+ color: unset;
+}
+
+.react-flow__pane:not(.dragging) .react-flow__viewport {
+ transition: transform var(--graph-animation-duration);
+}
+
+@media (prefers-reduced-motion) {
+ .react-flow__node,
+ .react-flow__edge,
+ .react-flow__edges path {
+ transition-duration: 0;
+ }
+ .react-flow__pane:not(.dragging) .react-flow__viewport {
+ transition-duration: 0;
+ }
+}
+
+.react-flow__controls-button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 36px;
+ width: 36px;
+ padding: 2px;
+ cursor: pointer;
+ user-select: none;
+}
diff --git a/frontend/src/components/resourceMap/GraphView.stories.tsx b/frontend/src/components/resourceMap/GraphView.stories.tsx
new file mode 100644
index 00000000000..2e80227409e
--- /dev/null
+++ b/frontend/src/components/resourceMap/GraphView.stories.tsx
@@ -0,0 +1,37 @@
+import Pod from '../../lib/k8s/pod';
+import { TestContext } from '../../test';
+import { podList } from '../pod/storyHelper';
+import { GraphNode, GraphSource } from './graph/graphModel';
+import { GraphView } from './GraphView';
+
+export default {
+ title: 'GraphView',
+ component: GraphView,
+ argTypes: {},
+ parameters: {},
+};
+
+const mockNodes: GraphNode[] = [
+ {
+ id: 'mock-id',
+ type: 'kubeObject',
+ data: {
+ resource: new Pod(podList[0]),
+ },
+ },
+];
+
+const mockSource: GraphSource = {
+ id: 'mock-source',
+ label: 'Pods',
+ useData() {
+ return { nodes: mockNodes, edges: [] };
+ },
+};
+
+export const BasicExample = () => (
+
+ ;
+
+);
+BasicExample.args = {};
diff --git a/frontend/src/components/resourceMap/GraphView.tsx b/frontend/src/components/resourceMap/GraphView.tsx
new file mode 100644
index 00000000000..2ec348fa05b
--- /dev/null
+++ b/frontend/src/components/resourceMap/GraphView.tsx
@@ -0,0 +1,434 @@
+import '@xyflow/react/dist/base.css';
+import './GraphView.css';
+import { Icon } from '@iconify/react';
+import { Box, Chip, Theme, ThemeProvider } from '@mui/material';
+import {
+ Edge,
+ getNodesBounds,
+ Node,
+ Panel,
+ ReactFlowProvider,
+ useReactFlow,
+ useStore,
+} from '@xyflow/react';
+import {
+ createContext,
+ ReactNode,
+ StrictMode,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import { useTypedSelector } from '../../redux/reducers/reducers';
+import { NamespacesAutocomplete } from '../common';
+import { GraphNodeDetails } from './details/GraphNodeDetails';
+import { filterGraph, GraphFilter } from './graph/graphFiltering';
+import {
+ collapseGraph,
+ findGroupContaining,
+ getGraphSize,
+ getParentNode,
+ GroupBy,
+ groupGraph,
+} from './graph/graphGrouping';
+import { applyGraphLayout } from './graph/graphLayout';
+import { GraphNode, GraphSource, GroupNode, isGroup, KubeObjectNode } from './graph/graphModel';
+import { GraphControlButton } from './GraphControls';
+import { GraphRenderer } from './GraphRenderer';
+import { NodeHighlight, useNodeHighlight } from './NodeHighlight';
+import { ResourceSearch } from './search/ResourceSearch';
+import { SelectionBreadcrumbs } from './SelectionBreadcrumbs';
+import { allSources, GraphSourceManager, useSources } from './sources/GraphSources';
+import { GraphSourcesView } from './sources/GraphSourcesView';
+import { useQueryParamsState } from './useQueryParamsState';
+
+interface GraphViewContent {
+ setNodeSelection: (nodeId: string) => void;
+ nodeSelection?: string;
+ highlights: NodeHighlight;
+}
+export const GraphViewContext = createContext({} as any);
+export const useGraphView = () => useContext(GraphViewContext);
+
+interface GraphViewContentProps {
+ /** Height of the Map */
+ height?: string;
+ /** ID of a node to select by default */
+ defaultNodeSelection?: string;
+ /**
+ * List of Graph Source to display
+ *
+ * See {@link GraphSource} for more information
+ */
+ defaultSources?: GraphSource[];
+
+ /** Default filters to apply */
+ defaultFilters?: GraphFilter[];
+}
+
+const defaultFiltersValue: GraphFilter[] = [];
+
+function GraphViewContent({
+ height,
+ defaultNodeSelection,
+ defaultSources = allSources,
+ defaultFilters = defaultFiltersValue,
+}: GraphViewContentProps) {
+ const { t } = useTranslation();
+
+ // List of selected namespaces
+ const namespaces = useTypedSelector(state => state.filter).namespaces;
+
+ // Filters
+ const [hasErrorsFilter, setHasErrorsFilter] = useState(false);
+
+ // Grouping state
+ const [groupBy, setGroupBy] = useQueryParamsState('group', 'namespace');
+
+ // Keep track if user moved the viewport
+ const viewportMovedRef = useRef(false);
+
+ // ID of the selected Node, undefined means nothing is selected
+ const [selectedNodeId, _setSelectedNodeId] = useQueryParamsState(
+ 'node',
+ defaultNodeSelection
+ );
+ const setSelectedNodeId = useCallback(
+ (id: string | undefined) => {
+ if (id === 'root') {
+ _setSelectedNodeId(undefined);
+ return;
+ }
+ _setSelectedNodeId(id);
+ },
+ [_setSelectedNodeId]
+ );
+
+ // Expand all groups state
+ const [expandAll, setExpandAll] = useState(false);
+
+ // Load source data
+ const { nodes, edges, selectedSources, sourceData, isLoading, toggleSelection } = useSources();
+
+ // Graph with applied layout, has sizes and positions for all elements
+ const [layoutedGraph, setLayoutedGraph] = useState<{ nodes: Node[]; edges: Edge[] }>({
+ nodes: [],
+ edges: [],
+ });
+
+ const flow = useReactFlow();
+
+ // Apply filters
+ const filteredGraph = useMemo(() => {
+ const filters = [...defaultFilters];
+ if (hasErrorsFilter) {
+ filters.push({ type: 'hasErrors' });
+ }
+ if (namespaces) {
+ filters.push({ type: 'namespace', namespaces });
+ }
+ return filterGraph(nodes, edges, filters);
+ }, [nodes, edges, hasErrorsFilter, namespaces, defaultFilters]);
+
+ // Group the graph
+ const { visibleGraph, fullGraph } = useMemo(() => {
+ const graph = groupGraph(filteredGraph.nodes as KubeObjectNode[], filteredGraph.edges, {
+ groupBy,
+ });
+
+ const visibleGraph = collapseGraph(graph, { selectedNodeId, expandAll }) as GroupNode;
+
+ return { visibleGraph, fullGraph: graph };
+ }, [filteredGraph, groupBy, selectedNodeId, expandAll]);
+
+ // Apply layout to visible graph
+ const aspectRatio = useStore(it => it.width / it.height);
+ const reactFlowWidth = useStore(it => it.width);
+ const reactFlowHeight = useStore(it => it.height);
+
+ /**
+ * Zooms the viewport to 100% zoom level
+ * It will center the nodes if they fit into view
+ * Or if they don't fit it:
+ * - align to top if they don't fit vertically
+ * - align to left if they don't fit horizontally
+ */
+ const zoomTo100 = useCallback(
+ (nodes: Node[]) => {
+ const bounds = getNodesBounds(nodes);
+
+ const defaultViewportPaddingPx = 50;
+
+ const topLeftOrigin = { x: defaultViewportPaddingPx, y: defaultViewportPaddingPx };
+ const centerOrigin = {
+ x: reactFlowWidth / 2 - bounds.width / 2,
+ y: reactFlowHeight / 2 - bounds.height / 2,
+ };
+
+ const xFits = bounds.width + defaultViewportPaddingPx * 2 <= reactFlowWidth;
+ const yFits = bounds.height + defaultViewportPaddingPx * 2 <= reactFlowHeight;
+
+ const defaultZoomViewport = {
+ x: xFits ? centerOrigin.x : topLeftOrigin.x,
+ y: yFits ? centerOrigin.y : topLeftOrigin.y,
+ zoom: 1,
+ };
+
+ flow.setViewport(defaultZoomViewport);
+ },
+ [flow, reactFlowWidth, reactFlowHeight]
+ );
+
+ useEffect(() => {
+ applyGraphLayout(visibleGraph, aspectRatio).then(layout => {
+ setLayoutedGraph(layout);
+
+ // Only fit bounds when user hasn't moved viewport manually
+ if (!viewportMovedRef.current) {
+ zoomTo100(layout.nodes);
+ }
+ });
+ }, [visibleGraph, aspectRatio, zoomTo100]);
+
+ // Reset after view change
+ useLayoutEffect(() => {
+ viewportMovedRef.current = false;
+ }, [selectedNodeId, groupBy, expandAll]);
+
+ const selectedNode = useMemo(
+ () => nodes.find((it: GraphNode) => it.id === selectedNodeId),
+ [selectedNodeId, nodes]
+ );
+
+ const selectedGroup = useMemo(() => {
+ if (selectedNodeId) {
+ return findGroupContaining(visibleGraph, selectedNodeId);
+ }
+ }, [selectedNodeId, visibleGraph, findGroupContaining]);
+ const highlights = useNodeHighlight(selectedNodeId);
+
+ const graphSize = getGraphSize(visibleGraph);
+ useEffect(() => {
+ if (expandAll && graphSize > 50) {
+ setExpandAll(false);
+ }
+ }, [graphSize]);
+
+ const contextValue = useMemo(
+ () => ({ nodeSelection: selectedNodeId, highlights, setNodeSelection: setSelectedNodeId }),
+ [selectedNodeId, setSelectedNodeId, highlights]
+ );
+ return (
+
+
+
+
+
+
+ it.type === 'kubeObject' ? [it.data.resource] : []
+ )}
+ onSearch={resource => {
+ setSelectedNodeId(resource.metadata.uid);
+ }}
+ />
+
+
+
+
+
+ {namespaces.size !== 1 && (
+ setGroupBy(groupBy === 'namespace' ? undefined : 'namespace')}
+ />
+ )}
+
+ setGroupBy(groupBy === 'instance' ? undefined : 'instance')}
+ />
+
+ setGroupBy(groupBy === 'node' ? undefined : 'node')}
+ />
+
+ setHasErrorsFilter(!hasErrorsFilter)}
+ />
+
+ {graphSize < 50 && (
+ setExpandAll(it => !it)}
+ />
+ )}
+
+
+
+
{
+ if (e === null) return;
+ viewportMovedRef.current = true;
+ }}
+ onBackgroundClick={() => {
+ // When node is selected (side panel is open) and user clicks on the background
+ // We should select parent node, closing the side panel
+ if (selectedNode && !isGroup(selectedNode)) {
+ setSelectedNodeId(getParentNode(fullGraph, selectedNode.id)?.id);
+ }
+ }}
+ controlActions={
+ zoomTo100(layoutedGraph.nodes)}
+ >
+ 100%
+
+ }
+ >
+
+ {selectedGroup && (
+ setSelectedNodeId(id)}
+ />
+ )}
+
+
+
+
+
+ {selectedNode && (
+ {
+ setSelectedNodeId(selectedGroup?.id ?? defaultNodeSelection);
+ }}
+ />
+ )}
+
+
+ );
+}
+
+function ChipToggleButton({
+ label,
+ isActive,
+ onClick,
+}: {
+ label: string;
+ isActive?: boolean;
+ icon?: ReactNode;
+ onClick: () => void;
+}): ReactNode {
+ return (
+ : undefined}
+ onClick={onClick}
+ sx={{
+ lineHeight: '1',
+ }}
+ />
+ );
+}
+
+function CustomThemeProvider({ children }: { children: ReactNode }) {
+ return (
+ ({
+ ...outer,
+ palette:
+ outer.palette.mode === 'light'
+ ? {
+ ...outer.palette,
+ primary: {
+ main: '#555',
+ contrastText: '#fff',
+ light: '#666',
+ dark: '#444',
+ },
+ }
+ : {
+ ...outer.palette,
+ primary: {
+ main: '#fafafa',
+ contrastText: '#444',
+ light: '#fff',
+ dark: '#f0f0f0',
+ },
+ },
+ components: {},
+ })}
+ >
+ {children}
+
+ );
+}
+
+/**
+ * Renders Map of Kubernetes resources
+ *
+ * @param params - Map parameters
+ * @returns
+ */
+export function GraphView(props: GraphViewContentProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/DeploymentGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/DeploymentGlance.tsx
new file mode 100644
index 00000000000..ff322843146
--- /dev/null
+++ b/frontend/src/components/resourceMap/KubeObjectGlance/DeploymentGlance.tsx
@@ -0,0 +1,24 @@
+import { Box } from '@mui/system';
+import { useTranslation } from 'react-i18next';
+import { KubeCondition } from '../../../lib/k8s/cluster';
+import Deployment from '../../../lib/k8s/deployment';
+import { StatusLabel } from '../../common';
+
+export function DeploymentGlance({ deployment }: { deployment: Deployment }) {
+ const { t } = useTranslation();
+ const { replicas, availableReplicas } = deployment.status;
+ const pods = `${availableReplicas || 0}/${replicas || 0}`;
+
+ return (
+
+
+ {t('glossary|Pods')}: {pods}
+
+ {deployment.status.conditions.map((it: KubeCondition) => (
+
+ {it.type}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/EndpointsGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/EndpointsGlance.tsx
new file mode 100644
index 00000000000..b1d7292ff36
--- /dev/null
+++ b/frontend/src/components/resourceMap/KubeObjectGlance/EndpointsGlance.tsx
@@ -0,0 +1,25 @@
+import { Box } from '@mui/system';
+import { useTranslation } from 'react-i18next';
+import Endpoints from '../../../lib/k8s/endpoints';
+import { StatusLabel } from '../../common';
+
+export function EndpointsGlance({ endpoints }: { endpoints: Endpoints }) {
+ const { t } = useTranslation();
+ const addresses = endpoints.subsets?.flatMap(it => it.addresses?.map(it => it.ip)) ?? [];
+ const ports = endpoints.subsets?.flatMap(it => it.ports) ?? [];
+
+ return (
+
+
+ {t('Addresses')}: {addresses.join(', ')}
+
+ {ports.map(it =>
+ it ? (
+
+ {it.protocol}:{it.port}
+
+ ) : null
+ )}
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/KubeObjectGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/KubeObjectGlance.tsx
new file mode 100644
index 00000000000..52f85be906d
--- /dev/null
+++ b/frontend/src/components/resourceMap/KubeObjectGlance/KubeObjectGlance.tsx
@@ -0,0 +1,87 @@
+import { Icon } from '@iconify/react';
+import { Box } from '@mui/system';
+import { memo, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { KubeObject } from '../../../lib/k8s/cluster';
+import Deployment from '../../../lib/k8s/deployment';
+import Endpoints from '../../../lib/k8s/endpoints';
+import Event from '../../../lib/k8s/event';
+import Pod from '../../../lib/k8s/pod';
+import ReplicaSet from '../../../lib/k8s/replicaSet';
+import Service from '../../../lib/k8s/service';
+import { DateLabel } from '../../common/Label';
+import { DeploymentGlance } from './DeploymentGlance';
+import { EndpointsGlance } from './EndpointsGlance';
+import { PodGlance } from './PodGlance';
+import { ReplicaSetGlance } from './ReplicaSetGlance';
+import { ServiceGlance } from './ServiceGlance';
+
+/**
+ * Little Popup preview of a Kube object
+ */
+export const KubeObjectGlance = memo(({ resource }: { resource: KubeObject }) => {
+ const { t } = useTranslation();
+ const [events, setEvents] = useState([]);
+ useEffect(() => {
+ Event.objectEvents(resource).then(it => setEvents(it));
+ }, []);
+
+ const kind = resource.kind;
+
+ const sections = [];
+
+ if (kind === 'Pod') {
+ sections.push( );
+ }
+
+ if (kind === 'Deployment') {
+ sections.push( );
+ }
+
+ if (kind === 'Service') {
+ sections.push( );
+ }
+
+ if (kind === 'Endpoints') {
+ sections.push( );
+ }
+
+ if (kind === 'ReplicaSet' || kind === 'StatefulSet') {
+ sections.push( );
+ }
+
+ if (events.length > 0) {
+ sections.push(
+
+
+
+ {t('glossary|Events')}
+
+ {events.slice(0, 5).map(it => (
+
+
+ {it.message}
+
+
+
+ ))}
+
+ );
+ }
+
+ return sections;
+});
diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/PodGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/PodGlance.tsx
new file mode 100644
index 00000000000..bbd1f2bb02a
--- /dev/null
+++ b/frontend/src/components/resourceMap/KubeObjectGlance/PodGlance.tsx
@@ -0,0 +1,20 @@
+import { Box } from '@mui/system';
+import { useTranslation } from 'react-i18next';
+import Pod from '../../../lib/k8s/pod';
+import { StatusLabel } from '../../common';
+import { makePodStatusLabel } from '../../pod/List';
+
+export function PodGlance({ pod }: { pod: Pod }) {
+ const { t } = useTranslation();
+ return (
+
+ {makePodStatusLabel(pod)}
+ {pod.spec.containers.map(it => (
+
+ {t('glossary|Container')}: {it.name}
+
+ ))}
+ {pod.status?.podIP && IP: {pod.status?.podIP} }
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/ReplicaSetGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/ReplicaSetGlance.tsx
new file mode 100644
index 00000000000..7b860a6e639
--- /dev/null
+++ b/frontend/src/components/resourceMap/KubeObjectGlance/ReplicaSetGlance.tsx
@@ -0,0 +1,18 @@
+import { Box } from '@mui/system';
+import { useTranslation } from 'react-i18next';
+import ReplicaSet from '../../../lib/k8s/replicaSet';
+import { StatusLabel } from '../../common';
+
+export function ReplicaSetGlance({ set }: { set: ReplicaSet }) {
+ const { t } = useTranslation();
+ const ready = set.status?.readyReplicas || 0;
+ const desired = set.spec?.replicas || 0;
+
+ return (
+
+
+ {t('glossary|Replicas')}: {ready}/{desired}
+
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx
new file mode 100644
index 00000000000..54526bbe7e8
--- /dev/null
+++ b/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx
@@ -0,0 +1,30 @@
+import { Box } from '@mui/system';
+import { useTranslation } from 'react-i18next';
+import Service from '../../../lib/k8s/service';
+import { StatusLabel } from '../../common';
+
+export function ServiceGlance({ service }: { service: Service }) {
+ const { t } = useTranslation();
+ const externalIP = service.getExternalAddresses();
+
+ return (
+
+
+ {t('Type')}: {service.spec.type}
+
+
+ {t('glossary|Cluster IP')}: {service.spec.clusterIP}
+
+ {externalIP && (
+
+ {t('glossary|External IP')}: {externalIP}
+
+ )}
+ {service.spec?.ports?.map(it => (
+
+ {it.protocol}:{it.port}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/NodeHighlight.tsx b/frontend/src/components/resourceMap/NodeHighlight.tsx
new file mode 100644
index 00000000000..811ade79b1c
--- /dev/null
+++ b/frontend/src/components/resourceMap/NodeHighlight.tsx
@@ -0,0 +1,52 @@
+import { useCallback, useState } from 'react';
+import { GraphEdge } from './graph/graphModel';
+
+export type NodeHighlight = ReturnType;
+
+export interface Highlight {
+ /** Label to display */
+ label?: string;
+ /** Set of node IDs to highlight */
+ nodeIds?: Set;
+ /** Set of edge IDs to highlight */
+ edgeIds?: Set;
+}
+
+/**
+ * Manage the state for node highlighting
+ */
+export const useNodeHighlight = (nodeSelection?: string, selectedEdge?: GraphEdge) => {
+ const [highlight, setHighlight] = useState(undefined);
+
+ const isNodeHighlighted = useCallback(
+ (nodeId: string) => {
+ if (selectedEdge) {
+ return nodeId === selectedEdge.source || nodeId === selectedEdge.target;
+ }
+ if (!highlight) return true;
+
+ return highlight.nodeIds?.has(nodeId);
+ },
+ [highlight, selectedEdge, nodeSelection]
+ );
+
+ const isEdgeHighlighted = useCallback(
+ (edgeId: string) => {
+ if (selectedEdge) {
+ return edgeId === selectedEdge.id;
+ }
+ if (!highlight) return true;
+
+ return highlight.edgeIds?.has(edgeId);
+ },
+ [highlight, selectedEdge, nodeSelection]
+ );
+
+ return {
+ highlight: highlight,
+ someHighlighted: highlight !== undefined || selectedEdge !== undefined,
+ setHighlight,
+ isNodeHighlighted,
+ isEdgeHighlighted,
+ };
+};
diff --git a/frontend/src/components/resourceMap/SelectionBreadcrumbs.tsx b/frontend/src/components/resourceMap/SelectionBreadcrumbs.tsx
new file mode 100644
index 00000000000..fe4f15812db
--- /dev/null
+++ b/frontend/src/components/resourceMap/SelectionBreadcrumbs.tsx
@@ -0,0 +1,83 @@
+import { Box, Breadcrumbs, Link } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import { GraphNode, isGroup } from './graph/graphModel';
+
+/**
+ * Find a path in graph from root to the selected node
+ */
+function findSelectionPath(graph: GraphNode, selectedNodeId?: string) {
+ function dfs(node: GraphNode, path: GraphNode[]): GraphNode[] | null {
+ path.push(node);
+
+ if (node.id === selectedNodeId) {
+ return path;
+ }
+
+ if (isGroup(node)) {
+ for (const child of node.data.nodes) {
+ const result = dfs(child, path);
+ if (result) {
+ return result;
+ }
+ }
+ }
+
+ path.pop();
+ return null;
+ }
+
+ const result = dfs(graph, []);
+ return result || [];
+}
+
+export interface SelectionBreadcrumbsProps {
+ /** The graph in which to search the selected node */
+ graph: GraphNode;
+ /** The ID of the selected node */
+ selectedNodeId?: string;
+ /** Callback when a node is clicked */
+ onNodeClick: (id: string) => void;
+}
+
+export function SelectionBreadcrumbs({
+ graph,
+ selectedNodeId,
+ onNodeClick,
+}: SelectionBreadcrumbsProps) {
+ const { t } = useTranslation();
+ const path = findSelectionPath(graph, selectedNodeId);
+
+ return (
+
+ {path.map((it, i) => {
+ const getLabel = (node: GraphNode) => {
+ if (node.type === 'kubeObject') {
+ return node.data.resource.metadata.name;
+ }
+ if (node.id === 'root') {
+ return t('translation|Home');
+ }
+ return node.data.label;
+ };
+ return i === path.length - 1 ? (
+ {getLabel(it)}
+ ) : (
+ onNodeClick(it.id)}
+ sx={{
+ textTransform: 'unset',
+ maxWidth: '200px',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {getLabel(it)}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/__snapshots__/GraphView.BasicExample.stories.storyshot b/frontend/src/components/resourceMap/__snapshots__/GraphView.BasicExample.stories.storyshot
new file mode 100644
index 00000000000..666d05d490e
--- /dev/null
+++ b/frontend/src/components/resourceMap/__snapshots__/GraphView.BasicExample.stories.storyshot
@@ -0,0 +1,587 @@
+
+
+
+
+
+
+
+
+
+ Search
+
+
+
+
+
+
+ Search
+
+
+
+
+
+
+
+
+
+
+
+ Group By: Namespace
+
+
+
+
+
+ Group By: Instance
+
+
+
+
+
+ Group By: Node
+
+
+
+
+
+ Status: Error or Warning
+
+
+
+
+
+ Expand All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Namespace: default
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pod
+
+
+ imagepullbackoff
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100%
+
+
+
+
+
+
+
+ Press enter or space to select a node.
+ You can then use the arrow keys to move the node around.
+ Press delete to remove it and escape to cancel.
+
+
+
+ Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.
+
+
+
+
+
+
+ ;
+
+
\ No newline at end of file
diff --git a/frontend/src/components/resourceMap/details/GraphNodeDetails.tsx b/frontend/src/components/resourceMap/details/GraphNodeDetails.tsx
new file mode 100644
index 00000000000..f67aa7e25a9
--- /dev/null
+++ b/frontend/src/components/resourceMap/details/GraphNodeDetails.tsx
@@ -0,0 +1,75 @@
+import { Box, Card } from '@mui/material';
+import { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ActionButton } from '../../common';
+import { GraphNode, KubeObjectNode } from '../graph/graphModel';
+import { KubeObjectDetails } from './KubeNodeDetails';
+
+interface GraphNodeDetailsSection {
+ id: string;
+ nodeType?: string;
+ render: (node: GraphNode) => ReactNode;
+}
+
+const kubeNodeDetailsSection: GraphNodeDetailsSection = {
+ id: 'kubeObjectDetails',
+ nodeType: 'kubeObject',
+ render: node => (
+
+ ),
+};
+
+const defaultSections = [kubeNodeDetailsSection];
+
+export interface GraphNodeDetailsProps {
+ /** Sections to render */
+ sections?: GraphNodeDetailsSection[];
+ /** Node to display */
+ node: GraphNode;
+ /** Callback when the panel is closed */
+ close: () => void;
+}
+
+/**
+ * Side panel display information about a selected Node
+ */
+export function GraphNodeDetails({
+ sections = defaultSections,
+ node,
+ close,
+}: GraphNodeDetailsProps) {
+ const { t } = useTranslation();
+
+ return (
+ ({
+ margin: '0',
+ padding: '1rem',
+ width: '900px',
+ overflowY: 'auto',
+ flexShrink: 0,
+ [theme.breakpoints.down('xl')]: {
+ width: '720px',
+ },
+ [theme.breakpoints.down('lg')]: {
+ zIndex: 1,
+ position: 'absolute',
+ width: '100%',
+ minWidth: '100%',
+ },
+ })}
+ >
+
+ {
+ close();
+ }}
+ icon="mdi:close"
+ description={t('Close')}
+ />
+
+
+ {sections.filter(it => it.nodeType === node.type).map(it => it.render(node))}
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/details/KubeNodeDetails.tsx b/frontend/src/components/resourceMap/details/KubeNodeDetails.tsx
new file mode 100644
index 00000000000..545b2f79eaf
--- /dev/null
+++ b/frontend/src/components/resourceMap/details/KubeNodeDetails.tsx
@@ -0,0 +1,105 @@
+import { Box } from '@mui/system';
+import { memo, ReactElement, useEffect } from 'react';
+import { KubeObject } from '../../../lib/k8s/cluster';
+import Deployment from '../../../lib/k8s/deployment';
+import Job from '../../../lib/k8s/job';
+import ReplicaSet from '../../../lib/k8s/replicaSet';
+import ConfigDetails from '../../configmap/Details';
+import CronJobDetails from '../../cronjob/Details';
+import DaemonSetDetails from '../../daemonset/Details';
+import EndpointDetails from '../../endpoints/Details';
+import HpaDetails from '../../horizontalPodAutoscaler/Details';
+import IngressClassDetails from '../../ingress/ClassDetails';
+import IngressDetails from '../../ingress/Details';
+import { LeaseDetails } from '../../lease/Details';
+import { LimitRangeDetails } from '../../limitRange/Details';
+import NamespaceDetails from '../../namespace/Details';
+import { NetworkPolicyDetails } from '../../networkpolicy/Details';
+import NodeDetails from '../../node/Details';
+import PodDetails from '../../pod/Details';
+import PDBDetails from '../../podDisruptionBudget/Details';
+import PriorityClassDetails from '../../priorityClass/Details';
+import ResourceQuotaDetails from '../../resourceQuota/Details';
+import RoleBindingDetails from '../../role/BindingDetails';
+import RoleDetails from '../../role/Details';
+import { RuntimeClassDetails } from '../../runtimeClass/Details';
+import SecretDetails from '../../secret/Details';
+import ServiceDetails from '../../service/Details';
+import ServiceAccountDetails from '../../serviceaccount/Details';
+import StatefulSetDetails from '../../statefulset/Details';
+import VolumeClaimDetails from '../../storage/ClaimDetails';
+import StorageClassDetails from '../../storage/ClassDetails';
+import VolumeDetails from '../../storage/VolumeDetails';
+import VpaDetails from '../../verticalPodAutoscaler/Details';
+import MutatingWebhookConfigList from '../../webhookconfiguration/MutatingWebhookConfigDetails';
+import ValidatingWebhookConfigurationDetails from '../../webhookconfiguration/ValidatingWebhookConfigDetails';
+import WorkloadDetails from '../../workload/Details';
+
+const kindComponentMap: Record<
+ string,
+ (props: { name?: string; namespace?: string }) => ReactElement
+> = {
+ Pod: PodDetails,
+ Deployment: props => ,
+ ReplicaSet: props => ,
+ Job: props => ,
+ Service: ServiceDetails,
+ CronJob: CronJobDetails,
+ DaemonSet: DaemonSetDetails,
+ ConfigMap: ConfigDetails,
+ Endpoints: EndpointDetails,
+ HorizontalPodAutoscaler: HpaDetails,
+ Ingress: IngressDetails,
+ Lease: LeaseDetails,
+ LimitRange: LimitRangeDetails,
+ Namespace: NamespaceDetails,
+ NetworkPolicy: NetworkPolicyDetails,
+ Node: NodeDetails,
+ PodDisruptionBudget: PDBDetails,
+ PriorityClass: PriorityClassDetails,
+ ResourceQuota: ResourceQuotaDetails,
+ ClusterRole: RoleDetails,
+ Role: RoleDetails,
+ RoleBinding: RoleBindingDetails,
+ RuntimeClass: RuntimeClassDetails,
+ Secret: SecretDetails,
+ ServiceAccount: ServiceAccountDetails,
+ StatefulSet: StatefulSetDetails,
+ PersistentVolumeClaim: VolumeClaimDetails,
+ StorageClass: StorageClassDetails,
+ PersistentVolume: VolumeDetails,
+ VerticalPodAutoscaler: VpaDetails,
+ MutatingWebhookConfiguration: MutatingWebhookConfigList,
+ ValidatingWebhookConfiguration: ValidatingWebhookConfigurationDetails,
+ IngressClass: IngressClassDetails,
+};
+
+function DetailsNotFound() {
+ return null;
+}
+
+/**
+ * Shows details page for a given Kube resource
+ */
+export const KubeObjectDetails = memo(({ resource }: { resource: KubeObject }) => {
+ const kind = resource.kind;
+ const { name, namespace } = resource.metadata;
+
+ const Component = kindComponentMap[kind] ?? DetailsNotFound;
+
+ useEffect(() => {
+ if (!kindComponentMap[kind]) {
+ console.error(
+ 'No details component for kind ${kind} was found. See KubeNodeDetails.tsx for more info'
+ );
+ }
+ }, [kind, kindComponentMap]);
+
+ return (
+
+
+
+
+
+ );
+});
diff --git a/frontend/src/components/resourceMap/edges/KubeRelationEdge.tsx b/frontend/src/components/resourceMap/edges/KubeRelationEdge.tsx
new file mode 100644
index 00000000000..decfe1d08a7
--- /dev/null
+++ b/frontend/src/components/resourceMap/edges/KubeRelationEdge.tsx
@@ -0,0 +1,44 @@
+import { alpha, useTheme } from '@mui/material';
+import { BaseEdge, EdgeProps } from '@xyflow/react';
+import { memo } from 'react';
+import { GraphEdge } from '../graph/graphModel';
+import { useGraphView } from '../GraphView';
+
+/**
+ * An edge between Kube Objects
+ */
+export const KubeRelationEdge = memo((props: EdgeProps & { data: GraphEdge['data'] }) => {
+ const theme = useTheme();
+ const graph = useGraphView();
+
+ const isHighlighted = graph.highlights.isEdgeHighlighted(props.id);
+
+ const data = props.data;
+
+ const parentOffset = data.parentOffset;
+
+ const dx = parentOffset.x;
+ const dy = parentOffset.y;
+
+ const sections = data.sections;
+
+ const { startPoint, endPoint, bendPoints } = sections[0];
+
+ // Generate the path data string
+ const svgPath = `M ${startPoint.x + dx},${startPoint.y + dy} C ${bendPoints[0].x + dx},${
+ bendPoints[0].y + dy
+ } ${bendPoints[1].x + dx},${bendPoints[1].y + dy} ${endPoint.x + dx},${endPoint.y + dy}`;
+
+ return (
+
+ );
+});
diff --git a/frontend/src/components/resourceMap/graph/graphLayout.tsx b/frontend/src/components/resourceMap/graph/graphLayout.tsx
new file mode 100644
index 00000000000..540371f1aff
--- /dev/null
+++ b/frontend/src/components/resourceMap/graph/graphLayout.tsx
@@ -0,0 +1,235 @@
+import { Edge, EdgeMarker, Node } from '@xyflow/react';
+import { ElkExtendedEdge, ElkNode } from 'elkjs';
+import ELK from 'elkjs';
+import { forEachNode, GraphNode, KubeObjectNode } from './graphModel';
+
+type ElkNodeWithData = Omit & {
+ type: string;
+ data: any;
+ edges?: ElkEdgeWithData[];
+};
+
+type ElkEdgeWithData = ElkExtendedEdge & {
+ type: string;
+ data: any;
+};
+
+const elk = new ELK({
+ defaultLayoutOptions: {},
+});
+
+const layoutOptions = {
+ nodeSize: {
+ width: 220,
+ height: 70,
+ },
+};
+
+const partitionLayers = [
+ ['Deployment'],
+ ['ReplicaSet', 'ServiceAccount', 'CronJob'],
+ ['Job'],
+ ['Pod', 'RoleBinding'],
+ ['Service', 'NetworkPolicy', 'Role'],
+];
+
+/**
+ * To increase readability of the graph we can sort nodes left-to-right
+ * Where more 'owner' nodes like Deployment or ReplicaSet are on the left
+ */
+function getPartitionLayer(node: KubeObjectNode) {
+ if (!('resource' in node.data)) return;
+ const { kind } = node.data.resource;
+ const partitionLayer = partitionLayers.findIndex(layer => layer.includes(kind));
+ return partitionLayer > -1 ? partitionLayer : undefined;
+}
+
+/**
+ * Prepare the node for the layout by converting it to the ELK node
+ *
+ * @param node - node
+ * @param aspectRatio - aspect ratio of the container
+ */
+function convertToElkNode(node: GraphNode, aspectRatio: number): ElkNodeWithData {
+ const isCollapsed = 'collapsed' in node.data && node.data.collapsed;
+
+ const convertedEdges =
+ 'edges' in node.data
+ ? (node.data.edges
+ .map(edge => {
+ // Make sure source and target exists
+ let hasSource = false;
+ let hasTarget = false;
+ forEachNode(node, n => {
+ if (n.id === edge.source) {
+ hasSource = true;
+ }
+ if (n.id === edge.target) {
+ hasTarget = true;
+ }
+ });
+
+ if (!hasSource || !hasTarget) {
+ return;
+ }
+
+ return {
+ type: edge.type,
+ id: edge.id,
+ sources: [edge.source],
+ targets: [edge.target],
+ label: edge.label,
+ labels: [{ text: edge.label, width: 70, height: 20 }],
+ animated: edge.animated,
+ hidden: false,
+ data: edge.data,
+ };
+ })
+ .filter(Boolean) as ElkEdgeWithData[])
+ : [];
+
+ const elkNode: ElkNodeWithData = {
+ id: node.id,
+ type: node.type,
+ data: node.data,
+ };
+
+ if (node.type === 'kubeObject') {
+ elkNode.layoutOptions = {
+ 'partitioning.partition': String(getPartitionLayer(node)),
+ };
+ elkNode.width = layoutOptions.nodeSize.width;
+ elkNode.height = layoutOptions.nodeSize.height;
+ return elkNode;
+ }
+
+ if (node.type === 'kubeGroup') {
+ elkNode.layoutOptions = {
+ 'partitioning.activate': 'true',
+ 'elk.direction': 'RIGHT',
+ 'elk.edgeRouting': 'SPLINES',
+ 'elk.nodeSize.minimum': '(220.0,70.0)',
+ 'elk.nodeSize.constraints': '[MINIMUM_SIZE]',
+ 'elk.algorithm': 'layered',
+ 'elk.spacing.nodeNode': isCollapsed ? '1' : '60',
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
+ 'org.eclipse.elk.stress.desiredEdgeLength': isCollapsed ? '20' : '250',
+ 'org.eclipse.elk.stress.epsilon': '0.1',
+ 'elk.padding': '[left=16, top=16, right=16, bottom=16]',
+ };
+ elkNode.edges = convertedEdges;
+ elkNode.children =
+ 'collapsed' in node.data && node.data.collapsed
+ ? []
+ : node.data.nodes.map(it => convertToElkNode(it, aspectRatio));
+ return elkNode;
+ }
+
+ if (node.type === 'group') {
+ elkNode.layoutOptions = {
+ 'elk.aspectRatio': String(aspectRatio),
+ 'elk.algorithm': 'rectpacking',
+ 'elk.rectpacking.widthApproximation.optimizationGoal': 'ASPECT_RATIO_DRIVEN',
+ 'elk.rectpacking.packing.compaction.rowHeightReevaluation': 'true',
+ 'elk.edgeRouting': 'SPLINES',
+ 'elk.spacing.nodeNode': '20',
+ 'elk.padding': '[left=24, top=24, right=24, bottom=24]',
+ };
+ elkNode.edges = convertedEdges;
+ elkNode.children = node.data.nodes.map(it => convertToElkNode(it, aspectRatio));
+
+ return elkNode;
+ }
+ return elkNode;
+}
+
+/**
+ * Convert ELK graph back to react-flow graph
+ */
+function convertToReactFlowGraph(elkGraph: ElkNodeWithData) {
+ const edges: Edge[] = [];
+ const nodes: Node[] = [];
+
+ const pushEdges = (node: ElkNodeWithData, parent?: ElkNodeWithData) => {
+ node.edges?.forEach(edge => {
+ edges.push({
+ id: edge.id,
+ source: edge.sources[0],
+ target: edge.targets[0],
+ type: edge.type ?? 'customEdge',
+ selectable: false,
+ focusable: false,
+ hidden: false,
+ markerEnd: {
+ type: 'arrowclosed',
+ } as EdgeMarker,
+ data: {
+ data: edge.data,
+ sections: edge.sections,
+ // @ts-ignore
+ label: edge?.label,
+ labels: edge.labels,
+ parentOffset: {
+ x: (node?.x ?? 0) + (parent?.x ?? 0),
+ y: (node?.y ?? 0) + (parent?.y ?? 0),
+ },
+ },
+ });
+ });
+ };
+
+ const pushNode = (node: ElkNodeWithData, parent?: ElkNodeWithData) => {
+ nodes.push({
+ id: node.id,
+ type: node.type,
+ style: {
+ width: node.width,
+ height: node.height,
+ },
+ hidden: false,
+ selectable: true,
+ draggable: false,
+ width: node.width,
+ height: node.height,
+ position: { x: node.x!, y: node.y! },
+ data: node.data,
+ parentId: parent?.id ?? undefined,
+ });
+ };
+
+ const convertElkNode = (node: ElkNodeWithData, parent?: ElkNodeWithData) => {
+ pushNode(node, parent);
+ pushEdges(node, parent);
+
+ node.children?.forEach(it => {
+ convertElkNode(it as ElkNodeWithData, node);
+ });
+ };
+
+ pushEdges(elkGraph);
+ elkGraph.children!.forEach(node => {
+ convertElkNode(node as ElkNodeWithData);
+ });
+
+ return { nodes, edges };
+}
+
+/**
+ * Takes a graph and returns a graph with layout applied
+ * Layout will set size and poisiton for all the elements
+ *
+ * @param graph - root node of the graph
+ * @param aspectRatio - aspect ratio of the container
+ * @returns
+ */
+export const applyGraphLayout = (graph: GraphNode, aspectRatio: number) => {
+ const elkGraph = convertToElkNode(graph, aspectRatio);
+
+ return elk
+ .layout(elkGraph, {
+ layoutOptions: {
+ 'elk.aspectRatio': String(aspectRatio),
+ },
+ })
+ .then(elkGraph => convertToReactFlowGraph(elkGraph as ElkNodeWithData));
+};
diff --git a/frontend/src/components/resourceMap/kubeIcon/KubeIcon.tsx b/frontend/src/components/resourceMap/kubeIcon/KubeIcon.tsx
new file mode 100644
index 00000000000..af402f330bf
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/KubeIcon.tsx
@@ -0,0 +1,160 @@
+import { Box } from '@mui/material';
+import CRoleIcon from './img/c-role.svg?react';
+import CmIcon from './img/cm.svg?react';
+import CrbIcon from './img/crb.svg?react';
+import CrdIcon from './img/crd.svg?react';
+import CronjobIcon from './img/cronjob.svg?react';
+import DeployIcon from './img/deploy.svg?react';
+import DsIcon from './img/ds.svg?react';
+import EpIcon from './img/ep.svg?react';
+import GroupIcon from './img/group.svg?react';
+import HpaIcon from './img/hpa.svg?react';
+import IngIcon from './img/ing.svg?react';
+import JobIcon from './img/job.svg?react';
+import LimitsIcon from './img/limits.svg?react';
+import NetpolIcon from './img/netpol.svg?react';
+import NsIcon from './img/ns.svg?react';
+import PodIcon from './img/pod.svg?react';
+import PspIcon from './img/psp.svg?react';
+import PvIcon from './img/pv.svg?react';
+import PvcIcon from './img/pvc.svg?react';
+import QuotaIcon from './img/quota.svg?react';
+import RbIcon from './img/rb.svg?react';
+import RoleIcon from './img/role.svg?react';
+import RsIcon from './img/rs.svg?react';
+import SaIcon from './img/sa.svg?react';
+import ScIcon from './img/sc.svg?react';
+import SecretIcon from './img/secret.svg?react';
+import StsIcon from './img/sts.svg?react';
+import SvcIcon from './img/svc.svg?react';
+import UserIcon from './img/user.svg?react';
+import VolIcon from './img/vol.svg?react';
+
+const kindToIcon = {
+ ClusterRole: CRoleIcon,
+ ClusterRoleBinding: CrbIcon,
+ CronJob: CronjobIcon,
+ DaemonSet: DsIcon,
+ Group: GroupIcon,
+ Ingress: IngIcon,
+ LimitRange: LimitsIcon,
+ Namespace: NsIcon,
+ PodSecurityPolicy: PspIcon,
+ PersistentVolumeClaim: PvcIcon,
+ RoleBinding: RbIcon,
+ ReplicaSet: RsIcon,
+ StorageClass: ScIcon,
+ StatefulSet: StsIcon,
+ User: UserIcon,
+ ConfigMap: CmIcon,
+ CustomResourceDefinition: CrdIcon,
+ Deployment: DeployIcon,
+ Endpoint: EpIcon,
+ Endpoints: EpIcon,
+ HorizontalPodAutoscaler: HpaIcon,
+ Job: JobIcon,
+ NetworkPolicy: NetpolIcon,
+ Pod: PodIcon,
+ PersistentVolume: PvIcon,
+ ResourceQuota: QuotaIcon,
+ Role: RoleIcon,
+ ServiceAccount: SaIcon,
+ Secret: SecretIcon,
+ Service: SvcIcon,
+ Volume: VolIcon,
+} as const;
+
+const kindGroups = {
+ workloads: new Set([
+ 'Pod',
+ 'Deployment',
+ 'ReplicaSet',
+ 'StatefulSet',
+ 'DaemonSet',
+ 'ReplicaSet',
+ 'Job',
+ 'CronJob',
+ ]),
+ storage: new Set(['PersistentVolumeClaim']),
+ network: new Set([
+ 'Service',
+ 'Endpoints',
+ 'Endpoint',
+ 'Ingress',
+ 'IngressClass',
+ 'NetworkPolicy',
+ ]),
+ security: new Set(['ServiceAccount', 'Role', 'RoleBinding']),
+ configuration: new Set([
+ 'ConfigMap',
+ 'Secret',
+ 'MutatingWebhookConfiguration',
+ 'ValidatingWebhookConfiguration',
+ ]),
+} as const;
+
+const getKindGroup = (kind: string) =>
+ Object.entries(kindGroups).find(([, set]) => set.has(kind))?.[0] as keyof typeof kindGroups;
+
+const lightness = '67.85%';
+const chroma = '0.12';
+
+const kindGroupColors = {
+ workloads: `oklch(${lightness} ${chroma} 182.18)`,
+ storage: `oklch(${lightness} ${chroma} 46.47)`,
+ network: `oklch(${lightness} ${chroma} 225.16)`,
+ security: `oklch(${lightness} ${chroma} 275.16)`,
+ configuration: `oklch(${lightness} ${chroma} 320.03)`,
+ other: `oklch(${lightness} 0 215.25)`,
+} as const;
+
+const getKindColor = (kind: string) => kindGroupColors[getKindGroup(kind) ?? 'other'];
+export const getKindGroupColor = (group: keyof typeof kindGroupColors) =>
+ kindGroupColors[group] ?? kindGroupColors.other;
+
+/**
+ * Icon for the Kube resource
+ * Color is based on the resource category (workload,storage, etc)
+ *
+ * Icons are taken from
+ * https://github.com/kubernetes/community/tree/master/icons
+ *
+ * @param params.kind - Resource kind
+ * @param params.width - width in css units
+ * @param params.height - width in css units
+ * @returns
+ */
+export function KubeIcon({
+ kind,
+ width,
+ height,
+}: {
+ kind: keyof typeof kindToIcon;
+ width?: string;
+ height?: string;
+}) {
+ const IconComponent = kindToIcon[kind] ?? kindToIcon['Pod'];
+
+ const color = getKindColor(kind);
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/c-role.svg b/frontend/src/components/resourceMap/kubeIcon/img/c-role.svg
new file mode 100644
index 00000000000..cc89f80e9ef
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/c-role.svg
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/cm.svg b/frontend/src/components/resourceMap/kubeIcon/img/cm.svg
new file mode 100644
index 00000000000..e5ba243e417
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/cm.svg
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/crb.svg b/frontend/src/components/resourceMap/kubeIcon/img/crb.svg
new file mode 100644
index 00000000000..41a10393bba
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/crb.svg
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/crd.svg b/frontend/src/components/resourceMap/kubeIcon/img/crd.svg
new file mode 100644
index 00000000000..8d380864b3b
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/crd.svg
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/cronjob.svg b/frontend/src/components/resourceMap/kubeIcon/img/cronjob.svg
new file mode 100644
index 00000000000..46681775674
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/cronjob.svg
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/deploy.svg b/frontend/src/components/resourceMap/kubeIcon/img/deploy.svg
new file mode 100644
index 00000000000..ce93ec51570
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/deploy.svg
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ds.svg b/frontend/src/components/resourceMap/kubeIcon/img/ds.svg
new file mode 100644
index 00000000000..d59a3e67fa4
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/ds.svg
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ep.svg b/frontend/src/components/resourceMap/kubeIcon/img/ep.svg
new file mode 100644
index 00000000000..e8ed2da8d1b
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/ep.svg
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/group.svg b/frontend/src/components/resourceMap/kubeIcon/img/group.svg
new file mode 100644
index 00000000000..d9e59df38b3
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/group.svg
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/hpa.svg b/frontend/src/components/resourceMap/kubeIcon/img/hpa.svg
new file mode 100644
index 00000000000..22c77165bdf
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/hpa.svg
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ing.svg b/frontend/src/components/resourceMap/kubeIcon/img/ing.svg
new file mode 100644
index 00000000000..634fe8351d2
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/ing.svg
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/job.svg b/frontend/src/components/resourceMap/kubeIcon/img/job.svg
new file mode 100644
index 00000000000..e103afa3f66
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/job.svg
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/limits.svg b/frontend/src/components/resourceMap/kubeIcon/img/limits.svg
new file mode 100644
index 00000000000..132257b9100
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/limits.svg
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/netpol.svg b/frontend/src/components/resourceMap/kubeIcon/img/netpol.svg
new file mode 100644
index 00000000000..aff3ae7b710
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/netpol.svg
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ns.svg b/frontend/src/components/resourceMap/kubeIcon/img/ns.svg
new file mode 100644
index 00000000000..3cda86bbf2f
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/ns.svg
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/pod.svg b/frontend/src/components/resourceMap/kubeIcon/img/pod.svg
new file mode 100644
index 00000000000..cfc49faab5f
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/pod.svg
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/psp.svg b/frontend/src/components/resourceMap/kubeIcon/img/psp.svg
new file mode 100644
index 00000000000..fd082e64862
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/psp.svg
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/pv.svg b/frontend/src/components/resourceMap/kubeIcon/img/pv.svg
new file mode 100644
index 00000000000..d84d91a9949
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/pv.svg
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/pvc.svg b/frontend/src/components/resourceMap/kubeIcon/img/pvc.svg
new file mode 100644
index 00000000000..f401fb2a97d
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/pvc.svg
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/quota.svg b/frontend/src/components/resourceMap/kubeIcon/img/quota.svg
new file mode 100644
index 00000000000..c4c127db875
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/quota.svg
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/rb.svg b/frontend/src/components/resourceMap/kubeIcon/img/rb.svg
new file mode 100644
index 00000000000..9fbd1c4b4f7
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/rb.svg
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/role.svg b/frontend/src/components/resourceMap/kubeIcon/img/role.svg
new file mode 100644
index 00000000000..0e322ec3a49
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/role.svg
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/rs.svg b/frontend/src/components/resourceMap/kubeIcon/img/rs.svg
new file mode 100644
index 00000000000..757ea82436c
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/rs.svg
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/sa.svg b/frontend/src/components/resourceMap/kubeIcon/img/sa.svg
new file mode 100644
index 00000000000..b3a175cf678
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/sa.svg
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/sc.svg b/frontend/src/components/resourceMap/kubeIcon/img/sc.svg
new file mode 100644
index 00000000000..57e5876dc28
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/sc.svg
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/secret.svg b/frontend/src/components/resourceMap/kubeIcon/img/secret.svg
new file mode 100644
index 00000000000..e343942634d
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/secret.svg
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/sts.svg b/frontend/src/components/resourceMap/kubeIcon/img/sts.svg
new file mode 100644
index 00000000000..8aa4bd8afc2
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/sts.svg
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/svc.svg b/frontend/src/components/resourceMap/kubeIcon/img/svc.svg
new file mode 100644
index 00000000000..9b1baeb1e59
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/svc.svg
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/user.svg b/frontend/src/components/resourceMap/kubeIcon/img/user.svg
new file mode 100644
index 00000000000..23edcce41f4
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/user.svg
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/kubeIcon/img/vol.svg b/frontend/src/components/resourceMap/kubeIcon/img/vol.svg
new file mode 100644
index 00000000000..41f1412c651
--- /dev/null
+++ b/frontend/src/components/resourceMap/kubeIcon/img/vol.svg
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/resourceMap/nodes/GroupNode.tsx b/frontend/src/components/resourceMap/nodes/GroupNode.tsx
new file mode 100644
index 00000000000..a8c68846b02
--- /dev/null
+++ b/frontend/src/components/resourceMap/nodes/GroupNode.tsx
@@ -0,0 +1,59 @@
+import { alpha, styled } from '@mui/material';
+import { NodeProps } from '@xyflow/react';
+import { memo } from 'react';
+import { GroupNode } from '../graph/graphModel';
+import { useGraphView } from '../GraphView';
+
+const Container = styled('div')<{ isSelected: boolean }>(({ theme, isSelected }) => ({
+ width: '100%',
+ height: '100%',
+ transition: 'border-color 0.1s',
+ background: alpha(theme.palette.background.paper, 0.6),
+ border: '1px solid',
+ borderColor: theme.palette.divider,
+ borderRadius: theme.spacing(1.5),
+ ':hover': {
+ borderColor: isSelected ? undefined : alpha(theme.palette.action.active, 0.4),
+ },
+}));
+
+const Label = styled('div')(({ theme }) => ({
+ position: 'absolute',
+ fontSize: '16px',
+ top: '-16px',
+ background: theme.palette.background.paper,
+ left: '22px',
+ padding: '4px',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ maxWidth: 'calc(100% - 52px)',
+ color: alpha(theme.palette.text.primary, 0.6),
+ borderRadius: 2,
+}));
+
+export const GroupNodeComponent = memo(({ id, data }: NodeProps & { data: GroupNode['data'] }) => {
+ const graph = useGraphView();
+ const isSelected = id === graph.nodeSelection;
+
+ const handleSelect = () => {
+ graph.setNodeSelection(id);
+ graph.highlights.setHighlight(undefined);
+ };
+
+ return (
+ {
+ if (e.key === 'Enter' || e.key === 'Space') {
+ handleSelect();
+ }
+ }}
+ >
+ {data.label}
+
+ );
+});
diff --git a/frontend/src/components/resourceMap/nodes/KubeGroupNode.tsx b/frontend/src/components/resourceMap/nodes/KubeGroupNode.tsx
new file mode 100644
index 00000000000..7bcb5c9098c
--- /dev/null
+++ b/frontend/src/components/resourceMap/nodes/KubeGroupNode.tsx
@@ -0,0 +1,177 @@
+import { Icon } from '@iconify/react';
+import { alpha, Box, styled, useTheme } from '@mui/material';
+import { NodeProps } from '@xyflow/react';
+import { memo, useState } from 'react';
+import { getMainNode } from '../graph/graphGrouping';
+import { KubeGroupNode } from '../graph/graphModel';
+import { useGraphView } from '../GraphView';
+import { KubeIcon } from '../kubeIcon/KubeIcon';
+import { getStatus } from './KubeObjectStatus';
+
+const Container = styled('div')<{ isFaded: boolean; isCollapsed: boolean }>(
+ ({ theme, isFaded, isCollapsed }) => ({
+ display: 'flex',
+ opacity: isFaded ? 0.6 : undefined,
+ filter: isFaded ? 'grayscale(1.0)' : undefined,
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100% !important',
+ height: '100%',
+ background: theme.palette.background.paper,
+ border: '1px solid',
+ borderColor: theme.palette.divider,
+ borderRadius: '10px',
+ transition: 'all 0.05s',
+ transitionTimingFunction: 'linear',
+
+ boxShadow: isCollapsed ? undefined : '6px 6px 12px rgba(0,0,0,0.03)',
+
+ ':hover': {
+ boxShadow: isCollapsed ? '2px 2px 6px rgba(0,0,0,0.05)' : undefined,
+ marginTop: isCollapsed ? '-4px' : undefined,
+ borderColor: isCollapsed ? alpha(theme.palette.action.active, 0.25) : undefined,
+ },
+ })
+);
+
+const CircleBadge = styled('div')<{ isHovered: boolean }>(({ theme, isHovered }) => ({
+ position: 'absolute',
+ right: 0,
+ width: '32px',
+ height: '32px',
+ borderRadius: '50%',
+ display: 'flex',
+ alignItems: 'center',
+ margin: theme.spacing(-1),
+ justifyContent: 'center',
+ background: theme.palette.background.paper,
+ boxShadow: '1px 1px 5px rgba(0,0,0,0.08)',
+ transition: 'top 0.05s',
+ transitionTimingFunction: 'linear',
+ border: '1px solid #e1e1e1',
+ borderColor: theme.palette.divider,
+ color: theme.typography.caption.color,
+ top: isHovered ? '-4px' : 0,
+}));
+
+const FakeContainer = styled('div')<{ isHovered: boolean }>(({ theme, isHovered }) => ({
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+
+ top: 0,
+ left: 0,
+ background: theme.palette.background.paper,
+ border: '1px solid',
+ borderRadius: '10px',
+ transition: 'all 0.025s',
+ transitionTimingFunction: 'linear',
+ boxShadow: isHovered ? '2px 2px 6px rgba(0,0,0,0.05)' : undefined,
+ borderColor: isHovered ? alpha(theme.palette.action.active, 0.25) : theme.palette.divider,
+}));
+
+export const KubeGroupNodeComponent = memo(
+ ({ data, id }: NodeProps & { data: KubeGroupNode['data'] }) => {
+ const theme = useTheme();
+ const graph = useGraphView();
+
+ const someHighlighted = data.nodes.length
+ ? data?.nodes?.some(it => graph.highlights.isNodeHighlighted(it.id))
+ : true;
+
+ const errors =
+ data?.nodes?.filter(it => getStatus(it?.data?.resource) === 'error')?.length ?? 0;
+ const warnings =
+ data?.nodes?.filter(it => getStatus(it?.data?.resource) === 'warning')?.length ?? 0;
+
+ const status = errors > 0 ? 'error' : warnings > 0 ? 'warning' : 'success';
+
+ const firstResource = getMainNode(data?.nodes ?? [])?.data?.resource;
+
+ const isCollapsed = !!data.collapsed;
+
+ const [isHovered, setIsHovered] = useState(false);
+ const isHighlighted = someHighlighted || graph.highlights.isNodeHighlighted(id);
+
+ const icon = ;
+
+ const handleSelect = () => {
+ graph.setNodeSelection(id);
+ graph.highlights.setHighlight(undefined);
+ };
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ onClick={handleSelect}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === 'Space') {
+ handleSelect();
+ }
+ }}
+ >
+ {isCollapsed && {data?.nodes?.length ?? 0} }
+ {isCollapsed && status !== 'success' && (
+
+
+
+ )}
+ {firstResource && (
+
+ {(data?.nodes?.length ?? 0) > 1 && (
+
+ )}
+ {(data?.nodes?.length ?? 0) > 2 && (
+
+ )}
+
+ {icon}
+
+
+
+ {firstResource.kind}
+
+
+ {firstResource.metadata.name}
+
+
+
+ )}
+
+ );
+ }
+);
diff --git a/frontend/src/components/resourceMap/nodes/KubeObjectChip.tsx b/frontend/src/components/resourceMap/nodes/KubeObjectChip.tsx
new file mode 100644
index 00000000000..8f85a45d7e6
--- /dev/null
+++ b/frontend/src/components/resourceMap/nodes/KubeObjectChip.tsx
@@ -0,0 +1,41 @@
+import { Box, styled } from '@mui/material';
+import { KubeObject } from '../../../lib/k8s/cluster';
+import { KubeIcon } from '../kubeIcon/KubeIcon';
+
+const Container = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+
+ background: theme.palette.background.paper,
+ borderRadius: theme.spacing(1),
+ border: '1px solid #e3e3e3',
+
+ borderColor: theme.palette.divider,
+
+ padding: theme.spacing(1),
+}));
+
+export function KubeObjectChip({ resource }: { resource: KubeObject }) {
+ return (
+
+
+
+
+
+ {resource.kind}
+
+
+ {resource.metadata.name}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/nodes/KubeObjectNode.tsx b/frontend/src/components/resourceMap/nodes/KubeObjectNode.tsx
new file mode 100644
index 00000000000..11503c80242
--- /dev/null
+++ b/frontend/src/components/resourceMap/nodes/KubeObjectNode.tsx
@@ -0,0 +1,189 @@
+import { Icon } from '@iconify/react';
+import { alpha, Box, styled, useTheme } from '@mui/material';
+import { Handle, NodeProps, Position, useEdges, useNodes } from '@xyflow/react';
+import { memo, startTransition, useEffect, useRef, useState } from 'react';
+import { KubeObjectNode } from '../graph/graphModel';
+import { useGraphView } from '../GraphView';
+import { KubeIcon } from '../kubeIcon/KubeIcon';
+import { KubeObjectGlance } from '../KubeObjectGlance/KubeObjectGlance';
+import { getStatus } from './KubeObjectStatus';
+
+const Container = styled('div')<{
+ isHovered: boolean;
+ isExpanded: boolean;
+ isFaded: boolean;
+ isSelected: boolean;
+}>(({ theme, isHovered, isFaded, isSelected, isExpanded }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ zIndex: isHovered ? 1 : undefined,
+ opacity: isFaded && !isHovered ? 0.5 : undefined,
+ filter: isFaded && !isHovered ? 'grayscale(0.0)' : undefined,
+
+ width: isExpanded ? 'auto' : '100%!important',
+ minWidth: '100%',
+
+ position: isHovered ? 'absolute' : undefined,
+ background: theme.palette.background.paper,
+ borderRadius: '10px',
+ border: '1px solid #e3e3e3',
+
+ borderColor: isSelected ? theme.palette.action.active : theme.palette.divider,
+
+ boxShadow: isHovered ? '4px 4px 6px rgba(0,0,0,0.06)' : undefined,
+ transform: isHovered ? 'translateY(-2px)' : undefined,
+ padding: isExpanded ? '16px' : '10px',
+ marginLeft: isExpanded ? '-6px' : 0,
+ marginTop: isExpanded ? '-6px' : 0,
+
+ transition: 'all 0.05s',
+
+ ':hover': {
+ borderColor: isSelected ? undefined : alpha(theme.palette.action.active, 0.2),
+ },
+}));
+
+const CircleBadge = styled('div')(({ theme }) => ({
+ position: 'absolute',
+ width: '32px',
+ height: '32px',
+ borderRadius: '50%',
+ display: 'flex',
+ alignItems: 'center',
+ margin: theme.spacing(-1),
+ justifyContent: 'center',
+ background: theme.palette.background.paper,
+ boxShadow: '1px 1px 5px rgba(0,0,0,0.08)',
+ transition: 'top 0.05s',
+ transitionTimingFunction: 'linear',
+ border: '1px solid #e1e1e1',
+ borderColor: theme.palette.divider,
+ color: theme.typography.caption.color,
+ top: 0,
+ right: '12px',
+}));
+
+const EXPAND_DELAY = 450;
+
+export const KubeObjectNodeComponent = memo(
+ ({ data, id }: NodeProps & { data: KubeObjectNode['data'] }) => {
+ const [isHovered, setHovered] = useState(false);
+ const [isExpanded, setIsExpanded] = useState(false);
+ const theme = useTheme();
+
+ const graph = useGraphView();
+ const nodes = useNodes();
+ const edges = useEdges();
+
+ const resource = data.resource;
+
+ const isSelected = id === graph.nodeSelection;
+ const isHighlighted = graph.highlights.isNodeHighlighted(id);
+
+ const status = getStatus(data.resource) ?? 'success';
+
+ const nodeRef = useRef(null);
+
+ useEffect(() => {
+ if (nodeRef.current && nodeRef.current.parentElement) {
+ let index = '0';
+ if (isSelected) index = '1003';
+ if (isHovered) index = '1004';
+ nodeRef.current.parentElement.style.zIndex = index;
+ }
+ }, [isSelected, isHovered]);
+
+ useEffect(() => {
+ if (!isHovered) {
+ setIsExpanded(false);
+ return;
+ }
+
+ const id = setTimeout(() => setIsExpanded(true), EXPAND_DELAY);
+ return () => clearInterval(id);
+ }, [isHovered]);
+
+ const icon = ;
+
+ function handleMouseEnter() {
+ const relatedEdges = edges.filter(it => it.source === id || it.target === id);
+ const relatedNodes = nodes.filter(node =>
+ relatedEdges.find(edge => edge.source === node.id || edge.target === node.id)
+ );
+
+ if (relatedNodes.length > 1) {
+ startTransition(() => {
+ graph.highlights.setHighlight({
+ label: undefined,
+ nodeIds: new Set(relatedNodes?.map(it => it.id) ?? []),
+ edgeIds: new Set(relatedEdges?.map(it => it.id) ?? []),
+ });
+ });
+ }
+ }
+
+ const openDetails = () => {
+ graph.setNodeSelection(id);
+ setHovered(false);
+ graph.highlights.setHighlight(undefined);
+ };
+
+ return (
+ startTransition(() => graph.highlights.setHighlight(undefined))}
+ onPointerEnter={() => setHovered(true)}
+ onPointerLeave={() => {
+ setHovered(false);
+ }}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === 'Space') {
+ openDetails();
+ }
+ }}
+ >
+
+
+
+ {status !== 'success' && (
+
+
+
+ )}
+
+
+ {icon}
+
+
+ {resource.kind}
+
+
+ {resource.metadata.name}
+
+
+
+ {isExpanded && }
+
+ );
+ }
+);
diff --git a/frontend/src/components/resourceMap/search/ResourceSearch.tsx b/frontend/src/components/resourceMap/search/ResourceSearch.tsx
new file mode 100644
index 00000000000..f12a5d304a4
--- /dev/null
+++ b/frontend/src/components/resourceMap/search/ResourceSearch.tsx
@@ -0,0 +1,106 @@
+import { Autocomplete, Box, TextField } from '@mui/material';
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { KubeObject } from '../../../lib/k8s/cluster';
+import { KubeIcon } from '../kubeIcon/KubeIcon';
+
+const MAX_RESULTS = 8;
+
+/**
+ * Search input that looks for resources by their name
+ *
+ * @param params.resources - list of Kube resources
+ * @param params.onSearch - on search callback
+ * @returns
+ */
+export function ResourceSearch({
+ resources,
+ onSearch,
+}: {
+ resources: KubeObject[];
+ onSearch: (resource: KubeObject) => void;
+}) {
+ const { t } = useTranslation();
+ const [query, setQuery] = useState('');
+
+ const results = useMemo(() => {
+ if (!resources || !query.trim()) return [];
+ const results = [];
+
+ for (let i = 0; i < resources.length; i++) {
+ const resource = resources[i];
+ if (resource.metadata.name.includes(query)) {
+ results.push(resource);
+ }
+ if (results.length >= MAX_RESULTS) {
+ break;
+ }
+ }
+
+ return results;
+ }, [query, resources]);
+
+ return (
+
+ (
+
+ )}
+ freeSolo
+ clearOnBlur
+ filterOptions={x => x}
+ getOptionLabel={option => (typeof option === 'string' ? option : option.metadata.name)}
+ onInputChange={(e, value) => {
+ setQuery(value);
+ }}
+ onChange={(e, value) => {
+ if (value && typeof value !== 'string') {
+ onSearch(value);
+ }
+ }}
+ options={results}
+ renderOption={(props, it) => {
+ return (
+
+
+
+
+
+
+
+
+ {it.kind}
+
+
+ {it.metadata.name}
+
+
+
+
+ );
+ }}
+ >
+
+ );
+}
diff --git a/frontend/src/components/resourceMap/sources/GraphSources.tsx b/frontend/src/components/resourceMap/sources/GraphSources.tsx
new file mode 100644
index 00000000000..ee872c2e5d6
--- /dev/null
+++ b/frontend/src/components/resourceMap/sources/GraphSources.tsx
@@ -0,0 +1,270 @@
+import { throttle } from 'lodash';
+import {
+ createContext,
+ memo,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import { KubeObject } from '../../../lib/k8s/cluster';
+import { GraphEdge, GraphNode, GraphSource } from '../graph/graphModel';
+import { configurationSource } from './definitions/configurationSource';
+import { networkSource } from './definitions/networkSource';
+import { securitySource } from './definitions/securitySource';
+import { storageSource } from './definitions/storageSource';
+import { workloadsSource } from './definitions/workloadSource';
+
+export const allSources: GraphSource[] = [
+ workloadsSource,
+ storageSource,
+ networkSource,
+ securitySource,
+ configurationSource,
+];
+
+/**
+ * Map of nodes and edges where the key is source id
+ */
+export type SourceData = Map;
+
+type MaybeNodesAndEdges = {
+ nodes?: GraphNode[];
+ edges?: GraphEdge[];
+} | null;
+
+interface GraphSourcesContext {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+ toggleSelection: (source: GraphSource) => void;
+ setSelectedSources: (sources: Set) => void;
+ selectedSources: Set;
+ sourceData?: SourceData;
+ isLoading?: boolean;
+}
+
+const Context = createContext(undefined as any);
+
+export const useSources = () => useContext(Context);
+
+/**
+ * Returns a flat list of all the sources
+ */
+function getFlatSources(sources: GraphSource[], result: GraphSource[] = []): GraphSource[] {
+ for (const source of sources) {
+ if ('sources' in source) {
+ getFlatSources(source.sources, result);
+ } else {
+ result.push(source);
+ }
+ }
+ return result;
+}
+
+/**
+ * Create Edges from object's ownerReferences
+ */
+export const kubeOwnersEdges = (obj: KubeObject): GraphEdge[] => {
+ return (
+ obj.metadata.ownerReferences?.map(owner => ({
+ id: `${obj.metadata.uid}-${owner.uid}`,
+ type: 'kubeRelation',
+ source: obj.metadata.uid,
+ target: owner.uid,
+ })) ?? []
+ );
+};
+
+/**
+ * Create an object from any Kube object
+ */
+export const makeKubeObjectNode = (obj: KubeObject): GraphNode => ({
+ id: obj.metadata.uid,
+ type: 'kubeObject',
+ data: {
+ resource: obj,
+ },
+});
+
+/**
+ * Make an edge connecting two Kube objects
+ */
+export const makeKubeToKubeEdge = (from: KubeObject, to: KubeObject): GraphEdge => ({
+ id: `${from.metadata.uid}-${to.metadata.uid}`,
+ type: 'kubeRelation',
+ source: from.metadata.uid,
+ target: to.metadata.uid,
+});
+
+/**
+ * Since we can't use hooks in a loop, we need to create a component for each source
+ * that will load the data and pass it to the parent component.
+ */
+const SourceLoader = memo(
+ ({
+ useHook,
+ onData,
+ id,
+ }: {
+ useHook: () => MaybeNodesAndEdges;
+ onData: (id: string, data: MaybeNodesAndEdges) => void;
+ id: string;
+ }) => {
+ const data = useHook();
+
+ useEffect(() => {
+ onData(id, data);
+ }, [id, data]);
+
+ return null;
+ }
+);
+
+export default function useThrottledMemo(factory: () => T, deps: any[], throttleMs: number): T {
+ const [state, setState] = useState(factory());
+
+ const debouncedSetState = useCallback(throttle(setState, throttleMs), []);
+
+ useEffect(() => {
+ debouncedSetState(factory());
+ }, deps);
+
+ return state;
+}
+
+export interface GraphSourceManagerProps {
+ /** List of sources to load */
+ sources: GraphSource[];
+ /** Children to render */
+ children: ReactNode;
+}
+
+/**
+ * Loads data from all the sources
+ */
+export function GraphSourceManager({ sources, children }: GraphSourceManagerProps) {
+ const [sourceData, setSourceData] = useState(new Map());
+ const [selectedSources, setSelectedSources] = useState(() => {
+ const _selectedSources = new Set();
+
+ const step = (source: GraphSource) => {
+ if (source.isEnabledByDefault ?? true) {
+ _selectedSources.add(source.id);
+ if ('sources' in source) {
+ source.sources.forEach(step);
+ }
+ }
+ };
+ sources.map(step);
+ return _selectedSources;
+ });
+
+ const toggleSelection = useCallback(
+ (source: GraphSource) => {
+ setSelectedSources(selection => {
+ const isSelected = (source: GraphSource): boolean =>
+ 'sources' in source ? source.sources.every(s => isSelected(s)) : selection.has(source.id);
+
+ const deselectAll = (source: GraphSource) => {
+ if ('sources' in source) {
+ source.sources.forEach(deselectAll);
+ } else {
+ selection.delete(source.id);
+ }
+ };
+
+ const selectAll = (source: GraphSource) => {
+ if ('sources' in source) {
+ source.sources.forEach(s => selectAll(s));
+ } else {
+ selection.add(source.id);
+ }
+ };
+
+ if (!('sources' in source)) {
+ // not a group, just toggle the selection
+ if (selection.has(source.id)) {
+ selection.delete(source.id);
+ } else {
+ selection.add(source.id);
+ }
+ } else {
+ // if all children are selected, deselect them
+ if (source.sources.every(isSelected)) {
+ source.sources.forEach(deselectAll);
+ selection.delete(source.id);
+ } else {
+ source.sources.forEach(selectAll);
+ }
+ }
+ return new Set(selection);
+ });
+ },
+ [setSelectedSources]
+ );
+
+ const onData = useCallback(
+ (id: string, data: MaybeNodesAndEdges) => {
+ setSourceData(map => new Map(map).set(id, data));
+ },
+ [setSourceData]
+ );
+
+ const components = useMemo(() => {
+ const allSources = getFlatSources(sources);
+
+ return allSources
+ .filter(it => selectedSources.has(it.id))
+ .filter(it => 'useData' in it)
+ .map(source => {
+ return {
+ props: {
+ useHook: source.useData,
+ onData: onData,
+ key: source.id,
+ id: source.id,
+ },
+ };
+ });
+ }, [sources, selectedSources]);
+
+ const contextValue = useThrottledMemo(
+ () => {
+ const nodes: GraphNode[] = [];
+ const edges: GraphEdge[] = [];
+
+ selectedSources.forEach(id => {
+ const data = sourceData.get(id);
+ nodes.push(...(data?.nodes ?? []));
+ edges.push(...(data?.edges ?? []));
+ });
+
+ const isLoading =
+ sourceData.size === 0 ||
+ selectedSources?.values()?.some?.(source => sourceData.get(source) === null);
+
+ return {
+ nodes,
+ edges,
+ toggleSelection,
+ setSelectedSources,
+ selectedSources,
+ sourceData,
+ isLoading,
+ };
+ },
+ [sources, selectedSources, sourceData, setSelectedSources],
+ 500
+ );
+
+ return (
+ <>
+ {components.map(it => (
+
+ ))}
+ {children}
+ >
+ );
+}
diff --git a/frontend/src/components/resourceMap/sources/GraphSourcesView.tsx b/frontend/src/components/resourceMap/sources/GraphSourcesView.tsx
new file mode 100644
index 00000000000..e8327370aad
--- /dev/null
+++ b/frontend/src/components/resourceMap/sources/GraphSourcesView.tsx
@@ -0,0 +1,228 @@
+import { Icon } from '@iconify/react';
+import {
+ alpha,
+ Badge,
+ Box,
+ Checkbox,
+ Chip,
+ CircularProgress,
+ Popover,
+ Stack,
+ styled,
+ Typography,
+} from '@mui/material';
+import { memo, useState } from 'react';
+import { GraphSource } from '../graph/graphModel';
+import { SourceData } from './GraphSources';
+
+const Node = styled('div')(() => ({
+ display: 'flex',
+ flexDirection: 'column',
+}));
+
+const NodeHeader = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderRadius: theme.spacing(1),
+ paddingLeft: theme.spacing(0.5),
+ paddingRight: theme.spacing(0.5),
+ paddingTop: theme.spacing(0.5),
+ paddingBottom: theme.spacing(0.5),
+
+ ':hover': {
+ background: theme.palette.action.hover,
+ },
+ ':active': {
+ background: alpha(theme.palette.action.active, theme.palette.action.activatedOpacity),
+ },
+}));
+
+/**
+ * Component that displays a Source and allows to check or uncheck it
+ * and its' descendants
+ *
+ * @returns
+ */
+function GraphSourceView({
+ source,
+ sourceData,
+ selection,
+ activeItemId,
+ setActiveItemId,
+ toggleSelection,
+}: {
+ /** Source definition */
+ source: GraphSource;
+ /** Loaded data for the sources */
+ sourceData: SourceData;
+ /** Set of selected source ids */
+ selection: Set;
+ /** Active (exapnded) source */
+ activeItemId: string | undefined;
+ toggleSelection: (source: GraphSource) => void;
+ setActiveItemId: (id: string | undefined) => void;
+}) {
+ const hasChildren = 'sources' in source;
+ const isSelected = (source: GraphSource): boolean =>
+ 'sources' in source ? source.sources.every(s => isSelected(s)) : selection.has(source.id);
+ const isChecked = isSelected(source);
+ const intermediate = 'sources' in source && source.sources.some(s => isSelected(s)) && !isChecked;
+
+ const data = sourceData.get(source.id);
+
+ const check = (
+ <>
+
+
+
+ {source.icon}
+
+
+
+ {source.label}
+ {!('sources' in source) && isChecked && !data && }
+ ({ marginLeft: 'auto' })}
+ checked={isChecked}
+ indeterminate={intermediate}
+ onClick={e => {
+ e.stopPropagation();
+ toggleSelection(source);
+ }}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.stopPropagation();
+ e.preventDefault();
+ toggleSelection(source);
+ }
+ }}
+ />
+ >
+ );
+
+ if (!('sources' in source)) {
+ return (
+ {
+ toggleSelection(source);
+ }}
+ >
+ {check}
+
+ );
+ }
+
+ const isActive = source.id === activeItemId;
+
+ return (
+
+ setActiveItemId(isActive ? undefined : source.id)}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setActiveItemId(isActive ? undefined : source.id);
+ }
+ }}
+ >
+
+
+ {check}
+
+
+
+ {source.id === activeItemId &&
+ source.sources?.map(source => (
+
+ ))}
+
+
+ );
+}
+
+export interface GraphSourcesViewProps {
+ /** List of sources to render */
+ sources: GraphSource[];
+ /** Data for each source */
+ sourceData: SourceData;
+ /** Selected sources */
+ selectedSources: Set;
+ /** Callback when a source is toggled */
+ toggleSource: (source: GraphSource) => void;
+}
+
+export const GraphSourcesView = memo(
+ ({ sources, sourceData, selectedSources, toggleSource }: GraphSourcesViewProps) => {
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [activeItemId, setActiveItemId] = useState(undefined);
+
+ const selected = sources.filter(source => {
+ const isSelected = selectedSources.has(source.id);
+ return 'sources' in source
+ ? source.sources.some(it => selectedSources.has(it.id))
+ : isSelected;
+ });
+ const selectedText =
+ selected.length > 2
+ ? `${selected[0].label}, ${selected[1].label}, +${selected.length - 2}`
+ : selected.map(it => it.label).join(', ');
+
+ return (
+ <>
+
+ {selectedText}{' '}
+
+ }
+ color="primary"
+ variant={'filled'}
+ onClick={e => setAnchorEl(e.currentTarget)}
+ sx={{
+ lineHeight: '1',
+ }}
+ />
+ setAnchorEl(null)}
+ anchorEl={anchorEl}
+ open={Boolean(anchorEl)}
+ >
+
+ {sources.map((source, index) => (
+ setActiveItemId(id)}
+ />
+ ))}
+
+
+ >
+ );
+ }
+);
diff --git a/frontend/src/components/resourceMap/sources/definitions/configurationSource.tsx b/frontend/src/components/resourceMap/sources/definitions/configurationSource.tsx
new file mode 100644
index 00000000000..f58ccebcca1
--- /dev/null
+++ b/frontend/src/components/resourceMap/sources/definitions/configurationSource.tsx
@@ -0,0 +1,208 @@
+import { Icon } from '@iconify/react';
+import { useMemo } from 'react';
+import ConfigMap from '../../../../lib/k8s/configMap';
+import Job from '../../../../lib/k8s/job';
+import MutatingWebhookConfiguration from '../../../../lib/k8s/mutatingWebhookConfiguration';
+import Pod from '../../../../lib/k8s/pod';
+import Secret from '../../../../lib/k8s/secret';
+import Service from '../../../../lib/k8s/service';
+import ValidatingWebhookConfiguration from '../../../../lib/k8s/validatingWebhookConfiguration';
+import { GraphEdge, GraphSource } from '../../graph/graphModel';
+import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon';
+import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources';
+
+const secretsSource: GraphSource = {
+ id: 'secrets',
+ label: 'Secrets',
+ icon: ,
+ isEnabledByDefault: false,
+ useData() {
+ const [secrets] = Secret.useList();
+
+ const [pods] = Pod.useList();
+
+ return useMemo(() => {
+ if (!secrets || !pods) return null;
+
+ const edges: GraphEdge[] = [];
+
+ // find used secrets
+ pods.forEach(pod => {
+ // container env
+ pod.spec.containers.forEach(container => {
+ container.env?.forEach(env => {
+ if (env.valueFrom?.secretKeyRef) {
+ const secret = secrets.find(
+ secret => secret.metadata.name === env.valueFrom?.secretKeyRef?.name
+ );
+ if (secret) {
+ if (
+ edges.find(it => it.id === `${secret.metadata.uid}-${pod.metadata.uid}`) ===
+ undefined
+ ) {
+ edges.push(makeKubeToKubeEdge(secret, pod));
+ }
+ }
+ }
+ });
+ });
+
+ // volumes projected sources
+ pod.spec.volumes?.forEach(volume => {
+ if (volume.projected) {
+ volume.projected.sources.forEach((source: any) => {
+ if (source.secret) {
+ const secret = secrets.find(secret => secret.metadata.name === source.secret!.name);
+ if (secret) {
+ edges.push(makeKubeToKubeEdge(secret, pod));
+ }
+ }
+ });
+ }
+ });
+ });
+
+ return {
+ nodes: secrets.map(makeKubeObjectNode) ?? [],
+ edges,
+ };
+ }, [pods, secrets]);
+ },
+};
+
+const configMapsSource: GraphSource = {
+ id: 'configMaps',
+ label: 'Config Maps',
+ isEnabledByDefault: false,
+ icon: ,
+ useData() {
+ const [configMaps] = ConfigMap.useList();
+ const [pods] = Pod.useList();
+ const [jobs] = Job.useList();
+
+ return useMemo(() => {
+ if (!configMaps || !pods || !jobs) return null;
+
+ const edges: GraphEdge[] = [];
+
+ // find used configmaps
+ pods.forEach(pod => {
+ pod.spec.volumes?.forEach(volume => {
+ if (volume.configMap) {
+ const cm = configMaps.find(cm => cm.metadata.name === volume.configMap!.name);
+ if (cm) {
+ edges.push(makeKubeToKubeEdge(cm, pod));
+ }
+ }
+ });
+ });
+
+ // in jobs
+ jobs.forEach(job => {
+ job.spec.template.spec.volumes?.forEach(volume => {
+ if (volume.configMap) {
+ const cm = configMaps.find(cm => cm.metadata.name === volume.configMap!.name);
+ if (cm) {
+ edges.push(makeKubeToKubeEdge(cm, job));
+ }
+ }
+ });
+ });
+
+ return {
+ nodes: configMaps.map(makeKubeObjectNode) ?? [],
+ edges,
+ };
+ }, [configMaps, pods, jobs]);
+ },
+};
+
+const validatingWebhookConfigurationSource: GraphSource = {
+ id: 'validatingWebhookConfigurations',
+ label: 'Validating Webhook Configurations',
+ icon: ,
+ isEnabledByDefault: false,
+ useData() {
+ const [vwc] = ValidatingWebhookConfiguration.useList();
+ const [services] = Service.useList();
+
+ return useMemo(() => {
+ if (!vwc || !services) return null;
+
+ const nodes = vwc.map(makeKubeObjectNode) ?? [];
+
+ const edges: GraphEdge[] = [];
+
+ vwc.forEach(vwc => {
+ vwc.webhooks.forEach(webhook => {
+ const service = services.find(
+ service => service.metadata.name === webhook.clientConfig.service?.name
+ );
+ if (service) {
+ edges.push(makeKubeToKubeEdge(service, vwc));
+ }
+ });
+ });
+
+ return { nodes, edges };
+ }, [vwc, services]);
+ },
+};
+
+const mutatingWebhookConfigurationSource: GraphSource = {
+ id: 'mutatingWebhookConfigurations',
+ label: 'Mutating Webhook Configurations',
+ icon: ,
+ isEnabledByDefault: false,
+ useData() {
+ const [mwc] = MutatingWebhookConfiguration.useList();
+ const [services] = Service.useList();
+
+ return useMemo(() => {
+ if (!mwc || !services) return null;
+
+ const edges: GraphEdge[] = [];
+
+ mwc.forEach(mwc => {
+ mwc.webhooks.forEach(webhook => {
+ const service = services.find(
+ service => service.metadata.name === webhook.clientConfig.service?.name
+ );
+ if (service) {
+ edges.push(makeKubeToKubeEdge(service, mwc));
+ }
+ });
+ });
+
+ return { nodes: mwc.map(makeKubeObjectNode) ?? [], edges };
+ }, [mwc, services]);
+ },
+};
+
+export const configurationSource: GraphSource = {
+ id: 'configuration',
+ label: 'Configuration',
+ icon: (
+
+ ),
+ sources: [
+ configMapsSource,
+ secretsSource,
+ // TODO: Implement the rest of resources
+ // hpa
+ // vpa
+ // pdb
+ // rq
+ // lr
+ // priorityClass
+ // runtimeClass
+ // leases
+ mutatingWebhookConfigurationSource,
+ validatingWebhookConfigurationSource,
+ ],
+};
diff --git a/frontend/src/components/resourceMap/sources/definitions/networkSource.tsx b/frontend/src/components/resourceMap/sources/definitions/networkSource.tsx
new file mode 100644
index 00000000000..eb00ff123e4
--- /dev/null
+++ b/frontend/src/components/resourceMap/sources/definitions/networkSource.tsx
@@ -0,0 +1,169 @@
+import { Icon } from '@iconify/react';
+import { useMemo } from 'react';
+import Endpoints from '../../../../lib/k8s/endpoints';
+import Ingress, { IngressRule } from '../../../../lib/k8s/ingress';
+import IngressClass from '../../../../lib/k8s/ingressClass';
+import NetworkPolicy from '../../../../lib/k8s/networkpolicy';
+import Pod from '../../../../lib/k8s/pod';
+import Secret from '../../../../lib/k8s/secret';
+import Service from '../../../../lib/k8s/service';
+import { GraphEdge, GraphSource } from '../../graph/graphModel';
+import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon';
+import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources';
+import { matchesSelector } from './workloadSource';
+
+const serviceSource: GraphSource = {
+ id: 'services',
+ label: 'Services',
+ icon: ,
+ useData() {
+ const [services] = Service.useList();
+ const [pods] = Pod.useList();
+
+ return useMemo(() => {
+ if (!services || !pods) return null;
+
+ const edges: GraphEdge[] = [];
+
+ services.forEach(service => {
+ const matchingPods = pods.filter(matchesSelector(service.spec.selector));
+
+ matchingPods?.forEach(pod => {
+ edges.push(makeKubeToKubeEdge(service, pod));
+ });
+ });
+
+ return {
+ edges,
+ nodes: services.map(makeKubeObjectNode) ?? [],
+ };
+ }, [services, pods]);
+ },
+};
+
+const endpointsSource: GraphSource = {
+ id: 'endpoints',
+ label: 'Endpoints',
+ icon: ,
+ useData() {
+ const [endpoints] = Endpoints.useList();
+ const [services] = Service.useList();
+
+ return useMemo(() => {
+ const nodes = endpoints?.map(makeKubeObjectNode) ?? [];
+ const edges: GraphEdge[] = [];
+
+ services?.forEach(service => {
+ endpoints?.forEach(endpoint => {
+ if (endpoint.getName() === service.getName()) {
+ edges.push(makeKubeToKubeEdge(service, endpoint));
+ }
+ });
+ });
+
+ return { nodes, edges };
+ }, [endpoints, services]);
+ },
+};
+
+const ingressListSource: GraphSource = {
+ id: 'ingressList',
+ label: 'Ingress',
+ icon: ,
+ useData() {
+ const [ingresses] = Ingress.useList();
+ const [services] = Service.useList();
+ const [secrets] = Secret.useList();
+
+ return useMemo(() => {
+ if (!ingresses || !services || !secrets) return null;
+
+ const edges: GraphEdge[] = [];
+
+ ingresses.forEach(ingress => {
+ ingress.spec.rules.forEach((rule: IngressRule) => {
+ rule.http.paths.forEach(path => {
+ const service = services.find(
+ service => service.metadata.name === path?.backend?.service?.name
+ );
+ if (service) {
+ edges.push(makeKubeToKubeEdge(service, ingress));
+ }
+ });
+ });
+
+ ingress.spec.tls?.forEach(tls => {
+ if (tls.secretName) {
+ const secret = secrets.find(secret => secret.metadata.name === tls.secretName);
+ if (secret) {
+ edges.push(makeKubeToKubeEdge(secret, ingress));
+ }
+ }
+ });
+ });
+
+ return {
+ edges,
+ nodes: ingresses.map(makeKubeObjectNode) ?? [],
+ };
+ }, [ingresses, services, secrets]);
+ },
+};
+
+const networkPoliciesSource: GraphSource = {
+ id: 'networkPolicies',
+ label: 'Network Policies',
+ icon: ,
+ useData() {
+ const [networkPolicies] = NetworkPolicy.useList();
+ const [pods] = Pod.useList();
+
+ return useMemo(() => {
+ if (!networkPolicies || !pods) return null;
+
+ const edges: GraphEdge[] = [];
+
+ networkPolicies.forEach(np => {
+ const matchingPods = pods.filter(matchesSelector(np.jsonData.spec.podSelector.matchLabels));
+
+ matchingPods?.forEach(pod => {
+ edges.push(makeKubeToKubeEdge(np, pod));
+ });
+ });
+
+ return {
+ nodes: networkPolicies.map(makeKubeObjectNode) ?? [],
+ edges,
+ };
+ }, [networkPolicies, pods]);
+ },
+};
+
+const ingressClassesSource: GraphSource = {
+ id: 'ingressClasses',
+ label: 'Ingress Classes',
+ icon: ,
+ useData() {
+ const [ingressClasses] = IngressClass.useList();
+
+ return useMemo(() => {
+ return {
+ nodes: ingressClasses?.map(makeKubeObjectNode) ?? [],
+ edges: [],
+ };
+ }, [ingressClasses]);
+ },
+};
+
+export const networkSource = {
+ id: 'network',
+ label: 'Network',
+ icon: ,
+ sources: [
+ serviceSource,
+ endpointsSource,
+ ingressListSource,
+ ingressClassesSource,
+ networkPoliciesSource,
+ ],
+};
diff --git a/frontend/src/components/resourceMap/sources/definitions/securitySource.tsx b/frontend/src/components/resourceMap/sources/definitions/securitySource.tsx
new file mode 100644
index 00000000000..e555e329e56
--- /dev/null
+++ b/frontend/src/components/resourceMap/sources/definitions/securitySource.tsx
@@ -0,0 +1,118 @@
+import { Icon } from '@iconify/react';
+import { useMemo } from 'react';
+import DaemonSet from '../../../../lib/k8s/daemonSet';
+import Deployment from '../../../../lib/k8s/deployment';
+import Role from '../../../../lib/k8s/role';
+import RoleBinding from '../../../../lib/k8s/roleBinding';
+import ServiceAccount from '../../../../lib/k8s/serviceAccount';
+import { GraphEdge, GraphSource } from '../../graph/graphModel';
+import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon';
+import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources';
+
+const rolesSource: GraphSource = {
+ id: 'roles',
+ label: 'Roles',
+ icon: ,
+ useData() {
+ const [roles] = Role.useList();
+
+ return useMemo(
+ () =>
+ roles
+ ? {
+ nodes: roles.map(makeKubeObjectNode) ?? [],
+ }
+ : null,
+ [roles]
+ );
+ },
+};
+
+const roleBindingsSource: GraphSource = {
+ id: 'roleBindings',
+ label: 'Role Bindings',
+ icon: ,
+ useData() {
+ const [roleBindings] = RoleBinding.useList();
+ const [roles] = Role.useList();
+ const [serviceAccounts] = ServiceAccount.useList();
+
+ return useMemo(() => {
+ if (!roleBindings || !roles || !serviceAccounts) return null;
+
+ const edges: GraphEdge[] = [];
+
+ roleBindings.forEach(roleBinding => {
+ const role = roles.find(role => role.metadata.name === roleBinding.roleRef.name);
+ if (role) {
+ edges.push(makeKubeToKubeEdge(role, roleBinding));
+ }
+
+ // subject
+ roleBinding.subjects.forEach(subject => {
+ if (subject.kind === 'ServiceAccount') {
+ const sa = serviceAccounts.find(sa => sa.metadata.name === subject.name);
+ if (sa) {
+ edges.push(makeKubeToKubeEdge(sa, roleBinding));
+ }
+ }
+ });
+ });
+
+ return {
+ nodes: roleBindings.map(makeKubeObjectNode) ?? [],
+ edges,
+ };
+ }, [roleBindings, roles, serviceAccounts]);
+ },
+};
+
+const serviceAccountsSource: GraphSource = {
+ id: 'serviceAccounts',
+ label: 'Service Accounts',
+ icon: ,
+ useData() {
+ const [serviceAccounts] = ServiceAccount.useList();
+ const [deployments] = Deployment.useList();
+ const [daemonSets] = DaemonSet.useList();
+
+ return useMemo(() => {
+ if (!serviceAccounts || !deployments || !daemonSets) return null;
+
+ const edges: GraphEdge[] = [];
+
+ serviceAccounts.forEach(sa => {
+ const matchingDeployments = deployments?.filter(
+ d =>
+ (d.spec?.template?.spec?.serviceAccountName ?? 'default') === sa.metadata.name &&
+ d.metadata.namespace === sa.metadata.namespace
+ );
+
+ matchingDeployments.forEach(d => {
+ edges.push(makeKubeToKubeEdge(sa, d));
+ });
+
+ daemonSets
+ ?.filter(
+ d =>
+ (d.spec?.template?.spec?.serviceAccountName ?? 'default') === sa.metadata.name &&
+ d.metadata.namespace === sa.metadata.namespace
+ )
+ .forEach(d => edges.push(makeKubeToKubeEdge(sa, d)));
+ });
+
+ return {
+ edges,
+ nodes: serviceAccounts.map(makeKubeObjectNode) ?? [],
+ };
+ }, [serviceAccounts, deployments, daemonSets]);
+ },
+};
+
+export const securitySource: GraphSource = {
+ id: 'security',
+ label: 'Security',
+ isEnabledByDefault: false,
+ icon: ,
+ sources: [serviceAccountsSource, rolesSource, roleBindingsSource],
+};
diff --git a/frontend/src/components/resourceMap/sources/definitions/storageSource.tsx b/frontend/src/components/resourceMap/sources/definitions/storageSource.tsx
new file mode 100644
index 00000000000..af60ef0b413
--- /dev/null
+++ b/frontend/src/components/resourceMap/sources/definitions/storageSource.tsx
@@ -0,0 +1,51 @@
+import { Icon } from '@iconify/react';
+import { useMemo } from 'react';
+import PersistentVolumeClaim from '../../../../lib/k8s/persistentVolumeClaim';
+import Pod from '../../../../lib/k8s/pod';
+import { GraphEdge, GraphSource } from '../../graph/graphModel';
+import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon';
+import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources';
+
+const pvcSource: GraphSource = {
+ id: 'pvcs',
+ label: 'PVCs',
+ icon: ,
+ useData() {
+ const [pvcs] = PersistentVolumeClaim.useList();
+ const [pods] = Pod.useList();
+
+ return useMemo(() => {
+ if (!pvcs || !pods) return null;
+
+ const edges: GraphEdge[] = [];
+
+ // find used pvc
+ pods.forEach(pod => {
+ pod.spec.volumes?.forEach(volume => {
+ if (volume.persistentVolumeClaim) {
+ const pvc = pvcs.find(
+ pvc => pvc.metadata.name === volume.persistentVolumeClaim!.claimName
+ );
+ if (pvc) {
+ edges.push(makeKubeToKubeEdge(pvc, pod));
+ }
+ }
+ });
+ });
+
+ return {
+ nodes: pvcs.map(makeKubeObjectNode) ?? [],
+ edges,
+ };
+ }, [pvcs, pods]);
+ },
+};
+
+export const storageSource: GraphSource = {
+ id: 'storage',
+ label: 'Storage',
+ icon: (
+
+ ),
+ sources: [pvcSource],
+};
diff --git a/frontend/src/components/resourceMap/sources/definitions/workloadSource.tsx b/frontend/src/components/resourceMap/sources/definitions/workloadSource.tsx
new file mode 100644
index 00000000000..0593cbf4929
--- /dev/null
+++ b/frontend/src/components/resourceMap/sources/definitions/workloadSource.tsx
@@ -0,0 +1,234 @@
+import { Icon } from '@iconify/react';
+import { useMemo } from 'react';
+import CronJob from '../../../../lib/k8s/cronJob';
+import DaemonSet from '../../../../lib/k8s/daemonSet';
+import Deployment from '../../../../lib/k8s/deployment';
+import Job from '../../../../lib/k8s/job';
+import { KubeObject } from '../../../../lib/k8s/KubeObject';
+import Node from '../../../../lib/k8s/node';
+import Pod from '../../../../lib/k8s/pod';
+import ReplicaSet from '../../../../lib/k8s/replicaSet';
+import Secret from '../../../../lib/k8s/secret';
+import StatefulSet from '../../../../lib/k8s/statefulSet';
+import { GraphEdge, GraphSource } from '../../graph/graphModel';
+import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon';
+import { kubeOwnersEdges, makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources';
+
+export const matchesSelector = (matchLabels: Record) => (item: KubeObject) => {
+ return (
+ matchLabels &&
+ item.metadata.labels &&
+ Object.entries(matchLabels).every(([key, value]) => item.metadata?.labels?.[key] === value)
+ );
+};
+
+const podsSource: GraphSource = {
+ id: 'pods',
+ label: 'Pods',
+ icon: ,
+ useData: () => {
+ const [pods] = Pod.useList();
+ const [nodes] = Node.useList();
+
+ return useMemo(() => {
+ if (!pods || !nodes) return null;
+
+ const edges: GraphEdge[] = [];
+
+ pods.forEach(pod => {
+ pod.metadata.ownerReferences?.forEach(owner => {
+ edges.push({
+ id: `${owner.uid}-${pod.metadata.uid}`,
+ type: 'kubeRelation',
+ source: owner.uid,
+ target: pod.metadata.uid,
+ });
+ });
+
+ const node = nodes.find(node => node.metadata.name === pod.spec.nodeName);
+
+ if (node) {
+ edges.push({
+ id: `${node.metadata.uid}-${pod.metadata.uid}`,
+ type: 'kubeRelation',
+ source: node.metadata.uid,
+ target: pod.metadata.uid,
+ });
+ }
+ });
+
+ return {
+ edges,
+ nodes:
+ pods.map(pod => ({
+ type: 'kubeObject',
+ id: pod.metadata.uid,
+ data: {
+ resource: pod,
+ },
+ })) ?? [],
+ };
+ }, [pods, nodes]);
+ },
+};
+
+const deploymentsSource: GraphSource = {
+ id: 'deployments',
+ label: 'Deployments',
+ icon: ,
+ useData() {
+ const [deployments] = Deployment.useList();
+
+ return useMemo(() => {
+ if (!deployments) return null;
+ return {
+ nodes: deployments?.map(makeKubeObjectNode) ?? [],
+ };
+ }, [deployments]);
+ },
+};
+
+const cronJobSource: GraphSource = {
+ id: 'cronJobs',
+ label: 'CronJobs',
+ icon: ,
+ useData() {
+ const [cronJobs] = CronJob.useList();
+
+ return useMemo(() => {
+ if (!cronJobs) return null;
+ return {
+ edges: [],
+ nodes: cronJobs?.map(it => makeKubeObjectNode(it)) ?? [],
+ };
+ }, [cronJobs]);
+ },
+};
+
+const jobsSource: GraphSource = {
+ id: 'jobs',
+ label: 'Jobs',
+ icon: ,
+ useData() {
+ const [jobs] = Job.useList();
+ const [secrets] = Secret.useList();
+
+ return useMemo(() => {
+ if (!jobs || !secrets) return null;
+
+ const edges: GraphEdge[] = [];
+
+ jobs?.forEach(job => {
+ edges.push(...kubeOwnersEdges(job));
+
+ job.spec.template.spec.containers.forEach(container => {
+ container.env?.forEach(env => {
+ if (env.valueFrom?.secretKeyRef) {
+ const secret = secrets?.find(
+ secret => secret.metadata.name === env.valueFrom?.secretKeyRef?.name
+ );
+ if (
+ secret &&
+ edges.find(it => it.id === `${secret.metadata.uid}-${job.metadata.uid}`) ===
+ undefined
+ ) {
+ edges.push(makeKubeToKubeEdge(secret, job));
+ }
+ }
+ });
+ });
+ });
+
+ return {
+ edges,
+ nodes:
+ jobs?.map(job => ({
+ type: 'kubeObject',
+ id: job.metadata.uid,
+ data: {
+ resource: job,
+ },
+ })) ?? [],
+ };
+ }, [jobs, secrets]);
+ },
+};
+
+const replicaSetsSource: GraphSource = {
+ id: 'replicaSets',
+ label: 'Replica Sets',
+ icon: ,
+ useData() {
+ const [replicaSets] = ReplicaSet.useList();
+
+ return useMemo(() => {
+ if (!replicaSets) return null;
+
+ const edges: GraphEdge[] = [];
+
+ replicaSets?.forEach(replicaSet => {
+ edges.push(...kubeOwnersEdges(replicaSet));
+ });
+
+ return {
+ edges,
+ nodes: replicaSets?.map(makeKubeObjectNode) ?? [],
+ };
+ }, [replicaSets]);
+ },
+};
+
+const statefulSetSource: GraphSource = {
+ id: 'statefulSets',
+ label: 'Stateful Sets',
+ icon: ,
+ useData() {
+ const [statefulSets] = StatefulSet.useList();
+
+ return useMemo(() => {
+ if (!statefulSets) return null;
+ return {
+ nodes: statefulSets?.map(makeKubeObjectNode) ?? [],
+ };
+ }, [statefulSets]);
+ },
+};
+
+const daemonSetSource: GraphSource = {
+ id: 'daemonSets',
+ label: 'Daemon Sets',
+ icon: ,
+ useData() {
+ const [daemonSets] = DaemonSet.useList();
+
+ return useMemo(() => {
+ if (!daemonSets) return null;
+
+ return {
+ nodes: daemonSets?.map(makeKubeObjectNode) ?? [],
+ };
+ }, [daemonSets]);
+ },
+};
+
+export const workloadsSource: GraphSource = {
+ id: 'workloads',
+ label: 'Workloads',
+ icon: (
+
+ ),
+ sources: [
+ podsSource,
+ deploymentsSource,
+ statefulSetSource,
+ daemonSetSource,
+ replicaSetsSource,
+ jobsSource,
+ cronJobSource,
+ ],
+};
diff --git a/frontend/src/components/resourceMap/useQueryParamsState.tsx b/frontend/src/components/resourceMap/useQueryParamsState.tsx
index c9f41fa6f71..23feb4945ff 100644
--- a/frontend/src/components/resourceMap/useQueryParamsState.tsx
+++ b/frontend/src/components/resourceMap/useQueryParamsState.tsx
@@ -27,7 +27,7 @@ export function useQueryParamsState(
const searchParams = new URLSearchParams(search);
const paramValue = searchParams.get(param);
- return paramValue !== null ? (decodeURIComponent(paramValue) as T) : initialState;
+ return paramValue !== null ? (decodeURIComponent(paramValue) as T) : undefined;
});
// Update the value from URL to state
@@ -62,9 +62,14 @@ export function useQueryParamsState(
history.push(newUrl);
}, [param, value]);
+ // Initi state with initial state value
+ useEffect(() => {
+ setValue(initialState);
+ }, []);
+
const handleSetValue = useCallback(
(newValue: T | undefined) => {
- if (typeof newValue !== 'string') {
+ if (newValue !== undefined && typeof newValue !== 'string') {
throw new Error("useQueryParamsState: Can't set a value to something that isn't a string");
}
setValue(newValue);
diff --git a/frontend/src/i18n/locales/de/glossary.json b/frontend/src/i18n/locales/de/glossary.json
index 3f66869c485..867d56c8296 100644
--- a/frontend/src/i18n/locales/de/glossary.json
+++ b/frontend/src/i18n/locales/de/glossary.json
@@ -141,6 +141,7 @@
"PriorityClass": "Prioritäts-Klasse",
"Replica Sets": "Replika-Sets",
"Generation": "Generation",
+ "Cluster IP": "Cluster-IP",
"Resource Quotas": "Ressourcen-Quotas",
"Reference Kind": "Referenzart",
"Reference Name": "Referenzname",
@@ -156,7 +157,6 @@
"Verbs": "Verben",
"RuntimeClass": "Laufzeit-Klasse",
"Secrets": "Secrets",
- "Cluster IP": "Cluster-IP",
"Services": "Dienste",
"Workloads": "Workloads",
"Stateful Sets": "Stateful Sets",
@@ -176,6 +176,7 @@
"Leases": "Leasingverträge",
"Mutating Webhook Configurations": "Mutierende Webhook-Konfigurationen",
"Validating Webhook Configurations": "Validierende Webhook-Konfigurationen",
+ "Map (beta)": "",
"Git Version": "Git-Version",
"Git Tree State": "Git-Tree-Status",
"Go Version": "Go Version",
diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json
index 37e6f52158d..040764054c1 100644
--- a/frontend/src/i18n/locales/de/translation.json
+++ b/frontend/src/i18n/locales/de/translation.json
@@ -375,6 +375,18 @@
"Preemption Policy": "Preemtions-Policy",
"Current//context:replicas": "Aktuell",
"Desired//context:replicas": "Gewünscht",
+ "Zoom in": "",
+ "Zoom out": "",
+ "Fit to screen": "",
+ "No data to be shown. Try to change filters or select a different namespace.": "",
+ "Group By: {{ name }}": "",
+ "Namespace": "",
+ "Instance": "",
+ "Node": "",
+ "Status: Error or Warning": "",
+ "Expand All": "",
+ "Zoom to 100%": "",
+ "Search": "",
"Used": "Genutzt",
"Hard": "Hart",
"Request": "Anfrage",
diff --git a/frontend/src/i18n/locales/en/glossary.json b/frontend/src/i18n/locales/en/glossary.json
index ee03bbe851f..fe69299f90c 100644
--- a/frontend/src/i18n/locales/en/glossary.json
+++ b/frontend/src/i18n/locales/en/glossary.json
@@ -141,6 +141,7 @@
"PriorityClass": "PriorityClass",
"Replica Sets": "Replica Sets",
"Generation": "Generation",
+ "Cluster IP": "Cluster IP",
"Resource Quotas": "Resource Quotas",
"Reference Kind": "Reference Kind",
"Reference Name": "Reference Name",
@@ -156,7 +157,6 @@
"Verbs": "Verbs",
"RuntimeClass": "RuntimeClass",
"Secrets": "Secrets",
- "Cluster IP": "Cluster IP",
"Services": "Services",
"Workloads": "Workloads",
"Stateful Sets": "Stateful Sets",
@@ -176,6 +176,7 @@
"Leases": "Leases",
"Mutating Webhook Configurations": "Mutating Webhook Configurations",
"Validating Webhook Configurations": "Validating Webhook Configurations",
+ "Map (beta)": "Map (beta)",
"Git Version": "Git Version",
"Git Tree State": "Git Tree State",
"Go Version": "Go Version",
diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json
index 8049d96c39e..06eff75b14f 100644
--- a/frontend/src/i18n/locales/en/translation.json
+++ b/frontend/src/i18n/locales/en/translation.json
@@ -375,6 +375,18 @@
"Preemption Policy": "Preemption Policy",
"Current//context:replicas": "Current",
"Desired//context:replicas": "Desired",
+ "Zoom in": "Zoom in",
+ "Zoom out": "Zoom out",
+ "Fit to screen": "Fit to screen",
+ "No data to be shown. Try to change filters or select a different namespace.": "No data to be shown. Try to change filters or select a different namespace.",
+ "Group By: {{ name }}": "Group By: {{ name }}",
+ "Namespace": "Namespace",
+ "Instance": "Instance",
+ "Node": "Node",
+ "Status: Error or Warning": "Status: Error or Warning",
+ "Expand All": "Expand All",
+ "Zoom to 100%": "Zoom to 100%",
+ "Search": "Search",
"Used": "Used",
"Hard": "Hard",
"Request": "Request",
diff --git a/frontend/src/i18n/locales/es/glossary.json b/frontend/src/i18n/locales/es/glossary.json
index dae91a0965f..c577910684a 100644
--- a/frontend/src/i18n/locales/es/glossary.json
+++ b/frontend/src/i18n/locales/es/glossary.json
@@ -141,6 +141,7 @@
"PriorityClass": "PriorityClass",
"Replica Sets": "Replica Sets",
"Generation": "Generación",
+ "Cluster IP": "IP del Cluster",
"Resource Quotas": "Cuotas de Recurso",
"Reference Kind": "«Kind» de la referencia",
"Reference Name": "Nombre de la referencia",
@@ -156,7 +157,6 @@
"Verbs": "Verbos",
"RuntimeClass": "RuntimeClass",
"Secrets": "Secrets",
- "Cluster IP": "IP del Cluster",
"Services": "Services",
"Workloads": "Cargas de Trabajo",
"Stateful Sets": "Stateful Sets",
@@ -176,6 +176,7 @@
"Leases": "Leases",
"Mutating Webhook Configurations": "Mutating Webhook Configurations",
"Validating Webhook Configurations": "Validating Webhook Configurations",
+ "Map (beta)": "",
"Git Version": "Versión de Git",
"Git Tree State": "Estado del Árbol de Git",
"Go Version": "Versión de Go",
diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json
index 9b656492765..35348771510 100644
--- a/frontend/src/i18n/locales/es/translation.json
+++ b/frontend/src/i18n/locales/es/translation.json
@@ -376,6 +376,18 @@
"Preemption Policy": "Política de \"Preemption\"",
"Current//context:replicas": "Actuales",
"Desired//context:replicas": "Deseadas",
+ "Zoom in": "",
+ "Zoom out": "",
+ "Fit to screen": "",
+ "No data to be shown. Try to change filters or select a different namespace.": "",
+ "Group By: {{ name }}": "",
+ "Namespace": "",
+ "Instance": "",
+ "Node": "",
+ "Status: Error or Warning": "",
+ "Expand All": "",
+ "Zoom to 100%": "",
+ "Search": "",
"Used": "Usado",
"Hard": "Duro",
"Request": "Solicitud",
diff --git a/frontend/src/i18n/locales/fr/glossary.json b/frontend/src/i18n/locales/fr/glossary.json
index a38b31f130b..5016b4448ae 100644
--- a/frontend/src/i18n/locales/fr/glossary.json
+++ b/frontend/src/i18n/locales/fr/glossary.json
@@ -141,6 +141,7 @@
"PriorityClass": "PriorityClass",
"Replica Sets": "Replica Sets",
"Generation": "Génération",
+ "Cluster IP": "IP cluster",
"Resource Quotas": "Resource Quotas",
"Reference Kind": "Type de référence",
"Reference Name": "Nom de référence",
@@ -156,7 +157,6 @@
"Verbs": "Verbes",
"RuntimeClass": "RuntimeClass",
"Secrets": "Secrets",
- "Cluster IP": "IP cluster",
"Services": "Services",
"Workloads": "Charges de travail",
"Stateful Sets": "Stateful Sets",
@@ -176,6 +176,7 @@
"Leases": "Baux",
"Mutating Webhook Configurations": "Mutating Webhook Configurations",
"Validating Webhook Configurations": "Validating Webhook Configurations",
+ "Map (beta)": "",
"Git Version": "Version Git",
"Git Tree State": "État de l'arbre Git",
"Go Version": "Version de Go",
diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json
index 519a0221516..68db9c1c3ce 100644
--- a/frontend/src/i18n/locales/fr/translation.json
+++ b/frontend/src/i18n/locales/fr/translation.json
@@ -376,6 +376,18 @@
"Preemption Policy": "Politique de préemption",
"Current//context:replicas": "Actuels",
"Desired//context:replicas": "Souhaités",
+ "Zoom in": "",
+ "Zoom out": "",
+ "Fit to screen": "",
+ "No data to be shown. Try to change filters or select a different namespace.": "",
+ "Group By: {{ name }}": "",
+ "Namespace": "",
+ "Instance": "",
+ "Node": "",
+ "Status: Error or Warning": "",
+ "Expand All": "",
+ "Zoom to 100%": "",
+ "Search": "",
"Used": "Utilisé",
"Hard": "Dur",
"Request": "Demande",
diff --git a/frontend/src/i18n/locales/pt/glossary.json b/frontend/src/i18n/locales/pt/glossary.json
index 40dc6db9a16..fdf7e072ca5 100644
--- a/frontend/src/i18n/locales/pt/glossary.json
+++ b/frontend/src/i18n/locales/pt/glossary.json
@@ -141,6 +141,7 @@
"PriorityClass": "PriorityClass",
"Replica Sets": "Replica Sets",
"Generation": "Geração",
+ "Cluster IP": "IP do Cluster",
"Resource Quotas": "Resource Quotas",
"Reference Kind": "Reference Kind",
"Reference Name": "Reference Name",
@@ -156,7 +157,6 @@
"Verbs": "Verbos",
"RuntimeClass": "RuntimeClass",
"Secrets": "Secrets",
- "Cluster IP": "IP do Cluster",
"Services": "Services",
"Workloads": "Workloads",
"Stateful Sets": "Stateful Sets",
@@ -176,6 +176,7 @@
"Leases": "Leases",
"Mutating Webhook Configurations": "Mutating Webhook Configurations",
"Validating Webhook Configurations": "Validating Webhook Configurations",
+ "Map (beta)": "",
"Git Version": "Versão do Git",
"Git Tree State": "Estado de Árvore do Git",
"Go Version": "Versão do Go",
diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json
index a55df62fd25..f7aa198d493 100644
--- a/frontend/src/i18n/locales/pt/translation.json
+++ b/frontend/src/i18n/locales/pt/translation.json
@@ -376,6 +376,18 @@
"Preemption Policy": "Política de \"Preemption\"",
"Current//context:replicas": "Actuais",
"Desired//context:replicas": "Desejadas",
+ "Zoom in": "",
+ "Zoom out": "",
+ "Fit to screen": "",
+ "No data to be shown. Try to change filters or select a different namespace.": "",
+ "Group By: {{ name }}": "",
+ "Namespace": "",
+ "Instance": "",
+ "Node": "",
+ "Status: Error or Warning": "",
+ "Expand All": "",
+ "Zoom to 100%": "",
+ "Search": "",
"Used": "Usado",
"Hard": "Rígido",
"Request": "Pedido",
diff --git a/frontend/src/lib/router.tsx b/frontend/src/lib/router.tsx
index bc2b98ba3d6..dd533784b25 100644
--- a/frontend/src/lib/router.tsx
+++ b/frontend/src/lib/router.tsx
@@ -119,8 +119,14 @@ export interface Route {
hideAppBar?: boolean;
/** Whether the route should be disabled (not registered). */
disabled?: boolean;
+ /** Render route for full width */
+ isFullWidth?: boolean;
}
+const LazyGraphView = React.lazy(() =>
+ import('../components/resourceMap/GraphView').then(it => ({ default: it.GraphView }))
+);
+
const defaultRoutes: {
[routeName: string]: Route;
} = {
@@ -787,6 +793,14 @@ const defaultRoutes: {
disabled: !helpers.isElectron(),
component: () => ,
},
+ map: {
+ path: '/map',
+ exact: true,
+ name: 'Map (beta)',
+ sidebar: 'map',
+ isFullWidth: true,
+ component: () => ,
+ },
};
// The NotFound route needs to be considered always in the last place when used
diff --git a/frontend/src/redux/actions/actions.tsx b/frontend/src/redux/actions/actions.tsx
index 46a352accde..ece0ef52f68 100644
--- a/frontend/src/redux/actions/actions.tsx
+++ b/frontend/src/redux/actions/actions.tsx
@@ -7,6 +7,7 @@ export const UI_HIDE_APP_BAR = 'UI_HIDE_APP_BAR';
export const UI_FUNCTIONS_OVERRIDE = 'UI_FUNCTIONS_OVERRIDE';
export const UI_VERSION_DIALOG_OPEN = 'UI_VERSION_DIALOG_OPEN';
export const UI_INITIALIZE_PLUGIN_VIEWS = 'UI_INITIALIZE_PLUGIN_VIEWS';
+export const UI_SET_IS_FULLWIDTH = 'UI_SET_IS_FULLWIDTH';
export interface BrandingProps {
logo: AppLogoType;
@@ -36,3 +37,7 @@ export type FunctionsToOverride = {
export function setFunctionsToOverride(override: FunctionsToOverride) {
return { type: UI_FUNCTIONS_OVERRIDE, override };
}
+
+export function setIsFullWidth(isFullWidth?: boolean) {
+ return { type: UI_SET_IS_FULLWIDTH, isFullWidth };
+}
diff --git a/frontend/src/redux/reducers/ui.tsx b/frontend/src/redux/reducers/ui.tsx
index bee11f5ef5f..3f3091e274f 100644
--- a/frontend/src/redux/reducers/ui.tsx
+++ b/frontend/src/redux/reducers/ui.tsx
@@ -7,6 +7,7 @@ import {
UI_HIDE_APP_BAR,
UI_INITIALIZE_PLUGIN_VIEWS,
UI_SET_CLUSTER_CHOOSER_BUTTON,
+ UI_SET_IS_FULLWIDTH,
UI_VERSION_DIALOG_OPEN,
} from '../actions/actions';
@@ -14,12 +15,14 @@ export interface UIState {
isVersionDialogOpen: boolean;
clusterChooserButtonComponent?: ClusterChooserType;
hideAppBar?: boolean;
+ isFullWidth?: boolean;
functionsToOverride: FunctionsToOverride;
}
export const INITIAL_STATE: UIState = {
isVersionDialogOpen: false,
hideAppBar: false,
+ isFullWidth: false,
functionsToOverride: {},
};
@@ -53,6 +56,10 @@ function reducer(state = _.cloneDeep(INITIAL_STATE), action: Action) {
}
break;
}
+ case UI_SET_IS_FULLWIDTH: {
+ newFilters.isFullWidth = action.isFullWidth;
+ break;
+ }
default:
return state;
}
diff --git a/plugins/headlamp-plugin/package-lock.json b/plugins/headlamp-plugin/package-lock.json
index 97686fa8147..29ed261fa93 100644
--- a/plugins/headlamp-plugin/package-lock.json
+++ b/plugins/headlamp-plugin/package-lock.json
@@ -19,6 +19,7 @@
"@babel/preset-env": "^7.11.0",
"@babel/preset-react": "^7.10.4",
"@babel/preset-typescript": "^7.10.4",
+ "@dagrejs/dagre": "^1.1.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlamp-k8s/eslint-config": "^0.6.0",
@@ -71,6 +72,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/xterm": "^5.5.0",
+ "@xyflow/react": "^12.2.0",
"babel-loader": "^8.2.5",
"base64-arraybuffer": "^1.0.2",
"buffer": "^6.0.3",
@@ -79,6 +81,7 @@
"cross-env": "^7.0.3",
"crypto-browserify": "^3.12.0",
"css-loader": "^6.7.1",
+ "elkjs": "^0.9.3",
"env-paths": "^2.2.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
@@ -148,6 +151,7 @@
"util": "^0.12.4",
"validate-npm-package-name": "^3.0.0",
"vm-browserify": "^1.1.2",
+ "web-worker": "^1.3.0",
"webpack": "^5.66.0",
"webpack-cli": "^4.9.0",
"ws": "^8.16.0",
@@ -2599,6 +2603,24 @@
"postcss-selector-parser": "^6.0.10"
}
},
+ "node_modules/@dagrejs/dagre": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
+ "integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dagrejs/graphlib": "2.2.4"
+ }
+ },
+ "node_modules/@dagrejs/graphlib": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz",
+ "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">17.0.0"
+ }
+ },
"node_modules/@discoveryjs/json-ext": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@@ -7617,6 +7639,15 @@
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
@@ -7643,6 +7674,12 @@
"@types/d3-time": "*"
}
},
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-shape": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
@@ -7661,6 +7698,25 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -8748,6 +8804,36 @@
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
},
+ "node_modules/@xyflow/react": {
+ "version": "12.3.2",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.3.2.tgz",
+ "integrity": "sha512-+bK3L61BDIvUX++jMiEqIjy5hIIyVmfeiUavpeOZIYKwg6NW0pR5EnHJM2JFfkVqZisFauzS9EgmI+tvTqx9Qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.43",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.43",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.43.tgz",
+ "integrity": "sha512-1zHgad1cWr1mKm2xbFaarK0Jg8WRgaQ8ubSBIo/pRdq3fEgCuqgNkL9NSAP6Rvm8zi3+Lu4JPUMN+EEx5QgX9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
@@ -10554,6 +10640,12 @@
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz",
"integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA=="
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/clean-css": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
@@ -11582,6 +11674,28 @@
"node": ">=12"
}
},
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -11632,6 +11746,15 @@
"node": ">=12"
}
},
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -11673,6 +11796,41 @@
"node": ">=12"
}
},
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -12281,6 +12439,12 @@
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.32.tgz",
"integrity": "sha512-M+7ph0VGBQqqpTT2YrabjNKSQ2fEl9PVx6AK3N558gDH9NO8O6XN9SXXFWRo9u9PbEg/bWq+tjXQr+eXmxubCw=="
},
+ "node_modules/elkjs": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
+ "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
+ "license": "EPL-2.0"
+ },
"node_modules/elliptic": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz",
@@ -30872,6 +31036,12 @@
"minimalistic-assert": "^1.0.0"
}
},
+ "node_modules/web-worker": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz",
+ "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==",
+ "license": "Apache-2.0"
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -31970,6 +32140,34 @@
"node": ">= 6"
}
},
+ "node_modules/zustand": {
+ "version": "4.5.5",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.5.tgz",
+ "integrity": "sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
diff --git a/plugins/headlamp-plugin/package.json b/plugins/headlamp-plugin/package.json
index 2dfd7492b33..78e428a21f6 100644
--- a/plugins/headlamp-plugin/package.json
+++ b/plugins/headlamp-plugin/package.json
@@ -23,6 +23,7 @@
"@babel/preset-env": "^7.11.0",
"@babel/preset-react": "^7.10.4",
"@babel/preset-typescript": "^7.10.4",
+ "@dagrejs/dagre": "^1.1.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlamp-k8s/eslint-config": "^0.6.0",
@@ -75,6 +76,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/xterm": "^5.5.0",
+ "@xyflow/react": "^12.2.0",
"babel-loader": "^8.2.5",
"base64-arraybuffer": "^1.0.2",
"buffer": "^6.0.3",
@@ -83,6 +85,7 @@
"cross-env": "^7.0.3",
"crypto-browserify": "^3.12.0",
"css-loader": "^6.7.1",
+ "elkjs": "^0.9.3",
"env-paths": "^2.2.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
@@ -152,6 +155,7 @@
"util": "^0.12.4",
"validate-npm-package-name": "^3.0.0",
"vm-browserify": "^1.1.2",
+ "web-worker": "^1.3.0",
"webpack": "^5.66.0",
"webpack-cli": "^4.9.0",
"ws": "^8.16.0",