diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts index e268984d..528ca287 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/WorkspaceDetailsActivity.cy.ts @@ -11,7 +11,12 @@ describe('WorkspaceDetailsActivity Component', () => { // This tests depends on the mocked workspaces data at home page, needs revisit once workspace data fetched from BE it('open workspace details, open activity tab, check all fields match', () => { - cy.findAllByTestId('table-body').first().findByTestId('action-column').click(); + cy.findAllByTestId('table-body') + .first() + .findByTestId('action-column') + .find('button') + .should('be.visible') + .click(); // Extract first workspace from mock data cy.wait('@getWorkspaces').then((interception) => { if (!interception.response || !interception.response.body) { diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts index 6e401139..35a29d3e 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/Workspaces.cy.ts @@ -119,7 +119,12 @@ describe('Workspaces Component', () => { }); function openDeleteModal() { - cy.findAllByTestId('table-body').first().findByTestId('action-column').click(); + cy.findAllByTestId('table-body') + .first() + .findByTestId('action-column') + .find('button') + .should('be.visible') + .click(); cy.findByTestId('action-delete').click(); cy.findByTestId('delete-modal-input').should('have.value', ''); } diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts index 9b468b13..748f4dd7 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts @@ -31,20 +31,36 @@ describe('Application', () => { it('filter rows with multiple filters', () => { home.visit(); + // First filter by name useFilter('name', 'Name', 'My'); - useFilter('podConfig', 'Pod Config', 'Tiny'); - cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); + + // Add second filter by image + useFilter('image', 'Image', 'jupyter'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); }); it('filter rows with multiple filters and remove one', () => { home.visit(); + // Add name filter useFilter('name', 'Name', 'My'); - useFilter('podConfig', 'Pod Config', 'Tiny'); - cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); - cy.get("[class$='pf-v6-c-label-group__close']").eq(1).click(); - cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); + + // Add image filter + useFilter('image', 'Image', 'jupyter'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); + + // Remove one filter (the first one) + cy.get("[class$='pf-v6-c-label-group__close']").first().click(); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); cy.get("[id$='workspaces-table-row-2']").contains('My Second Jupyter Notebook'); @@ -52,13 +68,20 @@ describe('Application', () => { it('filter rows with multiple filters and remove all', () => { home.visit(); + // Add name filter useFilter('name', 'Name', 'My'); - useFilter('podConfig', 'Pod Config', 'Tiny'); - cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); + cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); + + // Add image filter + useFilter('image', 'Image', 'jupyter'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").contains('Image'); + + // Clear all filters cy.get('*').contains('Clear all filters').click(); - cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Name'); + cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Image'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); }); }); diff --git a/workspaces/frontend/src/app/components/WorkspaceTable.tsx b/workspaces/frontend/src/app/components/WorkspaceTable.tsx index a2c11866..6254ff1c 100644 --- a/workspaces/frontend/src/app/components/WorkspaceTable.tsx +++ b/workspaces/frontend/src/app/components/WorkspaceTable.tsx @@ -10,6 +10,7 @@ import { Tooltip, Bullseye, Button, + Icon, } from '@patternfly/react-core'; import { Table, @@ -36,7 +37,6 @@ import { FilterableDataFieldKey, SortableDataFieldKey, } from '~/app/filterableDataHelper'; -import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; import { useTypedNavigate } from '~/app/routerHelper'; import { buildKindLogoDictionary, @@ -47,11 +47,12 @@ import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectA import CustomEmptyState from '~/shared/components/CustomEmptyState'; import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; import WithValidImage from '~/shared/components/WithValidImage'; +import ImageFallback from '~/shared/components/ImageFallback'; import { formatResourceFromWorkspace, formatWorkspaceIdleState, } from '~/shared/utilities/WorkspaceUtils'; -import ImageFallback from '~/shared/components/ImageFallback'; +import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; const { fields: wsTableColumns, @@ -59,21 +60,16 @@ const { sortableKeyArray: sortableWsTableColumnKeyArray, filterableKeyArray: filterableWsTableColumnKeyArray, } = defineDataFields({ - redirectStatus: { label: 'Redirect Status', isFilterable: false, isSortable: false }, - name: { label: 'Name', isFilterable: true, isSortable: true }, - kind: { label: 'Kind', isFilterable: true, isSortable: true }, - namespace: { label: 'Namespace', isFilterable: true, isSortable: true }, - image: { label: 'Image', isFilterable: true, isSortable: true }, - podConfig: { label: 'Pod Config', isFilterable: true, isSortable: true }, - state: { label: 'State', isFilterable: true, isSortable: true }, - homeVol: { label: 'Home Vol', isFilterable: true, isSortable: true }, - cpu: { label: 'CPU', isFilterable: false, isSortable: true }, - ram: { label: 'Memory', isFilterable: false, isSortable: true }, - gpu: { label: 'GPU', isFilterable: true, isSortable: true }, - idleGpu: { label: 'Idle GPU', isFilterable: true, isSortable: true }, - lastActivity: { label: 'Last Activity', isFilterable: false, isSortable: true }, - connect: { label: '', isFilterable: false, isSortable: false }, - actions: { label: '', isFilterable: false, isSortable: false }, + name: { label: 'Name', isFilterable: true, isSortable: true, width: 35 }, + image: { label: 'Image', isFilterable: true, isSortable: true, width: 25 }, + kind: { label: 'Kind', isFilterable: true, isSortable: true, width: 15 }, + namespace: { label: 'Namespace', isFilterable: true, isSortable: true, width: 15 }, + state: { label: 'State', isFilterable: true, isSortable: true, width: 15 }, + gpu: { label: 'GPU', isFilterable: true, isSortable: true, width: 15 }, + idleGpu: { label: 'Idle GPU', isFilterable: true, isSortable: true, width: 15 }, + lastActivity: { label: 'Last activity', isFilterable: false, isSortable: true, width: 15 }, + connect: { label: '', isFilterable: false, isSortable: false, width: 25 }, + actions: { label: '', isFilterable: false, isSortable: false, width: 10 }, }); export type WorkspaceTableColumnKeys = DataFieldKey; @@ -202,16 +198,12 @@ const WorkspaceTable = React.forwardRef( return ws.namespace.match(searchValueInput); case 'image': return ws.podTemplate.options.imageConfig.current.displayName.match(searchValueInput); - case 'podConfig': - return ws.podTemplate.options.podConfig.current.displayName.match(searchValueInput); case 'state': return ws.state.match(searchValueInput); case 'gpu': return formatResourceFromWorkspace(ws, 'gpu').match(searchValueInput); case 'idleGpu': return formatWorkspaceIdleState(ws).match(searchValueInput); - case 'homeVol': - return ws.podTemplate.volumes.home?.mountPath.match(searchValueInput); default: return true; } @@ -228,11 +220,7 @@ const WorkspaceTable = React.forwardRef( kind: workspace.workspaceKind.name, namespace: workspace.namespace, image: workspace.podTemplate.options.imageConfig.current.displayName, - podConfig: workspace.podTemplate.options.podConfig.current.displayName, state: workspace.state, - homeVol: workspace.podTemplate.volumes.home?.pvcName ?? '', - cpu: formatResourceFromWorkspace(workspace, 'cpu'), - ram: formatResourceFromWorkspace(workspace, 'memory'), gpu: formatResourceFromWorkspace(workspace, 'gpu'), idleGpu: formatWorkspaceIdleState(workspace), lastActivity: workspace.activity.lastActivity, @@ -280,6 +268,37 @@ const WorkspaceTable = React.forwardRef( }; }; + // Column-specific modifiers and special properties + const getColumnModifier = ( + columnKey: WorkspaceTableColumnKeys, + ): 'wrap' | 'nowrap' | undefined => { + switch (columnKey) { + case 'name': + case 'kind': + return 'nowrap'; + case 'image': + case 'namespace': + case 'state': + case 'gpu': + return 'wrap'; + case 'lastActivity': + return 'nowrap'; + default: + return undefined; + } + }; + + const getSpecialColumnProps = (columnKey: WorkspaceTableColumnKeys) => { + switch (columnKey) { + case 'connect': + return { screenReaderText: 'Connect action', hasContent: false }; + case 'actions': + return { screenReaderText: 'Primary action', hasContent: false }; + default: + return { hasContent: true }; + } + }; + const extractStateColor = (state: WorkspaceState) => { switch (state) { case WorkspaceState.WorkspaceStateRunning: @@ -305,31 +324,41 @@ const WorkspaceTable = React.forwardRef( case 'Info': return ( - ); case 'Warning': return ( - ); case 'Danger': return ( - ); case undefined: return ( - ); default: return ( - ); } @@ -371,19 +400,32 @@ const WorkspaceTable = React.forwardRef( } /> - +
- {canExpandRows && - ))} + {canExpandRows && + ); + })} {sortedWorkspaces.length > 0 && @@ -397,6 +439,7 @@ const WorkspaceTable = React.forwardRef( {canExpandRows && ( + ); + } + + if (columnKey === 'actions') { + return ( + + ); + } + + return ( + - ); - case 'name': - return ( - - ); - case 'kind': - return ( - - ); - case 'namespace': - return ( - - ); - case 'image': - return ( - - ); - case 'podConfig': - return ( - - ); - case 'state': - return ( - - ); - case 'homeVol': - return ( - - ); - case 'cpu': - return ( - - ); - case 'ram': - return ( - - ); - case 'gpu': - return ( - - ); - case 'idleGpu': - return ( - - ); - case 'lastActivity': - return ( - - ); - case 'connect': - return ( - - ); - case 'actions': - return ( - - ); - default: - return null; - } + {formatDistanceToNow(new Date(workspace.activity.lastActivity), { + addSuffix: true, + })} + + )} + + ); })} {isWorkspaceExpanded(workspace) && ( - + )} ))} diff --git a/workspaces/frontend/src/app/filterableDataHelper.ts b/workspaces/frontend/src/app/filterableDataHelper.ts index 5f3c3704..ed7c5891 100644 --- a/workspaces/frontend/src/app/filterableDataHelper.ts +++ b/workspaces/frontend/src/app/filterableDataHelper.ts @@ -2,6 +2,7 @@ export interface DataFieldDefinition { label: string; isSortable: boolean; isFilterable: boolean; + width?: number; } export type FilterableDataFieldKey> = { diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx index 164fee2c..f09d1102 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/summary/WorkspaceKindSummary.tsx @@ -92,7 +92,7 @@ const WorkspaceKindSummary: React.FC = () => { ref={workspaceTableRef} workspaces={workspaces} canCreateWorkspaces={false} - hiddenColumns={['connect', 'kind', 'homeVol']} + hiddenColumns={['connect', 'kind']} rowActions={tableRowActions} /> diff --git a/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx b/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx index 80f4e3c4..9c2a87bf 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/DataVolumesList.tsx @@ -3,12 +3,14 @@ import { ClipboardCopy, ClipboardCopyVariant, Content, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, Flex, FlexItem, List, ListItem, - Stack, - StackItem, Tooltip, } from '@patternfly/react-core'; import { DatabaseIcon, LockedIcon } from '@patternfly/react-icons'; @@ -43,32 +45,32 @@ export const DataVolumesList: React.FC = ({ workspace }) = )} - - - - Mount path:{' '} - - {data.mountPath} - - - - + + Mount path: + + + {data.mountPath} + + + ); return ( - - - Cluster storage - - - - {workspaceDataVol.map((data, index) => ( - {singleDataVolRenderer(data)} - ))} - - - + + + + Cluster storage + + + + {workspaceDataVol.map((data, index) => ( + {singleDataVolRenderer(data)} + ))} + + + + ); }; diff --git a/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx b/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx index 4d53caf8..bb122c4f 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/ExpandedWorkspaceRow.tsx @@ -1,38 +1,74 @@ import React from 'react'; -import { ExpandableRowContent, Td, Tr } from '@patternfly/react-table'; +import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { Workspace } from '~/shared/api/backendApiTypes'; -import { DataVolumesList } from '~/app/pages/Workspaces/DataVolumesList'; import { WorkspaceTableColumnKeys } from '~/app/components/WorkspaceTable'; +import { WorkspaceStorage } from './WorkspaceStorage'; +import { WorkspacePackageDetails } from './WorkspacePackageDetails'; +import { WorkspaceConfigDetails } from './WorkspaceConfigDetails'; interface ExpandedWorkspaceRowProps { workspace: Workspace; - columnKeys: WorkspaceTableColumnKeys[]; + visibleColumnKeys: WorkspaceTableColumnKeys[]; + canExpandRows: boolean; } export const ExpandedWorkspaceRow: React.FC = ({ workspace, - columnKeys, + visibleColumnKeys, + canExpandRows, }) => { - const renderExpandedData = () => - columnKeys.map((colKey, index) => { - switch (colKey) { - case 'name': + // Calculate total number of columns (including expand column if present) + const totalColumns = visibleColumnKeys.length + (canExpandRows ? 1 : 0); + + // Find the positions where we want to show our content + // We'll show storage in the first content column, package details in the second, + // and config details in the third + const getColumnIndex = (columnKey: WorkspaceTableColumnKeys) => { + const baseIndex = canExpandRows ? 1 : 0; // Account for expand column + return baseIndex + visibleColumnKeys.indexOf(columnKey); + }; + + const storageColumnIndex = visibleColumnKeys.includes('name') ? getColumnIndex('name') : 1; + const packageColumnIndex = visibleColumnKeys.includes('image') ? getColumnIndex('image') : 2; + const configColumnIndex = visibleColumnKeys.includes('kind') ? getColumnIndex('kind') : 3; + + return ( + + {/* Render cells for each column */} + {Array.from({ length: totalColumns }, (_, index) => { + if (index === storageColumnIndex) { return ( - ); - default: - return - + ); + } + + if (index === configColumnIndex) { + return ( + + ); + } + + // Empty cell for all other columns + return ); }; diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConfigDetails.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConfigDetails.tsx new file mode 100644 index 00000000..b59873e2 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConfigDetails.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, +} from '@patternfly/react-core'; +import { Workspace } from '~/shared/api/backendApiTypes'; +import { formatResourceFromWorkspace } from '~/shared/utilities/WorkspaceUtils'; + +interface WorkspaceConfigDetailsProps { + workspace: Workspace; +} + +export const WorkspaceConfigDetails: React.FC = ({ workspace }) => ( + + + Pod config + + {workspace.podTemplate.options.podConfig.current.displayName} + + + + CPU + + {formatResourceFromWorkspace(workspace, 'cpu')} + + + + Memory + + {formatResourceFromWorkspace(workspace, 'memory')} + + + +); diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx index ab2decb2..efc59fe7 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceConnectAction.tsx @@ -52,6 +52,7 @@ export const WorkspaceConnectAction: React.FunctionComponent) => ( Connect , diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspacePackageDetails.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspacePackageDetails.tsx new file mode 100644 index 00000000..1eb96fee --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspacePackageDetails.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListTerm, + DescriptionListDescription, + ListItem, + List, + DescriptionListGroup, +} from '@patternfly/react-core'; +import { Workspace } from '~/shared/api/backendApiTypes'; +import { extractPackageLabels, formatLabelKey } from '~/shared/utilities/WorkspaceUtils'; + +interface WorkspacePackageDetailsProps { + workspace: Workspace; +} + +export const WorkspacePackageDetails: React.FC = ({ workspace }) => { + const packageLabels = extractPackageLabels(workspace); + + const renderedItems = packageLabels.map((label) => ( + {`${formatLabelKey(label.key)} v${label.value}`} + )); + + return ( + + + Packages + + {renderedItems.length > 0 ? ( + {renderedItems} + ) : ( + No package information available + )} + + + + ); +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceStorage.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceStorage.tsx new file mode 100644 index 00000000..9109bdf0 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceStorage.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, +} from '@patternfly/react-core'; +import { Workspace } from '~/shared/api/backendApiTypes'; +import { DataVolumesList } from '~/app/pages/Workspaces/DataVolumesList'; + +interface WorkspaceStorageProps { + workspace: Workspace; +} + +export const WorkspaceStorage: React.FC = ({ workspace }) => ( + + + Home volume + + {workspace.podTemplate.volumes.home?.pvcName ?? 'None'} + + + + + + +); diff --git a/workspaces/frontend/src/shared/mock/mockBuilder.ts b/workspaces/frontend/src/shared/mock/mockBuilder.ts index 2ba0862a..3efcad00 100644 --- a/workspaces/frontend/src/shared/mock/mockBuilder.ts +++ b/workspaces/frontend/src/shared/mock/mockBuilder.ts @@ -81,6 +81,10 @@ export const buildMockWorkspace = (workspace?: Partial): Workspace => key: 'pythonVersion', value: '3.11', }, + { + key: 'jupyterlabVersion', + value: '1.9.0', + }, ], }, }, @@ -290,9 +294,30 @@ export const buildMockWorkspaceList = (args: { }): Workspace[] => { const states = Object.values(WorkspaceState); const imageConfigs = [ - { id: 'jupyterlab_scipy_190', displayName: `jupyter-scipy:v1.9.0` }, - { id: 'jupyterlab_scipy_200', displayName: `jupyter-scipy:v2.0.0` }, - { id: 'jupyterlab_scipy_210', displayName: `jupyter-scipy:v2.1.0` }, + { + id: 'jupyterlab_scipy_190', + displayName: `jupyter-scipy:v1.9.0`, + labels: [ + { key: 'pythonVersion', value: '3.12' }, + { key: 'jupyterlabVersion', value: '1.9.0' }, + ], + }, + { + id: 'jupyterlab_scipy_200', + displayName: `jupyter-scipy:v2.0.0`, + labels: [ + { key: 'pythonVersion', value: '3.12' }, + { key: 'jupyterlabVersion', value: '2.0.0' }, + ], + }, + { + id: 'jupyterlab_scipy_210', + displayName: `jupyter-scipy:v2.1.0`, + labels: [ + { key: 'pythonVersion', value: '3.13' }, + { key: 'jupyterlabVersion', value: '2.1.0' }, + ], + }, ]; const podConfigs = [ { id: 'tiny_cpu', displayName: 'Tiny CPU' }, @@ -353,12 +378,7 @@ export const buildMockWorkspaceList = (args: { id: imageConfig.id, displayName: imageConfig.displayName, description: 'JupyterLab, with SciPy Packages', - labels: [ - { - key: 'pythonVersion', - value: '3.11', - }, - ], + labels: imageConfig.labels, }, }, podConfig: { diff --git a/workspaces/frontend/src/shared/style/MUI-theme.scss b/workspaces/frontend/src/shared/style/MUI-theme.scss index ec4c1396..a50ae5b2 100644 --- a/workspaces/frontend/src/shared/style/MUI-theme.scss +++ b/workspaces/frontend/src/shared/style/MUI-theme.scss @@ -15,6 +15,7 @@ // Button --mui-button-font-weight: 500; + --mui-button--BorderWidth: 1px; --mui-button--hover--BorderWidth: 1px; --mui-button--PaddingBlockStart: 6px; --mui-button--PaddingBlockEnd: 6px; @@ -76,7 +77,7 @@ --mui-table--cell--PaddingInlineEnd: 16px; --mui-table--cell--PaddingBlockStart: 16px; --mui-table--cell--PaddingBlockEnd: 16px; - --mui-table--cell--first-last-child--PaddingInline: 0px; + --mui-table--cell--first-last-child--PaddingInline: 8px; --mui-table__thead--cell--FontSize: 14px; --mui-table__sort-indicator--MarginInlineStart: 4px; @@ -116,7 +117,6 @@ } .mui-theme .pf-v6-c-action-list__item .pf-v6-c-button { - --pf-v6-c-button--BorderRadius: 50%; --pf-v6-c-button--PaddingInlineStart: none; --pf-v6-c-button--PaddingInlineEnd: none; } @@ -152,7 +152,6 @@ --pf-v6-c-button--PaddingInlineStart: var(--mui-button--PaddingInlineStart); --pf-v6-c-button--PaddingInlineEnd: var(--mui-button--PaddingInlineEnd); --pf-v6-c-button--LineHeight: var(--mui-button--LineHeight); - --pf-v6-c-button--m-plain--BorderRadius: 50%; text-transform: var(--mui-text-transform); letter-spacing: 0.02857em; @@ -197,10 +196,6 @@ --pf-v6-c-card--m-selectable--m-selected--BorderColor: none; } -.mui-theme .pf-v6-c-description-list { - --pf-v6-c-description-list--RowGap: var(--pf-t--global--spacer--gap--group-to-group--horizontal--compact); -} - .mui-theme .pf-v6-c-description-list__term { font: var(--mui-font-subtitle2); } @@ -237,20 +232,26 @@ --pf-v6-c-form__section-title--MarginInlineEnd: 0px; } +// Base form label styles .mui-theme .pf-v6-c-form__label { + color: var(--mui-palette-grey-600); + pointer-events: none; + transition: all 0.2s ease; + --pf-v6-c-form__label-required--Color: currentColor; +} + +// Text input labels (text fields, textareas, selects) +.mui-theme .pf-v6-c-form__group:has(.pf-v6-c-form-control) .pf-v6-c-form__label, +.mui-theme .pf-v6-c-form__group:has(.pf-v6-c-text-input-group) .pf-v6-c-form__label { position: absolute; top: 35%; left: 12px; font-size: 14px; - color: var(--mui-palette-grey-600); - pointer-events: none; - transition: all 0.2s ease; transform-origin: left center; transform: translateY(-50%) scale(0.75); background-color: var(--mui-palette-common-white); padding: 0 4px; z-index: 1; - --pf-v6-c-form__label-required--Color: currentColor; } .mui-theme .pf-v6-c-form-control input::placeholder { @@ -514,10 +515,6 @@ align-self: stretch; } -.mui-theme .pf-v6-c-menu-toggle.pf-m-plain { - --pf-v6-c-menu-toggle--BorderRadius: 50%; -} - .mui-theme .pf-v6-c-menu-toggle.pf-m-primary { --pf-v6-c-menu-toggle--expanded--Color: var(--mui-palette-common-white); --pf-v6-c-menu-toggle--expanded--BackgroundColor: var(--mui-palette-primary-main); @@ -532,6 +529,13 @@ --pf-v6-c-menu-toggle--m-split-button--m-action--m-primary--child--BackgroundColor: var(--mui-palette-primary-dark); } +.pf-v6-c-menu-toggle.pf-m-secondary.pf-m-split-button { + --pf-v6-c-menu-toggle--BorderColor: var(--mui-palette-primary-main); + --pf-v6-c-menu-toggle--BorderWidth: var(--mui-button--BorderWidth); + --pf-v6-c-menu-toggle--BorderStyle: solid; + --pf-v6-c-menu-toggle--expanded--Color: var(--mui-palette-primary-dark); +} + .mui-theme .pf-v6-c-menu-toggle__button:has(.pf-v6-c-menu-toggle__toggle-icon) { --pf-v6-c-menu-toggle--PaddingBlockStart: var(--mui-spacing-4); --pf-v6-c-menu-toggle--PaddingBlockEnd: var(--mui-spacing-4); @@ -725,6 +729,12 @@ align-content: center; } +// Updates the expand button to be 36.5px wide to match menu toggle button width +.mui-theme .pf-v6-c-table__td.pf-v6-c-table__toggle .pf-v6-c-button.pf-m-plain { + --pf-v6-c-button--PaddingInlineStart: 6px; + --pf-v6-c-button--PaddingInlineEnd: 6px; +} + .mui-theme .pf-v6-c-label { --pf-v6-c-label--BorderRadius: 16px; --pf-v6-c-label--FontSize: 0.8125rem; @@ -775,10 +785,6 @@ --pf-v6-c-modal-box--BoxShadow: var(--mui-shadows-24); } -.mui-theme .pf-v6-c-button.pf-m-plain { - --pf-v6-c-button--BorderRadius: 50%; -} - .mui-theme .pf-v6-c-page__main-container { --pf-v6-c-page__main-container--BorderWidth: 0px; --pf-v6-c-page__main-container--BorderRadius: var(--mui-shape-borderRadius); diff --git a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts index ff07e661..cc08a44c 100644 --- a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts +++ b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts @@ -1,4 +1,4 @@ -import { Workspace, WorkspaceState } from '~/shared/api/backendApiTypes'; +import { Workspace, WorkspaceState, WorkspaceOptionLabel } from '~/shared/api/backendApiTypes'; import { CPU_UNITS, MEMORY_UNITS_FOR_PARSING, @@ -102,3 +102,24 @@ export const countGpusFromWorkspaces = (workspaces: Workspace[]): number => const [gpuValue] = splitValueUnit(extractResourceValue(workspace, 'gpu') || '0', OTHER); return total + (gpuValue ?? 0); }, 0); + +// Helper function to format label keys into human-readable names +export const formatLabelKey = (key: string): string => { + // Handle camelCase version labels (e.g., pythonVersion -> Python) + if (key.endsWith('Version')) { + const baseName = key.slice(0, -7); // Remove 'Version' suffix + return baseName.charAt(0).toUpperCase() + baseName.slice(1); + } + + // Otherwise just capitalize the first letter + return key.charAt(0).toUpperCase() + key.slice(1); +}; + +// Check if a label represents version/package information +export const isPackageLabel = (key: string): boolean => key.endsWith('Version'); + +// Extract package labels from workspace image config +export const extractPackageLabels = (workspace: Workspace): WorkspaceOptionLabel[] => + workspace.podTemplate.options.imageConfig.current.labels.filter((label) => + isPackageLabel(label.key), + );
} - {visibleColumnKeys.map((columnKey) => ( - - {wsTableColumns[columnKey].label} - } + {visibleColumnKeys.map((columnKey) => { + const specialProps = getSpecialColumnProps(columnKey); + const modifier = getColumnModifier(columnKey); + + return ( + + {specialProps.hasContent ? wsTableColumns[columnKey].label : undefined} +
( /> )} {visibleColumnKeys.map((columnKey) => { - switch (columnKey) { - case 'redirectStatus': - return ( - + if (columnKey === 'connect') { + return ( + + + + ({ + ...action, + 'data-testid': `action-${action.id || ''}`, + }))} + /> + + {columnKey === 'name' && workspace.name} + {columnKey === 'image' && ( + + {workspace.podTemplate.options.imageConfig.current.displayName}{' '} {workspaceRedirectStatus[workspace.workspaceKind.name] ? getRedirectStatusIcon( workspaceRedirectStatus[workspace.workspaceKind.name]?.message @@ -421,143 +497,57 @@ const WorkspaceTable = React.forwardRef( ?.text || 'No API response available', ) : getRedirectStatusIcon(undefined, 'No API response available')} - + )} + {columnKey === 'kind' && ( + + } > - {workspace.name} - - ( + + {workspace.workspaceKind.name} - } - > - {(validSrc) => ( - - {workspace.workspaceKind.name} - - )} - - - {workspace.namespace} - - {workspace.podTemplate.options.imageConfig.current.displayName} - + )} + + )} + {columnKey === 'namespace' && workspace.namespace} + {columnKey === 'state' && ( + + )} + {columnKey === 'gpu' && formatResourceFromWorkspace(workspace, 'gpu')} + {columnKey === 'idleGpu' && formatWorkspaceIdleState(workspace)} + {columnKey === 'lastActivity' && ( + - {workspace.podTemplate.options.podConfig.current.displayName} - - - - {workspace.podTemplate.volumes.home?.pvcName ?? ''} - - {formatResourceFromWorkspace(workspace, 'cpu')} - - {formatResourceFromWorkspace(workspace, 'memory')} - - {formatResourceFromWorkspace(workspace, 'gpu')} - - {formatWorkspaceIdleState(workspace)} - - - {formatDistanceToNow(new Date(workspace.activity.lastActivity), { - addSuffix: true, - })} - - - - - ({ - ...action, - 'data-testid': `action-${action.id || ''}`, - }))} - /> -
+ - + ; - } - }); + } - return ( -
- {renderExpandedData()} + if (index === packageColumnIndex) { + return ( + + + + + + + + + ; + })}