UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

169 lines (168 loc) 8.91 kB
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; import { createElement as _createElement } from "react"; /* * 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 { Icon } from '@iconify/react'; import { Tooltip } from '@mui/material'; import Autocomplete from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; import { useTheme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import React, { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { loadClusterSettings } from '../../helpers/clusterSettings'; import { useCluster, useClustersConf } from '../../lib/k8s'; import Namespace from '../../lib/k8s/namespace'; import { setNamespaceFilter } from '../../redux/filterSlice'; import { useTypedSelector } from '../../redux/reducers/reducers'; /** * addQuery will add a query parameter to the URL using history API. * * It will also remove the parameter if the value is the same as the default value. * If the tableName is provided, it will be added to the query string. * @param queryObj The query object to add to the URL. * @param queryParamDefaultObj The default query object to compare with. * @param history The history object from react-router. * @param location The location object from react-router. * @param tableName The table name to add to the query string. * @returns void */ function addQuery(queryObj, queryParamDefaultObj = {}, history, location, tableName = '') { const pathname = location.pathname; const searchParams = new URLSearchParams(location.search); if (!!tableName) { searchParams.set('tableName', tableName); } // Ensure that default values will not show up in the URL for (const key in queryObj) { const value = queryObj[key]; if (value !== queryParamDefaultObj[key]) { searchParams.set(key, value); } else { searchParams.delete(key); } } history.push({ pathname: pathname, search: searchParams.toString(), }); } export function PureNamespacesAutocomplete({ namespaceNames, onChange: onChangeFromProps, filter, }) { const theme = useTheme(); const { t } = useTranslation(['glossary', 'translation']); const [namespaceInput, setNamespaceInput] = React.useState(''); const maxNamespacesChars = 12; const onInputChange = (event, value, reason) => { // For some reason, the AutoComplete component resets the text after a short // delay, so we need to avoid that or the user won't be able to edit/use what they type. if (reason !== 'reset') { setNamespaceInput(value); } }; const onChange = (event, newValue) => { // Now we reset the input so it won't show next to the selected namespaces. setNamespaceInput(''); onChangeFromProps(event, newValue); }; return (_jsx(Autocomplete, { multiple: true, id: "namespaces-filter", autoComplete: true, openOnFocus: true, options: namespaceNames, onChange: onChange, onInputChange: onInputChange, inputValue: namespaceInput, // We reverse the namespaces so the last chosen appear as the first in the label. This // is useful since the label is ellipsized and this we get to see it change. value: [...filter.namespaces.values()].reverse(), renderOption: (props, option, { selected }) => (_createElement("li", { ...props, key: props.key }, _jsx(Checkbox, { icon: _jsx(Icon, { icon: "mdi:checkbox-blank-outline" }), checkedIcon: _jsx(Icon, { icon: "mdi:check-box-outline" }), style: { color: selected ? theme.palette.primary.main : theme.palette.text.primary, }, checked: selected }), option)), renderTags: (tags) => { if (tags.length === 0) { return _jsx(Typography, { variant: "body2", children: t('translation|All namespaces') }); } let namespacesToShow = tags[0]; const joiner = ', '; const joinerLength = joiner.length; let joinnedNamespaces = 1; const remainingTags = tags.slice(1); tags.slice(1).forEach(tag => { if (namespacesToShow.length + tag.length + joinerLength <= maxNamespacesChars) { namespacesToShow += joiner + tag; joinnedNamespaces++; } }); return (_jsxs(Typography, { style: { overflowWrap: 'anywhere' }, ml: 1, children: [namespacesToShow.length > maxNamespacesChars ? namespacesToShow.slice(0, maxNamespacesChars) + '…' : namespacesToShow, tags.length > joinnedNamespaces && (_jsxs(_Fragment, { children: [_jsx("span", { children: ",\u00A0" }), _jsx(Tooltip, { title: _jsx("ul", { style: { margin: 0, padding: 10, listStyle: 'none' }, children: remainingTags.map((tag, key) => (_jsx("li", { children: tag }, key))) }), arrow: true, placement: "top", children: _jsx("b", { style: { cursor: 'pointer' }, children: `+${tags.length - joinnedNamespaces}` }) })] }))] })); }, renderInput: params => (_jsx(Box, { width: "15rem", children: _jsx(TextField, { ...params, variant: "outlined", size: "small", label: t('Namespaces'), fullWidth: true, InputLabelProps: { shrink: true }, style: { marginTop: 0 }, placeholder: [...filter.namespaces.values()].length > 0 ? '' : 'Filter' }) })) })); } export function NamespacesAutocomplete() { const history = useHistory(); const location = useLocation(); const dispatch = useDispatch(); const filter = useTypedSelector(state => state.filter); const cluster = useCluster(); const [namespaceNames, setNamespaceNames] = React.useState([]); React.useEffect(() => { const settings = loadClusterSettings(cluster || ''); const allowedNamespaces = settings?.allowedNamespaces || []; if (allowedNamespaces.length > 0) { setNamespaceNames(allowedNamespaces); } }, [cluster]); const onChange = (event, newValue) => { addQuery({ namespace: newValue.join(' ') }, { namespace: '' }, history, location, ''); dispatch(setNamespaceFilter(newValue)); }; return namespaceNames.length > 0 ? (_jsx(PureNamespacesAutocomplete, { namespaceNames: namespaceNames, onChange: onChange, filter: filter })) : (_jsx(NamespacesFromClusterAutocomplete, { onChange: onChange, filter: filter })); } /** * This hook will try to select a namespace in a specific case * * If we failed to load namespaces it might be because the user * doesn't have access to list all the namespaces but still has * access to a specific namespace * * Sometimes in the kubeconfig there will be a default namespace set * which we can try to use as a fallback */ const useDefaultNamespaceFallback = (namespacesList, isNamespaceError) => { const selectedNamespaces = useTypedSelector(state => state.filter.namespaces); const allClustersConfigs = useClustersConf(); const currentCluster = useCluster(); const dispatch = useDispatch(); useEffect(() => { if (currentCluster && allClustersConfigs && isNamespaceError && (!namespacesList || namespacesList?.length === 0) && selectedNamespaces.size === 0) { const defaultNamespaceFromKubeconfig = allClustersConfigs[currentCluster]?.meta_data.namespace; if (defaultNamespaceFromKubeconfig) { dispatch(setNamespaceFilter([defaultNamespaceFromKubeconfig])); } } }, [namespacesList, isNamespaceError, currentCluster]); }; function NamespacesFromClusterAutocomplete(props) { const [namespacesList, error] = Namespace.useList(); const namespaceNames = useMemo(() => namespacesList ?.map(namespace => namespace.metadata.name) .slice() .sort((a, b) => a.localeCompare(b)) ?? [], [namespacesList]); useDefaultNamespaceFallback(namespacesList, Boolean(error)); return _jsx(PureNamespacesAutocomplete, { namespaceNames: namespaceNames, ...props }); }