diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/expanded_row.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/expanded_row.tsx index 33a8f9ccb8a2f0..d3c8d9c17e17a9 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/expanded_row.tsx @@ -18,6 +18,7 @@ import { EuiSpacer, EuiTabbedContent, EuiTitle, + EuiTabbedContentTab, } from '@elastic/eui'; import type { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -151,9 +152,10 @@ export const ExpandedRow: FC = ({ item }) => { [stats.deployment_stats] ); - const tabs = [ + const tabs: EuiTabbedContentTab[] = [ { id: 'details', + 'data-test-subj': 'mlTrainedModelDetails', name: ( = ({ item }) => { /> ), content: ( - <> +
@@ -203,13 +205,14 @@ export const ExpandedRow: FC = ({ item }) => { ) : null} - +
), }, ...(inferenceConfig ? [ { id: 'config', + 'data-test-subj': 'mlTrainedModelInferenceConfig', name: ( = ({ item }) => { /> ), content: ( - <> +
@@ -261,7 +264,7 @@ export const ExpandedRow: FC = ({ item }) => { )} - +
), }, ] @@ -270,6 +273,7 @@ export const ExpandedRow: FC = ({ item }) => { ? [ { id: 'stats', + 'data-test-subj': 'mlTrainedModelStats', name: ( = ({ item }) => { /> ), content: ( - <> +
@@ -317,8 +321,29 @@ export const ExpandedRow: FC = ({ item }) => { ) : null} + {isPopulatedObject(stats.model_size_stats) && + !isPopulatedObject(stats.inference_stats) ? ( + + + +
+ +
+
+ + +
+
+ ) : null}
- +
), }, ] @@ -327,6 +352,7 @@ export const ExpandedRow: FC = ({ item }) => { ? [ { id: 'pipelines', + 'data-test-subj': 'mlTrainedModelPipelines', name: ( <> = ({ item }) => { ), content: ( - <> +
- +
), }, ] @@ -355,6 +381,7 @@ export const ExpandedRow: FC = ({ item }) => { initialSelectedTab={tabs[0]} autoFocus="selected" onTabClick={(tab) => {}} + data-test-subj={'mlTrainedModelRowDetails'} /> ); }; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/pipelines.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/pipelines.tsx index 54c9c6b612d646..6ea883ed84706f 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/pipelines.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/pipelines/pipelines.tsx @@ -23,7 +23,7 @@ import { ProcessorsStats } from './expanded_row'; export type IngestStatsResponse = Exclude['ingest']; interface ModelPipelinesProps { - pipelines: Exclude; + pipelines: ModelItem['pipelines']; ingestStats: IngestStatsResponse; } @@ -32,11 +32,18 @@ export const ModelPipelines: FC = ({ pipelines, ingestStats services: { share }, } = useMlKibana(); + const pipelineNames = Object.keys(pipelines ?? ingestStats?.pipelines ?? {}); + + if (!pipelineNames.length) return null; + return ( <> - {Object.entries(pipelines).map(([pipelineName, pipelineDefinition], i) => { + {pipelineNames.map((pipelineName, i) => { // Expand first 3 pipelines by default const initialIsOpen = i <= 2; + + const pipelineDefinition = pipelines?.[pipelineName]; + return ( <> = ({ pipelines, ingestStats } extraAction={ - { - const locator = share.url.locators.get('INGEST_PIPELINES_APP_LOCATOR'); - if (!locator) return; - locator.navigate({ - page: 'pipeline_edit', - pipelineId: pipelineName, - absolute: true, - }); - }} - iconType={'documentEdit'} - iconSide="left" - > - - + pipelineDefinition ? ( + { + const locator = share.url.locators.get('INGEST_PIPELINES_APP_LOCATOR'); + if (!locator) return; + locator.navigate({ + page: 'pipeline_edit', + pipelineId: pipelineName, + absolute: true, + }); + }} + iconType={'documentEdit'} + iconSide="left" + > + + + ) : undefined } paddingSize="l" initialIsOpen={initialIsOpen} > {ingestStats?.pipelines ? ( - +
@@ -88,27 +98,29 @@ export const ModelPipelines: FC = ({ pipelines, ingestStats ) : null} - - - -
- -
-
- - {JSON.stringify(pipelineDefinition, null, 2)} - -
-
+ {pipelineDefinition ? ( + + + +
+ +
+
+ + {JSON.stringify(pipelineDefinition, null, 2)} + +
+
+ ) : null} diff --git a/x-pack/test/functional/apps/ml/model_management/model_list.ts b/x-pack/test/functional/apps/ml/model_management/model_list.ts index 955639dbe60a45..aef8f1d95302d2 100644 --- a/x-pack/test/functional/apps/ml/model_management/model_list.ts +++ b/x-pack/test/functional/apps/ml/model_management/model_list.ts @@ -42,96 +42,152 @@ export default function ({ getService }: FtrProviderContext) { modelTypes: ['regression', 'tree_ensemble'], }; - it('renders trained models list', async () => { - await ml.testExecution.logTestStep( - 'should display the stats bar with the total number of models' - ); - // +1 because of the built-in model - await ml.trainedModels.assertStats(31); - - await ml.testExecution.logTestStep('should display the table'); - await ml.trainedModels.assertTableExists(); - await ml.trainedModels.assertRowsNumberPerPage(10); - }); - - it('displays the built-in model and no actions are enabled', async () => { - await ml.testExecution.logTestStep('should display the model in the table'); - await ml.trainedModelsTable.filterWithSearchString(builtInModelData.modelId, 1); - - await ml.testExecution.logTestStep('displays expected row values for the model in the table'); - await ml.trainedModelsTable.assertModelsRowFields(builtInModelData.modelId, { - id: builtInModelData.modelId, - description: builtInModelData.description, - modelTypes: builtInModelData.modelTypes, + describe('for ML power user', () => { + before(async () => { + await ml.securityUI.loginAsMlPowerUser(); + await ml.navigation.navigateToTrainedModels(); }); - await ml.testExecution.logTestStep( - 'should not show collapsed actions menu for the model in the table' - ); - await ml.trainedModelsTable.assertModelCollapsedActionsButtonExists( - builtInModelData.modelId, - false - ); - - await ml.testExecution.logTestStep( - 'should not show delete action for the model in the table' - ); - await ml.trainedModelsTable.assertModelDeleteActionButtonExists( - builtInModelData.modelId, - false - ); - }); + after(async () => { + await ml.securityUI.logout(); + }); - it('displays a model with an ingest pipeline and delete action is disabled', async () => { - await ml.testExecution.logTestStep('should display the model in the table'); - await ml.trainedModelsTable.filterWithSearchString(modelWithPipelineData.modelId, 1); + it('renders trained models list', async () => { + await ml.testExecution.logTestStep( + 'should display the stats bar with the total number of models' + ); + // +1 because of the built-in model + await ml.trainedModels.assertStats(31); - await ml.testExecution.logTestStep('displays expected row values for the model in the table'); - await ml.trainedModelsTable.assertModelsRowFields(modelWithPipelineData.modelId, { - id: modelWithPipelineData.modelId, - description: modelWithPipelineData.description, - modelTypes: modelWithPipelineData.modelTypes, + await ml.testExecution.logTestStep('should display the table'); + await ml.trainedModels.assertTableExists(); + await ml.trainedModels.assertRowsNumberPerPage(10); }); - await ml.testExecution.logTestStep( - 'should show disabled delete action for the model in the table' - ); + it('renders expanded row content correctly for model with pipelines', async () => { + await ml.trainedModelsTable.ensureRowIsExpanded(modelWithPipelineData.modelId); + await ml.trainedModelsTable.assertDetailsTabContent(); + await ml.trainedModelsTable.assertInferenceConfigTabContent(); + await ml.trainedModelsTable.assertStatsTabContent(); + await ml.trainedModelsTable.assertPipelinesTabContent(true, [ + { pipelineName: `pipeline_${modelWithPipelineData.modelId}`, expectDefinition: true }, + ]); + }); - await ml.trainedModelsTable.assertModelDeleteActionButtonEnabled( - modelWithPipelineData.modelId, - false - ); - }); + it('renders expanded row content correctly for model without pipelines', async () => { + await ml.trainedModelsTable.ensureRowIsExpanded(modelWithoutPipelineData.modelId); + await ml.trainedModelsTable.assertDetailsTabContent(); + await ml.trainedModelsTable.assertInferenceConfigTabContent(); + await ml.trainedModelsTable.assertStatsTabContent(); + await ml.trainedModelsTable.assertPipelinesTabContent(false); + }); - it('displays a model without an ingest pipeline and model can be deleted', async () => { - await ml.testExecution.logTestStep('should display the model in the table'); - await ml.trainedModelsTable.filterWithSearchString(modelWithoutPipelineData.modelId, 1); + it('displays the built-in model and no actions are enabled', async () => { + await ml.testExecution.logTestStep('should display the model in the table'); + await ml.trainedModelsTable.filterWithSearchString(builtInModelData.modelId, 1); + + await ml.testExecution.logTestStep( + 'displays expected row values for the model in the table' + ); + await ml.trainedModelsTable.assertModelsRowFields(builtInModelData.modelId, { + id: builtInModelData.modelId, + description: builtInModelData.description, + modelTypes: builtInModelData.modelTypes, + }); + + await ml.testExecution.logTestStep( + 'should not show collapsed actions menu for the model in the table' + ); + await ml.trainedModelsTable.assertModelCollapsedActionsButtonExists( + builtInModelData.modelId, + false + ); + + await ml.testExecution.logTestStep( + 'should not show delete action for the model in the table' + ); + await ml.trainedModelsTable.assertModelDeleteActionButtonExists( + builtInModelData.modelId, + false + ); + }); - await ml.testExecution.logTestStep('displays expected row values for the model in the table'); - await ml.trainedModelsTable.assertModelsRowFields(modelWithoutPipelineData.modelId, { - id: modelWithoutPipelineData.modelId, - description: modelWithoutPipelineData.description, - modelTypes: modelWithoutPipelineData.modelTypes, + it('displays a model with an ingest pipeline and delete action is disabled', async () => { + await ml.testExecution.logTestStep('should display the model in the table'); + await ml.trainedModelsTable.filterWithSearchString(modelWithPipelineData.modelId, 1); + + await ml.testExecution.logTestStep( + 'displays expected row values for the model in the table' + ); + await ml.trainedModelsTable.assertModelsRowFields(modelWithPipelineData.modelId, { + id: modelWithPipelineData.modelId, + description: modelWithPipelineData.description, + modelTypes: modelWithPipelineData.modelTypes, + }); + + await ml.testExecution.logTestStep( + 'should show disabled delete action for the model in the table' + ); + + await ml.trainedModelsTable.assertModelDeleteActionButtonEnabled( + modelWithPipelineData.modelId, + false + ); }); - await ml.testExecution.logTestStep( - 'should show enabled delete action for the model in the table' - ); + it('displays a model without an ingest pipeline and model can be deleted', async () => { + await ml.testExecution.logTestStep('should display the model in the table'); + await ml.trainedModelsTable.filterWithSearchString(modelWithoutPipelineData.modelId, 1); + + await ml.testExecution.logTestStep( + 'displays expected row values for the model in the table' + ); + await ml.trainedModelsTable.assertModelsRowFields(modelWithoutPipelineData.modelId, { + id: modelWithoutPipelineData.modelId, + description: modelWithoutPipelineData.description, + modelTypes: modelWithoutPipelineData.modelTypes, + }); + + await ml.testExecution.logTestStep( + 'should show enabled delete action for the model in the table' + ); + + await ml.trainedModelsTable.assertModelDeleteActionButtonEnabled( + modelWithoutPipelineData.modelId, + true + ); + + await ml.testExecution.logTestStep('should show the delete modal'); + await ml.trainedModelsTable.clickDeleteAction(modelWithoutPipelineData.modelId); + + await ml.testExecution.logTestStep('should delete the model'); + await ml.trainedModelsTable.confirmDeleteModel(); + await ml.trainedModelsTable.assertModelDisplayedInTable( + modelWithoutPipelineData.modelId, + false + ); + }); + }); - await ml.trainedModelsTable.assertModelDeleteActionButtonEnabled( - modelWithoutPipelineData.modelId, - true - ); + describe('for ML user with read-only access', () => { + before(async () => { + await ml.securityUI.loginAsMlViewer(); + await ml.navigation.navigateToTrainedModels(); + }); - await ml.testExecution.logTestStep('should show the delete modal'); - await ml.trainedModelsTable.clickDeleteAction(modelWithoutPipelineData.modelId); + after(async () => { + await ml.securityUI.logout(); + }); - await ml.testExecution.logTestStep('should delete the model'); - await ml.trainedModelsTable.confirmDeleteModel(); - await ml.trainedModelsTable.assertModelDisplayedInTable( - modelWithoutPipelineData.modelId, - false - ); + it('renders expanded row content correctly for model with pipelines', async () => { + await ml.trainedModelsTable.ensureRowIsExpanded(modelWithPipelineData.modelId); + await ml.trainedModelsTable.assertDetailsTabContent(); + await ml.trainedModelsTable.assertInferenceConfigTabContent(); + await ml.trainedModelsTable.assertStatsTabContent(); + await ml.trainedModelsTable.assertPipelinesTabContent(true, [ + { pipelineName: `pipeline_${modelWithPipelineData.modelId}`, expectDefinition: false }, + ]); + }); }); }); } diff --git a/x-pack/test/functional/services/ml/trained_models_table.ts b/x-pack/test/functional/services/ml/trained_models_table.ts index ad5759460bd0d4..1f35d7d1f6d394 100644 --- a/x-pack/test/functional/services/ml/trained_models_table.ts +++ b/x-pack/test/functional/services/ml/trained_models_table.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { ProvidedType } from '@kbn/test'; +import { upperFirst } from 'lodash'; import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -208,5 +209,63 @@ export function TrainedModelsTableProvider({ getService }: FtrProviderContext) { await testSubjects.click(this.rowSelector(modelId, 'mlModelsTableRowDeleteAction')); await this.assertDeleteModalExists(); } + + public async ensureRowIsExpanded(modelId: string) { + await this.filterWithSearchString(modelId); + await retry.tryForTime(10 * 1000, async () => { + if (!(await testSubjects.exists('mlTrainedModelRowDetails'))) { + await testSubjects.click(`${this.rowSelector(modelId)} > mlModelsTableRowDetailsToggle`); + await testSubjects.existOrFail('mlTrainedModelRowDetails', { timeout: 1000 }); + } + }); + } + + public async assertTabContent( + type: 'details' | 'stats' | 'inferenceConfig' | 'pipelines', + expectVisible = true + ) { + const tabTestSubj = `mlTrainedModel${upperFirst(type)}`; + const tabContentTestSubj = `mlTrainedModel${upperFirst(type)}Content`; + + if (!expectVisible) { + await testSubjects.missingOrFail(tabTestSubj); + return; + } + + await testSubjects.existOrFail(tabTestSubj); + await testSubjects.click(tabTestSubj); + await testSubjects.existOrFail(tabContentTestSubj); + } + + public async assertDetailsTabContent(expectVisible = true) { + await this.assertTabContent('details', expectVisible); + } + + public async assertInferenceConfigTabContent(expectVisible = true) { + await this.assertTabContent('inferenceConfig', expectVisible); + } + + public async assertStatsTabContent(expectVisible = true) { + await this.assertTabContent('stats', expectVisible); + } + + public async assertPipelinesTabContent( + expectVisible = true, + pipelinesExpectOptions?: Array<{ pipelineName: string; expectDefinition: boolean }> + ) { + await this.assertTabContent('pipelines', expectVisible); + + if (Array.isArray(pipelinesExpectOptions)) { + for (const p of pipelinesExpectOptions) { + if (p.expectDefinition) { + await testSubjects.existOrFail(`mlTrainedModelPipelineEditButton_${p.pipelineName}`); + await testSubjects.existOrFail(`mlTrainedModelPipelineDefinition_${p.pipelineName}`); + } else { + await testSubjects.missingOrFail(`mlTrainedModelPipelineEditButton_${p.pipelineName}`); + await testSubjects.missingOrFail(`mlTrainedModelPipelineDefinition_${p.pipelineName}`); + } + } + } + } })(); }