diff --git a/frontend/src/components/project/NewProjectPopup.stories.tsx b/frontend/src/components/project/NewProjectPopup.stories.tsx index a7e0cd4cf2e..b9fff3f4975 100644 --- a/frontend/src/components/project/NewProjectPopup.stories.tsx +++ b/frontend/src/components/project/NewProjectPopup.stories.tsx @@ -56,6 +56,7 @@ const makeStore = () => { customCreateProject: {}, detailsTabs: {}, overviewSections: {}, + apiResources: [], }, }, }); diff --git a/frontend/src/components/project/ProjectCreateFromYaml.stories.tsx b/frontend/src/components/project/ProjectCreateFromYaml.stories.tsx index 38a2fec2128..e4d0d2c3118 100644 --- a/frontend/src/components/project/ProjectCreateFromYaml.stories.tsx +++ b/frontend/src/components/project/ProjectCreateFromYaml.stories.tsx @@ -55,6 +55,7 @@ const makeStore = () => { customCreateProject: {}, detailsTabs: {}, overviewSections: {}, + apiResources: [], }, }, }); diff --git a/frontend/src/components/project/ProjectDetails.tsx b/frontend/src/components/project/ProjectDetails.tsx index 376c292e6da..f177cf4a9bb 100644 --- a/frontend/src/components/project/ProjectDetails.tsx +++ b/frontend/src/components/project/ProjectDetails.tsx @@ -86,11 +86,15 @@ export default function ProjectDetails() { const { name } = useParams(); const { project, isLoading: isProjectLoading } = useProject(name); + const pluginApiResources = useTypedSelector(state => state.projects.apiResources); + if (isProjectLoading || !project || !name) { return ; } - // Key is provided to make sure we remount this component - return ; + // Key forces remount when project name or plugin resource list changes, + // which is required because useProjectItems → useKubeLists calls hooks + // per resource in a loop (the array length must stay stable per mount). + return ; } function ProjectOverview({ diff --git a/frontend/src/components/project/useProjectResources.ts b/frontend/src/components/project/useProjectResources.ts index 77112260a81..fa7ff133c07 100644 --- a/frontend/src/components/project/useProjectResources.ts +++ b/frontend/src/components/project/useProjectResources.ts @@ -15,13 +15,14 @@ */ import { uniqBy } from 'lodash'; -import { useMemo } from 'react'; +import { useState } from 'react'; import { apiResourceId } from '../../lib/k8s/api/v2/ApiResource'; +import { useTypedSelector } from '../../redux/hooks'; import { ProjectDefinition } from '../../redux/projectsSlice'; import { useKubeLists } from '../advancedSearch/utils/useKubeLists'; import { defaultApiResources } from './projectUtils'; -const MAX_RESOURCES_TO_WATCH = 20; +const MAX_RESOURCES_TO_WATCH = 30; const MAX_ITEMS = 1000; const REFETCH_INTERVAL_MS = 60_000; @@ -29,11 +30,14 @@ export function useProjectItems( project: ProjectDefinition, { disableWatch }: { disableWatch?: boolean } = { disableWatch: false } ) { - const resources = useMemo(() => { - const allResources = defaultApiResources; + const pluginApiResources = useTypedSelector(state => state.projects.apiResources); - return uniqBy(allResources, r => apiResourceId(r)); - }, []); + // Capture plugin resources once on mount so the resource list length stays + // stable across renders. useKubeLists calls hooks per resource, so changing + // the array length mid-lifecycle would violate the Rules of Hooks. + const [resources] = useState(() => + uniqBy([...defaultApiResources, ...pluginApiResources], r => apiResourceId(r)) + ); const { items, errors, isLoading } = useKubeLists( resources, diff --git a/frontend/src/plugin/__snapshots__/pluginLib.snapshot b/frontend/src/plugin/__snapshots__/pluginLib.snapshot index 7669602ea28..d568403792e 100644 --- a/frontend/src/plugin/__snapshots__/pluginLib.snapshot +++ b/frontend/src/plugin/__snapshots__/pluginLib.snapshot @@ -590,6 +590,7 @@ "registerMapSource": [Function], "registerOverviewChartsProcessor": [Function], "registerPluginSettings": [Function], + "registerProjectApiResource": [Function], "registerProjectDeleteButton": [Function], "registerProjectDetailsTab": [Function], "registerProjectHeaderAction": [Function], diff --git a/frontend/src/plugin/registry.tsx b/frontend/src/plugin/registry.tsx index be51ebe9a52..df0255eb7bc 100644 --- a/frontend/src/plugin/registry.tsx +++ b/frontend/src/plugin/registry.tsx @@ -39,6 +39,7 @@ import { DefaultSidebars, SidebarEntryProps } from '../components/Sidebar'; import { setSidebarItem, setSidebarItemFilter } from '../components/Sidebar/sidebarSlice'; import { getHeadlampAPIHeaders } from '../helpers/getHeadlampAPIHeaders'; import { AppTheme } from '../lib/AppTheme'; +import type { ApiResource } from '../lib/k8s/api/v2/ApiResource'; import { KubeObject } from '../lib/k8s/KubeObject'; import type { Route } from '../lib/router/Route'; import { @@ -96,6 +97,7 @@ import { addDetailsTab, addHeaderAction, addOverviewSection, + addProjectApiResource, CustomCreateProject, ProjectDeleteButton, ProjectDetailsTab, @@ -149,6 +151,8 @@ export type { IconDefinition, OverviewChartsProcessor, }; + +export type { ApiResource } from '../lib/k8s/api/v2/ApiResource'; export const DefaultHeadlampEvents = HeadlampEventType; export const DetailsViewDefaultHeaderActions = DefaultHeaderAction; export type { AppBarActionProcessorType }; @@ -1166,6 +1170,54 @@ export function registerProjectHeaderAction(projectHeaderAction: ProjectHeaderAc store.dispatch(addHeaderAction(projectHeaderAction)); } +/** + * Register a custom API resource to be included in Project resource fetching. + * + * This allows plugins to extend the default list of resources that Projects + * track, enabling CRD-based resources to appear in project resource counts, + * health status, and the Resources tab. + * + * Only namespaced resources should be registered, as Projects are scoped to namespaces. + * + * @param apiResource - The API resource definition to register. + * Must include apiVersion, version, pluralName, singularName, kind, and isNamespaced. + * + * @example + * ```tsx + * registerProjectApiResource({ + * apiVersion: 'argoproj.io/v1alpha1', + * version: 'v1alpha1', + * groupName: 'argoproj.io', + * pluralName: 'applications', + * singularName: 'application', + * kind: 'Application', + * isNamespaced: true, + * }); + * ``` + * + * @note If the total number of watched resources (defaults + plugin-registered) + * grows too large, the fetch strategy may fall back from watch to polling. + * Register only resources that are needed for project health/status. + */ +export function registerProjectApiResource(apiResource: ApiResource) { + if (!apiResource.isNamespaced) { + console.warn( + `registerProjectApiResource: Ignored non-namespaced resource "${apiResource.kind}" ` + + 'because Projects are namespace-scoped.' + ); + return; + } + + // Normalize groupName from apiVersion (e.g. 'argoproj.io/v1alpha1' → 'argoproj.io') + // when not explicitly provided, to ensure consistent deduplication via apiResourceId. + const normalizedResource = + !apiResource.groupName && apiResource.apiVersion.includes('/') + ? { ...apiResource, groupName: apiResource.apiVersion.split('/')[0] } + : apiResource; + + store.dispatch(addProjectApiResource(normalizedResource)); +} + export { DefaultAppBarAction, DefaultDetailsViewSection, diff --git a/frontend/src/redux/projectsSlice.ts b/frontend/src/redux/projectsSlice.ts index 0ff31730f8c..a9ae500749b 100644 --- a/frontend/src/redux/projectsSlice.ts +++ b/frontend/src/redux/projectsSlice.ts @@ -18,6 +18,8 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { ReactNode } from 'react'; import type { ButtonStyle } from '../components/common/ActionButton/ActionButton'; +import type { ApiResource } from '../lib/k8s/api/v2/ApiResource'; +import { apiResourceId } from '../lib/k8s/api/v2/ApiResource'; import type { KubeObject } from '../lib/k8s/KubeObject'; export interface ProjectDefinition { @@ -75,6 +77,8 @@ export interface ProjectsState { detailsTabs: Record; projectDeleteButton?: ProjectDeleteButton; headerActions: Record; + /** Plugin-registered API resources for project resource fetching */ + apiResources: ApiResource[]; } const initialState: ProjectsState = { @@ -82,6 +86,7 @@ const initialState: ProjectsState = { detailsTabs: {}, overviewSections: {}, headerActions: {}, + apiResources: [], }; const projectsSlice = createSlice({ @@ -112,6 +117,15 @@ const projectsSlice = createSlice({ addHeaderAction(state, action: PayloadAction) { state.headerActions[action.payload.id] = action.payload; }, + + /** Register additional API resource for project resource fetching */ + addProjectApiResource(state, action: PayloadAction) { + const id = apiResourceId(action.payload); + const exists = state.apiResources.some(r => apiResourceId(r) === id); + if (!exists) { + state.apiResources.push(action.payload); + } + }, }, }); @@ -121,6 +135,7 @@ export const { addOverviewSection, setProjectDeleteButton, addHeaderAction, + addProjectApiResource, } = projectsSlice.actions; export default projectsSlice.reducer; diff --git a/plugins/headlamp-plugin/src/index.ts b/plugins/headlamp-plugin/src/index.ts index be678f6cd42..dbf00bc6046 100644 --- a/plugins/headlamp-plugin/src/index.ts +++ b/plugins/headlamp-plugin/src/index.ts @@ -64,6 +64,7 @@ import Registry, { registerMapSource, registerOverviewChartsProcessor, registerPluginSettings, + registerProjectApiResource, registerProjectDeleteButton, registerProjectDetailsTab, registerProjectHeaderAction, @@ -76,6 +77,7 @@ import Registry, { registerUIPanel, runCommand, } from './plugin/registry'; +export type { ApiResource } from './plugin/registry'; // We export k8s (lowercase) since someone may use it as we do in the Headlamp source code. export { @@ -130,6 +132,7 @@ export { registerProjectOverviewSection, registerProjectHeaderAction, registerClusterStatus, + registerProjectApiResource, registerProjectDeleteButton, Activity, };