@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
236 lines (235 loc) • 12.4 kB
JavaScript
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 }) }) }) }));
}