@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
296 lines (295 loc) • 19.1 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 } from '@iconify/react';
import { Box, Button, Card, CardContent, Grid, Tab, Tabs, Typography } from '@mui/material';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import ResourceQuota from '../../lib/k8s/resourceQuota';
import Role from '../../lib/k8s/role';
import RoleBinding from '../../lib/k8s/roleBinding';
import { SelectedClustersContext } from '../../lib/k8s/SelectedClustersContext';
import { useTypedSelector } from '../../redux/hooks';
import { Activity } from '../activity/Activity';
import { EditButton, EditorDialog, Loader, StatusLabel } from '../common';
import Link from '../common/Link';
import ResourceTable from '../common/Resource/ResourceTable';
import SectionBox from '../common/SectionBox';
import { GraphView } from '../resourceMap/GraphView';
import { ResourceQuotaTable } from '../resourceQuota/Details';
import { ProjectDeleteButton } from './ProjectDeleteButton';
import { useProject } from './ProjectList';
import { ProjectResourcesTab, useResourceCategoriesList } from './ProjectResourcesTab';
import { getHealthIcon, getResourcesHealth } from './projectUtils';
import { ResourceCategoriesList } from './ResourceCategoriesList';
import { useProjectItems } from './useProjectResources';
// Tab ID constants
const TAB_IDS = {
OVERVIEW: 'headlamp-projects.tabs.overview',
RESOURCES: 'headlamp-projects.tabs.resources',
ACCESS: 'headlamp-projects.tabs.access',
MAP: 'headlamp-projects.tabs.map',
};
// Default tabs configuration with their IDs
const DEFAULT_TABS = {
[TAB_IDS.OVERVIEW]: {
id: TAB_IDS.OVERVIEW,
icon: 'mdi:view-dashboard',
label: _jsx(Trans, { children: "Overview" }),
component: ProjectOverview,
},
[TAB_IDS.RESOURCES]: {
id: TAB_IDS.RESOURCES,
icon: 'mdi:format-list-bulleted',
label: _jsx(Trans, { children: "Resources" }),
component: ProjectResources,
},
[TAB_IDS.ACCESS]: {
id: TAB_IDS.ACCESS,
icon: 'mdi:account-lock',
label: _jsx(Trans, { children: "Access" }),
component: ProjectAccess,
},
[TAB_IDS.MAP]: {
id: TAB_IDS.MAP,
icon: 'mdi:map',
label: _jsx(Trans, { children: "Map" }),
component: ProjectGraph,
},
};
export default function ProjectDetails() {
const { t } = useTranslation();
const { name } = useParams();
const { project, isLoading: isProjectLoading } = useProject(name);
if (isProjectLoading || !project || !name) {
return _jsx(Loader, { title: t('Loading') });
}
// Key is provided to make sure we remount this component
return _jsx(ProjectDetailsContent, { project: project }, name);
}
function ProjectOverview({ project, projectResources, }) {
const { t } = useTranslation();
const detailsContext = useContext(ProjectDetailsContext);
if (!detailsContext) {
throw new Error('Missing ProjectDetailsContext');
}
const { setSelectedCategoryName, setSelectedTab } = detailsContext;
const additionalOverviewSections = Object.values(useTypedSelector(state => state.projects.overviewSections));
const resourceQuotas = useMemo(() => projectResources?.filter(it => it.kind === 'ResourceQuota') ?? [], [projectResources]);
const categoryList = useResourceCategoriesList(projectResources);
const projectHealth = useMemo(() => getResourcesHealth(projectResources), [projectResources]);
return (_jsxs(Grid, { container: true, spacing: 3, sx: { pt: 2 }, children: [_jsx(Grid, { item: true, xs: 12, md: 4, children: _jsx(Card, { sx: { height: '100%' }, children: _jsxs(CardContent, { children: [_jsx(Typography, { variant: "h6", children: t('Status') }), _jsxs(Box, { sx: { display: 'flex', gap: 2 }, children: [_jsxs(Box, { children: [_jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Project Status') }), _jsx(Box, { display: "flex", alignItems: "center", gap: 1, children: _jsxs(StatusLabel, { status: projectHealth.error > 0
? 'error'
: projectHealth.warning > 0
? 'warning'
: 'success', children: [_jsx(Icon, { icon: getHealthIcon(projectHealth.success, projectHealth.error, projectHealth.warning), style: {
fontSize: 24,
} }), projectHealth.success === 0
? t('No Workloads')
: projectHealth.error > 0
? t('Unhealthy')
: projectHealth.warning > 0
? t('Degraded')
: t('Healthy')] }) })] }), _jsxs(Box, { children: [_jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Resources') }), projectResources.length > 0 && (_jsxs(Box, { display: "flex", flexWrap: "wrap", gap: 1, children: [projectHealth.success > 0 && (_jsxs(StatusLabel, { status: "success", children: [projectHealth.success, " ", t('Healthy')] })), projectHealth.warning > 0 && (_jsxs(StatusLabel, { status: "warning", children: [projectHealth.warning, " ", t('Warning')] })), projectHealth.error > 0 && (_jsxs(StatusLabel, { status: "error", children: [projectHealth.error, " ", t('Unhealthy')] }))] }))] })] }), _jsxs(Box, { sx: { mt: 2 }, children: [_jsx(Typography, { variant: "body2", color: "text.secondary", children: project.clusters.length === 1
? t('translation|Cluster')
: t('translation|Clusters') }), _jsx(Box, { display: "flex", flexWrap: "wrap", gap: 1, sx: { mt: 0.5 }, children: project.clusters.map(cluster => (_jsx(Link, { routeName: "cluster", params: { cluster }, children: cluster }, cluster))) })] })] }) }) }), _jsx(Grid, { item: true, xs: 12, md: 4, children: _jsx(Card, { sx: { height: '100%' }, children: _jsxs(CardContent, { children: [_jsx(Typography, { variant: "h6", children: t('Resources') }), _jsx(ResourceCategoriesList, { categoryList: categoryList, onCategoryClick: category => {
setSelectedCategoryName(category);
setSelectedTab(TAB_IDS.RESOURCES);
} })] }) }) }), _jsx(Grid, { item: true, xs: 12, md: 4, children: _jsx(Card, { sx: { height: '100%' }, children: _jsxs(CardContent, { children: [_jsx(Typography, { variant: "h6", children: t('Resource Quotas') }), _jsxs(Box, { children: [resourceQuotas.map(it => (_jsxs(Box, { sx: { mb: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center' }, children: [_jsx(Typography, { variant: "h6", sx: { mr: 'auto' }, children: it.metadata.name }), _jsx(EditButton, { item: it })] }), _jsx(ResourceQuotaTable, { resourceStats: it.resourceStats })] }))), resourceQuotas.length === 0 && (_jsxs(Box, { sx: {
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
my: 2,
}, children: [_jsx(Typography, { variant: "body2", color: "text.secondary", paragraph: true, children: t('Create Resource Quota to limit resource consumption within this project') }), _jsx(Button, { startIcon: _jsx(Icon, { icon: "mdi:plus" }), color: "secondary", variant: "contained", onClick: () => {
const activityId = 'create-resource-resourcequotas';
const item = ResourceQuota.getBaseObject();
item.metadata.namespace = project.namespaces[0];
item.cluster = project.clusters[0];
Activity.launch({
id: activityId,
title: t('translation|Create {{ name }}', {
name: ResourceQuota.kind,
}),
location: 'full',
cluster: project.clusters[0],
icon: _jsx(Icon, { icon: "mdi:plus-circle" }),
content: (_jsx(EditorDialog, { noDialog: true, item: item, open: true, setOpen: () => { }, onClose: () => Activity.close(activityId), saveLabel: t('translation|Apply'), title: t('translation|Create {{ name }}', { name }), "aria-label": t('translation|Create {{ name }}', { name }) })),
});
}, children: _jsx(Trans, { children: "Create resource quota" }) })] }))] })] }) }) }), additionalOverviewSections.map(section => (_jsx(Grid, { item: true, xs: 12, md: 4, children: _jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsx(section.component, { project: project, projectResources: projectResources }, section.id) }) }) })))] }));
}
/** Resources tab for the Project Details */
function ProjectResources({ project, projectResources, }) {
const detailsContext = useContext(ProjectDetailsContext);
if (!detailsContext) {
throw new Error('Missing ProjectDetailsContext');
}
const { selectedCategoryName, setSelectedCategoryName } = detailsContext;
return (_jsx(ProjectResourcesTab, { projectResources: projectResources, showClusterColumn: project.clusters.length > 1, selectedCategoryName: selectedCategoryName, setSelectedCategoryName: setSelectedCategoryName }));
}
/** Access tab for the Project Details */
function ProjectAccess({ project }) {
const { t } = useTranslation();
return (_jsx(Box, { sx: { my: 3 }, children: _jsxs(SelectedClustersContext.Provider, { value: project.clusters, children: [_jsx(Typography, { variant: "h6", children: t('Roles') }), _jsx(ResourceTable, { resourceClass: Role, columns: ['type', 'name', 'age'], namespaces: project.namespaces, enableRowActions: true }), _jsx(Typography, { variant: "h6", children: t('Role Bindings') }), _jsx(ResourceTable, { resourceClass: RoleBinding, columns: ['type', 'name', 'age'], namespaces: project.namespaces, enableRowActions: true })] }) }));
}
/** Context for Project Details page state that can be shared with different tabs */
const ProjectDetailsContext = createContext(undefined);
/**
* Project Details page
*/
function ProjectDetailsContent({ project }) {
const { t } = useTranslation();
const registeredTabs = useTypedSelector(state => state.projects.detailsTabs);
const customDeleteButton = useTypedSelector(state => state.projects.projectDeleteButton);
const registeredHeaderActions = useTypedSelector(state => state.projects.headerActions);
const [DeleteButton, setDeleteButton] = useState(() => ProjectDeleteButton);
const [headerActions, setHeaderActions] = useState([]);
// Load custom delete button
useEffect(() => {
if (!customDeleteButton)
return;
let isCurrent = true;
if (customDeleteButton.isEnabled) {
customDeleteButton
.isEnabled({ project })
.then(isEnabled => {
if (isEnabled && isCurrent) {
setDeleteButton(() => customDeleteButton.component);
}
})
.catch(e => {
console.error(`Failed to check if custom delete button is ready`, e);
});
}
else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDeleteButton(() => customDeleteButton.component);
}
return () => {
isCurrent = false;
};
}, [customDeleteButton, project]);
// Load custom header actions
useEffect(() => {
let isCurrent = true;
async function loadHeaderActions() {
const actionsList = Object.values(registeredHeaderActions);
// Get a list of enabled header actions
const enabledActions = (await Promise.all(actionsList.map(action => action.isEnabled
? action
.isEnabled({ project })
.then(isEnabled => (isEnabled ? action : undefined))
.catch(e => {
console.error('Failed to check if header action is enabled', action, e);
return undefined;
})
: Promise.resolve(action)))).filter(Boolean);
if (isCurrent) {
const actions = enabledActions
.map(action => (action ? _jsx(action.component, { project: project }, action.id) : null))
.filter(Boolean);
setHeaderActions(actions);
}
}
loadHeaderActions();
return () => {
isCurrent = false;
};
}, [registeredHeaderActions, project]);
const [selectedTab, setSelectedTab] = useState();
const [selectedCategoryName, setSelectedCategoryName] = React.useState();
const { items, isLoading } = useProjectItems(project);
const [allTabs, setAllTabs] = useState(DEFAULT_TABS);
useEffect(() => {
async function loadTabs() {
const registeredTabsList = Object.values(registeredTabs);
// Get a list of enabled Tabs
const enabledTabs = (await Promise.all(registeredTabsList.map(tab => tab.isEnabled
? // if tab provides isEnabled function we call it
tab
.isEnabled({ project })
.then(isEnabled => (isEnabled ? tab : undefined))
.catch(e => {
// if isEnabled check failed then we don't show it
console.error('Failed to check if tab is enabled', tab, e);
return undefined;
})
: // if no isEnabled function then it's enabled by default
Promise.resolve(tab)))).filter(Boolean);
const enabledTabsById = Object.fromEntries(enabledTabs.map(tab => [tab.id, tab]));
// Merge default tabs with custom tabs
const allTabs = {
...DEFAULT_TABS,
...enabledTabsById,
};
setAllTabs(allTabs);
}
loadTabs();
}, [registeredTabs, project]);
// Set initial selected tab to the first available tab
const tabIds = Object.keys(allTabs);
if (tabIds.length > 0 && !selectedTab) {
setSelectedTab(tabIds[0]);
}
// Get the definition for the currently selected tab
const selectedTabData = selectedTab ? allTabs[selectedTab] : undefined;
const handleTabChange = (event, newValue) => {
setSelectedTab(newValue);
};
const contextValue = useMemo(() => ({
setSelectedCategoryName,
selectedCategoryName,
setSelectedTab,
}), [setSelectedCategoryName, selectedCategoryName, setSelectedTab]);
if (isLoading) {
return _jsx(Loader, { title: t('Loading') });
}
return (_jsx(ProjectDetailsContext.Provider, { value: contextValue, children: _jsx(Box, { sx: { display: 'flex', flexDirection: 'column', height: '100%', alignItems: 'flex-start' }, children: _jsxs(SectionBox, { outterBoxProps: {
sx: { flexGrow: 1, display: 'flex', flexDirection: 'column', width: '100%' },
}, sx: {
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
mb: 3,
}, backLink: true, title: _jsxs(Box, { display: "flex", alignItems: "center", gap: 1, sx: { py: 2 }, children: [_jsx(Typography, { variant: "h5", component: "span", sx: { mr: 'auto' }, children: project.id }), headerActions, _jsx(DeleteButton, { project: project })] }), children: [_jsx(Box, { sx: { borderBottom: 1, borderColor: 'divider' }, children: _jsx(Tabs, { value: selectedTab, onChange: handleTabChange, children: Object.values(allTabs)
.filter(tab => tab.component)
.map(tab => (_jsx(Tab, { value: tab.id, label: _jsxs(_Fragment, { children: [typeof tab.icon === 'string' ? _jsx(Icon, { icon: tab.icon }) : tab.icon, _jsx(Typography, { children: tab.label })] }), sx: {
flexDirection: 'row',
gap: 1,
fontSize: '1.25rem',
} }, tab.id))) }) }), selectedTabData && selectedTabData.component ? (_jsx(selectedTabData.component, { project: project, projectResources: items }, selectedTabData.id)) : null] }) }) }));
}
/** Map tab for the Project Details */
function ProjectGraph({ project: { namespaces, clusters } }) {
const filters = useMemo(() => [
namespaces.length > 0
? {
type: 'namespace',
namespaces: new Set(namespaces),
}
: undefined,
].filter(Boolean),
// eslint-disable-next-line react-hooks/exhaustive-deps
[namespaces, clusters]);
return (_jsx(Box, { sx: {
border: '1px solid',
borderColor: 'divider',
borderTop: 0,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
}, children: _jsx(SelectedClustersContext.Provider, { value: clusters, children: _jsx(GraphView, { defaultFilters: filters }) }) }));
}