diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts index 1fef8eee57..f352fbcef5 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelCatalog.ts @@ -1,3 +1,4 @@ +import { MANAGE_COLUMNS_TEST_IDS } from '~/__tests__/cypress/cypress/support/constants'; import { appChrome } from './appChrome'; class ModelCatalogFilter { @@ -421,6 +422,43 @@ class ModelCatalog { return cy.get('[data-testid^="compression-variant-"]'); } + // Manage Columns Modal + findManageColumnsButton() { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.button); + } + + findManageColumnsModal() { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.modal); + } + + findManageColumnsSearch() { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.search); + } + + findManageColumnsRestoreDefaults() { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.restoreDefaults); + } + + findManageColumnsUpdateButton() { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.updateButton); + } + + findManageColumnsCancelButton() { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.cancelButton); + } + + findManageColumnsSection(categoryId: string) { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.section(categoryId)); + } + + findManageColumnsCheckbox(columnId: string) { + return cy.get(`#${MANAGE_COLUMNS_TEST_IDS.checkbox(columnId)}`); + } + + findManageColumnsSelectedCount() { + return cy.findByTestId(MANAGE_COLUMNS_TEST_IDS.selectedCount); + } + // Performance Empty State findPerformanceEmptyState() { return cy.findByTestId('performance-empty-state'); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/support/constants/modelCatalog.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/support/constants/modelCatalog.ts index cd30c62b4c..073cfdcf25 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/support/constants/modelCatalog.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/support/constants/modelCatalog.ts @@ -54,6 +54,21 @@ export const ALERT_TEST_IDS = { performanceFiltersUpdated: 'performance-filters-updated-alert', } as const; +/** + * Test IDs for the CategorizedManageColumnsModal + */ +export const MANAGE_COLUMNS_TEST_IDS = { + button: 'manage-columns-button', + modal: 'hardware-config-manage-columns', + search: 'hardware-config-manage-columns-search', + restoreDefaults: 'hardware-config-manage-columns-restore-defaults', + updateButton: 'hardware-config-manage-columns-update-button', + cancelButton: 'hardware-config-manage-columns-cancel-button', + selectedCount: 'hardware-config-manage-columns-selected-count', + section: (categoryId: string): string => `hardware-config-manage-columns-section-${categoryId}`, + checkbox: (columnId: string): string => `hardware-config-manage-columns-checkbox-${columnId}`, +}; + /** * Non-breaking space character used in table column headers */ diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/manageColumns.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/manageColumns.cy.ts new file mode 100644 index 0000000000..6d118db482 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelCatalog/manageColumns.cy.ts @@ -0,0 +1,338 @@ +/* eslint-disable camelcase */ +import { modelCatalog } from '~/__tests__/cypress/cypress/pages/modelCatalog'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { + setupModelCatalogIntercepts, + interceptPerformanceArtifactsList, + interceptArtifactsList, + type ModelCatalogInterceptOptions, +} from '~/__tests__/cypress/cypress/support/interceptHelpers/modelCatalog'; +import { NBSP } from '~/__tests__/cypress/cypress/support/constants'; + +const initIntercepts = (options: Partial = {}) => { + const resolvedOptions = { + useValidatedModel: true, + includePerformanceArtifacts: true, + ...options, + }; + + setupModelCatalogIntercepts(resolvedOptions); + interceptArtifactsList(); + interceptPerformanceArtifactsList(); +}; + +const navigateToPerformanceTab = () => { + modelCatalog.visit(); + modelCatalog.findModelCatalogCards().should('have.length.at.least', 1); + modelCatalog.findModelCatalogDetailLink().first().click(); + modelCatalog.clickPerformanceInsightsTab(); + modelCatalog.findHardwareConfigurationTable().should('be.visible'); +}; + +describe('Categorized Manage Columns Modal', () => { + beforeEach(() => { + cy.intercept('GET', '/model-registry/api/v1/model_registry*', [ + mockModelRegistry({ name: 'modelregistry-sample' }), + ]).as('getModelRegistries'); + + initIntercepts(); + navigateToPerformanceTab(); + }); + + describe('Opening and Closing', () => { + it('should open the modal when Customize columns button is clicked', () => { + modelCatalog.findManageColumnsButton().should('be.visible').click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + }); + + it('should close the modal when Cancel button is clicked', () => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + + modelCatalog.findManageColumnsCancelButton().click(); + modelCatalog.findManageColumnsModal().should('not.exist'); + }); + + it('should close the modal when Update button is clicked', () => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + + modelCatalog.findManageColumnsUpdateButton().click(); + modelCatalog.findManageColumnsModal().should('not.exist'); + }); + }); + + describe('Modal Structure', () => { + beforeEach(() => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + }); + + it('should display the modal title and description', () => { + modelCatalog.findManageColumnsModal().should('contain.text', 'Customize columns'); + modelCatalog + .findManageColumnsModal() + .should( + 'contain.text', + 'Manage the columns that appear in the hardware configuration table.', + ); + }); + + it('should display the search input', () => { + modelCatalog.findManageColumnsSearch().should('be.visible'); + }); + + it('should display selected count label', () => { + modelCatalog.findManageColumnsSelectedCount().should('contain.text', 'selected'); + }); + + it('should display Restore default columns button', () => { + modelCatalog.findManageColumnsRestoreDefaults().should('be.visible'); + }); + + it('should display Update and Cancel buttons', () => { + modelCatalog.findManageColumnsUpdateButton().should('be.visible'); + modelCatalog.findManageColumnsCancelButton().should('be.visible'); + }); + }); + + describe('Category Sections', () => { + beforeEach(() => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + }); + + it('should display all category sections', () => { + modelCatalog.findManageColumnsSection('general').should('be.visible'); + modelCatalog.findManageColumnsSection('ttft-latency').should('be.visible'); + modelCatalog.findManageColumnsSection('e2e-latency').should('be.visible'); + modelCatalog.findManageColumnsSection('itl-latency').scrollIntoView().should('be.visible'); + modelCatalog.findManageColumnsSection('tps').scrollIntoView().should('be.visible'); + }); + + it('should display category labels', () => { + modelCatalog.findManageColumnsModal().should('contain.text', 'General'); + modelCatalog.findManageColumnsModal().should('contain.text', 'TTFT Latency'); + modelCatalog.findManageColumnsModal().should('contain.text', 'E2E Latency'); + modelCatalog.findManageColumnsModal().should('contain.text', 'ITL Latency'); + modelCatalog.findManageColumnsModal().should('contain.text', 'Throughput (TPS)'); + }); + + it('should display columns within the General category', () => { + modelCatalog.findManageColumnsSection('general').should('contain.text', 'Replicas'); + modelCatalog + .findManageColumnsSection('general') + .should('contain.text', `RPS${NBSP}per Replica`); + modelCatalog.findManageColumnsSection('general').should('contain.text', 'Total RPS'); + modelCatalog + .findManageColumnsSection('general') + .should('contain.text', `Mean${NBSP}Input Tokens`); + modelCatalog + .findManageColumnsSection('general') + .should('contain.text', `Mean${NBSP}Output Tokens`); + modelCatalog.findManageColumnsSection('general').should('contain.text', 'vLLM Version'); + }); + }); + + describe('Search Functionality', () => { + beforeEach(() => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + }); + + it('should filter columns by search term', () => { + modelCatalog.findManageColumnsSearch().type('TTFT'); + + // TTFT section should be visible + modelCatalog.findManageColumnsSection('ttft-latency').should('be.visible'); + + // Non-matching sections should be hidden + modelCatalog.findManageColumnsSection('e2e-latency').should('not.exist'); + modelCatalog.findManageColumnsSection('itl-latency').should('not.exist'); + }); + + it('should show empty state when no columns match the search', () => { + modelCatalog.findManageColumnsSearch().type('nonexistent column xyz'); + + modelCatalog.findManageColumnsModal().should('contain.text', 'No results found'); + }); + + it('should show all categories again after clearing search', () => { + modelCatalog.findManageColumnsSearch().type('TTFT'); + modelCatalog.findManageColumnsSection('e2e-latency').should('not.exist'); + + // Clear the search + modelCatalog.findManageColumnsSearch().find('button[aria-label="Reset"]').click(); + + // All sections should reappear + modelCatalog.findManageColumnsSection('general').should('be.visible'); + modelCatalog.findManageColumnsSection('ttft-latency').should('be.visible'); + modelCatalog.findManageColumnsSection('e2e-latency').should('be.visible'); + }); + + it('should filter across categories when searching for a shared term', () => { + modelCatalog.findManageColumnsSearch().type('Mean'); + + // Sections with "Mean" columns should still be visible + modelCatalog.findManageColumnsSection('ttft-latency').should('be.visible'); + modelCatalog.findManageColumnsSection('e2e-latency').should('be.visible'); + modelCatalog.findManageColumnsSection('itl-latency').should('be.visible'); + modelCatalog.findManageColumnsSection('tps').should('be.visible'); + modelCatalog.findManageColumnsSection('general').should('be.visible'); + }); + }); + + describe('Column Toggle', () => { + beforeEach(() => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + }); + + it('should toggle a column checkbox off and on', () => { + // Replicas is visible by default - uncheck it + modelCatalog.findManageColumnsCheckbox('replicas').should('be.checked'); + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + modelCatalog.findManageColumnsCheckbox('replicas').should('not.be.checked'); + + // Check it again + modelCatalog.findManageColumnsCheckbox('replicas').check(); + modelCatalog.findManageColumnsCheckbox('replicas').should('be.checked'); + }); + + it('should update selected count when toggling columns', () => { + // Get the initial count text + modelCatalog + .findManageColumnsSelectedCount() + .invoke('text') + .then((initialText) => { + const initialCount = parseInt(initialText, 10); + + // Uncheck a currently checked column + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + + modelCatalog + .findManageColumnsSelectedCount() + .should('contain.text', `${initialCount - 1}`); + }); + }); + }); + + describe('Restore Defaults', () => { + beforeEach(() => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsModal().should('be.visible'); + }); + + it('should become disabled after restoring defaults', () => { + // The latency filter effect modifies columns from defaults on mount, + // so restore defaults starts enabled. Click it to get to default state. + modelCatalog.findManageColumnsRestoreDefaults().click(); + modelCatalog.findManageColumnsRestoreDefaults().should('be.disabled'); + }); + + it('should be enabled after modifying column visibility from default state', () => { + // First restore to defaults + modelCatalog.findManageColumnsRestoreDefaults().click(); + modelCatalog.findManageColumnsRestoreDefaults().should('be.disabled'); + + // Uncheck a default-visible column + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + modelCatalog.findManageColumnsRestoreDefaults().should('not.be.disabled'); + }); + + it('should restore default columns when clicked', () => { + // Uncheck a column that is currently checked + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + modelCatalog.findManageColumnsCheckbox('replicas').should('not.be.checked'); + + // Click restore defaults + modelCatalog.findManageColumnsRestoreDefaults().click(); + + // Column should be checked again and button should be disabled + modelCatalog.findManageColumnsCheckbox('replicas').should('be.checked'); + modelCatalog.findManageColumnsRestoreDefaults().should('be.disabled'); + }); + }); + + describe('Apply Changes to Table', () => { + it('should hide a column from the table after unchecking and updating', () => { + // Replicas is always visible in the current column set + modelCatalog.findHardwareConfigurationTableHeaders().should('contain.text', 'Replicas'); + + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + modelCatalog.findManageColumnsUpdateButton().click(); + + modelCatalog.findHardwareConfigurationTableHeaders().should('not.contain.text', 'Replicas'); + }); + + it('should show a previously hidden column after checking and updating', () => { + // First hide Replicas + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + modelCatalog.findManageColumnsUpdateButton().click(); + modelCatalog.findHardwareConfigurationTableHeaders().should('not.contain.text', 'Replicas'); + + // Now show it again + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsCheckbox('replicas').check(); + modelCatalog.findManageColumnsUpdateButton().click(); + modelCatalog.findHardwareConfigurationTableHeaders().should('contain.text', 'Replicas'); + }); + + it('should not apply changes when Cancel is clicked', () => { + // Replicas column is visible + modelCatalog.findHardwareConfigurationTableHeaders().should('contain.text', 'Replicas'); + + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + modelCatalog.findManageColumnsCancelButton().click(); + + // Column should still be visible since we cancelled + modelCatalog.findHardwareConfigurationTableHeaders().should('contain.text', 'Replicas'); + }); + + it('should show a non-default column in the table after enabling it', () => { + // E2E Mean is not visible by default + modelCatalog + .findHardwareConfigurationTableHeaders() + .should('not.contain.text', `E2E${NBSP}Latency Mean`); + + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsCheckbox('e2e_mean').check(); + modelCatalog.findManageColumnsUpdateButton().click(); + + // E2E Mean should now be visible + modelCatalog + .findHardwareConfigurationTableHeaders() + .should('contain.text', `E2E${NBSP}Latency Mean`); + }); + }); + + describe('Modal State Reset', () => { + it('should reset unsaved changes when modal is reopened', () => { + modelCatalog.findManageColumnsButton().click(); + + // Uncheck replicas but cancel + modelCatalog.findManageColumnsCheckbox('replicas').uncheck(); + modelCatalog.findManageColumnsCheckbox('replicas').should('not.be.checked'); + modelCatalog.findManageColumnsCancelButton().click(); + + // Reopen modal - replicas should be checked again + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsCheckbox('replicas').should('be.checked'); + }); + + it('should clear search when modal is reopened', () => { + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsSearch().type('TTFT'); + modelCatalog.findManageColumnsSection('e2e-latency').should('not.exist'); + modelCatalog.findManageColumnsCancelButton().click(); + + // Reopen modal - search should be cleared and all sections visible + modelCatalog.findManageColumnsButton().click(); + modelCatalog.findManageColumnsSearch().should('have.value', ''); + modelCatalog.findManageColumnsSection('e2e-latency').should('be.visible'); + }); + }); +}); diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/CategorizedManageColumnsModal.scss b/clients/ui/frontend/src/app/pages/modelCatalog/components/CategorizedManageColumnsModal.scss new file mode 100644 index 0000000000..d6c38b3e61 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/CategorizedManageColumnsModal.scss @@ -0,0 +1,21 @@ +.categorized-manage-columns { + &__section { + border-bottom: var(--pf-t--global--border--width--divider--default) solid + var(--pf-t--global--border--color--default); + + &:last-child { + border-bottom: none; + } + } + + &__section-header { + display: flex; + align-items: center; + gap: var(--pf-t--global--spacer--xs); + } + + &__draggable { + display: flex; + align-items: center; + } +} diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/CategorizedManageColumnsModal.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/components/CategorizedManageColumnsModal.tsx new file mode 100644 index 0000000000..6d9dfdfa5c --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/CategorizedManageColumnsModal.tsx @@ -0,0 +1,329 @@ +import * as React from 'react'; +import { + Button, + Checkbox, + EmptyState, + EmptyStateBody, + EmptyStateVariant, + ExpandableSection, + Flex, + FlexItem, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Popover, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { HelpIcon, SearchIcon } from '@patternfly/react-icons'; +import { DragDropSort } from '@patternfly/react-drag-drop'; +import { ManageColumnSearchInput, ManagedColumn, UseManageColumnsResult } from 'mod-arch-shared'; +import { reorderColumns } from './categorizedManageColumnsUtils'; +import type { ColumnCategory } from './HardwareConfigurationTableColumns'; +import './CategorizedManageColumnsModal.scss'; + +type CategorizedManageColumnsModalProps = { + manageColumnsResult: Pick< + UseManageColumnsResult, + | 'managedColumns' + | 'setVisibleColumnIds' + | 'defaultVisibleColumnIds' + | 'isModalOpen' + | 'closeModal' + >; + categories: ColumnCategory[]; + title?: string; + description?: string; + searchPlaceholder?: string; + dataTestId?: string; +}; + +type CategoryGroup = { + category: ColumnCategory; + columns: ManagedColumn[]; +}; + +const normalizeWhitespace = (str: string): string => str.replace(/\s+/g, ' '); + +const CategorizedManageColumnsModal: React.FC = ({ + manageColumnsResult, + categories, + title = 'Customize columns', + description, + searchPlaceholder = 'Filter by column name', + dataTestId = 'manage-columns-modal', +}) => { + const { + managedColumns: initialColumns, + setVisibleColumnIds, + defaultVisibleColumnIds, + isModalOpen, + closeModal, + } = manageColumnsResult; + + const [columns, setColumns] = React.useState(initialColumns); + const [searchValue, setSearchValue] = React.useState(''); + const [expandedCategories, setExpandedCategories] = React.useState>( + () => new Set(categories.map((c) => c.id)), + ); + + const headingId = React.useId(); + const descriptionId = React.useId(); + + React.useEffect(() => { + if (isModalOpen) { + setColumns(initialColumns); + setSearchValue(''); + setExpandedCategories(new Set(categories.map((c) => c.id))); + } + }, [isModalOpen, initialColumns, categories]); + + const columnsMatchingSearch = React.useMemo( + () => + searchValue + ? columns.filter((col) => + normalizeWhitespace(col.label.toLowerCase()).includes( + normalizeWhitespace(searchValue.toLowerCase()), + ), + ) + : columns, + [columns, searchValue], + ); + + const categoryGroups = React.useMemo((): CategoryGroup[] => { + const matchingIds = new Set(columnsMatchingSearch.map((c) => c.id)); + + return categories + .map((category) => { + const categoryColumnIds = new Set(category.columnIds); + const categoryColumns = columns.filter( + (col) => categoryColumnIds.has(col.id) && matchingIds.has(col.id), + ); + return { category, columns: categoryColumns }; + }) + .filter((group) => group.columns.length > 0); + }, [categories, columns, columnsMatchingSearch]); + + const selectedCount = columns.filter((col) => col.isVisible).length; + + const isDefaultState = React.useMemo(() => { + if (!defaultVisibleColumnIds) { + return false; + } + const defaultSet = new Set(defaultVisibleColumnIds); + const currentVisibleIds = new Set(columns.filter((c) => c.isVisible).map((c) => c.id)); + if (defaultSet.size !== currentVisibleIds.size) { + return false; + } + return [...defaultSet].every((id) => currentVisibleIds.has(id)); + }, [columns, defaultVisibleColumnIds]); + + const handleUpdate = React.useCallback(() => { + const visibleColumnIds = columns.filter((col) => col.isVisible).map((col) => col.id); + setVisibleColumnIds(visibleColumnIds); + closeModal(); + }, [columns, setVisibleColumnIds, closeModal]); + + const handleToggleColumn = React.useCallback((columnId: string, isChecked: boolean) => { + setColumns((prev) => + prev.map((col) => (col.id === columnId ? { ...col, isVisible: isChecked } : col)), + ); + }, []); + + const handleSectionDrop = React.useCallback( + (category: ColumnCategory, _: unknown, newItems: { id: string | number }[]) => { + const reorderedIds = newItems.map((item) => String(item.id)); + const matchingIds = new Set(category.columnIds); + setColumns((prev) => reorderColumns(prev, matchingIds, reorderedIds)); + }, + [], + ); + + const handleRestoreDefaults = React.useCallback(() => { + if (!defaultVisibleColumnIds) { + return; + } + setColumns((prev) => { + const defaultSet = new Set(defaultVisibleColumnIds); + const defaultColumns = defaultVisibleColumnIds + .map((id) => prev.find((col) => col.id === id)) + .filter((col): col is ManagedColumn => col !== undefined) + .map((col) => ({ ...col, isVisible: true })); + const nonDefaultColumns = prev + .filter((col) => !defaultSet.has(col.id)) + .map((col) => ({ ...col, isVisible: false })); + return [...defaultColumns, ...nonDefaultColumns]; + }); + }, [defaultVisibleColumnIds]); + + const handleToggleExpanded = React.useCallback((categoryId: string) => { + setExpandedCategories((prev) => { + const next = new Set(prev); + if (next.has(categoryId)) { + next.delete(categoryId); + } else { + next.add(categoryId); + } + return next; + }); + }, []); + + if (!isModalOpen) { + return null; + } + + const buildDraggableItems = (sectionColumns: ManagedColumn[]) => + sectionColumns.map((col) => { + const currentCol = columns.find((c) => c.id === col.id) ?? col; + const checkbox = ( + handleToggleColumn(col.id, checked)} + /> + ); + return { + id: col.id, + content: ( +
+ + {checkbox} + {col.label} + +
+ ), + props: { + checked: currentCol.isVisible, + className: 'categorized-manage-columns__draggable', + }, + }; + }); + + const hasNoResults = columnsMatchingSearch.length === 0 && searchValue; + + return ( + + + + {description && {description}} + + + + + + + + + {defaultVisibleColumnIds && ( + + + + )} + + + + + } + data-testid="generic-modal-header" + /> + + {hasNoResults ? ( + + + No columns match your search. Try adjusting your search terms. + + + ) : ( + + {categoryGroups.map(({ category, columns: sectionColumns }) => ( + + + {category.label} + {category.description && ( + + + + + + ); +}; + +export default CategorizedManageColumnsModal; diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTable.tsx b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTable.tsx index fb86408a1f..1714461be2 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTable.tsx +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTable.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { DashboardEmptyTableView, Table, ManageColumnsModal } from 'mod-arch-shared'; +import { DashboardEmptyTableView, Table } from 'mod-arch-shared'; import { Button, Spinner } from '@patternfly/react-core'; import { ColumnsIcon } from '@patternfly/react-icons'; import { OuterScrollContainer } from '@patternfly/react-table'; @@ -10,6 +10,8 @@ import { SortOrder } from '~/concepts/modelCatalog/const'; import HardwareConfigurationTableRow from './HardwareConfigurationTableRow'; import HardwareConfigurationFilterToolbar from './HardwareConfigurationFilterToolbar'; import { useHardwareConfigColumns, ControlledTableSortProps } from './useHardwareConfigColumns'; +import CategorizedManageColumnsModal from './CategorizedManageColumnsModal'; +import { HARDWARE_CONFIG_COLUMN_CATEGORIES } from './HardwareConfigurationTableColumns'; type HardwareConfigurationTableProps = { performanceArtifacts: CatalogPerformanceMetricsArtifact[]; @@ -122,8 +124,9 @@ const HardwareConfigurationTable: React.FC = ({ )} /> - diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts index 31a2d57756..b4d52665e0 100644 --- a/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/HardwareConfigurationTableColumns.ts @@ -401,3 +401,58 @@ export const TPS_COLUMN_FIELDS: HardwareConfigColumnField[] = Object.values(Late * Storage key for localStorage persistence of column visibility. */ export const HARDWARE_CONFIG_COLUMNS_STORAGE_KEY = 'hardware-config-table-columns'; + +/** + * Column category definition for grouping columns in the ManageColumnsModal. + */ +export type ColumnCategory = { + id: string; + label: string; + description?: string; + columnIds: HardwareConfigColumnField[]; +}; + +/** + * Categories for organizing manageable columns into expandable sections. + * Columns not in any category will appear in an "Other" fallback section. + */ +export const HARDWARE_CONFIG_COLUMN_CATEGORIES: ColumnCategory[] = [ + { + id: 'general', + label: 'General', + description: 'Core deployment and throughput metrics.', + columnIds: [ + 'replicas', + PerformancePropertyKey.REQUESTS_PER_SECOND, + 'total_requests_per_second', + 'mean_input_tokens', + 'mean_output_tokens', + 'framework_version', + ], + }, + { + id: 'ttft-latency', + label: 'TTFT Latency', + description: 'Time to First Token — how long until the model begins generating output.', + columnIds: ['ttft_mean', 'ttft_p90', 'ttft_p95', 'ttft_p99'], + }, + { + id: 'e2e-latency', + label: 'E2E Latency', + description: 'End-to-End latency — total time from request submission to complete response.', + columnIds: ['e2e_mean', 'e2e_p90', 'e2e_p95', 'e2e_p99'], + }, + { + id: 'itl-latency', + label: 'ITL Latency', + description: 'Inter-Token Latency — average time between consecutive generated tokens.', + columnIds: ['itl_mean', 'itl_p90', 'itl_p95', 'itl_p99'], + }, + { + id: 'tps', + label: 'Throughput (TPS)', + description: + 'Tokens Per Second — throughput measured in generated tokens per second. Higher is better.', + columnIds: ['tps_mean', 'tps_p90', 'tps_p95', 'tps_p99'], + }, +]; diff --git a/clients/ui/frontend/src/app/pages/modelCatalog/components/categorizedManageColumnsUtils.ts b/clients/ui/frontend/src/app/pages/modelCatalog/components/categorizedManageColumnsUtils.ts new file mode 100644 index 0000000000..5362874e3b --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelCatalog/components/categorizedManageColumnsUtils.ts @@ -0,0 +1,40 @@ +import type { ManagedColumn } from 'mod-arch-shared'; + +/** + * Reorders columns while preserving the positions of non-matching columns. + * + * When a user reorders columns via drag-drop within a category section, only the + * columns in that section should change their relative order. Columns outside the + * section stay in their original positions. + * + * Example: + * - columns: [A, B, C, D, E] + * - matchingIds: {B, D} (columns in the dragged section) + * - reorderedIds: [D, B] (user dragged D before B) + * - result: [A, D, C, B, E] (B and D swap positions, others unchanged) + */ +export const reorderColumns = ( + columns: ManagedColumn[], + matchingIds: Set, + reorderedIds: string[], +): ManagedColumn[] => { + const columnMap = new Map(columns.map((col) => [col.id, col])); + + const matchingIndices: number[] = []; + columns.forEach((col, index) => { + if (matchingIds.has(col.id)) { + matchingIndices.push(index); + } + }); + + const result = [...columns]; + + reorderedIds.forEach((id, i) => { + const col = columnMap.get(id); + if (col && i < matchingIndices.length) { + result[matchingIndices[i]] = col; + } + }); + + return result; +};