UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

235 lines (234 loc) 13.5 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 { Icon, InlineIcon } from '@iconify/react'; import Autocomplete from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import ButtonBase from '@mui/material/ButtonBase'; import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import Container from '@mui/material/Container'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; import { useTheme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import useMediaQuery from '@mui/material/useMediaQuery'; import { visuallyHidden } from '@mui/utils'; import _ from 'lodash'; import React, { isValidElement } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { generatePath } from 'react-router'; import { useHistory } from 'react-router-dom'; import { getClusterAppearanceFromMeta } from '../../helpers/clusterAppearance'; import { isElectron } from '../../helpers/isElectron'; import { getRecentClusters, setRecentCluster } from '../../helpers/recentClusters'; import { useClustersConf } from '../../lib/k8s'; import { createRouteURL } from '../../lib/router/createRouteURL'; import { useShortcut } from '../../lib/useShortcut'; import { getCluster, getClusterPrefixedPath } from '../../lib/util'; import { useTypedSelector } from '../../redux/hooks'; import { uiSlice } from '../../redux/uiSlice'; import { AppLogo } from '../App/AppLogo'; import ActionButton from '../common/ActionButton'; import { DialogTitle } from '../common/Dialog'; import ErrorBoundary from '../common/ErrorBoundary'; import Loader from '../common/Loader'; import ClusterChooser from './ClusterChooser'; import ClusterChooserPopup from './ClusterChooserPopup'; export function ClusterTitle(props) { const { cluster, clusters, onClick } = props; const [anchorEl, setAnchorEl] = React.useState(null); const buttonRef = React.useRef(null); const arePluginsLoaded = useTypedSelector(state => state.plugins.loaded); const ChooserButton = useTypedSelector(state => state.ui.clusterChooserButtonComponent); useShortcut('CLUSTER_CHOOSER', () => { setAnchorEl(buttonRef.current); }); if (!cluster) { return null; } if (!arePluginsLoaded || _.isNull(ChooserButton)) { return null; } if (!ChooserButton && Object.keys(clusters || {}).length <= 1) { return null; } return (_jsxs(ErrorBoundary, { children: [ChooserButton ? (isValidElement(ChooserButton) ? (ChooserButton) : ( // eslint-disable-next-line react-hooks/static-components _jsx(ChooserButton, { clickHandler: e => { onClick && onClick(e); e?.currentTarget && setAnchorEl(e.currentTarget); }, cluster: cluster }))) : (_jsx(ClusterChooser, { ref: buttonRef, clickHandler: e => { onClick && onClick(e); e?.currentTarget && setAnchorEl(e.currentTarget); }, cluster: cluster, icon: getClusterAppearanceFromMeta(cluster).icon, accentColor: getClusterAppearanceFromMeta(cluster).accentColor })), _jsx(ClusterChooserPopup, { anchor: anchorEl, onClose: () => setAnchorEl(null) })] })); } function ClusterButton(props) { const theme = useTheme(); const { cluster, onClick = undefined, focusedRef } = props; const appearance = getClusterAppearanceFromMeta(cluster?.name || ''); const icon = appearance.icon || 'mdi:kubernetes'; const iconColor = appearance.accentColor || theme.palette.primaryColor; return (_jsx(ButtonBase, { focusRipple: true, ref: focusedRef, onClick: onClick, children: _jsx(Card, { sx: { width: 128, height: 115, paddingTop: '10%', }, children: _jsxs(CardContent, { sx: { textAlign: 'center', paddingTop: 0, }, children: [_jsx(Icon, { icon: icon, width: "50", height: "50", color: iconColor }), _jsx(Typography, { color: "textSecondary", gutterBottom: true, sx: { textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', display: 'block', }, title: cluster.name, children: cluster.name })] }) }) })); } function ClusterList(props) { const { clusters, onButtonClick } = props; const theme = useTheme(); const focusedRef = React.useCallback((node) => { if (node !== null) { node.focus(); } }, []); const { t } = useTranslation(); const recentClustersLabelId = 'recent-clusters-label'; const maxRecentClusters = 3; // We slice it here for the maximum recent clusters just for extra safety, since this // is an entry point to the rest of the functionality const recentClusterNames = getRecentClusters().slice(0, maxRecentClusters); let recentClusters = []; // If we have more than the maximum number of recent clusters allowed, we show the most // recent ones. Otherwise, just show the clusters in the order they are received. if (clusters.length > maxRecentClusters) { // Get clusters matching the recent cluster names, if they exist still. recentClusters = recentClusterNames .map(name => clusters.find(cluster => cluster.name === name)) .filter(item => !!item); // See whether we need to fill with new clusters (when the recent clusters were less than the // maximum/wanted). const neededClusters = maxRecentClusters - recentClusters.length; if (neededClusters > 0) { recentClusters = recentClusters.concat(clusters.filter(item => !recentClusters.includes(item)).slice(0, neededClusters)); } } else { recentClusters = clusters; } return (_jsx(Container, { style: { maxWidth: '500px', paddingBottom: theme.spacing(2) }, children: _jsxs(Grid, { container: true, direction: "column", alignItems: "stretch", justifyContent: "space-between", spacing: 4, children: [recentClusters.length !== clusters.length && (_jsx(Grid, { item: true, children: _jsx(Typography, { align: "center", id: recentClustersLabelId, children: t('translation|Recent clusters') }) })), _jsx(Grid, { "aria-labelledby": `#${recentClustersLabelId}`, item: true, container: true, alignItems: "center", justifyContent: clusters.length > maxRecentClusters ? 'space-between' : 'center', spacing: 2, children: recentClusters.map((cluster, i) => (_jsx(Grid, { item: true, children: _jsx(ClusterButton, { focusedRef: i === 0 ? focusedRef : undefined, cluster: cluster, onClick: () => onButtonClick(cluster) }) }, cluster.name))) }), clusters.length > 3 && (_jsx(Grid, { item: true, xs: 12, children: _jsx(Autocomplete, { id: "cluster-selector-autocomplete", options: clusters, getOptionLabel: option => option.name, style: { width: '100%' }, disableClearable: true, autoComplete: true, includeInputInList: true, openOnFocus: true, renderInput: params => (_jsx(TextField, { ...params, label: t('translation|All clusters'), variant: "outlined" })), onChange: (_event, cluster) => onButtonClick(cluster) }) }))] }) })); } export function ClusterDialog(props) { const theme = useTheme(); const { t } = useTranslation(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const { open, onClose = null, useCover = false, showInfoButton = true, children = [], ...otherProps } = props; // Only used if open is not provided const [show, setShow] = React.useState(true); const dispatch = useDispatch(); function handleClose() { if (onClose !== null) { onClose(); return; } // Only use show if open is not provided if (open === undefined) { setShow(false); } } return (_jsxs(Dialog, { fullScreen: fullScreen, open: open !== undefined ? open : show, onClose: handleClose, sx: useCover ? { background: theme.palette.common.black, } : {}, ...otherProps, children: [_jsxs(DialogTitle, { sx: { textAlign: 'center', alignItems: 'center', display: 'flex', }, buttons: [ showInfoButton && (_jsx(IconButton, { "aria-label": t('Show build information'), onClick: () => { handleClose(); dispatch(uiSlice.actions.setVersionDialogOpen(true)); }, size: "small", children: _jsx(InlineIcon, { icon: 'mdi:information-outline' }) })), ], children: [_jsx(AppLogo, { logoType: 'large', sx: { height: '32px', width: 'auto', } }), _jsx(Box, { component: "span", sx: visuallyHidden, children: "Headlamp" })] }), _jsx(DialogContent, { dividers: true, sx: { [theme.breakpoints.up('sm')]: { minWidth: 500, }, '& .MuiTypography-h4': { textAlign: 'center', fontSize: '2.2rem', color: theme.palette.primaryColor, paddingTop: theme.spacing(3), paddingBottom: theme.spacing(3), }, }, children: children })] })); } function Chooser(props) { const history = useHistory(); const clusters = useClustersConf(); const { open = null, onClose, children = [], ...otherProps } = props; // Only used if open is not provided const [show, setShow] = React.useState(props.open); const { t } = useTranslation(); React.useEffect(() => { if (open !== null && open !== show) { setShow(open); return; } // If we only have one cluster configured, then we skip offering // the choice to the user. if (!!clusters && Object.keys(clusters).length === 1) { handleButtonClick(Object.values(clusters)[0]); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [open, show, clusters]); function handleButtonClick(cluster) { if (cluster.name !== getCluster()) { setRecentCluster(cluster); history.push({ pathname: generatePath(getClusterPrefixedPath(), { cluster: cluster.name, }), }); } setShow(false); if (!!onClose) { onClose(); } } function handleClose() { if (open === null) { setShow(false); } if (!!onClose) { onClose(); } } const clusterList = Object.values(clusters || {}); if (!show) { return null; } return (_jsx(Box, { component: "main", children: _jsxs(ClusterDialog, { open: show, onClose: onClose || handleClose, "aria-labelledby": "chooser-dialog-title", "aria-busy": clusterList.length === 0 && clusters === null, ...otherProps, children: [_jsx(DialogTitle, { id: "chooser-dialog-title", focusTitle: true, children: t('Choose a cluster') }), clusterList.length === 0 ? (_jsx(React.Fragment, { children: clusters === null ? (_jsxs(_Fragment, { children: [_jsx(DialogContentText, { children: t('Wait while fetching clusters…') }), _jsx(Loader, { title: t('Loading cluster information') })] })) : (_jsxs(_Fragment, { children: [_jsx(DialogContentText, { children: t('There seems to be no clusters configured…') }), _jsx(DialogContentText, { children: t('Please make sure you have at least one cluster configured.') }), isElectron() && (_jsx(DialogContentText, { children: t('Or try running Headlamp with a different kube config.') }))] })) })) : (_jsx(ClusterList, { clusters: clusterList, onButtonClick: handleButtonClick })), isElectron() ? (_jsx(Box, { style: { justifyContent: 'center', display: 'flex' }, children: _jsx(ActionButton, { description: t('Load from a file'), onClick: () => history.push(createRouteURL('loadKubeConfig')), icon: "mdi:plus" }) })) : null, React.Children.toArray(children).length > 0 && (_jsx(DialogActions, { children: _jsx(Grid, { container: true, direction: "row", justifyContent: "space-between", alignItems: "center", children: React.Children.toArray(children).map((child, index) => (_jsx(Grid, { item: true, children: child }, index))) }) }))] }) })); } export default Chooser;