Skip to content

Commit 2a5c5df

Browse files
Feat(RHOAIENG-47110): Include Cluster Kueue details in the kueue hardware popover (opendatahub-io#6178)
* feat:added hardware profile details popover, utils,context and hook for project * feat:added cypress and unit test cases * fix:added fix for failing test cases
1 parent 53020a7 commit 2a5c5df

File tree

9 files changed

+491
-2
lines changed

9 files changed

+491
-2
lines changed

frontend/src/concepts/hardwareProfiles/HardwareProfileDetailsPopover.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import { QuestionCircleIcon } from '@patternfly/react-icons';
1414
import { Toleration, NodeSelector, ContainerResources } from '#~/types';
1515
import { HardwareProfileKind } from '#~/k8sTypes';
1616
import {
17+
getClusterQueueNameFromLocalQueues,
1718
getHardwareProfileDescription,
1819
getHardwareProfileDisplayName,
1920
} from '#~/pages/hardwareProfiles/utils.ts';
21+
import { ProjectDetailsContext } from '#~/pages/projects/ProjectDetailsContext';
2022
import { formatToleration, formatNodeSelector, formatResource, formatResourceValue } from './utils';
2123

2224
type HardwareProfileDetailsPopoverProps = {
@@ -38,6 +40,12 @@ const HardwareProfileDetailsPopover: React.FC<HardwareProfileDetailsPopoverProps
3840
hardwareProfile,
3941
tableView = false,
4042
}) => {
43+
const { localQueues } = React.useContext(ProjectDetailsContext);
44+
const clusterQueueName = React.useMemo(
45+
() => getClusterQueueNameFromLocalQueues(localQueueName, localQueues),
46+
[localQueueName, localQueues.data, localQueues.loaded],
47+
);
48+
4149
const renderSection = (title: string, items: string[]) => (
4250
<DescriptionList>
4351
<DescriptionListGroup>
@@ -69,7 +77,12 @@ const HardwareProfileDetailsPopover: React.FC<HardwareProfileDetailsPopoverProps
6977
}));
7078
}, [resources, hardwareProfile]);
7179

72-
if (!tolerations && !nodeSelector && !resources) {
80+
if (
81+
!tolerations &&
82+
!nodeSelector &&
83+
!resources &&
84+
!(localQueueName || clusterQueueName || priorityClass)
85+
) {
7386
return null;
7487
}
7588
const description = hardwareProfile && getHardwareProfileDescription(hardwareProfile);
@@ -109,6 +122,9 @@ const HardwareProfileDetailsPopover: React.FC<HardwareProfileDetailsPopoverProps
109122
{localQueueName && (
110123
<StackItem>{renderSection('Local queue', [localQueueName])}</StackItem>
111124
)}
125+
{clusterQueueName && (
126+
<StackItem>{renderSection('Cluster queue', [clusterQueueName])}</StackItem>
127+
)}
112128
{priorityClass && (
113129
<StackItem>{renderSection('Workload priority', [priorityClass])}</StackItem>
114130
)}

frontend/src/concepts/hardwareProfiles/__tests__/HardwareProfileSelect.spec.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ProjectDetailsContext,
99
ProjectDetailsContextType,
1010
} from '#~/pages/projects/ProjectDetailsContext';
11+
import { DEFAULT_LIST_FETCH_STATE } from '#~/utilities/const';
1112
import { ProjectsContext } from '#~/concepts/projects/ProjectsContext';
1213
import { HardwareProfileKind, ProjectKind, KnownLabels } from '#~/k8sTypes';
1314
import { SchedulingType } from '#~/types';
@@ -126,6 +127,7 @@ const renderComponent = (
126127
{
127128
currentProject,
128129
refresh: jest.fn(),
130+
localQueues: DEFAULT_LIST_FETCH_STATE,
129131
} as unknown as ProjectDetailsContextType
130132
}
131133
>

frontend/src/pages/hardwareProfiles/__tests__/utils.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { mockHardwareProfile } from '#~/__mocks__/mockHardwareProfile';
22
import { Identifier, IdentifierResourceType } from '#~/types';
33
import {
44
determineIdentifierUnit,
5+
getClusterQueueNameFromLocalQueues,
56
isHardwareProfileIdentifierValid,
67
validateProfileWarning,
78
} from '#~/pages/hardwareProfiles/utils';
9+
import { mockLocalQueueK8sResource } from '#~/__mocks__/mockLocalQueueK8sResource';
810
import { HardwareProfileWarningType } from '#~/concepts/hardwareProfiles/types';
911
import { CPU_UNITS, MEMORY_UNITS_FOR_SELECTION, OTHER } from '#~/utilities/valueUnits';
1012

@@ -420,3 +422,82 @@ describe('determine unit', () => {
420422
expect(determineIdentifierUnit(nodeUnknownResource)).toEqual(OTHER);
421423
});
422424
});
425+
426+
describe('getClusterQueueNameFromLocalQueues', () => {
427+
it('should return undefined when localQueueName is undefined', () => {
428+
const localQueues = {
429+
data: [mockLocalQueueK8sResource({ name: 'my-queue', namespace: 'test-project' })],
430+
loaded: true,
431+
};
432+
expect(getClusterQueueNameFromLocalQueues(undefined, localQueues)).toBeUndefined();
433+
});
434+
435+
it('should return undefined when localQueueName is empty string', () => {
436+
const localQueues = {
437+
data: [mockLocalQueueK8sResource({ name: 'my-queue', namespace: 'test-project' })],
438+
loaded: true,
439+
};
440+
expect(getClusterQueueNameFromLocalQueues('', localQueues)).toBeUndefined();
441+
});
442+
443+
it('should return undefined when localQueues.loaded is false', () => {
444+
const localQueues = {
445+
data: [mockLocalQueueK8sResource({ name: 'my-queue', namespace: 'test-project' })],
446+
loaded: false,
447+
};
448+
expect(getClusterQueueNameFromLocalQueues('my-queue', localQueues)).toBeUndefined();
449+
});
450+
451+
it('should return undefined when localQueues.data is not an array', () => {
452+
expect(
453+
getClusterQueueNameFromLocalQueues('my-queue', {
454+
data: undefined as unknown as never[],
455+
loaded: true,
456+
}),
457+
).toBeUndefined();
458+
});
459+
460+
it('should return undefined when localQueues.data is empty', () => {
461+
expect(
462+
getClusterQueueNameFromLocalQueues('my-queue', { data: [], loaded: true }),
463+
).toBeUndefined();
464+
});
465+
466+
it('should return cluster queue name when matching LocalQueue is found', () => {
467+
const localQueues = {
468+
data: [
469+
mockLocalQueueK8sResource({ name: 'test-queue', namespace: 'test-project' }),
470+
mockLocalQueueK8sResource({
471+
name: 'other-queue',
472+
namespace: 'test-project',
473+
}),
474+
],
475+
loaded: true,
476+
};
477+
expect(getClusterQueueNameFromLocalQueues('test-queue', localQueues)).toBe(
478+
'test-cluster-queue',
479+
);
480+
});
481+
482+
it('should return undefined when no LocalQueue matches localQueueName', () => {
483+
const localQueues = {
484+
data: [mockLocalQueueK8sResource({ name: 'other-queue', namespace: 'test-project' })],
485+
loaded: true,
486+
};
487+
expect(getClusterQueueNameFromLocalQueues('nonexistent-queue', localQueues)).toBeUndefined();
488+
});
489+
490+
it('should return undefined when matching queue has no spec.clusterQueue', () => {
491+
const queueWithoutClusterQueue = mockLocalQueueK8sResource({
492+
name: 'no-cluster-queue',
493+
namespace: 'test-project',
494+
});
495+
const localQueues = {
496+
data: [{ ...queueWithoutClusterQueue, spec: { ...queueWithoutClusterQueue.spec } }],
497+
loaded: true,
498+
};
499+
// Remove clusterQueue to simulate missing field
500+
delete (localQueues.data[0].spec as { clusterQueue?: string }).clusterQueue;
501+
expect(getClusterQueueNameFromLocalQueues('no-cluster-queue', localQueues)).toBeUndefined();
502+
});
503+
});

frontend/src/pages/hardwareProfiles/utils.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { HardwareProfileKind } from '#~/k8sTypes';
1+
import { HardwareProfileKind, LocalQueueKind } from '#~/k8sTypes';
22
import { DisplayNameAnnotation, Identifier, IdentifierResourceType } from '#~/types';
33
import {
44
HardwareProfileWarningType,
@@ -243,3 +243,19 @@ export const orderHardwareProfiles = (
243243
}
244244
return alphaSortHardwareProfilesByName(profiles);
245245
};
246+
247+
export const getClusterQueueNameFromLocalQueues = (
248+
localQueueName: string | undefined,
249+
localQueues: { data: LocalQueueKind[]; loaded: boolean },
250+
): string | undefined => {
251+
if (
252+
!localQueueName ||
253+
!localQueues.loaded ||
254+
!Array.isArray(localQueues.data) ||
255+
localQueues.data.length === 0
256+
) {
257+
return undefined;
258+
}
259+
const queue = localQueues.data.find((q) => q.metadata?.name === localQueueName);
260+
return queue?.spec.clusterQueue;
261+
};

frontend/src/pages/projects/ProjectDetailsContext.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
GroupKind,
55
HardwareProfileKind,
66
InferenceServiceKind,
7+
LocalQueueKind,
78
PersistentVolumeClaimKind,
89
ProjectKind,
910
RoleBindingKind,
@@ -34,6 +35,7 @@ import { SupportedArea, useIsAreaAvailable } from '#~/concepts/areas';
3435
import { Connection } from '#~/concepts/connectionTypes/types';
3536
import { useGroups, useTemplates } from '#~/api';
3637
import { useWatchHardwareProfiles } from '#~/utilities/useWatchHardwareProfiles';
38+
import useProjectKueueInfo from './useProjectKueueInfo';
3739
import { NotebookState } from './notebook/types';
3840
import useProjectNotebookStates from './notebook/useProjectNotebookStates';
3941
import useProjectPvcs from './screens/detail/storage/useProjectPvcs';
@@ -55,6 +57,7 @@ export type ProjectDetailsContextType = {
5557
projectSharingRB: FetchStateObject<RoleBindingKind[]>;
5658
groups: CustomWatchK8sResult<GroupKind[]>;
5759
projectHardwareProfiles: CustomWatchK8sResult<HardwareProfileKind[]>;
60+
localQueues: FetchStateObject<LocalQueueKind[]>;
5861
};
5962

6063
export const ProjectDetailsContext = React.createContext<ProjectDetailsContextType>({
@@ -72,6 +75,7 @@ export const ProjectDetailsContext = React.createContext<ProjectDetailsContextTy
7275
projectSharingRB: DEFAULT_LIST_FETCH_STATE,
7376
groups: DEFAULT_LIST_WATCH_RESULT,
7477
projectHardwareProfiles: DEFAULT_LIST_WATCH_RESULT,
78+
localQueues: DEFAULT_LIST_FETCH_STATE,
7579
});
7680

7781
const ProjectDetailsContextProvider: React.FC = () => {
@@ -97,6 +101,7 @@ const ProjectDetailsContextProvider: React.FC = () => {
97101
const groups = useGroups();
98102
const projectHardwareProfiles = useWatchHardwareProfiles(namespace);
99103

104+
const { localQueues } = useProjectKueueInfo(project, namespace);
100105
const pageName = 'project details';
101106

102107
const filterTokens = React.useCallback(
@@ -138,6 +143,7 @@ const ProjectDetailsContextProvider: React.FC = () => {
138143
projectSharingRB,
139144
groups,
140145
projectHardwareProfiles,
146+
localQueues,
141147
}
142148
: null,
143149
[
@@ -155,6 +161,7 @@ const ProjectDetailsContextProvider: React.FC = () => {
155161
projectSharingRB,
156162
groups,
157163
projectHardwareProfiles,
164+
localQueues,
158165
],
159166
);
160167

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { testHook } from '@odh-dashboard/jest-config/hooks';
2+
import { mockProjectK8sResource } from '#~/__mocks__/mockProjectK8sResource';
3+
import { mockLocalQueueK8sResource } from '#~/__mocks__/mockLocalQueueK8sResource';
4+
import useProjectKueueInfo from '#~/pages/projects/useProjectKueueInfo';
5+
import {
6+
useKueueConfiguration,
7+
KueueFilteringState,
8+
} from '#~/concepts/hardwareProfiles/kueueUtils';
9+
import useLocalQueues from '#~/concepts/distributedWorkloads/useLocalQueues';
10+
11+
jest.mock('#~/concepts/hardwareProfiles/kueueUtils', () => ({
12+
...jest.requireActual('#~/concepts/hardwareProfiles/kueueUtils'),
13+
useKueueConfiguration: jest.fn(),
14+
}));
15+
16+
jest.mock('#~/concepts/distributedWorkloads/useLocalQueues', () => ({
17+
__esModule: true,
18+
default: jest.fn(),
19+
}));
20+
21+
const useKueueConfigurationMock = jest.mocked(useKueueConfiguration);
22+
const useLocalQueuesMock = jest.mocked(useLocalQueues);
23+
24+
const mockKueueConfig = {
25+
isKueueDisabled: false,
26+
isKueueFeatureEnabled: true,
27+
isProjectKueueEnabled: true,
28+
kueueFilteringState: KueueFilteringState.ONLY_KUEUE_PROFILES,
29+
};
30+
31+
const mockLocalQueues = [
32+
mockLocalQueueK8sResource({ name: 'test-queue', namespace: 'test-project' }),
33+
];
34+
35+
describe('useProjectKueueInfo', () => {
36+
beforeEach(() => {
37+
useKueueConfigurationMock.mockReturnValue(mockKueueConfig);
38+
useLocalQueuesMock.mockReturnValue([mockLocalQueues, true, undefined, jest.fn()]);
39+
});
40+
41+
it('should call useKueueConfiguration with undefined when project is null', () => {
42+
testHook(useProjectKueueInfo)(null, 'test-project');
43+
expect(useKueueConfigurationMock).toHaveBeenCalledWith(undefined);
44+
});
45+
46+
it('should call useKueueConfiguration with project when project is provided', () => {
47+
const project = mockProjectK8sResource({ k8sName: 'my-project', enableKueue: true });
48+
testHook(useProjectKueueInfo)(project, 'my-project');
49+
expect(useKueueConfigurationMock).toHaveBeenCalledWith(project);
50+
});
51+
52+
it('should call useLocalQueues with namespace when Kueue feature and project Kueue are enabled', () => {
53+
useKueueConfigurationMock.mockReturnValue({
54+
...mockKueueConfig,
55+
isKueueFeatureEnabled: true,
56+
isProjectKueueEnabled: true,
57+
});
58+
const renderResult = testHook(useProjectKueueInfo)(
59+
mockProjectK8sResource({ enableKueue: true }),
60+
'test-project',
61+
);
62+
expect(useLocalQueuesMock).toHaveBeenCalledWith('test-project');
63+
expect(renderResult.result.current.kueueConfig).toEqual(
64+
expect.objectContaining({
65+
isKueueFeatureEnabled: true,
66+
isProjectKueueEnabled: true,
67+
}),
68+
);
69+
expect(renderResult.result.current.localQueues).toEqual({
70+
data: mockLocalQueues,
71+
loaded: true,
72+
error: undefined,
73+
refresh: expect.any(Function),
74+
});
75+
});
76+
77+
it('should call useLocalQueues with undefined when Kueue feature is disabled', () => {
78+
useKueueConfigurationMock.mockReturnValue({
79+
...mockKueueConfig,
80+
isKueueFeatureEnabled: false,
81+
isProjectKueueEnabled: true,
82+
});
83+
testHook(useProjectKueueInfo)(mockProjectK8sResource({ enableKueue: true }), 'test-project');
84+
expect(useLocalQueuesMock).toHaveBeenCalledWith(undefined);
85+
});
86+
87+
it('should call useLocalQueues with undefined when project Kueue is not enabled', () => {
88+
useKueueConfigurationMock.mockReturnValue({
89+
...mockKueueConfig,
90+
isKueueFeatureEnabled: true,
91+
isProjectKueueEnabled: false,
92+
});
93+
testHook(useProjectKueueInfo)(mockProjectK8sResource({ enableKueue: false }), 'test-project');
94+
expect(useLocalQueuesMock).toHaveBeenCalledWith(undefined);
95+
});
96+
97+
it('should return localQueues with data, loaded, error, and refresh from useLocalQueues', () => {
98+
const refreshFn = jest.fn();
99+
useLocalQueuesMock.mockReturnValue([mockLocalQueues, true, undefined, refreshFn]);
100+
const renderResult = testHook(useProjectKueueInfo)(
101+
mockProjectK8sResource({ enableKueue: true }),
102+
'test-project',
103+
);
104+
expect(renderResult.result.current.localQueues).toEqual({
105+
data: mockLocalQueues,
106+
loaded: true,
107+
error: undefined,
108+
refresh: refreshFn,
109+
});
110+
});
111+
112+
it('should return localQueues with error when useLocalQueues returns error', () => {
113+
const error = new Error('Failed to fetch');
114+
useLocalQueuesMock.mockReturnValue([[], false, error, jest.fn()]);
115+
const renderResult = testHook(useProjectKueueInfo)(
116+
mockProjectK8sResource({ enableKueue: true }),
117+
'test-project',
118+
);
119+
expect(renderResult.result.current.localQueues).toEqual({
120+
data: [],
121+
loaded: false,
122+
error,
123+
refresh: expect.any(Function),
124+
});
125+
});
126+
});

0 commit comments

Comments
 (0)