Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const makeStore = () => {
customCreateProject: {},
detailsTabs: {},
overviewSections: {},
apiResources: [],
},
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const makeStore = () => {
customCreateProject: {},
detailsTabs: {},
overviewSections: {},
apiResources: [],
},
},
});
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/project/ProjectDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,15 @@ export default function ProjectDetails() {
const { name } = useParams<ProjectDetailsParams>();
const { project, isLoading: isProjectLoading } = useProject(name);

const pluginApiResources = useTypedSelector(state => state.projects.apiResources);

if (isProjectLoading || !project || !name) {
return <Loader title={t('Loading')} />;
}
// Key is provided to make sure we remount this component
return <ProjectDetailsContent key={name} project={project} />;
// 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 <ProjectDetailsContent key={`${name}-${pluginApiResources.length}`} project={project} />;
}

function ProjectOverview({
Expand Down
16 changes: 10 additions & 6 deletions frontend/src/components/project/useProjectResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,29 @@
*/

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;

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))
);
Comment thread
NAME-ASHWANIYADAV marked this conversation as resolved.

const { items, errors, isLoading } = useKubeLists(
resources,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/plugin/__snapshots__/pluginLib.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@
"registerMapSource": [Function],
"registerOverviewChartsProcessor": [Function],
"registerPluginSettings": [Function],
"registerProjectApiResource": [Function],
"registerProjectDeleteButton": [Function],
"registerProjectDetailsTab": [Function],
"registerProjectHeaderAction": [Function],
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/plugin/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -96,6 +97,7 @@ import {
addDetailsTab,
addHeaderAction,
addOverviewSection,
addProjectApiResource,
CustomCreateProject,
ProjectDeleteButton,
ProjectDetailsTab,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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) {
Comment thread
NAME-ASHWANIYADAV marked this conversation as resolved.
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));
}
Comment thread
NAME-ASHWANIYADAV marked this conversation as resolved.

export {
DefaultAppBarAction,
DefaultDetailsViewSection,
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/redux/projectsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -75,13 +77,16 @@ export interface ProjectsState {
detailsTabs: Record<string, ProjectDetailsTab>;
projectDeleteButton?: ProjectDeleteButton;
headerActions: Record<string, ProjectHeaderAction>;
/** Plugin-registered API resources for project resource fetching */
apiResources: ApiResource[];
}

const initialState: ProjectsState = {
customCreateProject: {},
detailsTabs: {},
overviewSections: {},
headerActions: {},
apiResources: [],
};

const projectsSlice = createSlice({
Expand Down Expand Up @@ -112,6 +117,15 @@ const projectsSlice = createSlice({
addHeaderAction(state, action: PayloadAction<ProjectHeaderAction>) {
state.headerActions[action.payload.id] = action.payload;
},

/** Register additional API resource for project resource fetching */
addProjectApiResource(state, action: PayloadAction<ApiResource>) {
const id = apiResourceId(action.payload);
const exists = state.apiResources.some(r => apiResourceId(r) === id);
if (!exists) {
state.apiResources.push(action.payload);
}
Comment thread
NAME-ASHWANIYADAV marked this conversation as resolved.
},
},
});

Expand All @@ -121,6 +135,7 @@ export const {
addOverviewSection,
setProjectDeleteButton,
addHeaderAction,
addProjectApiResource,
} = projectsSlice.actions;

export default projectsSlice.reducer;
3 changes: 3 additions & 0 deletions plugins/headlamp-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import Registry, {
registerMapSource,
registerOverviewChartsProcessor,
registerPluginSettings,
registerProjectApiResource,
registerProjectDeleteButton,
registerProjectDetailsTab,
registerProjectHeaderAction,
Expand All @@ -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 {
Expand Down Expand Up @@ -130,6 +132,7 @@ export {
registerProjectOverviewSection,
registerProjectHeaderAction,
registerClusterStatus,
registerProjectApiResource,
registerProjectDeleteButton,
Activity,
};
Expand Down
Loading