@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
235 lines (234 loc) • 13.5 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 { 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;