UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

296 lines (295 loc) 19.1 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 } 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 }) }) })); }