diff --git a/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.spec.ts b/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.spec.ts index 98b9924715..68f7b2d68f 100644 --- a/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.spec.ts +++ b/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.spec.ts @@ -15,3 +15,39 @@ test('Empty State no Accelerator profile', async ({ page }) => { // Test that the button is enabled await expect(page.getByRole('button', { name: 'Add new accelerator profile' })).toBeEnabled(); }); + +test('List Accelerator profiles', async ({ page }) => { + await page.goto( + navigateToStory('pages-acceleratorprofiles-acceleratorprofiles', 'list-accelerator-profiles'), + ); + + // wait for page to load + await page.waitForSelector('text=Accelerator profiles'); + + // Check for name + expect(page.getByText('TensorRT')).toBeTruthy(); + expect(page.getByText('Test Accelerator')).toBeTruthy(); + + // Check for description + expect( + page.getByText('Lorem, ipsum dolor sit amet consectetur adipisicing elit. Saepe, quis'), + ).toBeTruthy(); + expect(page.getByText('Test description')).toBeTruthy(); + + // Check for identifier + expect(page.getByText('tensor.com/gpu')).toBeTruthy(); + expect(page.getByText('nvidia.com/gpu')).toBeTruthy(); + + // Check column sorting by identifier + await page + .locator('th:has-text("Identifier")') + .getByRole('button', { name: 'Identifier', exact: true }) + .click(); + const tableBodyLocator = await page.locator('table#accelerator-profile-table tbody'); + const firstRowIdentifier = await tableBodyLocator + .locator('tr') + .nth(0) + .locator('td:nth-child(2)') + .allInnerTexts(); + await expect(firstRowIdentifier[0]).toBe('nvidia.com/gpu'); +}); diff --git a/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx b/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx index fec2232a74..df1b40ab41 100644 --- a/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx +++ b/frontend/src/__tests__/integration/pages/acceleratorProfiles/AcceleratorProfiles.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { StoryFn, Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/testing-library'; import { rest } from 'msw'; import { mockAcceleratorProfile } from '~/__mocks__/mockAcceleratorProfile'; import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; @@ -22,7 +23,24 @@ export default { ], accelerators: rest.get( '/api/k8s/apis/dashboard.opendatahub.io/v1/namespaces/opendatahub/acceleratorprofiles', - (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockAcceleratorProfile()]))), + (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([ + mockAcceleratorProfile({}), + mockAcceleratorProfile({ + metadata: { name: 'some-other-gpu' }, + spec: { + displayName: 'TensorRT', + enabled: false, + identifier: 'tensor.com/gpu', + description: + 'Lorem, ipsum dolor sit amet consectetur adipisicing elit. Saepe, quis', + }, + }), + ]), + ), + ), ), }, }, @@ -48,3 +66,14 @@ export const EmptyStateNoAcceleratorProfile: StoryObj = { }, }, }; + +export const ListAcceleratorProfiles: StoryObj = { + render: Template, + + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Test Accelerator', undefined, { timeout: 5000 }); + await canvas.findByText('TensorRT'); + }, +}; diff --git a/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfileEnableToggle.tsx b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfileEnableToggle.tsx new file mode 100644 index 0000000000..efe5bd36d1 --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfileEnableToggle.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { Switch } from '@patternfly/react-core'; +import DisableAcceleratorProfileModal from '~/pages/acceleratorProfiles/screens/list/DisableAcceleratorProfileModal'; +import { updateAcceleratorProfile } from '~/services/acceleratorProfileService'; +import useNotification from '~/utilities/useNotification'; + +type AcceleratorProfileEnableToggleProps = { + enabled: boolean; + name: string; +}; + +const AcceleratorProfileEnableToggle: React.FC = ({ + enabled, + name, +}) => { + const label = enabled ? 'enabled' : 'stopped'; + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [isEnabled, setEnabled] = React.useState(enabled); + const [isLoading, setLoading] = React.useState(false); + const notification = useNotification(); + + const handleChange = (checked: boolean) => { + setLoading(true); + updateAcceleratorProfile(name, { + enabled: checked, + }) + .then(() => { + setEnabled(checked); + }) + .catch((e) => { + notification.error( + `Error ${checked ? 'enable' : 'disable'} the accelerator profile`, + e.message, + ); + setEnabled(!checked); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + <> + { + isEnabled ? setIsModalOpen(true) : handleChange(true); + }} + /> + { + if (confirmStatus) { + handleChange(false); + } + setIsModalOpen(false); + }} + /> + + ); +}; + +export default AcceleratorProfileEnableToggle; diff --git a/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfiles.tsx b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfiles.tsx index f19ed78202..7484c50a8e 100644 --- a/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfiles.tsx +++ b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfiles.tsx @@ -2,14 +2,11 @@ import * as React from 'react'; import { Button, ButtonVariant, - Flex, - FlexItem, EmptyState, EmptyStateIcon, EmptyStateVariant, EmptyStateBody, PageSection, - PageSectionVariants, Title, } from '@patternfly/react-core'; import { PlusCircleIcon } from '@patternfly/react-icons'; @@ -17,6 +14,7 @@ import { useNavigate } from 'react-router-dom'; import ApplicationsPage from '~/pages/ApplicationsPage'; import useAccelerators from '~/pages/notebookController/screens/server/useAccelerators'; import { useDashboardNamespace } from '~/redux/selectors'; +import AcceleratorProfilesTable from '~/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTable'; const description = `Manage accelerator profile settings for users in your organization`; @@ -60,12 +58,9 @@ const AcceleratorProfiles: React.FC = () => { loadError={loadError} errorMessage="Unable to load accelerator profiles." emptyStatePage={noAcceleratorProfilePageSection} + provideChildrenPadding > - - - {/* Todo: Create accelerator table */} - - + ); }; diff --git a/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTable.tsx b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTable.tsx new file mode 100644 index 0000000000..472cf54b7f --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTable.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Button, ToolbarItem } from '@patternfly/react-core'; +import { useNavigate } from 'react-router-dom'; +import { Table } from '~/components/table'; +import AcceleratorProfilesTableRow from '~/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTableRow'; +import { AcceleratorKind } from '~/k8sTypes'; +import { columns } from '~/pages/acceleratorProfiles/screens/list/const'; + +type AcceleratorProfilesTableProps = { + accelerators: AcceleratorKind[]; +}; + +const AcceleratorProfilesTable: React.FC = ({ accelerators }) => { + const navigate = useNavigate(); + + return ( + ( + + )} + toolbarContent={ + + + + } + /> + ); +}; + +export default AcceleratorProfilesTable; diff --git a/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTableRow.tsx b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTableRow.tsx new file mode 100644 index 0000000000..08484e1070 --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/list/AcceleratorProfilesTableRow.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { + Text, + TextContent, + TextVariants, + Timestamp, + TimestampTooltipVariant, + Truncate, +} from '@patternfly/react-core'; +import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { useNavigate } from 'react-router-dom'; +import { AcceleratorKind } from '~/k8sTypes'; +import AcceleratorProfileEnableToggle from '~/pages/acceleratorProfiles/screens/list/AcceleratorProfileEnableToggle'; +import { relativeTime } from '~/utilities/time'; + +type AcceleratorProfilesTableRow = { + accelerator: AcceleratorKind; +}; + +const AcceleratorProfilesTableRow: React.FC = ({ accelerator }) => { + const navigate = useNavigate(); + const modifiedDate = accelerator.metadata.annotations?.['opendatahub.io/modified-date']; + + return ( + + + + + + + + ); +}; + +export default AcceleratorProfilesTableRow; diff --git a/frontend/src/pages/acceleratorProfiles/screens/list/DisableAcceleratorProfileModal.tsx b/frontend/src/pages/acceleratorProfiles/screens/list/DisableAcceleratorProfileModal.tsx new file mode 100644 index 0000000000..d59e9dc809 --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/list/DisableAcceleratorProfileModal.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Button, Modal } from '@patternfly/react-core'; + +type DisableAcceleratorProfileModal = { + isOpen: boolean; + onClose: (confirmStatus: boolean) => void; +}; + +const DisableAcceleratorProfileModal: React.FC = ({ + isOpen, + onClose, +}) => ( + onClose(false)} + actions={[ + , + , + ]} + > + Disable this will disable accelerators for existing images and runtimes that want to use it. + +); + +export default DisableAcceleratorProfileModal; diff --git a/frontend/src/pages/acceleratorProfiles/screens/list/const.ts b/frontend/src/pages/acceleratorProfiles/screens/list/const.ts new file mode 100644 index 0000000000..fdb8b1600c --- /dev/null +++ b/frontend/src/pages/acceleratorProfiles/screens/list/const.ts @@ -0,0 +1,47 @@ +import { SortableData } from '~/components/table'; +import { AcceleratorKind } from '~/k8sTypes'; + +export const columns: SortableData[] = [ + { + field: 'name', + label: 'Name', + sortable: (a, b) => a.spec.displayName.localeCompare(b.spec.displayName), + width: 30, + }, + { + field: 'identifier', + label: 'Identifier', + sortable: (a, b) => a.spec.identifier.localeCompare(b.spec.identifier), + info: { + popover: 'The resource identifier of the accelerator device.', + popoverProps: { + showClose: false, + }, + }, + }, + { + field: 'enablement', + label: 'Enable', + sortable: (a, b) => Number(a.spec.enabled ?? false) - Number(b.spec.enabled ?? false), + info: { + popover: 'Indicates whether the accelerator profile is available for new resources.', + popoverProps: { + showClose: false, + }, + }, + }, + { + field: 'last_modified', + label: 'Last modified', + sortable: (a, b): number => { + const first = a.metadata.annotations?.['opendatahub.io/modified-date']; + const second = b.metadata.annotations?.['opendatahub.io/modified-date']; + return new Date(first ?? 0).getTime() - new Date(second ?? 0).getTime(); + }, + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +];
+ + + + + {accelerator.spec.description && ( + + + + )} + + {accelerator.spec.identifier} + + + {modifiedDate && !isNaN(new Date(modifiedDate).getTime()) ? ( + + {relativeTime(Date.now(), new Date(modifiedDate).getTime())} + + ) : ( + '--' + )} + + navigate(`/acceleratorProfiles/edit/${accelerator.metadata.name}`), + }, + { + title: 'Delete', + onClick: () => undefined, + }, + ]} + /> +