diff --git a/kueue/locales/en/translation.json b/kueue/locales/en/translation.json new file mode 100644 index 0000000000..17bfc8dd74 --- /dev/null +++ b/kueue/locales/en/translation.json @@ -0,0 +1,11 @@ +{ + "Topologies": "Topologies", + "Kueue Topologies define node and hardware hierarchy levels for Topology-Aware Scheduling (TAS).": "Kueue Topologies define node and hardware hierarchy levels for Topology-Aware Scheduling (TAS).", + "Total Levels": "Total Levels", + "Levels Chain": "Levels Chain", + "Topology Levels Hierarchy": "Topology Levels Hierarchy", + "Unnamed Level": "Unnamed Level", + "Node Label": "Node Label", + "None": "None", + "Levels Hierarchy": "Levels Hierarchy" +} diff --git a/kueue/package.json b/kueue/package.json index 0ca3f2cea1..2adcd4e46a 100644 --- a/kueue/package.json +++ b/kueue/package.json @@ -37,5 +37,8 @@ }, "overrides": { "ws": "8.21.0" + }, + "headlamp": { + "i18n": [] } } diff --git a/kueue/src/components/topologies/Detail.tsx b/kueue/src/components/topologies/Detail.tsx new file mode 100644 index 0000000000..23c59b98de --- /dev/null +++ b/kueue/src/components/topologies/Detail.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from '@kinvolk/headlamp-plugin/lib'; +import { DetailsGrid } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { SectionBox } from '@kinvolk/headlamp-plugin/lib/components/common'; +import { Box, Chip, Paper, Typography } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { Topology } from '../../resources/topology'; +import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; + +export default function TopologyDetail() { + const { t } = useTranslation(); + const { name } = useParams<{ name: string }>(); + + return ( + + + item + ? [ + { + name: t('Total Levels'), + value: item.levelsCount, + }, + { + name: t('Levels Chain'), + value: item.levelsDisplay, + }, + ] + : [] + } + extraSections={item => + item && item.levels.length > 0 + ? [ + + + {item.levels.map((lvl, index) => ( + + + + + {lvl.name || t('Unnamed Level')} + + + + + {t('Node Label')}: + + + + + ))} + + , + ] + : [] + } + /> + + ); +} diff --git a/kueue/src/components/topologies/List.tsx b/kueue/src/components/topologies/List.tsx new file mode 100644 index 0000000000..9bbbe60c22 --- /dev/null +++ b/kueue/src/components/topologies/List.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from '@kinvolk/headlamp-plugin/lib'; +import { ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { Topology } from '../../resources/topology'; +import KueueAdminResourceAccess from '../common/KueueAdminResourceAccess'; + +export default function TopologyList() { + const { t } = useTranslation(); + + return ( + + topology.levelsDisplay, + }, + { + id: 'levelsCount', + label: t('Total Levels'), + getValue: (topology: Topology) => topology.levelsCount, + }, + 'age', + ]} + /> + + ); +} diff --git a/kueue/src/index.tsx b/kueue/src/index.tsx index 58e82872af..2c84e4aa6b 100644 --- a/kueue/src/index.tsx +++ b/kueue/src/index.tsx @@ -5,6 +5,8 @@ import LocalQueueDetail from './components/localqueues/Detail'; import LocalQueueList from './components/localqueues/List'; import ResourceFlavorDetail from './components/resourceflavors/Detail'; import ResourceFlavorList from './components/resourceflavors/List'; +import TopologyDetail from './components/topologies/Detail'; +import TopologyList from './components/topologies/List'; import WorkloadDetail from './components/workloads/Detail'; import WorkloadList from './components/workloads/List'; import { kueueRouteNames, kueueRoutePaths } from './utils/kueueRoutes'; @@ -38,6 +40,13 @@ registerSidebarEntry({ url: kueueRoutePaths.resourceFlavorsList, }); +registerSidebarEntry({ + parent: 'kueue', + name: 'kueue-topologies', + label: 'Topologies', + url: kueueRoutePaths.topologiesList, +}); + registerSidebarEntry({ parent: 'kueue', name: 'kueue-workloads', @@ -93,6 +102,22 @@ registerRoute({ component: () => , }); +registerRoute({ + path: kueueRoutePaths.topologiesList, + sidebar: 'kueue-topologies', + name: kueueRouteNames.topologiesList, + exact: true, + component: () => , +}); + +registerRoute({ + path: kueueRoutePaths.topologyDetail, + sidebar: 'kueue-topologies', + name: kueueRouteNames.topologyDetail, + exact: true, + component: () => , +}); + registerRoute({ path: kueueRoutePaths.workloadsList, sidebar: 'kueue-workloads', diff --git a/kueue/src/resources/topology.test.ts b/kueue/src/resources/topology.test.ts new file mode 100644 index 0000000000..a3a7536bbc --- /dev/null +++ b/kueue/src/resources/topology.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + getTopologyLevelNames, + renderTopologyLevel, + renderTopologyLevelsCount, + renderTopologyLevelsSummary, +} from './topologyFormatters'; + +describe('Topology formatters', () => { + describe('renderTopologyLevel', () => { + it('formats single topology level with node label', () => { + expect( + renderTopologyLevel({ + name: 'rack', + nodeLabel: 'topology.kubernetes.io/rack', + }) + ).toBe('rack (topology.kubernetes.io/rack)'); + }); + + it('formats level without node label', () => { + expect( + renderTopologyLevel({ + name: 'block', + nodeLabel: '', + }) + ).toBe('block'); + }); + + it('returns dash fallback for empty or missing level', () => { + expect(renderTopologyLevel(null)).toBe('-'); + expect(renderTopologyLevel(undefined)).toBe('-'); + expect(renderTopologyLevel({ name: '', nodeLabel: '' })).toBe('-'); + }); + }); + + describe('renderTopologyLevelsSummary', () => { + it('formats hierarchy chain with arrow separators', () => { + const levels = [ + { name: 'rack', nodeLabel: 'topology.kubernetes.io/rack' }, + { name: 'block', nodeLabel: 'topology.kubernetes.io/block' }, + { name: 'host', nodeLabel: 'kubernetes.io/hostname' }, + ]; + expect(renderTopologyLevelsSummary(levels)).toBe( + 'rack (topology.kubernetes.io/rack) → block (topology.kubernetes.io/block) → host (kubernetes.io/hostname)' + ); + }); + + it('returns dash fallback for empty or undefined levels array', () => { + expect(renderTopologyLevelsSummary([])).toBe('-'); + expect(renderTopologyLevelsSummary(null)).toBe('-'); + expect(renderTopologyLevelsSummary(undefined)).toBe('-'); + }); + }); + + describe('renderTopologyLevelsCount', () => { + it('returns total count of levels', () => { + expect( + renderTopologyLevelsCount([ + { name: 'rack', nodeLabel: 'rack' }, + { name: 'host', nodeLabel: 'host' }, + ]) + ).toBe(2); + expect(renderTopologyLevelsCount([])).toBe(0); + expect(renderTopologyLevelsCount(undefined)).toBe(0); + }); + }); + + describe('getTopologyLevelNames', () => { + it('extracts list of non-empty level names', () => { + expect( + getTopologyLevelNames([ + { name: 'rack', nodeLabel: '' }, + { name: 'block', nodeLabel: '' }, + { name: 'host', nodeLabel: '' }, + ]) + ).toEqual(['rack', 'block', 'host']); + }); + + it('returns empty array when levels are missing', () => { + expect(getTopologyLevelNames([])).toEqual([]); + expect(getTopologyLevelNames(null)).toEqual([]); + }); + }); +}); diff --git a/kueue/src/resources/topology.ts b/kueue/src/resources/topology.ts new file mode 100644 index 0000000000..6a3d8b46cd --- /dev/null +++ b/kueue/src/resources/topology.ts @@ -0,0 +1,48 @@ +import { KubeObject, KubeObjectInterface } from '@kinvolk/headlamp-plugin/lib/k8s/cluster'; +import { kueueApiVersions } from '../utils/kueueApi'; +import { kueueRoutePaths } from '../utils/kueueRoutes'; +import { + getTopologyLevelNames, + renderTopologyLevelsCount, + renderTopologyLevelsSummary, + TopologyLevel, +} from './topologyFormatters'; + +export interface TopologySpec { + levels?: TopologyLevel[]; +} + +export interface KubeTopology extends KubeObjectInterface { + spec?: TopologySpec; +} + +export class Topology extends KubeObject { + static kind = 'Topology'; + static apiName = 'topologies'; + static apiVersion = kueueApiVersions; + static isNamespaced = false; + + static get detailsRoute() { + return kueueRoutePaths.topologyDetail; + } + + get spec(): TopologySpec { + return this.jsonData.spec ?? {}; + } + + get levels(): TopologyLevel[] { + return this.spec.levels ?? []; + } + + get levelsDisplay(): string { + return renderTopologyLevelsSummary(this.levels); + } + + get levelsCount(): number { + return renderTopologyLevelsCount(this.levels); + } + + get levelNames(): string[] { + return getTopologyLevelNames(this.levels); + } +} diff --git a/kueue/src/resources/topologyFormatters.ts b/kueue/src/resources/topologyFormatters.ts new file mode 100644 index 0000000000..6cec6b9df8 --- /dev/null +++ b/kueue/src/resources/topologyFormatters.ts @@ -0,0 +1,34 @@ +export interface TopologyLevel { + name: string; + nodeLabel: string; +} + +/** Render a single level's display string. */ +export function renderTopologyLevel(level?: TopologyLevel | null): string { + if (!level || !level.name) { + return '-'; + } + const label = level.nodeLabel ? ` (${level.nodeLabel})` : ''; + return `${level.name}${label}`; +} + +/** Render full topology levels hierarchy string. */ +export function renderTopologyLevelsSummary(levels?: TopologyLevel[] | null): string { + if (!levels || levels.length === 0) { + return '-'; + } + return levels.map(lvl => renderTopologyLevel(lvl)).join(' → '); +} + +/** Render total count of defined topology levels. */ +export function renderTopologyLevelsCount(levels?: TopologyLevel[] | null): number { + return levels?.length ?? 0; +} + +/** Extract list of level names. */ +export function getTopologyLevelNames(levels?: TopologyLevel[] | null): string[] { + if (!levels || levels.length === 0) { + return []; + } + return levels.map(l => l.name).filter(Boolean); +} diff --git a/kueue/src/utils/kueueRoutes.ts b/kueue/src/utils/kueueRoutes.ts index 73fc24b62d..b1d34c1600 100644 --- a/kueue/src/utils/kueueRoutes.ts +++ b/kueue/src/utils/kueueRoutes.ts @@ -5,6 +5,8 @@ export const kueueRouteNames = { localQueueDetail: 'kueue-localqueue-detail', resourceFlavorsList: 'kueue-resourceflavors-list', resourceFlavorDetail: 'kueue-resourceflavor-detail', + topologiesList: 'kueue-topologies-list', + topologyDetail: 'kueue-topology-detail', workloadsList: 'kueue-workloads-list', workloadDetail: 'kueue-workload-detail', } as const; @@ -16,6 +18,8 @@ export const kueueRoutePaths = { localQueueDetail: '/kueue/localqueues/:namespace/:name', resourceFlavorsList: '/kueue/resourceflavors', resourceFlavorDetail: '/kueue/resourceflavors/:name', + topologiesList: '/kueue/topologies', + topologyDetail: '/kueue/topologies/:name', workloadsList: '/kueue/workloads', workloadDetail: '/kueue/workloads/:namespace/:name', } as const;