From 16c8b21382d140af0732d3b1e4824f574c2882e0 Mon Sep 17 00:00:00 2001 From: Utkarshpandey0001 Date: Tue, 4 Aug 2026 12:04:19 +0530 Subject: [PATCH 1/3] kueue: Add Cohort views Signed-off-by: Utkarshpandey0001 --- kueue/README.md | 15 +- kueue/src/components/clusterqueues/Detail.tsx | 17 +- kueue/src/components/clusterqueues/List.tsx | 19 +- kueue/src/components/cohorts/Detail.tsx | 335 ++++++++++++++++++ kueue/src/components/cohorts/List.tsx | 100 ++++++ kueue/src/index.tsx | 25 ++ kueue/src/resources/cohort.ts | 141 ++++++++ kueue/src/resources/cohortFormatters.test.ts | 75 ++++ kueue/src/resources/cohortFormatters.ts | 50 +++ kueue/src/resources/cohortRelations.test.ts | 41 +++ kueue/src/resources/cohortRelations.ts | 41 +++ kueue/src/utils/kueueApi.test.ts | 5 + kueue/src/utils/kueueRoutes.ts | 4 + kueue/test-files/deploy/cohort-default.yaml | 48 +++ 14 files changed, 910 insertions(+), 6 deletions(-) create mode 100644 kueue/src/components/cohorts/Detail.tsx create mode 100644 kueue/src/components/cohorts/List.tsx create mode 100644 kueue/src/resources/cohort.ts create mode 100644 kueue/src/resources/cohortFormatters.test.ts create mode 100644 kueue/src/resources/cohortFormatters.ts create mode 100644 kueue/src/resources/cohortRelations.test.ts create mode 100644 kueue/src/resources/cohortRelations.ts create mode 100644 kueue/test-files/deploy/cohort-default.yaml diff --git a/kueue/README.md b/kueue/README.md index bd9f493fdc..c747076c23 100644 --- a/kueue/README.md +++ b/kueue/README.md @@ -6,7 +6,9 @@ See the [Kueue getting started guide](https://kueue.sigs.k8s.io/docs/getting-sta ## Current Scope -This plugin currently reads Kueue `ClusterQueue`, `LocalQueue`, `ResourceFlavor`, and `Workload` resources from the Kubernetes API and displays them in basic list and detail pages. It registers a Kueue sidebar section with entries for these resources. +This plugin currently reads Kueue `Cohort`, `ClusterQueue`, `LocalQueue`, `ResourceFlavor`, and `Workload` resources from the Kubernetes API and displays them in basic list and detail pages. It registers a Kueue sidebar section with entries for these resources. + +The views are read-only. Cohort pages show parent and child Cohort relationships, member ClusterQueues, ResourceFlavor references, resource groups, and fair-sharing values. Additional queueing views will be added in later PRs. @@ -14,13 +16,16 @@ Additional queueing views will be added in later PRs. Kueue CRDs must be installed in the cluster before the plugin can list resources. See the [Kueue installation guide](https://kueue.sigs.k8s.io/docs/getting-started/installation/) for installation instructions. -You can check for the `ClusterQueue`, `LocalQueue`, `ResourceFlavor`, and `Workload` CRDs and resources with: +You can check for the `Cohort`, `ClusterQueue`, `LocalQueue`, `ResourceFlavor`, and `Workload` CRDs and resources with: ```bash +kubectl get crd cohorts.kueue.x-k8s.io kubectl get crd clusterqueues.kueue.x-k8s.io kubectl get crd localqueues.kueue.x-k8s.io kubectl get crd resourceflavors.kueue.x-k8s.io kubectl get crd workloads.kueue.x-k8s.io +kubectl get cohorts +kubectl get cohort default -o yaml kubectl get clusterqueues kubectl get localqueues -A kubectl get localqueue -n -o yaml @@ -31,7 +36,7 @@ kubectl get workload -n -o yaml ## Test Files -Sample `ClusterQueue`, `LocalQueue`, `ResourceFlavor`, and Job manifests are available in `test-files/deploy/`. The sample Job uses the `team-a-queue` LocalQueue so Kueue can create a real `Workload` for local UI testing. +Sample `Cohort`, `ClusterQueue`, `LocalQueue`, `ResourceFlavor`, and Job manifests are available in `test-files/deploy/`. The sample ClusterQueue belongs to the `default` Cohort, and the sample Job uses the `team-a-queue` LocalQueue so Kueue can create a real `Workload` for local UI testing. Apply them to a cluster with Kueue installed: @@ -39,13 +44,15 @@ Apply them to a cluster with Kueue installed: kubectl apply -f test-files/deploy/resourceflavor-default.yaml kubectl apply -f test-files/deploy/resourceflavor-spot.yaml kubectl apply -f test-files/deploy/resourceflavor-topology.yaml +kubectl apply -f test-files/deploy/cohort-default.yaml kubectl apply -f test-files/deploy/clusterqueue-team-a.yaml kubectl apply -f test-files/deploy/localqueue-team-a.yaml kubectl apply -f test-files/deploy/job-sample-workload.yaml +kubectl get cohorts kubectl get workloads -A ``` -After applying the examples, open `Kueue` > `ClusterQueues`, `Kueue` > `LocalQueues`, `Kueue` > `ResourceFlavors`, or `Kueue` > `Workloads` in Headlamp. +After applying the examples, open `Kueue` > `Cohorts`, `Kueue` > `ClusterQueues`, `Kueue` > `LocalQueues`, `Kueue` > `ResourceFlavors`, or `Kueue` > `Workloads` in Headlamp. ## Development diff --git a/kueue/src/components/clusterqueues/Detail.tsx b/kueue/src/components/clusterqueues/Detail.tsx index 66019fbb11..c247f2b9f0 100644 --- a/kueue/src/components/clusterqueues/Detail.tsx +++ b/kueue/src/components/clusterqueues/Detail.tsx @@ -62,6 +62,21 @@ function renderFlavorLink(flavorName: string) { ); } +/** Render a Cohort reference as a detail-page link. */ +function renderCohortLink(clusterQueue: ClusterQueue) { + const cohortName = clusterQueue.spec.cohortName; + + if (!cohortName) { + return '-'; + } + + return ( + + {cohortName} + + ); +} + /** Convert nested ClusterQueue resource groups into table rows. */ function getResourceGroupRows(resourceGroups: ResourceGroup[]): ResourceGroupRow[] { return resourceGroups.flatMap((group, groupIndex): ResourceGroupRow[] => { @@ -296,7 +311,7 @@ export default function ClusterQueueDetail() { ? [ { name: 'Cohort', - value: clusterQueue.cohortName, + value: renderCohortLink(clusterQueue), }, { name: 'Queueing Strategy', diff --git a/kueue/src/components/clusterqueues/List.tsx b/kueue/src/components/clusterqueues/List.tsx index d21c3b8b07..5f0e1663cc 100644 --- a/kueue/src/components/clusterqueues/List.tsx +++ b/kueue/src/components/clusterqueues/List.tsx @@ -1,7 +1,23 @@ -import { ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { Link, ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { ClusterQueue } from '../../resources/clusterQueue'; +import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; +/** Render a ClusterQueue Cohort value as a detail-page link when present. */ +function renderCohortLink(clusterQueue: ClusterQueue) { + const cohortName = clusterQueue.spec.cohortName; + + if (!cohortName) { + return '-'; + } + + return ( + + {cohortName} + + ); +} + export default function ClusterQueueList() { return ( clusterQueue.cohortName, + render: (clusterQueue: ClusterQueue) => renderCohortLink(clusterQueue), }, { id: 'queueingStrategy', diff --git a/kueue/src/components/cohorts/Detail.tsx b/kueue/src/components/cohorts/Detail.tsx new file mode 100644 index 0000000000..1cd7214223 --- /dev/null +++ b/kueue/src/components/cohorts/Detail.tsx @@ -0,0 +1,335 @@ +import { + DetailsGrid, + Link, + SectionBox, + SimpleTable, +} from '@kinvolk/headlamp-plugin/lib/components/common'; +import { useParams } from 'react-router-dom'; +import { ClusterQueue, ResourceGroup, ResourceQuota } from '../../resources/clusterQueue'; +import { Cohort } from '../../resources/cohort'; +import { getChildCohorts, getCohortClusterQueues } from '../../resources/cohortRelations'; +import { kueueRouteNames } from '../../utils/kueueRoutes'; +import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; + +/** Flattened row rendered in the Cohort resource groups table. */ +interface ResourceGroupRow { + /** Display label for the resource group index from spec.resourceGroups. */ + group: string; + /** Comma-separated resources covered by this group, such as cpu and memory. */ + coveredResources: string; + /** ResourceFlavor name associated with this resource quota row. */ + flavor: string; + /** Resource name within the flavor quota, for example cpu or memory. */ + resource: string; + /** Shared nominal quota configured for the resource and flavor pair. */ + nominalQuota: string | number; + /** Optional quota this Cohort subtree can borrow from its parent subtree. */ + borrowingLimit?: string | number; + /** Optional quota this Cohort subtree can lend to its parent subtree. */ + lendingLimit?: string | number; +} + +/** Render a Cohort name as a Headlamp link to its detail page. */ +function renderCohortLink(cohortName: string) { + return ( + + {cohortName} + + ); +} + +/** Render a ResourceFlavor name as a Headlamp link to its detail page. */ +function renderFlavorLink(flavorName: string) { + return ( + + {flavorName} + + ); +} + +/** Render a ClusterQueue name as a Headlamp link to its detail page. */ +function renderClusterQueueLink(clusterQueueName: string) { + return ( + + {clusterQueueName} + + ); +} + +/** Render the parent Cohort value as a link when one is configured. */ +function renderParentLink(cohort: Cohort) { + const parentName = cohort.spec.parentName; + + if (!parentName) { + return 'Root'; + } + + return renderCohortLink(parentName); +} + +/** Convert nested Cohort resource groups into table rows. */ +function getResourceGroupRows(resourceGroups: ResourceGroup[]): ResourceGroupRow[] { + return resourceGroups.flatMap((group, groupIndex): ResourceGroupRow[] => { + const groupLabel = `Group ${groupIndex + 1}`; + const coveredResources = group.coveredResources?.join(', ') || '-'; + + if (!group.flavors?.length) { + return [ + { + group: groupLabel, + coveredResources, + flavor: '-', + resource: '-', + nominalQuota: '-', + }, + ]; + } + + return group.flavors.flatMap((flavor): ResourceGroupRow[] => { + if (!flavor.resources?.length) { + return [ + { + group: groupLabel, + coveredResources, + flavor: flavor.name, + resource: '-', + nominalQuota: '-', + }, + ]; + } + + return flavor.resources.map((resource: ResourceQuota) => ({ + group: groupLabel, + coveredResources, + flavor: flavor.name, + resource: resource.name, + nominalQuota: resource.nominalQuota, + borrowingLimit: resource.borrowingLimit, + lendingLimit: resource.lendingLimit, + })); + }); + }); +} + +/** Build the detail section that shows spec.resourceGroups as a table. */ +function getResourceGroupsSection(cohort: Cohort) { + const rows = getResourceGroupRows(cohort.resourceGroups); + + if (rows.length === 0) { + return null; + } + + return { + id: 'resource-groups', + section: ( + + row.group, + }, + { + label: 'Covered Resources', + getter: (row: ResourceGroupRow) => row.coveredResources, + }, + { + label: 'ResourceFlavor', + getter: (row: ResourceGroupRow) => + row.flavor === '-' ? '-' : renderFlavorLink(row.flavor), + }, + { + label: 'Resource', + getter: (row: ResourceGroupRow) => row.resource, + }, + { + label: 'Nominal Quota', + getter: (row: ResourceGroupRow) => row.nominalQuota, + }, + { + label: 'Borrowing Limit', + getter: (row: ResourceGroupRow) => row.borrowingLimit ?? '-', + }, + { + label: 'Lending Limit', + getter: (row: ResourceGroupRow) => row.lendingLimit ?? '-', + }, + ]} + /> + + ), + }; +} + +/** Build a linked ResourceFlavor section for all flavors referenced by the Cohort. */ +function getReferencedFlavorsSection(cohort: Cohort) { + if (!cohort.referencedFlavorNames.length) { + return null; + } + + const rows = cohort.referencedFlavorNames.map(flavorName => ({ name: flavorName })); + + return { + id: 'referenced-resourceflavors', + section: ( + + renderFlavorLink(row.name), + }, + ]} + /> + + ), + }; +} + +/** Build a linked table for child Cohorts. */ +function getChildCohortsSection(cohort: Cohort, cohorts: Cohort[] | null) { + const rows = getChildCohorts(cohorts, cohort.metadata.name); + + if (rows.length === 0) { + return null; + } + + return { + id: 'child-cohorts', + section: ( + + renderCohortLink(childCohort.metadata.name), + }, + { + label: 'Resource Groups', + getter: (childCohort: Cohort) => childCohort.resourceGroupsDisplay, + }, + { + label: 'Fair Sharing Weight', + getter: (childCohort: Cohort) => childCohort.fairSharingWeight, + }, + { + label: 'Weighted Share', + getter: (childCohort: Cohort) => childCohort.weightedShare, + }, + { + label: 'Age', + getter: (childCohort: Cohort) => childCohort.getAge(), + }, + ]} + /> + + ), + }; +} + +/** Build a linked table for ClusterQueues that belong to the Cohort. */ +function getMemberClusterQueuesSection(cohort: Cohort, clusterQueues: ClusterQueue[] | null) { + const rows = getCohortClusterQueues(clusterQueues, cohort.metadata.name); + + if (rows.length === 0) { + return null; + } + + return { + id: 'member-clusterqueues', + section: ( + + + renderClusterQueueLink(clusterQueue.metadata.name), + }, + { + label: 'Queueing Strategy', + getter: (clusterQueue: ClusterQueue) => clusterQueue.queueingStrategy, + }, + { + label: 'Resource Groups', + getter: (clusterQueue: ClusterQueue) => clusterQueue.resourceGroupsDisplay, + }, + { + label: 'Pending Workloads', + getter: (clusterQueue: ClusterQueue) => clusterQueue.pendingWorkloads, + }, + { + label: 'Admitted Workloads', + getter: (clusterQueue: ClusterQueue) => clusterQueue.admittedWorkloads, + }, + { + label: 'Status', + getter: (clusterQueue: ClusterQueue) => clusterQueue.statusDisplay, + }, + { + label: 'Age', + getter: (clusterQueue: ClusterQueue) => clusterQueue.getAge(), + }, + ]} + /> + + ), + }; +} + +/** Detail view for a cluster-scoped Kueue Cohort resource. */ +export default function CohortDetail() { + const { name } = useParams<{ name: string }>(); + const [clusterQueues] = ClusterQueue.useList(); + const [cohorts] = Cohort.useList(); + + return ( + + + cohort + ? [ + { + name: 'Parent Cohort', + value: renderParentLink(cohort), + }, + { + name: 'Fair Sharing Weight', + value: cohort.fairSharingWeight, + }, + { + name: 'Weighted Share', + value: cohort.weightedShare, + }, + { + name: 'Resource Groups', + value: cohort.resourceGroupsDisplay, + }, + { + name: 'Referenced ResourceFlavors', + value: cohort.referencedFlavorNamesDisplay, + }, + ] + : [] + } + extraSections={cohort => + cohort + ? [ + getResourceGroupsSection(cohort), + getReferencedFlavorsSection(cohort), + getChildCohortsSection(cohort, cohorts), + getMemberClusterQueuesSection(cohort, clusterQueues), + ].filter(Boolean) + : [] + } + /> + + ); +} diff --git a/kueue/src/components/cohorts/List.tsx b/kueue/src/components/cohorts/List.tsx new file mode 100644 index 0000000000..a6d84f29eb --- /dev/null +++ b/kueue/src/components/cohorts/List.tsx @@ -0,0 +1,100 @@ +import { Link, ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { useMemo } from 'react'; +import { ClusterQueue } from '../../resources/clusterQueue'; +import { Cohort } from '../../resources/cohort'; +import { renderRelatedCount } from '../../resources/cohortFormatters'; +import { getChildCohorts, getCohortClusterQueues } from '../../resources/cohortRelations'; +import { kueueRouteNames } from '../../utils/kueueRoutes'; +import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; + +/** Render a Cohort parent reference as a detail-page link. */ +function renderParentLink(cohort: Cohort) { + const parentName = cohort.spec.parentName; + + if (!parentName) { + return 'Root'; + } + + return ( + + {parentName} + + ); +} + +export default function CohortList() { + const [clusterQueues] = ClusterQueue.useList(); + const [cohorts] = Cohort.useList(); + + const clusterQueuesByCohort = useMemo( + () => + new Map( + (cohorts || []).map(cohort => [ + cohort.metadata.name, + getCohortClusterQueues(clusterQueues, cohort.metadata.name), + ]) + ), + [clusterQueues, cohorts] + ); + const childCohortsByCohort = useMemo( + () => + new Map( + (cohorts || []).map(cohort => [ + cohort.metadata.name, + getChildCohorts(cohorts, cohort.metadata.name), + ]) + ), + [cohorts] + ); + + return ( + + cohort.parentNameDisplay, + render: (cohort: Cohort) => renderParentLink(cohort), + }, + { + id: 'clusterQueues', + label: 'ClusterQueues', + getValue: (cohort: Cohort) => + renderRelatedCount(clusterQueuesByCohort.get(cohort.metadata.name)), + }, + { + id: 'childCohorts', + label: 'Child Cohorts', + getValue: (cohort: Cohort) => + renderRelatedCount(childCohortsByCohort.get(cohort.metadata.name)), + }, + { + id: 'resourceGroups', + label: 'Resource Groups', + getValue: (cohort: Cohort) => cohort.resourceGroupsDisplay, + }, + { + id: 'resourceFlavors', + label: 'Resource Flavors', + getValue: (cohort: Cohort) => cohort.referencedFlavorNamesDisplay, + }, + { + id: 'fairSharingWeight', + label: 'Fair Sharing Weight', + getValue: (cohort: Cohort) => cohort.fairSharingWeight, + }, + { + id: 'weightedShare', + label: 'Weighted Share', + getValue: (cohort: Cohort) => cohort.weightedShare, + }, + 'age', + ]} + /> + + ); +} diff --git a/kueue/src/index.tsx b/kueue/src/index.tsx index 58e82872af..62755a470b 100644 --- a/kueue/src/index.tsx +++ b/kueue/src/index.tsx @@ -1,6 +1,8 @@ import { registerRoute, registerSidebarEntry } from '@kinvolk/headlamp-plugin/lib'; import ClusterQueueDetail from './components/clusterqueues/Detail'; import ClusterQueueList from './components/clusterqueues/List'; +import CohortDetail from './components/cohorts/Detail'; +import CohortList from './components/cohorts/List'; import LocalQueueDetail from './components/localqueues/Detail'; import LocalQueueList from './components/localqueues/List'; import ResourceFlavorDetail from './components/resourceflavors/Detail'; @@ -24,6 +26,13 @@ registerSidebarEntry({ url: kueueRoutePaths.clusterQueuesList, }); +registerSidebarEntry({ + parent: 'kueue', + name: 'kueue-cohorts', + label: 'Cohorts', + url: kueueRoutePaths.cohortsList, +}); + registerSidebarEntry({ parent: 'kueue', name: 'kueue-localqueues', @@ -61,6 +70,22 @@ registerRoute({ component: () => , }); +registerRoute({ + path: kueueRoutePaths.cohortsList, + sidebar: 'kueue-cohorts', + name: kueueRouteNames.cohortsList, + exact: true, + component: () => , +}); + +registerRoute({ + path: kueueRoutePaths.cohortDetail, + sidebar: 'kueue-cohorts', + name: kueueRouteNames.cohortDetail, + exact: true, + component: () => , +}); + registerRoute({ path: kueueRoutePaths.localQueuesList, sidebar: 'kueue-localqueues', diff --git a/kueue/src/resources/cohort.ts b/kueue/src/resources/cohort.ts new file mode 100644 index 0000000000..302418e173 --- /dev/null +++ b/kueue/src/resources/cohort.ts @@ -0,0 +1,141 @@ +import { KubeObject, KubeObjectInterface } from '@kinvolk/headlamp-plugin/lib/k8s/cluster'; +import { kueueApiVersions } from '../utils/kueueApi'; +import { kueueRoutePaths } from '../utils/kueueRoutes'; +import type { FairSharing, FairSharingStatus, ResourceGroup } from './clusterQueue'; +import { renderFairSharing } from './clusterQueueFormatters'; +import { + getCohortUniqueFlavorNames, + renderCohortFlavorNames, + renderCohortResourceGroupsSummary, + renderFairSharingWeight, + renderParentName, + renderParentNameDisplay, + renderWeightedShare, +} from './cohortFormatters'; + +const COHORT_API_DOCS = 'https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohort'; +const COHORT_SPEC_DOCS = 'https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortspec'; +const COHORT_STATUS_DOCS = 'https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortstatus'; + +/** + * Desired state of a Kueue Cohort. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortspec + */ +export interface CohortSpec { + /** + * Parent Cohort name. Empty means this Cohort is a root. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortspec + */ + parentName?: string; + /** + * Resource groups with resources and ResourceFlavors that provide shared Cohort quota. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortspec + */ + resourceGroups?: ResourceGroup[]; + /** + * FairSharing settings used when Kueue fair sharing is enabled. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortspec + */ + fairSharing?: FairSharing; +} + +/** + * Observed state of a Kueue Cohort. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortstatus + */ +export interface CohortStatus { + /** + * Current FairSharing state reported by Kueue. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortstatus + */ + fairSharing?: FairSharingStatus; +} + +/** + * Kubernetes Cohort object returned by the Kueue API. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohort + */ +export interface KubeCohort extends KubeObjectInterface { + /** + * Kubernetes object metadata for the Cohort. + * + * @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta + */ + metadata: KubeObjectInterface['metadata']; + /** + * Cohort desired state. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortspec + */ + spec?: CohortSpec; + /** + * Cohort observed state. + * + * @see https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/#cohortstatus + */ + status?: CohortStatus; +} + +export class Cohort extends KubeObject { + static kind = 'Cohort'; + static apiName = 'cohorts'; + static apiVersion = kueueApiVersions; + static isNamespaced = false; + + static get detailsRoute() { + return kueueRoutePaths.cohortDetail; + } + + get spec(): CohortSpec { + return this.jsonData.spec ?? {}; + } + + get status(): CohortStatus { + return this.jsonData.status ?? {}; + } + + get parentName() { + return renderParentName(this.spec.parentName); + } + + get parentNameDisplay() { + return renderParentNameDisplay(this.spec.parentName); + } + + get resourceGroups() { + return this.spec.resourceGroups || []; + } + + get resourceGroupsDisplay() { + return renderCohortResourceGroupsSummary(this.resourceGroups); + } + + get referencedFlavorNames() { + return getCohortUniqueFlavorNames(this.resourceGroups); + } + + get referencedFlavorNamesDisplay() { + return renderCohortFlavorNames(this.resourceGroups); + } + + get fairSharingWeight() { + return renderFairSharingWeight(this.spec.fairSharing); + } + + get fairSharingDisplay() { + return renderFairSharing(this.spec.fairSharing, this.status.fairSharing); + } + + get weightedShare() { + return renderWeightedShare(this.status.fairSharing); + } +} + +export { COHORT_API_DOCS, COHORT_SPEC_DOCS, COHORT_STATUS_DOCS }; diff --git a/kueue/src/resources/cohortFormatters.test.ts b/kueue/src/resources/cohortFormatters.test.ts new file mode 100644 index 0000000000..689e5a9ec8 --- /dev/null +++ b/kueue/src/resources/cohortFormatters.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + getCohortUniqueFlavorNames, + renderCohortFlavorNames, + renderCohortResourceGroupsSummary, + renderFairSharingWeight, + renderParentName, + renderParentNameDisplay, + renderRelatedCount, + renderWeightedShare, +} from './cohortFormatters'; + +describe('Cohort formatters', () => { + it('formats an empty parent name as a root fallback', () => { + expect(renderParentName()).toBe('-'); + expect(renderParentName('')).toBe('-'); + expect(renderParentNameDisplay()).toBe('Root'); + }); + + it('formats empty resource groups safely', () => { + expect(renderCohortResourceGroupsSummary([])).toBe('-'); + expect(renderCohortFlavorNames([])).toBe('-'); + }); + + it('summarizes resource groups', () => { + expect( + renderCohortResourceGroupsSummary([ + { + coveredResources: ['cpu', 'memory'], + flavors: [ + { name: 'default', resources: [{ name: 'cpu', nominalQuota: '8' }] }, + { name: 'spot', resources: [{ name: 'cpu', nominalQuota: '16' }] }, + ], + }, + { + coveredResources: ['nvidia.com/gpu'], + flavors: [{ name: 'gpu', resources: [{ name: 'nvidia.com/gpu', nominalQuota: '2' }] }], + }, + ]) + ).toBe('2 groups, 3 flavors'); + }); + + it('extracts unique ResourceFlavor names', () => { + expect( + getCohortUniqueFlavorNames([ + { + flavors: [{ name: 'spot' }, { name: 'default' }], + }, + { + flavors: [{ name: 'spot' }, { name: '' }], + }, + ]) + ).toEqual(['default', 'spot']); + }); + + it('formats missing fair-sharing values', () => { + expect(renderFairSharingWeight()).toBe('-'); + expect(renderWeightedShare()).toBe('-'); + }); + + it('formats configured fair-sharing weight', () => { + expect(renderFairSharingWeight({ weight: '0.75' })).toBe('0.75'); + expect(renderFairSharingWeight({ weight: 2 })).toBe(2); + }); + + it('formats status weighted share', () => { + expect(renderWeightedShare({ weightedShare: 12 })).toBe(12); + expect(renderWeightedShare({ weightedShare: 0 })).toBe(0); + }); + + it('renders related resource counts without loading data', () => { + expect(renderRelatedCount(null)).toBe(0); + expect(renderRelatedCount([{ name: 'one' }, { name: 'two' }])).toBe(2); + }); +}); diff --git a/kueue/src/resources/cohortFormatters.ts b/kueue/src/resources/cohortFormatters.ts new file mode 100644 index 0000000000..ea33dd9281 --- /dev/null +++ b/kueue/src/resources/cohortFormatters.ts @@ -0,0 +1,50 @@ +import type { + FairSharingLike, + FairSharingStatusLike, + ResourceGroupLike, +} from './clusterQueueFormatters'; +import { + getUniqueFlavorNames, + renderResourceGroupsSummary, + renderStringList, +} from './clusterQueueFormatters'; + +/** Render a Cohort parent name, falling back when this Cohort is a root. */ +export function renderParentName(parentName?: string) { + return parentName || '-'; +} + +/** Render a Cohort parent name for detail/list views, using Root for root Cohorts. */ +export function renderParentNameDisplay(parentName?: string) { + return parentName || 'Root'; +} + +/** Render a Cohort resource group summary. */ +export function renderCohortResourceGroupsSummary(resourceGroups: ResourceGroupLike[]) { + return renderResourceGroupsSummary(resourceGroups); +} + +/** Return unique ResourceFlavor names referenced by Cohort resource groups. */ +export function getCohortUniqueFlavorNames(resourceGroups: ResourceGroupLike[]) { + return getUniqueFlavorNames(resourceGroups); +} + +/** Render ResourceFlavor references from Cohort resource groups. */ +export function renderCohortFlavorNames(resourceGroups: ResourceGroupLike[]) { + return renderStringList(getCohortUniqueFlavorNames(resourceGroups)); +} + +/** Render the configured fair-sharing weight for a Cohort. */ +export function renderFairSharingWeight(fairSharing?: FairSharingLike) { + return fairSharing?.weight !== undefined ? fairSharing.weight : '-'; +} + +/** Render the status weighted share for a Cohort. */ +export function renderWeightedShare(fairSharingStatus?: FairSharingStatusLike) { + return fairSharingStatus?.weightedShare !== undefined ? fairSharingStatus.weightedShare : '-'; +} + +/** Render a related-resource count while related resources are still loading. */ +export function renderRelatedCount(resources?: unknown[] | null) { + return resources?.length ?? 0; +} diff --git a/kueue/src/resources/cohortRelations.test.ts b/kueue/src/resources/cohortRelations.test.ts new file mode 100644 index 0000000000..a014821ae9 --- /dev/null +++ b/kueue/src/resources/cohortRelations.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { getChildCohorts, getCohortClusterQueues } from './cohortRelations'; + +describe('Cohort relationship helpers', () => { + it('handles undefined related-resource lists', () => { + expect(getChildCohorts(undefined, 'default')).toEqual([]); + expect(getCohortClusterQueues(undefined, 'default')).toEqual([]); + }); + + it('handles empty related-resource lists', () => { + expect(getChildCohorts([], 'default')).toEqual([]); + expect(getCohortClusterQueues([], 'default')).toEqual([]); + }); + + it('matches exact child Cohorts', () => { + const child = { spec: { parentName: 'default' } }; + + expect(getChildCohorts([child], 'default')).toEqual([child]); + }); + + it('excludes Cohorts with a different parent', () => { + expect( + getChildCohorts( + [{ spec: { parentName: 'default' } }, { spec: { parentName: 'other' } }], + 'default' + ) + ).toHaveLength(1); + }); + + it('matches exact ClusterQueue Cohorts', () => { + const clusterQueue = { spec: { cohortName: 'default' } }; + + expect(getCohortClusterQueues([clusterQueue], 'default')).toEqual([clusterQueue]); + }); + + it('excludes ClusterQueues without cohortName', () => { + expect( + getCohortClusterQueues([{ spec: { cohortName: 'default' } }, { spec: {} }], 'default') + ).toHaveLength(1); + }); +}); diff --git a/kueue/src/resources/cohortRelations.ts b/kueue/src/resources/cohortRelations.ts new file mode 100644 index 0000000000..b7fb3c4bb8 --- /dev/null +++ b/kueue/src/resources/cohortRelations.ts @@ -0,0 +1,41 @@ +/** Minimal Cohort shape needed to derive parent-child relationships. */ +export interface CohortRelationshipLike { + /** Desired Cohort state. */ + spec?: { + /** Optional parent Cohort name. */ + parentName?: string; + }; +} + +/** Minimal ClusterQueue shape needed to derive Cohort membership. */ +export interface ClusterQueueCohortRelationshipLike { + /** Desired ClusterQueue state. */ + spec?: { + /** Optional Cohort name this ClusterQueue belongs to. */ + cohortName?: string; + }; +} + +/** Return Cohorts whose parentName points at the given Cohort. */ +export function getChildCohorts( + cohorts: T[] | null | undefined, + parentName?: string +) { + if (!parentName) { + return []; + } + + return (cohorts || []).filter(cohort => cohort.spec?.parentName === parentName); +} + +/** Return ClusterQueues whose cohortName points at the given Cohort. */ +export function getCohortClusterQueues( + clusterQueues: T[] | null | undefined, + cohortName?: string +) { + if (!cohortName) { + return []; + } + + return (clusterQueues || []).filter(clusterQueue => clusterQueue.spec?.cohortName === cohortName); +} diff --git a/kueue/src/utils/kueueApi.test.ts b/kueue/src/utils/kueueApi.test.ts index 72edf669fc..4e7eb164fe 100644 --- a/kueue/src/utils/kueueApi.test.ts +++ b/kueue/src/utils/kueueApi.test.ts @@ -18,6 +18,11 @@ describe('Kueue API constants', () => { expect(kueueRoutePaths.clusterQueueDetail).toBe('/kueue/clusterqueues/:name'); }); + it('defines Cohort list and detail routes', () => { + expect(kueueRoutePaths.cohortsList).toBe('/kueue/cohorts'); + expect(kueueRoutePaths.cohortDetail).toBe('/kueue/cohorts/:name'); + }); + it('defines Workload list and namespaced detail routes', () => { expect(kueueRoutePaths.workloadsList).toBe('/kueue/workloads'); expect(kueueRoutePaths.workloadDetail).toBe('/kueue/workloads/:namespace/:name'); diff --git a/kueue/src/utils/kueueRoutes.ts b/kueue/src/utils/kueueRoutes.ts index 73fc24b62d..4f726c4c33 100644 --- a/kueue/src/utils/kueueRoutes.ts +++ b/kueue/src/utils/kueueRoutes.ts @@ -1,4 +1,6 @@ export const kueueRouteNames = { + cohortsList: 'kueue-cohorts-list', + cohortDetail: 'kueue-cohort-detail', clusterQueuesList: 'kueue-clusterqueues-list', clusterQueueDetail: 'kueue-clusterqueue-detail', localQueuesList: 'kueue-localqueues-list', @@ -10,6 +12,8 @@ export const kueueRouteNames = { } as const; export const kueueRoutePaths = { + cohortsList: '/kueue/cohorts', + cohortDetail: '/kueue/cohorts/:name', clusterQueuesList: '/kueue/clusterqueues', clusterQueueDetail: '/kueue/clusterqueues/:name', localQueuesList: '/kueue/localqueues', diff --git a/kueue/test-files/deploy/cohort-default.yaml b/kueue/test-files/deploy/cohort-default.yaml new file mode 100644 index 0000000000..e6a81eb744 --- /dev/null +++ b/kueue/test-files/deploy/cohort-default.yaml @@ -0,0 +1,48 @@ +apiVersion: kueue.x-k8s.io/v1beta2 +kind: Cohort +metadata: + name: default +spec: + fairSharing: + weight: "1" + resourceGroups: + - coveredResources: + - cpu + - memory + flavors: + - name: default + resources: + - name: cpu + nominalQuota: "12" + - name: memory + nominalQuota: 48Gi + - name: spot + resources: + - name: cpu + nominalQuota: "24" + - name: memory + nominalQuota: 96Gi +--- +apiVersion: kueue.x-k8s.io/v1beta2 +kind: Cohort +metadata: + name: engineering +spec: + parentName: default + fairSharing: + weight: "0.75" + resourceGroups: + - coveredResources: + - cpu + - memory + flavors: + - name: default + resources: + - name: cpu + nominalQuota: "4" + borrowingLimit: "8" + lendingLimit: "2" + - name: memory + nominalQuota: 16Gi + borrowingLimit: 32Gi + lendingLimit: 8Gi From 22a549ebba74f4c360c8c327f1a6f2d527f9af64 Mon Sep 17 00:00:00 2001 From: Utkarshpandey0001 Date: Tue, 11 Aug 2026 22:14:12 +0530 Subject: [PATCH 2/3] kueue: Address Cohort review feedback Signed-off-by: Utkarshpandey0001 --- kueue/src/components/clusterqueues/Detail.tsx | 18 +----- kueue/src/components/clusterqueues/List.tsx | 21 +------ kueue/src/components/cohorts/Detail.tsx | 54 +++++++++++++++-- kueue/src/components/cohorts/List.tsx | 58 +++++++++++-------- .../components/common/KueueResourceLinks.tsx | 15 +++++ kueue/src/resources/cohortRelations.test.ts | 33 ++++++++++- kueue/src/resources/cohortRelations.ts | 42 ++++++++++++++ 7 files changed, 175 insertions(+), 66 deletions(-) create mode 100644 kueue/src/components/common/KueueResourceLinks.tsx diff --git a/kueue/src/components/clusterqueues/Detail.tsx b/kueue/src/components/clusterqueues/Detail.tsx index c247f2b9f0..98b2b0a388 100644 --- a/kueue/src/components/clusterqueues/Detail.tsx +++ b/kueue/src/components/clusterqueues/Detail.tsx @@ -14,6 +14,7 @@ import { } from '../../resources/clusterQueue'; import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; +import { renderCohortLink } from '../common/KueueResourceLinks'; /** Flattened row rendered in the ClusterQueue resource groups table. */ interface ResourceGroupRow { @@ -62,21 +63,6 @@ function renderFlavorLink(flavorName: string) { ); } -/** Render a Cohort reference as a detail-page link. */ -function renderCohortLink(clusterQueue: ClusterQueue) { - const cohortName = clusterQueue.spec.cohortName; - - if (!cohortName) { - return '-'; - } - - return ( - - {cohortName} - - ); -} - /** Convert nested ClusterQueue resource groups into table rows. */ function getResourceGroupRows(resourceGroups: ResourceGroup[]): ResourceGroupRow[] { return resourceGroups.flatMap((group, groupIndex): ResourceGroupRow[] => { @@ -311,7 +297,7 @@ export default function ClusterQueueDetail() { ? [ { name: 'Cohort', - value: renderCohortLink(clusterQueue), + value: renderCohortLink(clusterQueue.spec.cohortName), }, { name: 'Queueing Strategy', diff --git a/kueue/src/components/clusterqueues/List.tsx b/kueue/src/components/clusterqueues/List.tsx index 5f0e1663cc..59a49c75aa 100644 --- a/kueue/src/components/clusterqueues/List.tsx +++ b/kueue/src/components/clusterqueues/List.tsx @@ -1,22 +1,7 @@ -import { Link, ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { ClusterQueue } from '../../resources/clusterQueue'; -import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; - -/** Render a ClusterQueue Cohort value as a detail-page link when present. */ -function renderCohortLink(clusterQueue: ClusterQueue) { - const cohortName = clusterQueue.spec.cohortName; - - if (!cohortName) { - return '-'; - } - - return ( - - {cohortName} - - ); -} +import { renderCohortLink } from '../common/KueueResourceLinks'; export default function ClusterQueueList() { return ( @@ -34,7 +19,7 @@ export default function ClusterQueueList() { id: 'cohort', label: 'Cohort', getValue: (clusterQueue: ClusterQueue) => clusterQueue.cohortName, - render: (clusterQueue: ClusterQueue) => renderCohortLink(clusterQueue), + render: (clusterQueue: ClusterQueue) => renderCohortLink(clusterQueue.spec.cohortName), }, { id: 'queueingStrategy', diff --git a/kueue/src/components/cohorts/Detail.tsx b/kueue/src/components/cohorts/Detail.tsx index 1cd7214223..a0d15bdc44 100644 --- a/kueue/src/components/cohorts/Detail.tsx +++ b/kueue/src/components/cohorts/Detail.tsx @@ -1,6 +1,8 @@ import { DetailsGrid, + EmptyContent, Link, + Loader, SectionBox, SimpleTable, } from '@kinvolk/headlamp-plugin/lib/components/common'; @@ -189,7 +191,25 @@ function getReferencedFlavorsSection(cohort: Cohort) { } /** Build a linked table for child Cohorts. */ -function getChildCohortsSection(cohort: Cohort, cohorts: Cohort[] | null) { +function getChildCohortsSection(cohort: Cohort, cohorts: Cohort[] | null, error: unknown) { + if (error) { + return { + id: 'child-cohorts', + section: ( + + Child Cohorts are unavailable. + + ), + }; + } + + if (!cohorts) { + return { + id: 'child-cohorts', + section: , + }; + } + const rows = getChildCohorts(cohorts, cohort.metadata.name); if (rows.length === 0) { @@ -231,7 +251,29 @@ function getChildCohortsSection(cohort: Cohort, cohorts: Cohort[] | null) { } /** Build a linked table for ClusterQueues that belong to the Cohort. */ -function getMemberClusterQueuesSection(cohort: Cohort, clusterQueues: ClusterQueue[] | null) { +function getMemberClusterQueuesSection( + cohort: Cohort, + clusterQueues: ClusterQueue[] | null, + error: unknown +) { + if (error) { + return { + id: 'member-clusterqueues', + section: ( + + Member ClusterQueues are unavailable. + + ), + }; + } + + if (!clusterQueues) { + return { + id: 'member-clusterqueues', + section: , + }; + } + const rows = getCohortClusterQueues(clusterQueues, cohort.metadata.name); if (rows.length === 0) { @@ -284,8 +326,8 @@ function getMemberClusterQueuesSection(cohort: Cohort, clusterQueues: ClusterQue /** Detail view for a cluster-scoped Kueue Cohort resource. */ export default function CohortDetail() { const { name } = useParams<{ name: string }>(); - const [clusterQueues] = ClusterQueue.useList(); - const [cohorts] = Cohort.useList(); + const [clusterQueues, clusterQueuesError] = ClusterQueue.useList(); + const [cohorts, cohortsError] = Cohort.useList(); return ( @@ -324,8 +366,8 @@ export default function CohortDetail() { ? [ getResourceGroupsSection(cohort), getReferencedFlavorsSection(cohort), - getChildCohortsSection(cohort, cohorts), - getMemberClusterQueuesSection(cohort, clusterQueues), + getChildCohortsSection(cohort, cohorts, cohortsError), + getMemberClusterQueuesSection(cohort, clusterQueues, clusterQueuesError), ].filter(Boolean) : [] } diff --git a/kueue/src/components/cohorts/List.tsx b/kueue/src/components/cohorts/List.tsx index a6d84f29eb..853e32b1f1 100644 --- a/kueue/src/components/cohorts/List.tsx +++ b/kueue/src/components/cohorts/List.tsx @@ -3,7 +3,10 @@ import { useMemo } from 'react'; import { ClusterQueue } from '../../resources/clusterQueue'; import { Cohort } from '../../resources/cohort'; import { renderRelatedCount } from '../../resources/cohortFormatters'; -import { getChildCohorts, getCohortClusterQueues } from '../../resources/cohortRelations'; +import { + groupChildCohortsByParent, + groupClusterQueuesByCohort, +} from '../../resources/cohortRelations'; import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; @@ -23,29 +26,36 @@ function renderParentLink(cohort: Cohort) { } export default function CohortList() { - const [clusterQueues] = ClusterQueue.useList(); - const [cohorts] = Cohort.useList(); + const [clusterQueues, clusterQueuesError] = ClusterQueue.useList(); + const [cohorts, cohortsError] = Cohort.useList(); const clusterQueuesByCohort = useMemo( - () => - new Map( - (cohorts || []).map(cohort => [ - cohort.metadata.name, - getCohortClusterQueues(clusterQueues, cohort.metadata.name), - ]) - ), - [clusterQueues, cohorts] - ); - const childCohortsByCohort = useMemo( - () => - new Map( - (cohorts || []).map(cohort => [ - cohort.metadata.name, - getChildCohorts(cohorts, cohort.metadata.name), - ]) - ), - [cohorts] + () => groupClusterQueuesByCohort(clusterQueues), + [clusterQueues] ); + const childCohortsByCohort = useMemo(() => groupChildCohortsByParent(cohorts), [cohorts]); + const getClusterQueueCount = (cohort: Cohort) => { + if (clusterQueuesError) { + return 'Unavailable'; + } + + if (!clusterQueues) { + return 'Loading'; + } + + return renderRelatedCount(clusterQueuesByCohort.get(cohort.metadata.name)); + }; + const getChildCohortCount = (cohort: Cohort) => { + if (cohortsError) { + return 'Unavailable'; + } + + if (!cohorts) { + return 'Loading'; + } + + return renderRelatedCount(childCohortsByCohort.get(cohort.metadata.name)); + }; return ( @@ -63,14 +73,12 @@ export default function CohortList() { { id: 'clusterQueues', label: 'ClusterQueues', - getValue: (cohort: Cohort) => - renderRelatedCount(clusterQueuesByCohort.get(cohort.metadata.name)), + getValue: (cohort: Cohort) => getClusterQueueCount(cohort), }, { id: 'childCohorts', label: 'Child Cohorts', - getValue: (cohort: Cohort) => - renderRelatedCount(childCohortsByCohort.get(cohort.metadata.name)), + getValue: (cohort: Cohort) => getChildCohortCount(cohort), }, { id: 'resourceGroups', diff --git a/kueue/src/components/common/KueueResourceLinks.tsx b/kueue/src/components/common/KueueResourceLinks.tsx new file mode 100644 index 0000000000..a0cc677213 --- /dev/null +++ b/kueue/src/components/common/KueueResourceLinks.tsx @@ -0,0 +1,15 @@ +import { Link } from '@kinvolk/headlamp-plugin/lib/components/common'; +import { kueueRouteNames } from '../../utils/kueueRoutes'; + +/** Render a Cohort reference as a detail-page link when present. */ +export function renderCohortLink(cohortName?: string) { + if (!cohortName) { + return '-'; + } + + return ( + + {cohortName} + + ); +} diff --git a/kueue/src/resources/cohortRelations.test.ts b/kueue/src/resources/cohortRelations.test.ts index a014821ae9..44753075bf 100644 --- a/kueue/src/resources/cohortRelations.test.ts +++ b/kueue/src/resources/cohortRelations.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { getChildCohorts, getCohortClusterQueues } from './cohortRelations'; +import { + getChildCohorts, + getCohortClusterQueues, + groupChildCohortsByParent, + groupClusterQueuesByCohort, +} from './cohortRelations'; describe('Cohort relationship helpers', () => { it('handles undefined related-resource lists', () => { @@ -38,4 +43,30 @@ describe('Cohort relationship helpers', () => { getCohortClusterQueues([{ spec: { cohortName: 'default' } }, { spec: {} }], 'default') ).toHaveLength(1); }); + + it('groups child Cohorts by parentName', () => { + const childA = { spec: { parentName: 'default' } }; + const childB = { spec: { parentName: 'default' } }; + const root = { spec: {} }; + + const groups = groupChildCohortsByParent([childA, childB, root]); + + expect(groups.get('default')).toEqual([childA, childB]); + expect(groups.has('')).toBe(false); + }); + + it('groups ClusterQueues by cohortName', () => { + const clusterQueueA = { spec: { cohortName: 'default' } }; + const clusterQueueB = { spec: { cohortName: 'default' } }; + const standaloneClusterQueue = { spec: {} }; + + const groups = groupClusterQueuesByCohort([ + clusterQueueA, + clusterQueueB, + standaloneClusterQueue, + ]); + + expect(groups.get('default')).toEqual([clusterQueueA, clusterQueueB]); + expect(groups.has('')).toBe(false); + }); }); diff --git a/kueue/src/resources/cohortRelations.ts b/kueue/src/resources/cohortRelations.ts index b7fb3c4bb8..e980582126 100644 --- a/kueue/src/resources/cohortRelations.ts +++ b/kueue/src/resources/cohortRelations.ts @@ -39,3 +39,45 @@ export function getCohortClusterQueues clusterQueue.spec?.cohortName === cohortName); } + +/** Group Cohorts by parentName in one pass for list relationship counts. */ +export function groupChildCohortsByParent( + cohorts: T[] | null | undefined +) { + const cohortsByParent = new Map(); + + for (const cohort of cohorts || []) { + const parentName = cohort.spec?.parentName; + + if (!parentName) { + continue; + } + + const siblings = cohortsByParent.get(parentName) || []; + siblings.push(cohort); + cohortsByParent.set(parentName, siblings); + } + + return cohortsByParent; +} + +/** Group ClusterQueues by cohortName in one pass for list relationship counts. */ +export function groupClusterQueuesByCohort( + clusterQueues: T[] | null | undefined +) { + const clusterQueuesByCohort = new Map(); + + for (const clusterQueue of clusterQueues || []) { + const cohortName = clusterQueue.spec?.cohortName; + + if (!cohortName) { + continue; + } + + const members = clusterQueuesByCohort.get(cohortName) || []; + members.push(clusterQueue); + clusterQueuesByCohort.set(cohortName, members); + } + + return clusterQueuesByCohort; +} From 5a3f7c55a6f7497ae17cb54a592d5d6548407c66 Mon Sep 17 00:00:00 2001 From: Utkarshpandey0001 Date: Sat, 15 Aug 2026 18:51:11 +0530 Subject: [PATCH 3/3] Reuse Kueue resource helpers Signed-off-by: Utkarshpandey0001 --- kueue/src/components/clusterqueues/Detail.tsx | 89 +------------ kueue/src/components/cohorts/Detail.tsx | 117 ++---------------- kueue/src/components/cohorts/List.tsx | 21 +--- .../components/common/KueueResourceLinks.tsx | 49 ++++++++ kueue/src/components/localqueues/Detail.tsx | 25 +--- kueue/src/components/workloads/Detail.tsx | 38 +----- kueue/src/resources/clusterQueue.test.ts | 45 +++++++ kueue/src/resources/clusterQueueFormatters.ts | 62 ++++++++++ 8 files changed, 182 insertions(+), 264 deletions(-) diff --git a/kueue/src/components/clusterqueues/Detail.tsx b/kueue/src/components/clusterqueues/Detail.tsx index 98b2b0a388..12fecbac67 100644 --- a/kueue/src/components/clusterqueues/Detail.tsx +++ b/kueue/src/components/clusterqueues/Detail.tsx @@ -1,38 +1,14 @@ import { ConditionsSection, DetailsGrid, - Link, SectionBox, SimpleTable, } from '@kinvolk/headlamp-plugin/lib/components/common'; import { useParams } from 'react-router-dom'; -import { - ClusterQueue, - FlavorUsage, - ResourceGroup, - ResourceQuota, -} from '../../resources/clusterQueue'; -import { kueueRouteNames } from '../../utils/kueueRoutes'; +import { ClusterQueue, FlavorUsage } from '../../resources/clusterQueue'; +import { getResourceGroupRows, ResourceGroupRow } from '../../resources/clusterQueueFormatters'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; -import { renderCohortLink } from '../common/KueueResourceLinks'; - -/** Flattened row rendered in the ClusterQueue resource groups table. */ -interface ResourceGroupRow { - /** Display label for the resource group index from spec.resourceGroups. */ - group: string; - /** Comma-separated resources covered by this group, such as cpu and memory. */ - coveredResources: string; - /** ResourceFlavor name associated with this resource quota row. */ - flavor: string; - /** Resource name within the flavor quota, for example cpu or memory. */ - resource: string; - /** Guaranteed quota configured for the resource and flavor pair. */ - nominalQuota: string | number; - /** Optional quota this ClusterQueue can borrow from the cohort. */ - borrowingLimit?: string | number; - /** Optional quota this ClusterQueue can lend to the cohort. */ - lendingLimit?: string | number; -} +import { renderCohortLink, renderResourceFlavorLink } from '../common/KueueResourceLinks'; /** Flattened row rendered for status flavor reservations or flavor usage. */ interface FlavorUsageRow { @@ -54,59 +30,6 @@ interface AdmissionCheckRow { flavors: string[]; } -/** Render a ResourceFlavor name as a Headlamp link to its detail page. */ -function renderFlavorLink(flavorName: string) { - return ( - - {flavorName} - - ); -} - -/** Convert nested ClusterQueue resource groups into table rows. */ -function getResourceGroupRows(resourceGroups: ResourceGroup[]): ResourceGroupRow[] { - return resourceGroups.flatMap((group, groupIndex): ResourceGroupRow[] => { - const groupLabel = `Group ${groupIndex + 1}`; - const coveredResources = group.coveredResources?.join(', ') || '-'; - - if (!group.flavors?.length) { - return [ - { - group: groupLabel, - coveredResources, - flavor: '-', - resource: '-', - nominalQuota: '-', - }, - ]; - } - - return group.flavors.flatMap((flavor): ResourceGroupRow[] => { - if (!flavor.resources?.length) { - return [ - { - group: groupLabel, - coveredResources, - flavor: flavor.name, - resource: '-', - nominalQuota: '-', - }, - ]; - } - - return flavor.resources.map((resource: ResourceQuota) => ({ - group: groupLabel, - coveredResources, - flavor: flavor.name, - resource: resource.name, - nominalQuota: resource.nominalQuota, - borrowingLimit: resource.borrowingLimit, - lendingLimit: resource.lendingLimit, - })); - }); - }); -} - /** Convert status flavor usage or reservation entries into table rows. */ function getFlavorUsageRows(flavorUsage: FlavorUsage[] = []): FlavorUsageRow[] { return flavorUsage.flatMap(flavor => { @@ -164,7 +87,7 @@ function getResourceGroupsSection(clusterQueue: ClusterQueue) { { label: 'ResourceFlavor', getter: (row: ResourceGroupRow) => - row.flavor === '-' ? '-' : renderFlavorLink(row.flavor), + row.flavor === '-' ? '-' : renderResourceFlavorLink(row.flavor), }, { label: 'Resource', @@ -207,7 +130,7 @@ function getFlavorUsageSection(title: string, id: string, flavorUsage?: FlavorUs { label: 'ResourceFlavor', getter: (row: FlavorUsageRow) => - row.flavor === '-' ? '-' : renderFlavorLink(row.flavor), + row.flavor === '-' ? '-' : renderResourceFlavorLink(row.flavor), }, { label: 'Resource', @@ -255,7 +178,7 @@ function getAdmissionChecksSection(clusterQueue: ClusterQueue) { {row.flavors.map((flavor, index) => ( {index > 0 ? ', ' : ''} - {renderFlavorLink(flavor)} + {renderResourceFlavorLink(flavor)} ))} diff --git a/kueue/src/components/cohorts/Detail.tsx b/kueue/src/components/cohorts/Detail.tsx index a0d15bdc44..33530be656 100644 --- a/kueue/src/components/cohorts/Detail.tsx +++ b/kueue/src/components/cohorts/Detail.tsx @@ -1,117 +1,22 @@ import { DetailsGrid, EmptyContent, - Link, Loader, SectionBox, SimpleTable, } from '@kinvolk/headlamp-plugin/lib/components/common'; import { useParams } from 'react-router-dom'; -import { ClusterQueue, ResourceGroup, ResourceQuota } from '../../resources/clusterQueue'; +import { ClusterQueue } from '../../resources/clusterQueue'; +import { getResourceGroupRows, ResourceGroupRow } from '../../resources/clusterQueueFormatters'; import { Cohort } from '../../resources/cohort'; import { getChildCohorts, getCohortClusterQueues } from '../../resources/cohortRelations'; -import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; - -/** Flattened row rendered in the Cohort resource groups table. */ -interface ResourceGroupRow { - /** Display label for the resource group index from spec.resourceGroups. */ - group: string; - /** Comma-separated resources covered by this group, such as cpu and memory. */ - coveredResources: string; - /** ResourceFlavor name associated with this resource quota row. */ - flavor: string; - /** Resource name within the flavor quota, for example cpu or memory. */ - resource: string; - /** Shared nominal quota configured for the resource and flavor pair. */ - nominalQuota: string | number; - /** Optional quota this Cohort subtree can borrow from its parent subtree. */ - borrowingLimit?: string | number; - /** Optional quota this Cohort subtree can lend to its parent subtree. */ - lendingLimit?: string | number; -} - -/** Render a Cohort name as a Headlamp link to its detail page. */ -function renderCohortLink(cohortName: string) { - return ( - - {cohortName} - - ); -} - -/** Render a ResourceFlavor name as a Headlamp link to its detail page. */ -function renderFlavorLink(flavorName: string) { - return ( - - {flavorName} - - ); -} - -/** Render a ClusterQueue name as a Headlamp link to its detail page. */ -function renderClusterQueueLink(clusterQueueName: string) { - return ( - - {clusterQueueName} - - ); -} - -/** Render the parent Cohort value as a link when one is configured. */ -function renderParentLink(cohort: Cohort) { - const parentName = cohort.spec.parentName; - - if (!parentName) { - return 'Root'; - } - - return renderCohortLink(parentName); -} - -/** Convert nested Cohort resource groups into table rows. */ -function getResourceGroupRows(resourceGroups: ResourceGroup[]): ResourceGroupRow[] { - return resourceGroups.flatMap((group, groupIndex): ResourceGroupRow[] => { - const groupLabel = `Group ${groupIndex + 1}`; - const coveredResources = group.coveredResources?.join(', ') || '-'; - - if (!group.flavors?.length) { - return [ - { - group: groupLabel, - coveredResources, - flavor: '-', - resource: '-', - nominalQuota: '-', - }, - ]; - } - - return group.flavors.flatMap((flavor): ResourceGroupRow[] => { - if (!flavor.resources?.length) { - return [ - { - group: groupLabel, - coveredResources, - flavor: flavor.name, - resource: '-', - nominalQuota: '-', - }, - ]; - } - - return flavor.resources.map((resource: ResourceQuota) => ({ - group: groupLabel, - coveredResources, - flavor: flavor.name, - resource: resource.name, - nominalQuota: resource.nominalQuota, - borrowingLimit: resource.borrowingLimit, - lendingLimit: resource.lendingLimit, - })); - }); - }); -} +import { + renderClusterQueueLink, + renderCohortLink, + renderParentCohortLink, + renderResourceFlavorLink, +} from '../common/KueueResourceLinks'; /** Build the detail section that shows spec.resourceGroups as a table. */ function getResourceGroupsSection(cohort: Cohort) { @@ -139,7 +44,7 @@ function getResourceGroupsSection(cohort: Cohort) { { label: 'ResourceFlavor', getter: (row: ResourceGroupRow) => - row.flavor === '-' ? '-' : renderFlavorLink(row.flavor), + row.flavor === '-' ? '-' : renderResourceFlavorLink(row.flavor), }, { label: 'Resource', @@ -181,7 +86,7 @@ function getReferencedFlavorsSection(cohort: Cohort) { columns={[ { label: 'Name', - getter: (row: { name: string }) => renderFlavorLink(row.name), + getter: (row: { name: string }) => renderResourceFlavorLink(row.name), }, ]} /> @@ -340,7 +245,7 @@ export default function CohortDetail() { ? [ { name: 'Parent Cohort', - value: renderParentLink(cohort), + value: renderParentCohortLink(cohort.spec.parentName), }, { name: 'Fair Sharing Weight', diff --git a/kueue/src/components/cohorts/List.tsx b/kueue/src/components/cohorts/List.tsx index 853e32b1f1..4a44c6461c 100644 --- a/kueue/src/components/cohorts/List.tsx +++ b/kueue/src/components/cohorts/List.tsx @@ -1,4 +1,4 @@ -import { Link, ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { useMemo } from 'react'; import { ClusterQueue } from '../../resources/clusterQueue'; import { Cohort } from '../../resources/cohort'; @@ -7,23 +7,8 @@ import { groupChildCohortsByParent, groupClusterQueuesByCohort, } from '../../resources/cohortRelations'; -import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; - -/** Render a Cohort parent reference as a detail-page link. */ -function renderParentLink(cohort: Cohort) { - const parentName = cohort.spec.parentName; - - if (!parentName) { - return 'Root'; - } - - return ( - - {parentName} - - ); -} +import { renderParentCohortLink } from '../common/KueueResourceLinks'; export default function CohortList() { const [clusterQueues, clusterQueuesError] = ClusterQueue.useList(); @@ -68,7 +53,7 @@ export default function CohortList() { id: 'parentCohort', label: 'Parent Cohort', getValue: (cohort: Cohort) => cohort.parentNameDisplay, - render: (cohort: Cohort) => renderParentLink(cohort), + render: (cohort: Cohort) => renderParentCohortLink(cohort.spec.parentName), }, { id: 'clusterQueues', diff --git a/kueue/src/components/common/KueueResourceLinks.tsx b/kueue/src/components/common/KueueResourceLinks.tsx index a0cc677213..f3cde25440 100644 --- a/kueue/src/components/common/KueueResourceLinks.tsx +++ b/kueue/src/components/common/KueueResourceLinks.tsx @@ -1,4 +1,5 @@ import { Link } from '@kinvolk/headlamp-plugin/lib/components/common'; +import { renderParentNameDisplay } from '../../resources/cohortFormatters'; import { kueueRouteNames } from '../../utils/kueueRoutes'; /** Render a Cohort reference as a detail-page link when present. */ @@ -13,3 +14,51 @@ export function renderCohortLink(cohortName?: string) { ); } + +/** Render a parent Cohort reference, using Root when this Cohort has no parent. */ +export function renderParentCohortLink(parentName?: string) { + if (!parentName) { + return renderParentNameDisplay(parentName); + } + + return renderCohortLink(parentName); +} + +/** Render a ResourceFlavor reference as a detail-page link when present. */ +export function renderResourceFlavorLink(flavorName?: string) { + if (!flavorName) { + return '-'; + } + + return ( + + {flavorName} + + ); +} + +/** Render a ClusterQueue reference as a detail-page link when present. */ +export function renderClusterQueueLink(clusterQueueName?: string) { + if (!clusterQueueName) { + return '-'; + } + + return ( + + {clusterQueueName} + + ); +} + +/** Render a LocalQueue reference as a detail-page link when present. */ +export function renderLocalQueueLink(queueName?: string, namespace?: string) { + if (!queueName || !namespace) { + return '-'; + } + + return ( + + {queueName} + + ); +} diff --git a/kueue/src/components/localqueues/Detail.tsx b/kueue/src/components/localqueues/Detail.tsx index bf5b5edce1..0b22f094d0 100644 --- a/kueue/src/components/localqueues/Detail.tsx +++ b/kueue/src/components/localqueues/Detail.tsx @@ -1,27 +1,8 @@ -import { - ConditionsSection, - DetailsGrid, - Link, -} from '@kinvolk/headlamp-plugin/lib/components/common'; +import { ConditionsSection, DetailsGrid } from '@kinvolk/headlamp-plugin/lib/components/common'; import { useParams } from 'react-router-dom'; import { LocalQueue } from '../../resources/localQueue'; -import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; - -/** Render a ClusterQueue reference as a detail-page link. */ -function renderClusterQueueLink(localQueue: LocalQueue) { - const clusterQueueName = localQueue.clusterQueueName; - - if (!clusterQueueName) { - return '-'; - } - - return ( - - {clusterQueueName} - - ); -} +import { renderClusterQueueLink } from '../common/KueueResourceLinks'; /** Build the standard Headlamp conditions section for LocalQueue status. */ function getConditionsSection(localQueue: LocalQueue) { @@ -55,7 +36,7 @@ export default function LocalQueueDetail() { ? [ { name: 'ClusterQueue', - value: renderClusterQueueLink(localQueue), + value: renderClusterQueueLink(localQueue.clusterQueueName), }, { name: 'Stop Policy', diff --git a/kueue/src/components/workloads/Detail.tsx b/kueue/src/components/workloads/Detail.tsx index 95d49b4d11..6202bd0c49 100644 --- a/kueue/src/components/workloads/Detail.tsx +++ b/kueue/src/components/workloads/Detail.tsx @@ -1,7 +1,6 @@ import { ConditionsSection, DetailsGrid, - Link, SectionBox, SimpleTable, } from '@kinvolk/headlamp-plugin/lib/components/common'; @@ -21,8 +20,8 @@ import { renderStringMap, renderText, } from '../../resources/workloadFormatters'; -import { kueueRouteNames } from '../../utils/kueueRoutes'; import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; +import { renderClusterQueueLink, renderLocalQueueLink } from '../common/KueueResourceLinks'; /** Row rendered for Workload spec.podSets. */ interface PodSetRow { @@ -98,37 +97,6 @@ interface EvictionRow { count: number; } -/** Render the LocalQueue reference as a detail-page link when possible. */ -function renderLocalQueueLink(workload: Workload) { - const queueName = workload.queueName; - const namespace = workload.metadata.namespace; - - if (!queueName || !namespace) { - return '-'; - } - - return ( - - {queueName} - - ); -} - -/** Render the admitted ClusterQueue as a detail-page link when possible. */ -function renderClusterQueueLink(workload: Workload) { - const clusterQueue = workload.admissionClusterQueue; - - if (!clusterQueue) { - return '-'; - } - - return ( - - {clusterQueue} - - ); -} - /** Convert Workload podSets into table rows. */ function getPodSetRows(podSets: PodSet[]): PodSetRow[] { return podSets.map(podSet => ({ @@ -473,7 +441,7 @@ export default function WorkloadDetail() { ? [ { name: 'Queue', - value: renderLocalQueueLink(workload), + value: renderLocalQueueLink(workload.queueName, workload.metadata.namespace), }, { name: 'Priority', @@ -513,7 +481,7 @@ export default function WorkloadDetail() { }, { name: 'Assigned ClusterQueue', - value: renderClusterQueueLink(workload), + value: renderClusterQueueLink(workload.admissionClusterQueue), }, { name: 'Assigned ResourceFlavors', diff --git a/kueue/src/resources/clusterQueue.test.ts b/kueue/src/resources/clusterQueue.test.ts index af3f70bb0d..4e12dc1597 100644 --- a/kueue/src/resources/clusterQueue.test.ts +++ b/kueue/src/resources/clusterQueue.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getResourceGroupRows, getUniqueFlavorNames, renderClusterQueueStatus, renderLabelSelector, @@ -26,6 +27,50 @@ describe('ClusterQueue formatters', () => { expect(getUniqueFlavorNames(resourceGroups)).toEqual(['default', 'spot']); }); + it('flattens resource groups for detail tables', () => { + expect( + getResourceGroupRows([ + { + coveredResources: ['cpu', 'memory'], + flavors: [ + { + name: 'default', + resources: [ + { + name: 'cpu', + nominalQuota: '8', + borrowingLimit: '4', + lendingLimit: '2', + }, + ], + }, + ], + }, + { + coveredResources: ['nvidia.com/gpu'], + flavors: [{ name: 'gpu' }], + }, + ]) + ).toEqual([ + { + group: 'Group 1', + coveredResources: 'cpu, memory', + flavor: 'default', + resource: 'cpu', + nominalQuota: '8', + borrowingLimit: '4', + lendingLimit: '2', + }, + { + group: 'Group 2', + coveredResources: 'nvidia.com/gpu', + flavor: 'gpu', + resource: '-', + nominalQuota: '-', + }, + ]); + }); + it('derives a readable status from the Active condition', () => { expect(renderClusterQueueStatus({ type: 'Active', status: 'True', reason: 'Ready' })).toBe( 'Active' diff --git a/kueue/src/resources/clusterQueueFormatters.ts b/kueue/src/resources/clusterQueueFormatters.ts index e2d686698f..1d24eeeef3 100644 --- a/kueue/src/resources/clusterQueueFormatters.ts +++ b/kueue/src/resources/clusterQueueFormatters.ts @@ -41,6 +41,24 @@ export interface ResourceGroupLike { flavors?: FlavorQuotasLike[]; } +/** Flattened row rendered in resource group detail tables. */ +export interface ResourceGroupRow { + /** Display label for the resource group index from spec.resourceGroups. */ + group: string; + /** Comma-separated resources covered by this group, such as cpu and memory. */ + coveredResources: string; + /** ResourceFlavor name associated with this resource quota row. */ + flavor: string; + /** Resource name within the flavor quota, for example cpu or memory. */ + resource: string; + /** Nominal quota configured for the resource and flavor pair. */ + nominalQuota: string | number; + /** Optional quota that can be borrowed. */ + borrowingLimit?: string | number; + /** Optional quota that can be lent. */ + lendingLimit?: string | number; +} + /** Namespace selector used to decide where a ClusterQueue can admit workloads. */ export interface LabelSelectorLike { /** Exact label matches required by the selector. */ @@ -173,6 +191,50 @@ export function renderResourceGroupsSummary(resourceGroups: ResourceGroupLike[]) return `${resourceGroups.length} ${groupLabel}, ${flavorCount} ${flavorLabel}`; } +/** Convert nested resource groups into table rows. */ +export function getResourceGroupRows(resourceGroups: ResourceGroupLike[]): ResourceGroupRow[] { + return resourceGroups.flatMap((group, groupIndex): ResourceGroupRow[] => { + const groupLabel = `Group ${groupIndex + 1}`; + const coveredResources = group.coveredResources?.join(', ') || '-'; + + if (!group.flavors?.length) { + return [ + { + group: groupLabel, + coveredResources, + flavor: '-', + resource: '-', + nominalQuota: '-', + }, + ]; + } + + return group.flavors.flatMap((flavor): ResourceGroupRow[] => { + if (!flavor.resources?.length) { + return [ + { + group: groupLabel, + coveredResources, + flavor: flavor.name, + resource: '-', + nominalQuota: '-', + }, + ]; + } + + return flavor.resources.map(resource => ({ + group: groupLabel, + coveredResources, + flavor: flavor.name, + resource: resource.name, + nominalQuota: resource.nominalQuota, + borrowingLimit: resource.borrowingLimit, + lendingLimit: resource.lendingLimit, + })); + }); + }); +} + /** Render a comma-separated list, falling back to '-' when empty. */ export function renderStringList(values: string[]) { return values.length > 0 ? values.join(', ') : '-';