diff --git a/frontend/src/components/project/ProjectDetails.tsx b/frontend/src/components/project/ProjectDetails.tsx index 7f901610ed1..f5506708999 100644 --- a/frontend/src/components/project/ProjectDetails.tsx +++ b/frontend/src/components/project/ProjectDetails.tsx @@ -190,7 +190,8 @@ function ProjectDetailsContent({ project }: { project: ProjectDefinition }) { value={tab.id} label={ <> - {typeof tab.icon === 'string' ? : tab.icon} + {tab.icon && + (typeof tab.icon === 'string' ? : tab.icon)} {tab.label} } diff --git a/frontend/src/components/project/ProjectList.tsx b/frontend/src/components/project/ProjectList.tsx index 3b3fd2fdbc4..44d2482a68a 100644 --- a/frontend/src/components/project/ProjectList.tsx +++ b/frontend/src/components/project/ProjectList.tsx @@ -19,9 +19,11 @@ import { Box, Button, Typography } from '@mui/material'; import { groupBy, uniq } from 'lodash'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; import { useClustersConf } from '../../lib/k8s'; import Namespace from '../../lib/k8s/namespace'; import { ProjectDefinition } from '../../redux/projectsSlice'; +import { RootState } from '../../redux/stores/store'; import { StatusLabel } from '../common'; import Link from '../common/Link'; import Table, { TableColumn } from '../common/Table/Table'; @@ -29,7 +31,7 @@ import { NewProjectPopup } from './NewProjectPopup'; import { getHealthIcon, getResourcesHealth, PROJECT_ID_LABEL } from './projectUtils'; import { useProjectItems } from './useProjectResources'; -const useProjects = (): ProjectDefinition[] => { +const useDefaultProjects = (): ProjectDefinition[] => { const clusterConf = useClustersConf(); const clusters = Object.values(clusterConf ?? {}); @@ -53,6 +55,30 @@ const useProjects = (): ProjectDefinition[] => { return projects; }; +const useProjects = (): ProjectDefinition[] => { + const defaultProjects = useDefaultProjects(); + const projectListProcessors = useSelector( + (state: RootState) => state.projects.projectListProcessors + ); + + return useMemo(() => { + // Start with the default namespace-based projects + let projects = defaultProjects; + + // Apply processors in order, each one can modify the project list + for (const processor of projectListProcessors) { + try { + projects = processor.processor(projects); + } catch (error) { + console.error(`Project List: Error in list processor ${processor.id}:`, error); + } + } + + console.log('Final projects:', projects); + return projects; + }, [defaultProjects, projectListProcessors]); +}; + export const useProject = (name: string) => { const clusterConf = useClustersConf(); const clusters = Object.values(clusterConf ?? {}); diff --git a/frontend/src/plugin/registry.tsx b/frontend/src/plugin/registry.tsx index 9c74038444a..066c94af728 100644 --- a/frontend/src/plugin/registry.tsx +++ b/frontend/src/plugin/registry.tsx @@ -95,8 +95,11 @@ import { addCustomCreateProject, addDetailsTab, addOverviewSection, + addProjectListProcessor, CustomCreateProject, ProjectDetailsTab, + ProjectListProcessor, + ProjectListProcessorFunction, ProjectOverviewSection, } from '../redux/projectsSlice'; import { setRoute, setRouteFilter } from '../redux/routesSlice'; @@ -161,6 +164,7 @@ export type sectionFunc = (resource: KubeObject) => SectionFuncProps | null | un // @todo: HeaderActionType should be deprecated. export type DetailsViewHeaderActionType = HeaderActionType; export type DetailsViewHeaderActionsProcessor = HeaderActionsProcessor; +export type { ProjectListProcessor, ProjectListProcessorFunction }; export default class Registry { /** @@ -1072,7 +1076,7 @@ export function registerCustomCreateProject(customCreateProject: CustomCreatePro * @param projectDetailsTab - The tab configuration to register * @param projectDetailsTab.id - Unique identifier for the tab * @param projectDetailsTab.label - Display label for the tab - * @param projectDetailsTab.icon - Display icon for the tab + * @param projectDetailsTab.icon - Display icon for the tab (optional) * @param projectDetailsTab.component - React component to render in the tab content * * @example @@ -1110,6 +1114,53 @@ export function registerProjectOverviewSection(projectOverviewSection: ProjectOv store.dispatch(addOverviewSection(projectOverviewSection)); } +/** + * Register a project list processor. + * + * This allows plugins to override or modify how projects are discovered and listed. + * Processors receive the current list of projects (from namespaces or previous processors) + * and can modify, filter, or extend the list as needed. + * + * @param processor - The processor function or processor object to register + * + * @example + * ```tsx + * import { registerProjectListProcessor } from '@kinvolk/headlamp-plugin/lib'; + * + * // Add new projects while keeping existing ones + * registerProjectListProcessor((currentProjects) => { + * const newProjects = [ + * { + * id: 'my-custom-project', + * namespaces: ['default', 'kube-system'], + * clusters: ['cluster1'] + * } + * ]; + * + * // Add only if the project doesn't already exist + * const existingIds = currentProjects.map(p => p.id); + * const projectsToAdd = newProjects.filter(p => !existingIds.includes(p.id)); + * + * return [...currentProjects, ...projectsToAdd]; + * }); + * + * // Object-based processor with ID + * registerProjectListProcessor({ + * id: 'custom-resource-projects', + * processor: (currentProjects) => { + * // Fetch projects from a Custom Resource or external API + * const customProjects = getProjectsFromCustomResource(); + * return [...currentProjects, ...customProjects]; + * } + * }); + * ``` + */ +export function registerProjectListProcessor( + processor: ProjectListProcessor | ProjectListProcessorFunction +) { + store.dispatch(addProjectListProcessor(processor)); +} + export { DefaultAppBarAction, DefaultDetailsViewSection, diff --git a/frontend/src/redux/projectsSlice.ts b/frontend/src/redux/projectsSlice.ts index 071e6bb9c30..98687a00a9a 100644 --- a/frontend/src/redux/projectsSlice.ts +++ b/frontend/src/redux/projectsSlice.ts @@ -24,6 +24,15 @@ export interface ProjectDefinition { clusters: string[]; } +export type ProjectListProcessorFunction = ( + currentProjects: ProjectDefinition[] +) => ProjectDefinition[]; + +export interface ProjectListProcessor { + id?: string; + processor: ProjectListProcessorFunction; +} + /** Define custom way to create new Projects */ export interface CustomCreateProject { id: string; @@ -49,7 +58,7 @@ export interface ProjectOverviewSection { export interface ProjectDetailsTab { id: string; label: string; - icon: string | ReactNode; + icon?: string | ReactNode; component: (props: { project: ProjectDefinition; projectResources: KubeObject[] }) => ReactNode; } @@ -57,14 +66,37 @@ export interface ProjectsState { customCreateProject: Record; overviewSections: Record; detailsTabs: Record; + projectListProcessors: ProjectListProcessor[]; } const initialState: ProjectsState = { customCreateProject: {}, detailsTabs: {}, overviewSections: {}, + projectListProcessors: [], }; +/** + * Normalizes a project list processor by ensuring it has an 'id' and a processor function. + */ +function _normalizeProjectListProcessor( + action: PayloadAction +): ProjectListProcessor { + let processor: ProjectListProcessor = action.payload as ProjectListProcessor; + if (typeof action.payload === 'function') { + processor = { + id: `generated-id-${Date.now().toString(36)}`, + processor: action.payload, + }; + } + + if (!processor.id) { + processor.id = `generated-id-${Date.now().toString(36)}`; + } + + return processor; +} + const projectsSlice = createSlice({ name: 'projects', initialState, @@ -83,9 +115,22 @@ const projectsSlice = createSlice({ addOverviewSection(state, action: PayloadAction) { state.overviewSections[action.payload.id] = action.payload; }, + + /** Register a project list processor */ + addProjectListProcessor( + state, + action: PayloadAction + ) { + state.projectListProcessors.push(_normalizeProjectListProcessor(action)); + }, }, }); -export const { addCustomCreateProject, addDetailsTab, addOverviewSection } = projectsSlice.actions; +export const { + addCustomCreateProject, + addDetailsTab, + addOverviewSection, + addProjectListProcessor, +} = projectsSlice.actions; export default projectsSlice.reducer; diff --git a/plugins/examples/projects/README.md b/plugins/examples/projects/README.md index 376e399085d..9e51d1411a8 100644 --- a/plugins/examples/projects/README.md +++ b/plugins/examples/projects/README.md @@ -1,6 +1,11 @@ # Projects customization example -This plugin demonstrates how to customize projects feature +This plugin demonstrates how to customize projects feature including: + +- Custom project creation workflows +- Custom project details tabs +- Custom project overview sections +- Custom project list processors (extend or modify project discovery) ```bash cd plugins/examples/projects @@ -8,3 +13,42 @@ npm start ``` The main code for the example plugin is in [src/index.tsx](src/index.tsx). + +## Project List Processors + +The `registerProjectListProcessor` function allows plugins to extend or modify how projects are discovered and listed. Processors receive the current list of projects (from namespaces or previous processors) and can: + +- Add new projects from Custom Resources, external APIs, or other sources +- Filter existing projects based on conditions +- Modify project properties like namespaces or clusters +- Completely replace the project list if needed + +### Key Features + +- **Additive by default**: Processors receive existing projects and can extend the list +- **Chainable**: Multiple processors can be registered and will run in sequence +- **Error handling**: Failed processors don't break the application +- **Duplicate prevention**: Easy to check for existing projects by ID + +### Example Usage + +```typescript +// Add new projects while keeping existing ones +registerProjectListProcessor((currentProjects) => { + const newProjects = [ + { + id: 'my-custom-project', + namespaces: ['default', 'kube-system'], + clusters: ['cluster1'] + } + ]; + + // Only add projects that don't already exist + const existingIds = currentProjects.map(p => p.id); + const projectsToAdd = newProjects.filter(p => !existingIds.includes(p.id)); + + return [...currentProjects, ...projectsToAdd]; +}); +``` + +This approach ensures backward compatibility while providing maximum flexibility for project customization. diff --git a/plugins/examples/projects/src/index.tsx b/plugins/examples/projects/src/index.tsx index 5bc940b2cc9..b2d56e9854c 100644 --- a/plugins/examples/projects/src/index.tsx +++ b/plugins/examples/projects/src/index.tsx @@ -18,6 +18,7 @@ import { ApiProxy, registerCustomCreateProject, registerProjectDetailsTab, + registerProjectListProcessor, registerProjectOverviewSection, } from '@kinvolk/headlamp-plugin/lib'; @@ -55,10 +56,34 @@ registerCustomCreateProject({ registerProjectDetailsTab({ id: 'my-tab', label: 'Metrics', - component: ({ project }) =>
Metrics for project {project.name}
, + icon: 'mdi:chart-line', + component: ({ project }) =>
Metrics for project {project.id}
, }); registerProjectOverviewSection({ id: 'resource-usage', - component: ({ project }) =>
Custom resource usage for project {project.name}
, + component: ({ project }) =>
Custom resource usage for project {project.id}
, +}); + +// Example 1: Extend the project list with additional projects +// This keeps the existing namespace-based projects and adds new ones +registerProjectListProcessor((currentProjects) => { + const newProjects = [ + { + id: 'example-project-1', + namespaces: ['default', 'kube-system'], + clusters: ['cluster1'] + }, + { + id: 'example-project-2', + namespaces: ['example-ns'], + clusters: ['cluster1', 'cluster2'] + } + ]; + + // Only add projects that don't already exist + const existingIds = currentProjects.map(p => p.id); + const projectsToAdd = newProjects.filter(p => !existingIds.includes(p.id)); + + return [...currentProjects, ...projectsToAdd]; }); diff --git a/plugins/headlamp-plugin/src/index.ts b/plugins/headlamp-plugin/src/index.ts index aa865497936..5b039c5579e 100644 --- a/plugins/headlamp-plugin/src/index.ts +++ b/plugins/headlamp-plugin/src/index.ts @@ -38,6 +38,8 @@ import Registry, { DetailsViewSectionProps, getHeadlampAPIHeaders, PluginManager, + ProjectListProcessor, + ProjectListProcessorFunction, registerAddClusterProvider, registerAppBarAction, registerAppLogo, @@ -55,6 +57,7 @@ import Registry, { registerMapSource, registerPluginSettings, registerProjectDetailsTab, + registerProjectListProcessor, registerProjectOverviewSection, registerResourceTableColumnsProcessor, registerRoute, @@ -101,6 +104,7 @@ export { registerKindIcon, registerMapSource, PluginManager, + registerProjectListProcessor, registerUIPanel, registerAppTheme, registerKubeObjectGlance, @@ -115,4 +119,6 @@ export type { ClusterChooserProps, DetailsViewSectionProps, DefaultSidebars, + ProjectListProcessor, + ProjectListProcessorFunction, };