diff --git a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Experiments.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Experiments.cy.ts index f738f2374f..4682f1fc48 100644 --- a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Experiments.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Experiments.cy.ts @@ -1,4 +1,5 @@ /* eslint-disable camelcase */ +import { buildMockExperimentKF, buildMockRunKF } from '~/__mocks__'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; import { mockDataSciencePipelineApplicationK8sResource } from '~/__mocks__/mockDataSciencePipelinesApplicationK8sResource'; import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; @@ -11,24 +12,75 @@ import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; import { mockRouteK8sResource } from '~/__mocks__/mockRouteK8sResource'; import { mockStatus } from '~/__mocks__/mockStatus'; import { experimentsTabs } from '~/__tests__/cypress/cypress/pages/pipelines/experiments'; +import { RuntimeStateKF } from '~/concepts/pipelines/kfTypes'; const projectName = 'test-project-name'; const initialMockPipeline = buildMockPipelineV2({ display_name: 'Test pipeline' }); const initialMockPipelineVersion = buildMockPipelineVersionV2({ pipeline_id: initialMockPipeline.pipeline_id, }); +const mockExperimentArray = [ + buildMockExperimentKF({ + display_name: 'Test experiment 1', + experiment_id: '1', + }), + buildMockExperimentKF({ + display_name: 'Test experiment 2', + experiment_id: '2', + }), + buildMockExperimentKF({ + display_name: 'Test experiment 3', + experiment_id: '3', + }), +]; + +const runs = Array.from({ length: 5 }, (_, i) => + buildMockRunKF({ + display_name: 'Test triggered run 1', + run_id: `run-${i}`, + pipeline_version_reference: { + pipeline_id: initialMockPipeline.pipeline_id, + pipeline_version_id: initialMockPipelineVersion.pipeline_version_id, + }, + experiment_id: '1', + created_at: '2024-02-01T00:00:00Z', + state: RuntimeStateKF.SUCCEEDED, + }), +); describe('Pipeline Experiments', () => { beforeEach(() => { initIntercepts(); + experimentsTabs.mockGetExperiments(mockExperimentArray); experimentsTabs.visit(projectName); }); it('shows empty states', () => { + experimentsTabs.mockGetExperiments([]); + experimentsTabs.visit(projectName); experimentsTabs.findActiveTab().click(); - experimentsTabs.findActiveEmptyState().should('exist'); + experimentsTabs.getActiveExperimentsTable().findEmptyState().should('exist'); experimentsTabs.findArchivedTab().click(); - experimentsTabs.findArchivedEmptyState().should('exist'); + experimentsTabs.getArchivedExperimentsTable().findEmptyState().should('exist'); + }); + + it('filters by experiment name', () => { + experimentsTabs.findActiveTab().click(); + // Verify initial run rows exist + experimentsTabs.getActiveExperimentsTable().findRows().should('have.length', 3); + + // Select the "Name" filter, enter a value to filter by + experimentsTabs.getActiveExperimentsTable().selectFilterByName('Experiment'); + experimentsTabs.getActiveExperimentsTable().findFilterTextField().type('Test experiment 2'); + + // Mock runs (filtered by typed run name) + experimentsTabs.mockGetExperiments( + mockExperimentArray.filter((exp) => exp.display_name.includes('Test experiment 2')), + ); + + // Verify only rows with the typed run name exist + experimentsTabs.getActiveExperimentsTable().findRows().should('have.length', 1); + experimentsTabs.getActiveExperimentsTable().findRowByName('Test experiment 2'); }); }); @@ -53,7 +105,7 @@ const initIntercepts = () => { pathname: `/api/k8s/apis/route.openshift.io/v1/namespaces/${projectName}/routes/ds-pipeline-dspa`, }, mockRouteK8sResource({ - notebookName: 'ds-pipeline-pipelines-definition', + notebookName: 'ds-pipeline-dspa', namespace: projectName, }), ); @@ -61,10 +113,7 @@ const initIntercepts = () => { { pathname: '/api/k8s/apis/project.openshift.io/v1/projects', }, - mockK8sResourceList([ - mockProjectK8sResource({ k8sName: projectName }), - mockProjectK8sResource({ k8sName: `${projectName}-2`, displayName: 'Test Project 2' }), - ]), + mockK8sResourceList([mockProjectK8sResource({ k8sName: projectName })]), ); cy.intercept( @@ -81,4 +130,10 @@ const initIntercepts = () => { }, buildMockPipelineVersionsV2([initialMockPipelineVersion]), ); + cy.intercept( + { + pathname: '/api/proxy/apis/v2beta1/runs', + }, + { runs }, + ); }; diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts index 746a5bbe00..78cd7c524b 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts @@ -1,5 +1,7 @@ /* eslint-disable camelcase */ +import { ExperimentKFv2 } from '~/concepts/pipelines/kfTypes'; + class ExperimentsTabs { visit(namespace?: string, tab?: string) { cy.visitWithLogin( @@ -21,12 +23,80 @@ class ExperimentsTabs { return cy.findByTestId('experiments-archived-tab'); } - findActiveEmptyState() { - return cy.findByTestId('global-no-active-experiments'); + getActiveExperimentsTable() { + return new ExperimentsTable(() => cy.findByTestId('experiments-active-tab-content')); + } + + getArchivedExperimentsTable() { + return new ExperimentsTable(() => cy.findByTestId('experiments-archived-tab-content')); + } + + mockGetExperiments(experiments: ExperimentKFv2[]) { + return cy.intercept( + { + method: 'POST', + pathname: '/api/proxy/apis/v2beta1/experiments', + }, + { experiments, total_size: experiments.length }, + ); + } +} + +class ExperimentsTable { + private findContainer: () => Cypress.Chainable>; + + constructor(findContainer: () => Cypress.Chainable>) { + this.findContainer = findContainer; + } + + find() { + return this.findContainer().findByTestId('experiment-table'); + } + + findRowByName(name: string) { + return this.find().findAllByRole('cell', { name }).parents('tr'); + } + + findRows() { + return this.find().find('[data-label=Experiment]').parents('tr'); + } + + findRowKebabByName(name: string) { + return this.findRowByName(name).findByRole('button', { name: 'Kebab toggle' }); + } + + findActionsKebab() { + return this.findContainer() + .findByTestId('experiment-table-toolbar') + .findByTestId('experiment-table-toolbar-actions'); + } + + findEmptyState() { + return this.findContainer().findByTestId('global-no-experiments'); + } + + selectFilterByName(name: string) { + this.findContainer() + .findByTestId('experiment-table-toolbar') + .findByTestId('pipeline-filter-dropdown') + .findDropdownItem(name) + .click(); + } + + findFilterTextField() { + return this.findContainer() + .findByTestId('experiment-table-toolbar') + .findByTestId('run-table-toolbar-filter-text-field'); } - findArchivedEmptyState() { - return cy.findByTestId('global-no-archived-experiments'); + selectRowActionByName(rowName: string, actionName: string) { + this.findRowKebabByName(rowName).click(); + this.findContainer() + .findByRole('menu') + .get('span') + .contains(actionName) + .parents('button') + .click(); } } diff --git a/frontend/src/api/pipelines/custom.ts b/frontend/src/api/pipelines/custom.ts index 919f0d779e..5255d75f94 100644 --- a/frontend/src/api/pipelines/custom.ts +++ b/frontend/src/api/pipelines/custom.ts @@ -117,7 +117,16 @@ export const listPipelines: ListPipelinesAPI = (hostPath) => (opts, params) => export const listPipelineRuns: ListPipelinesRunAPI = (hostPath) => (opts, params) => handlePipelineFailures( - proxyGET(hostPath, '/apis/v2beta1/runs', pipelineParamsToQuery(params), opts), + proxyGET( + hostPath, + '/apis/v2beta1/runs', + { + ...pipelineParamsToQuery(params), + // eslint-disable-next-line camelcase + experiment_id: params?.experiment_id, + }, + opts, + ), ); export const listPipelineActiveRuns: ListPipelinesRunAPI = (hostPath) => (opts, params) => { diff --git a/frontend/src/components/SimpleMenuActions.tsx b/frontend/src/components/SimpleMenuActions.tsx index 91111366d7..10ec7bd54d 100644 --- a/frontend/src/components/SimpleMenuActions.tsx +++ b/frontend/src/components/SimpleMenuActions.tsx @@ -11,6 +11,7 @@ type Item = { label: React.ReactNode; onClick: (key: string) => void; isDisabled?: boolean; + tooltip?: React.ReactNode; }; type Spacer = { isSpacer: true }; const isSpacer = (v: Item | Spacer): v is Spacer => 'isSpacer' in v; @@ -39,6 +40,7 @@ const SimpleMenuActions: React.FC = ({ dropdownItems, ...pr { itemOrSpacer.onClick(itemOrSpacer.key); setOpen(false); diff --git a/frontend/src/concepts/pipelines/apiHooks/useExperiments.ts b/frontend/src/concepts/pipelines/apiHooks/useExperiments.ts index 8c435b769e..3eb3866aef 100644 --- a/frontend/src/concepts/pipelines/apiHooks/useExperiments.ts +++ b/frontend/src/concepts/pipelines/apiHooks/useExperiments.ts @@ -1,24 +1,51 @@ import * as React from 'react'; -import { ExperimentKFv2 } from '~/concepts/pipelines/kfTypes'; +import { ExperimentKFv2, PipelinesFilterOp, StorageStateKF } from '~/concepts/pipelines/kfTypes'; import { usePipelinesAPI } from '~/concepts/pipelines/context'; import usePipelineQuery from '~/concepts/pipelines/apiHooks/usePipelineQuery'; import { PipelineListPaged, PipelineOptions } from '~/concepts/pipelines/types'; import { FetchState } from '~/utilities/useFetchState'; -const useExperiments = ( +const useExperimentsByStorageState = ( options?: PipelineOptions, + storageState?: StorageStateKF, ): FetchState> => { const { api } = usePipelinesAPI(); return usePipelineQuery( React.useCallback( - (opts, params) => - api - .listExperiments(opts, params) - .then((result) => ({ ...result, items: result.experiments })), - [api], + (opts, params) => { + const predicates = params?.filter?.predicates || []; + if (storageState) { + predicates.push({ + key: 'storage_state', + operation: PipelinesFilterOp.EQUALS, + // eslint-disable-next-line camelcase + string_value: storageState, + }); + } + return api + .listExperiments(opts, { + ...params, + filter: { predicates }, + }) + .then((result) => ({ ...result, items: result.experiments })); + }, + [api, storageState], ), options, ); }; +const useExperiments = (options?: PipelineOptions): FetchState> => + useExperimentsByStorageState(options); + +export const useActiveExperiments = ( + options?: PipelineOptions, +): FetchState> => + useExperimentsByStorageState(options, StorageStateKF.AVAILABLE); + +export const useArchivedExperiments = ( + options?: PipelineOptions, +): FetchState> => + useExperimentsByStorageState(options, StorageStateKF.ARCHIVED); + export default useExperiments; diff --git a/frontend/src/concepts/pipelines/apiHooks/usePipelineRuns.ts b/frontend/src/concepts/pipelines/apiHooks/usePipelineRuns.ts index e3e58100ac..e9286af946 100644 --- a/frontend/src/concepts/pipelines/apiHooks/usePipelineRuns.ts +++ b/frontend/src/concepts/pipelines/apiHooks/usePipelineRuns.ts @@ -38,3 +38,22 @@ export const usePipelineArchivedRuns = ( options, ); }; + +export const usePipelineRunsByExperiment = ( + experimentId: string, + options?: PipelineOptions, +): FetchState> => { + const { api } = usePipelinesAPI(); + + return usePipelineQuery( + React.useCallback( + (opts, params) => + api + // eslint-disable-next-line camelcase + .listPipelineRuns(opts, { ...params, experiment_id: experimentId }) + .then((result) => ({ ...result, items: result.runs })), + [api, experimentId], + ), + options, + ); +}; diff --git a/frontend/src/concepts/pipelines/content/tables/columns.ts b/frontend/src/concepts/pipelines/content/tables/columns.ts index 2fe82dd080..3370f10d32 100644 --- a/frontend/src/concepts/pipelines/content/tables/columns.ts +++ b/frontend/src/concepts/pipelines/content/tables/columns.ts @@ -9,6 +9,7 @@ import { PipelineKFv2, PipelineRunJobKFv2, PipelineRunKFv2, + ExperimentKFv2, } from '~/concepts/pipelines/kfTypes'; export const pipelineColumns: SortableData[] = [ @@ -58,6 +59,31 @@ export const pipelineVersionColumns: SortableData[] = [ kebabTableColumn(), ]; +export const experimentColumns: SortableData[] = [ + checkboxTableColumn(), + { + label: 'Experiment', + field: 'display_name', + sortable: true, + }, + { + label: 'Description', + field: 'description', + sortable: true, + }, + { + label: 'Created', + field: 'created_at', + sortable: true, + }, + { + label: 'Last 5 runs', + field: 'last_5_runs', + sortable: false, + }, + kebabTableColumn(), +]; + export const pipelineRunColumns: SortableData[] = [ checkboxTableColumn(), { diff --git a/frontend/src/concepts/pipelines/content/tables/experiment/ActiveExperimentTable.tsx b/frontend/src/concepts/pipelines/content/tables/experiment/ActiveExperimentTable.tsx new file mode 100644 index 0000000000..843633b0c1 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/experiment/ActiveExperimentTable.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { ExperimentKFv2 } from '~/concepts/pipelines/kfTypes'; +import ExperimentTableBase from './ExperimentTableBase'; +import { ActiveExperimentTableToolbar } from './ExperimentTableToolbar'; + +type ActiveExperimentTableProps = Omit< + React.ComponentProps, + 'toolbarContentRenderer' | 'getActionColumnItems' +>; + +const ActiveExperimentTable: React.FC = ({ ...baseTable }) => { + const { experiments } = baseTable; + + const [, setArchiveResources] = React.useState([]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const onArchive = (experiment: ExperimentKFv2) => null; + + return ( + <> + [ + { + title: 'Archive', + onClick: () => { + onArchive(experiment); + }, + }, + ]} + toolbarContentRenderer={(selections) => ( + 0} + onArchiveAll={() => + setArchiveResources( + selections + .map((selection) => + experiments.find( + ({ experiment_id: experimentId }) => experimentId === selection, + ), + ) + .filter((v): v is ExperimentKFv2 => !!v), + ) + } + /> + )} + /> + + ); +}; + +export default ActiveExperimentTable; diff --git a/frontend/src/concepts/pipelines/content/tables/experiment/ArchivedExperimentTable.tsx b/frontend/src/concepts/pipelines/content/tables/experiment/ArchivedExperimentTable.tsx new file mode 100644 index 0000000000..fcec5c368c --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/experiment/ArchivedExperimentTable.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { ExperimentKFv2 } from '~/concepts/pipelines/kfTypes'; +import ExperimentTableBase from './ExperimentTableBase'; +import { ArchivedExperimentTableToolbar } from './ExperimentTableToolbar'; + +type ArchivedExperimentTableProps = Omit< + React.ComponentProps, + 'toolbarContentRenderer' | 'getActionColumnItems' +>; + +const ArchivedExperimentTable: React.FC = ({ ...baseTable }) => { + const { experiments } = baseTable; + + const [, setRestoreResources] = React.useState([]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const onRestore = (experiment: ExperimentKFv2) => null; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const onDelete = (experiment: ExperimentKFv2) => null; + + return ( + <> + [ + { + title: 'Restore', + onClick: () => { + onRestore(experiment); + }, + }, + { + title: 'Delete', + isDisabled: experiment.display_name === 'Default', + onClick: () => { + onDelete(experiment); + }, + }, + ]} + toolbarContentRenderer={(selections) => ( + 0} + onRestoreAll={() => + setRestoreResources( + selections + .map((selection) => + experiments.find( + ({ experiment_id: experimentId }) => experimentId === selection, + ), + ) + .filter((v): v is ExperimentKFv2 => !!v), + ) + } + /> + )} + /> + + ); +}; + +export default ArchivedExperimentTable; diff --git a/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableBase.tsx b/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableBase.tsx new file mode 100644 index 0000000000..f6e24c95dc --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableBase.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { IAction } from '@patternfly/react-table'; +import { TableBase, getTableColumnSort, useCheckboxTable } from '~/components/table'; +import { ExperimentKFv2 } from '~/concepts/pipelines/kfTypes'; +import { experimentColumns } from '~/concepts/pipelines/content/tables/columns'; +import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; +import usePipelineFilter from '~/concepts/pipelines/content/tables/usePipelineFilter'; +import { PipelinesFilter } from '~/concepts/pipelines/types'; +import ExperimentTableRow from './ExperimentTableRow'; +import { ExperimentTableToolbar } from './ExperimentTableToolbar'; + +type ExperimentTableProps = { + experiments: ExperimentKFv2[]; + loading?: boolean; + totalSize: number; + page: number; + pageSize: number; + setPage: (page: number) => void; + setPageSize: (pageSize: number) => void; + sortField?: string; + sortDirection?: 'asc' | 'desc'; + setSortField: (field: string) => void; + setSortDirection: (dir: 'asc' | 'desc') => void; + setFilter: (filter?: PipelinesFilter) => void; + toolbarContentRenderer: ( + selections: string[], + ) => React.ComponentProps['children']; + getActionColumnItems: (experiment: ExperimentKFv2) => IAction[]; +}; + +const ExperimentTable: React.FC = ({ + experiments, + loading, + totalSize, + page, + pageSize, + setPage, + setPageSize, + toolbarContentRenderer, + setFilter, + getActionColumnItems, + ...tableProps +}) => { + const filterToolbarProps = usePipelineFilter(setFilter); + const { + selections, + tableProps: checkboxTableProps, + toggleSelection, + isSelected, + } = useCheckboxTable(experiments.map(({ experiment_id: experimentId }) => experimentId)); + + return ( + { + if (newPage < page || !loading) { + setPage(newPage); + } + }} + onPerPageSelect={(_, newSize) => setPageSize(newSize)} + itemCount={totalSize} + data={experiments} + columns={experimentColumns} + enablePagination + emptyTableView={ + + } + toolbarContent={ + + {toolbarContentRenderer(selections)} + + } + rowRenderer={(experiment) => ( + toggleSelection(experiment.experiment_id)} + experiment={experiment} + actionColumnItems={getActionColumnItems(experiment)} + /> + )} + getColumnSort={getTableColumnSort({ columns: experimentColumns, ...tableProps })} + data-testid="experiment-table" + /> + ); +}; +export default ExperimentTable; diff --git a/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableRow.tsx b/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableRow.tsx new file mode 100644 index 0000000000..748cadb4f3 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableRow.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { ActionsColumn, IAction, Td, Tr } from '@patternfly/react-table'; +import { ExperimentKFv2 } from '~/concepts/pipelines/kfTypes'; +import { CheckboxTd } from '~/components/table'; +import { ExperimentCreated, LastExperimentRuns } from './renderUtils'; + +type ExperimentTableRowProps = { + isChecked: boolean; + onToggleCheck: () => void; + experiment: ExperimentKFv2; + actionColumnItems: IAction[]; +}; + +const ExperimentTableRow: React.FC = ({ + isChecked, + onToggleCheck, + experiment, + actionColumnItems, +}) => ( + + + {experiment.display_name} + {experiment.description} + + + + + + + + + + +); + +export default ExperimentTableRow; diff --git a/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableToolbar.tsx b/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableToolbar.tsx new file mode 100644 index 0000000000..aeda1c536f --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/experiment/ExperimentTableToolbar.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { Button, TextInput, ToolbarItem } from '@patternfly/react-core'; +import PipelineFilterBar from '~/concepts/pipelines/content/tables/PipelineFilterBar'; +import { FilterOptions } from '~/concepts/pipelines/content/tables/usePipelineFilter'; +import DashboardDatePicker from '~/components/DashboardDatePicker'; +import SimpleMenuActions from '~/components/SimpleMenuActions'; + +const options = { + [FilterOptions.NAME]: 'Experiment', + [FilterOptions.CREATED_AT]: 'Created', +}; + +export type FilterProps = Pick< + React.ComponentProps, + 'filterData' | 'onFilterUpdate' | 'onClearFilters' +>; + +type ExperimentTableToolbarProps = FilterProps & { + children: React.ReactElement | React.ReactElement[]; +}; + +export const ExperimentTableToolbar: React.FC = ({ + children, + ...toolbarProps +}) => ( + + {...toolbarProps} + filterOptions={options} + filterOptionRenders={{ + [FilterOptions.NAME]: ({ onChange, ...props }) => ( + onChange(value)} + /> + ), + [FilterOptions.CREATED_AT]: ({ onChange, ...props }) => ( + { + if (date || !value) { + onChange(value); + } + }} + /> + ), + }} + > + {children} + +); + +type ActiveExperimentTableToolbarProps = { + archiveAllEnabled: boolean; + onArchiveAll: () => void; +}; + +export const ActiveExperimentTableToolbar: React.FC = ({ + archiveAllEnabled, + onArchiveAll, +}) => ( + <> + + + + + + + +); + +type ArchivedExperimentTableToolbarProps = { + restoreAllEnabled: boolean; + onRestoreAll: () => void; +}; + +export const ArchivedExperimentTableToolbar: React.FC = ({ + restoreAllEnabled, + onRestoreAll, +}) => ( + + + +); diff --git a/frontend/src/concepts/pipelines/content/tables/experiment/renderUtils.tsx b/frontend/src/concepts/pipelines/content/tables/experiment/renderUtils.tsx new file mode 100644 index 0000000000..8de348eef8 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/experiment/renderUtils.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Split, SplitItem } from '@patternfly/react-core'; +import { ExperimentKFv2 } from '~/concepts/pipelines/kfTypes'; +import PipelinesTableRowTime from '~/concepts/pipelines/content/tables/PipelinesTableRowTime'; +import { usePipelineRunsByExperiment } from '~/concepts/pipelines/apiHooks/usePipelineRuns'; +import { RunStatus } from '~/concepts/pipelines/content/tables/renderUtils'; + +type ExperimentUtil

> = React.FC<{ experiment: ExperimentKFv2 } & P>; + +export const ExperimentCreated: ExperimentUtil = ({ experiment }) => { + const createdDate = new Date(experiment.created_at); + return ; +}; + +export const LastExperimentRuns: ExperimentUtil = ({ experiment }) => { + const [runs] = usePipelineRunsByExperiment(experiment.experiment_id, { + sortDirection: 'desc', + sortField: 'created_at', + }); + + if (runs.items.length === 0) { + return <>-; + } + + const last5runs = runs.items.slice(0, 5); + + return ( + + {last5runs.map((run) => ( + + + + ))} + + ); +}; diff --git a/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx b/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx index 826d70d9b3..a70e6ab68e 100644 --- a/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx +++ b/frontend/src/concepts/pipelines/content/tables/renderUtils.tsx @@ -4,6 +4,8 @@ import { Level, LevelItem, Spinner, + Stack, + StackItem, Switch, Timestamp, TimestampTooltipVariant, @@ -45,8 +47,8 @@ export const RunNameForPipeline: RunUtil = ({ run }) => { }; export const RunStatus: RunUtil<{ justIcon?: boolean }> = ({ justIcon, run }) => { - const { icon, status, label, details } = computeRunStatus(run); - let tooltipContent = details; + const { icon, status, label, details, createdAt } = computeRunStatus(run); + let tooltipContent: React.ReactNode = details; const content = (

@@ -59,7 +61,12 @@ export const RunStatus: RunUtil<{ justIcon?: boolean }> = ({ justIcon, run }) => if (justIcon && !tooltipContent) { // If we are just an icon with no tooltip -- make it the status for ease of understanding - tooltipContent = runtimeStateLabels[run.state]; + tooltipContent = ( + + {`Status: ${runtimeStateLabels[run.state]}`} + {`Started: ${createdAt}`} + + ); } if (tooltipContent) { diff --git a/frontend/src/concepts/pipelines/content/utils.tsx b/frontend/src/concepts/pipelines/content/utils.tsx index 3f4a7b9bde..b35b1cf875 100644 --- a/frontend/src/concepts/pipelines/content/utils.tsx +++ b/frontend/src/concepts/pipelines/content/utils.tsx @@ -17,12 +17,14 @@ import { RuntimeStateKF, runtimeStateLabels, } from '~/concepts/pipelines/kfTypes'; +import { relativeTime } from '~/utilities/time'; export type RunStatusDetails = { icon: React.ReactNode; label: PipelineRunKFv2['state'] | string; status?: React.ComponentProps['status']; details?: string; + createdAt?: string; }; const UNKNOWN_ICON = ; @@ -36,6 +38,7 @@ export const computeRunStatus = (run?: PipelineRunKFv2 | null): RunStatusDetails let status: React.ComponentProps['status']; let details: string | undefined; let label: string; + const createdAt = relativeTime(Date.now(), new Date(run.created_at).getTime()); switch (run.state) { case RuntimeStateKF.PENDING: @@ -82,7 +85,7 @@ export const computeRunStatus = (run?: PipelineRunKFv2 | null): RunStatusDetails details = run.state ?? 'No status yet'; } - return { icon, label, status, details }; + return { icon, label, status, details, createdAt }; }; export const getPipelineAndVersionDeleteString = ( diff --git a/frontend/src/concepts/pipelines/types.ts b/frontend/src/concepts/pipelines/types.ts index a65516212d..685ee49ae0 100644 --- a/frontend/src/concepts/pipelines/types.ts +++ b/frontend/src/concepts/pipelines/types.ts @@ -27,8 +27,12 @@ export type PipelineParams = { sortDirection?: 'asc' | 'desc'; filter?: PipelinesFilter; }; +export type PipelineParamsWithExperiments = PipelineParams & { experiment_id?: string }; export type PipelineOptions = Omit & { page?: number }; +export type PipelineOptionsWithExperiments = PipelineOptions & { + experiment_id?: string; +}; export type PipelineListPaged = { totalSize: number; @@ -82,7 +86,7 @@ export type ListPipelines = ( ) => Promise; export type ListPipelineRuns = ( opts: K8sAPIOptions, - params?: PipelineParams, + params?: PipelineParamsWithExperiments, ) => Promise; export type ListPipelineRunJobs = ( opts: K8sAPIOptions, diff --git a/frontend/src/pages/pipelines/global/experiments/ActiveExperimentsList.tsx b/frontend/src/pages/pipelines/global/experiments/ActiveExperimentsList.tsx new file mode 100644 index 0000000000..fd9df74a48 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/ActiveExperimentsList.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateHeader, + EmptyStateIcon, + Spinner, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import createUsePipelineTable from '~/concepts/pipelines/content/tables/usePipelineTable'; +import { useActiveExperiments } from '~/concepts/pipelines/apiHooks/useExperiments'; +import ActiveExperimentTable from '~/concepts/pipelines/content/tables/experiment/ActiveExperimentTable'; + +const ActiveExperimentsList: React.FC = () => { + const [[{ items: experiments, totalSize }, loaded, error], { initialLoaded, ...tableProps }] = + createUsePipelineTable(useActiveExperiments)(); + + if (error) { + return ( + + + } + headingLevel="h2" + /> + {error.message} + + + ); + } + + if (!loaded && !initialLoaded) { + return ( + + + + ); + } + + if (loaded && totalSize === 0 && !tableProps.filter) { + return ( + + } + headingLevel="h4" + /> + Click the button below to create a new active experiment. + + + + + ); + } + + return ( + + ); +}; +export default ActiveExperimentsList; diff --git a/frontend/src/pages/pipelines/global/experiments/ArchivedExperimentsList.tsx b/frontend/src/pages/pipelines/global/experiments/ArchivedExperimentsList.tsx new file mode 100644 index 0000000000..6ba9703ce2 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/ArchivedExperimentsList.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { + Bullseye, + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + Spinner, +} from '@patternfly/react-core'; +import { CubesIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; +import createUsePipelineTable from '~/concepts/pipelines/content/tables/usePipelineTable'; +import { useArchivedExperiments } from '~/concepts/pipelines/apiHooks/useExperiments'; +import ArchivedExperimentTable from '~/concepts/pipelines/content/tables/experiment/ArchivedExperimentTable'; + +const ArchivedExperimentsList: React.FC = () => { + const [[{ items: experiments, totalSize }, loaded, error], { initialLoaded, ...tableProps }] = + createUsePipelineTable(useArchivedExperiments)(); + + if (error) { + return ( + + + } + headingLevel="h2" + /> + {error.message} + + + ); + } + + if (!loaded && !initialLoaded) { + return ( + + + + ); + } + + if (loaded && totalSize === 0 && !tableProps.filter) { + return ( + + } + headingLevel="h4" + /> + + When you are finished with an experiment, you can archive it in the Active tab. You can + view the archived experiment here. + + + ); + } + + return ( + + ); +}; +export default ArchivedExperimentsList; diff --git a/frontend/src/pages/pipelines/global/experiments/ExperimentsList.tsx b/frontend/src/pages/pipelines/global/experiments/ExperimentsList.tsx deleted file mode 100644 index a00f923f40..0000000000 --- a/frontend/src/pages/pipelines/global/experiments/ExperimentsList.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react'; -import GlobalNoExperiments from './GlobalNoExperiments'; -import { ExperimentListTabs } from './const'; - -type ExperimentListProps = { - tab: ExperimentListTabs; -}; - -const ExperimentsList: React.FC = ({ tab }) => ( - -); -export default ExperimentsList; diff --git a/frontend/src/pages/pipelines/global/experiments/GlobalExperiments.tsx b/frontend/src/pages/pipelines/global/experiments/GlobalExperiments.tsx index cefc2d6787..c1e4a93fe9 100644 --- a/frontend/src/pages/pipelines/global/experiments/GlobalExperiments.tsx +++ b/frontend/src/pages/pipelines/global/experiments/GlobalExperiments.tsx @@ -21,6 +21,7 @@ const GlobalExperiments: React.FC = ({ tab }) => { description={experimentsPageDescription} headerAction={} getRedirectPath={(namespace) => `/pipelineExperiments/${namespace}`} + overrideChildPadding > diff --git a/frontend/src/pages/pipelines/global/experiments/GlobalExperimentsTabs.tsx b/frontend/src/pages/pipelines/global/experiments/GlobalExperimentsTabs.tsx index e02fb19cfd..a8fb9fd91a 100644 --- a/frontend/src/pages/pipelines/global/experiments/GlobalExperimentsTabs.tsx +++ b/frontend/src/pages/pipelines/global/experiments/GlobalExperimentsTabs.tsx @@ -3,8 +3,9 @@ import { PageSection, Tab, Tabs, TabTitleText } from '@patternfly/react-core'; import { useNavigate } from 'react-router'; import '~/pages/pipelines/global/runs/GlobalPipelineRunsTabs.scss'; import { usePipelinesAPI } from '~/concepts/pipelines/context'; -import ExperimentsList from './ExperimentsList'; import { ExperimentListTabs } from './const'; +import ActiveExperimentsList from './ActiveExperimentsList'; +import ArchivedExperimentsList from './ArchivedExperimentsList'; type GlobalExperimentsTabProps = { tab: ExperimentListTabs; @@ -30,8 +31,8 @@ const GlobalExperimentsTabs: React.FC = ({ tab }) => className="odh-pipeline-runs-page-tabs__content" data-testid="experiments-active-tab" > - - + + = ({ tab }) => className="odh-pipeline-runs-page-tabs__content" data-testid="experiments-archived-tab" > - - + + diff --git a/frontend/src/pages/pipelines/global/experiments/GlobalNoExperiments.tsx b/frontend/src/pages/pipelines/global/experiments/GlobalNoExperiments.tsx index 716700dd4e..7a1d613d40 100644 --- a/frontend/src/pages/pipelines/global/experiments/GlobalNoExperiments.tsx +++ b/frontend/src/pages/pipelines/global/experiments/GlobalNoExperiments.tsx @@ -17,7 +17,7 @@ type GlobalNoExperimentsProps = { const GlobalNoExperiments: React.FC = ({ tab }) => { if (tab === ExperimentListTabs.ARCHIVED) { return ( - + } @@ -33,7 +33,7 @@ const GlobalNoExperiments: React.FC = ({ tab }) => { } return ( - + }