UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

236 lines (235 loc) 12.4 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; /* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import '@xyflow/react/dist/base.css'; import './GraphView.css'; import { Icon } from '@iconify/react'; import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import { styled } from '@mui/material/styles'; import ThemeProvider from '@mui/system/ThemeProvider'; import { Panel, ReactFlowProvider } from '@xyflow/react'; import { createContext, StrictMode, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import Namespace from '../../lib/k8s/namespace'; import K8sNode from '../../lib/k8s/node'; import { setNamespaceFilter } from '../../redux/filterSlice'; import { useTypedSelector } from '../../redux/reducers/reducers'; import { NamespacesAutocomplete } from '../common'; import { GraphNodeDetails } from './details/GraphNodeDetails'; import { filterGraph } from './graph/graphFiltering'; import { collapseGraph, findGroupContaining, getGraphSize, groupGraph, } from './graph/graphGrouping'; import { applyGraphLayout } from './graph/graphLayout'; import { makeGraphLookup } from './graph/graphLookup'; import { forEachNode } from './graph/graphModel'; import { GraphControlButton } from './GraphControls'; import { GraphRenderer } from './GraphRenderer'; import { SelectionBreadcrumbs } from './SelectionBreadcrumbs'; import { kubeObjectRelations } from './sources/definitions/relations'; import { allSources } from './sources/definitions/sources'; import { GraphSourceManager, useSources } from './sources/GraphSources'; import { GraphSourcesView } from './sources/GraphSourcesView'; import { useGraphViewport } from './useGraphViewport'; import { useQueryParamsState } from './useQueryParamsState'; export const GraphViewContext = createContext({}); export const useGraphView = () => useContext(GraphViewContext); export const FullGraphContext = createContext({}); export const useFullGraphContext = () => useContext(FullGraphContext); export const useNode = (id) => { const { lookup } = useFullGraphContext(); return lookup.getNode(id); }; const defaultFiltersValue = []; const ChipGroup = styled(Box)({ display: 'flex', '.MuiChip-root': { borderRadius: 0, }, '.MuiChip-root:first-child': { borderRadius: '16px 0 0 16px', }, '.MuiChip-root:last-child': { borderRadius: '0 16px 16px 0', }, }); function GraphViewContent({ height, defaultNodeSelection, defaultSources = allSources, defaultFilters = defaultFiltersValue, }) { const { t } = useTranslation(); const dispatch = useDispatch(); // List of selected namespaces const namespaces = useTypedSelector(state => state.filter).namespaces; // Sync namespace and URL const [namespacesParam] = useQueryParamsState('namespace', ''); useEffect(() => { const list = namespacesParam?.split(' ') ?? []; dispatch(setNamespaceFilter(list)); }, [namespacesParam, dispatch]); // 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) => { 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: [], edges: [], }); // Apply filters const filteredGraph = useMemo(() => { const filters = [...defaultFilters]; if (hasErrorsFilter) { filters.push({ type: 'hasErrors' }); } if (namespaces?.size > 0) { filters.push({ type: 'namespace', namespaces }); } return filterGraph(nodes, edges, filters); }, [nodes, edges, hasErrorsFilter, namespaces, defaultFilters]); // Group the graph const [allNamespaces] = Namespace.useList(); const [allNodes] = K8sNode.useList(); const { visibleGraph, fullGraph } = useMemo(() => { const graph = groupGraph(filteredGraph.nodes, filteredGraph.edges, { groupBy, namespaces: allNamespaces ?? [], k8sNodes: allNodes ?? [], }); const visibleGraph = collapseGraph(graph, { selectedNodeId, expandAll }); return { visibleGraph, fullGraph: graph }; }, [filteredGraph, groupBy, selectedNodeId, expandAll, allNamespaces]); const viewport = useGraphViewport(); useEffect(() => { applyGraphLayout(visibleGraph, viewport.aspectRatio).then(layout => { setLayoutedGraph(layout); // Only fit bounds when user hasn't moved viewport manually if (!viewportMovedRef.current) { viewport.updateViewport({ nodes: layout.nodes }); } }); }, [visibleGraph, viewport]); // Reset after view change useLayoutEffect(() => { viewportMovedRef.current = false; }, [selectedNodeId, groupBy, expandAll]); const selectedGroup = useMemo(() => { if (selectedNodeId) { return findGroupContaining(visibleGraph, selectedNodeId, true); } }, [selectedNodeId, visibleGraph, findGroupContaining]); const graphSize = getGraphSize(visibleGraph); useEffect(() => { if (expandAll && graphSize > 50) { setExpandAll(false); } }, [graphSize]); const contextValue = useMemo(() => ({ nodeSelection: selectedNodeId, setNodeSelection: setSelectedNodeId }), [selectedNodeId, setSelectedNodeId]); const fullGraphContext = useMemo(() => { let nodes = []; let edges = []; forEachNode(visibleGraph, node => { if (node.nodes) { nodes = nodes.concat(node.nodes); } if (node.edges) { edges = edges.concat(node.edges); } }); return { visibleGraph, lookup: makeGraphLookup(nodes, edges), }; }, [visibleGraph]); const maybeSelectedNode = selectedNodeId ? fullGraphContext.lookup.getNode(selectedNodeId) : undefined; return (_jsx(GraphViewContext.Provider, { value: contextValue, children: _jsx(FullGraphContext.Provider, { value: fullGraphContext, children: _jsxs(Box, { sx: { position: 'relative', height: height ?? '800px', display: 'flex', flexDirection: 'row', flex: 1, }, children: [_jsx(CustomThemeProvider, { children: _jsxs(Box, { sx: { overflow: 'hidden', display: 'flex', flexDirection: 'column', position: 'relative', flexGrow: 1, background: '#00000002', }, children: [_jsxs(Box, { padding: 2, pb: 0, display: "flex", gap: 1, alignItems: "center", mb: 1, flexWrap: "wrap", children: [_jsx(NamespacesAutocomplete, {}), _jsx(GraphSourcesView, { sources: defaultSources, selectedSources: selectedSources, toggleSource: toggleSelection, sourceData: sourceData ?? new Map() }), _jsx(Box, { sx: { fontSize: '14px', marginLeft: 1 }, children: t('Group By') }), _jsxs(ChipGroup, { children: [namespaces.size !== 1 && (_jsx(ChipToggleButton, { label: t('Namespace'), isActive: groupBy === 'namespace', onClick: () => setGroupBy(groupBy === 'namespace' ? undefined : 'namespace') })), _jsx(ChipToggleButton, { label: t('Instance'), isActive: groupBy === 'instance', onClick: () => setGroupBy(groupBy === 'instance' ? undefined : 'instance') }), _jsx(ChipToggleButton, { label: t('Node'), isActive: groupBy === 'node', onClick: () => setGroupBy(groupBy === 'node' ? undefined : 'node') })] }), _jsx(ChipToggleButton, { label: t('Status: Error or Warning'), isActive: hasErrorsFilter, onClick: () => setHasErrorsFilter(!hasErrorsFilter) }), graphSize < 50 && (_jsx(ChipToggleButton, { label: t('Expand All'), isActive: expandAll, onClick: () => setExpandAll(it => !it) }))] }), _jsx("div", { style: { flexGrow: 1 }, children: _jsx(GraphRenderer, { nodes: layoutedGraph.nodes, edges: layoutedGraph.edges, isLoading: isLoading, onMoveStart: e => { if (e === null) return; viewportMovedRef.current = true; }, controlActions: _jsxs(_Fragment, { children: [_jsx(GraphControlButton, { title: t('Fit to screen'), onClick: () => viewport.updateViewport({ mode: 'fit' }), children: _jsx(Icon, { icon: "mdi:fit-to-screen" }) }), _jsx(GraphControlButton, { title: t('Zoom to 100%'), onClick: () => viewport.updateViewport({ mode: '100%' }), children: "100%" })] }), children: _jsx(Panel, { position: "top-left", children: selectedGroup && (_jsx(SelectionBreadcrumbs, { graph: fullGraph, selectedNodeId: selectedNodeId, onNodeClick: id => setSelectedNodeId(id) })) }) }) })] }) }), maybeSelectedNode && (_jsx(GraphNodeDetails, { node: maybeSelectedNode, close: () => { setSelectedNodeId(selectedGroup?.id ?? defaultNodeSelection); } }))] }) }) })); } function ChipToggleButton({ label, isActive, onClick, }) { return (_jsx(Chip, { label: label, color: isActive ? 'primary' : undefined, variant: isActive ? 'filled' : 'outlined', icon: isActive ? _jsx(Icon, { icon: "mdi:check" }) : undefined, onClick: onClick, sx: { lineHeight: '1', } })); } function CustomThemeProvider({ children }) { return (_jsx(ThemeProvider, { theme: (outer) => ({ ...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: children })); } /** * Renders Map of Kubernetes resources * * @param params - Map parameters * @returns */ export function GraphView(props) { const propsSources = props.defaultSources ?? allSources; // Load plugin defined sources const pluginGraphSources = useTypedSelector(state => state.graphView.graphSources); const sources = useMemo(() => [...propsSources, ...pluginGraphSources], [propsSources, pluginGraphSources]); return (_jsx(StrictMode, { children: _jsx(ReactFlowProvider, { children: _jsx(GraphSourceManager, { sources: sources, relations: kubeObjectRelations, children: _jsx(GraphViewContent, { ...props, defaultSources: sources }) }) }) })); }