[ML] Fix Trained Model stats and pipelines tab (#125382)

This commit is contained in:
Dima Arnautov 2022-02-11 19:03:50 +01:00 committed by GitHub
parent f1fcbe7f54
commit 67fb388acd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 285 additions and 131 deletions

View file

@ -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<ExpandedRowProps> = ({ item }) => {
[stats.deployment_stats]
);
const tabs = [
const tabs: EuiTabbedContentTab[] = [
{
id: 'details',
'data-test-subj': 'mlTrainedModelDetails',
name: (
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.detailsTabLabel"
@ -161,7 +163,7 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
/>
),
content: (
<>
<div data-test-subj={'mlTrainedModelDetailsContent'}>
<EuiSpacer size={'s'} />
<EuiFlexGrid columns={2} gutterSize={'m'}>
<EuiFlexItem>
@ -203,13 +205,14 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
</EuiFlexItem>
) : null}
</EuiFlexGrid>
</>
</div>
),
},
...(inferenceConfig
? [
{
id: 'config',
'data-test-subj': 'mlTrainedModelInferenceConfig',
name: (
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.configTabLabel"
@ -217,7 +220,7 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
/>
),
content: (
<>
<div data-test-subj={'mlTrainedModelInferenceConfigContent'}>
<EuiSpacer size={'s'} />
<EuiFlexGrid columns={2} gutterSize={'m'}>
<EuiFlexItem>
@ -261,7 +264,7 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
</EuiFlexItem>
)}
</EuiFlexGrid>
</>
</div>
),
},
]
@ -270,6 +273,7 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
? [
{
id: 'stats',
'data-test-subj': 'mlTrainedModelStats',
name: (
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.statsTabLabel"
@ -277,7 +281,7 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
/>
),
content: (
<>
<div data-test-subj={'mlTrainedModelStatsContent'}>
<EuiSpacer size={'s'} />
<EuiFlexGrid columns={2} gutterSize={'m'}>
@ -317,8 +321,29 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
</EuiPanel>
</EuiFlexItem>
) : null}
{isPopulatedObject(stats.model_size_stats) &&
!isPopulatedObject(stats.inference_stats) ? (
<EuiFlexItem>
<EuiPanel>
<EuiTitle size={'xs'}>
<h5>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.modelSizeStatsTitle"
defaultMessage="Model size stats"
/>
</h5>
</EuiTitle>
<EuiSpacer size={'m'} />
<EuiDescriptionList
compressed={true}
type="column"
listItems={formatToListItems(stats.model_size_stats)}
/>
</EuiPanel>
</EuiFlexItem>
) : null}
</EuiFlexGrid>
</>
</div>
),
},
]
@ -327,6 +352,7 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
? [
{
id: 'pipelines',
'data-test-subj': 'mlTrainedModelPipelines',
name: (
<>
<FormattedMessage
@ -337,10 +363,10 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
</>
),
content: (
<>
<div data-test-subj={'mlTrainedModelPipelinesContent'}>
<EuiSpacer size={'s'} />
<ModelPipelines pipelines={pipelines!} ingestStats={stats.ingest} />
</>
</div>
),
},
]
@ -355,6 +381,7 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
initialSelectedTab={tabs[0]}
autoFocus="selected"
onTabClick={(tab) => {}}
data-test-subj={'mlTrainedModelRowDetails'}
/>
);
};

View file

@ -23,7 +23,7 @@ import { ProcessorsStats } from './expanded_row';
export type IngestStatsResponse = Exclude<ModelItem['stats'], undefined>['ingest'];
interface ModelPipelinesProps {
pipelines: Exclude<ModelItem['pipelines'], null | undefined>;
pipelines: ModelItem['pipelines'];
ingestStats: IngestStatsResponse;
}
@ -32,11 +32,18 @@ export const ModelPipelines: FC<ModelPipelinesProps> = ({ 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 (
<>
<EuiAccordion
@ -48,31 +55,34 @@ export const ModelPipelines: FC<ModelPipelinesProps> = ({ pipelines, ingestStats
</EuiTitle>
}
extraAction={
<EuiButtonEmpty
onClick={() => {
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"
>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.editPipelineLabel"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
pipelineDefinition ? (
<EuiButtonEmpty
data-test-subj={`mlTrainedModelPipelineEditButton_${pipelineName}`}
onClick={() => {
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"
>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.editPipelineLabel"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
) : undefined
}
paddingSize="l"
initialIsOpen={initialIsOpen}
>
<EuiFlexGrid columns={2}>
{ingestStats?.pipelines ? (
<EuiFlexItem>
<EuiFlexItem data-test-subj={`mlTrainedModelPipelineIngestStats_${pipelineName}`}>
<EuiPanel>
<EuiTitle size={'xxs'}>
<h6>
@ -88,27 +98,29 @@ export const ModelPipelines: FC<ModelPipelinesProps> = ({ pipelines, ingestStats
</EuiFlexItem>
) : null}
<EuiFlexItem>
<EuiPanel>
<EuiTitle size={'xxs'}>
<h6>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.processorsTitle"
defaultMessage="Definition"
/>
</h6>
</EuiTitle>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="m"
overflowHeight={300}
isCopyable
>
{JSON.stringify(pipelineDefinition, null, 2)}
</EuiCodeBlock>
</EuiPanel>
</EuiFlexItem>
{pipelineDefinition ? (
<EuiFlexItem data-test-subj={`mlTrainedModelPipelineDefinition_${pipelineName}`}>
<EuiPanel>
<EuiTitle size={'xxs'}>
<h6>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.expandedRow.processorsTitle"
defaultMessage="Definition"
/>
</h6>
</EuiTitle>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="m"
overflowHeight={300}
isCopyable
>
{JSON.stringify(pipelineDefinition, null, 2)}
</EuiCodeBlock>
</EuiPanel>
</EuiFlexItem>
) : null}
</EuiFlexGrid>
</EuiAccordion>
</>

View file

@ -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
);
});
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,
after(async () => {
await ml.securityUI.logout();
});
await ml.testExecution.logTestStep(
'should show disabled delete action for the model in the table'
);
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.trainedModelsTable.assertModelDeleteActionButtonEnabled(
modelWithPipelineData.modelId,
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);
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 display the table');
await ml.trainedModels.assertTableExists();
await ml.trainedModels.assertRowsNumberPerPage(10);
});
await ml.testExecution.logTestStep(
'should show enabled 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(
modelWithoutPipelineData.modelId,
true
);
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);
});
await ml.testExecution.logTestStep('should show the delete modal');
await ml.trainedModelsTable.clickDeleteAction(modelWithoutPipelineData.modelId);
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('should delete the model');
await ml.trainedModelsTable.confirmDeleteModel();
await ml.trainedModelsTable.assertModelDisplayedInTable(
modelWithoutPipelineData.modelId,
false
);
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
);
});
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
);
});
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
);
});
});
describe('for ML user with read-only access', () => {
before(async () => {
await ml.securityUI.loginAsMlViewer();
await ml.navigation.navigateToTrainedModels();
});
after(async () => {
await ml.securityUI.logout();
});
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 },
]);
});
});
});
}

View file

@ -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}`);
}
}
}
}
})();
}