diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue index afd48df93e4a2959e6b57b6e3983d953ffee682e..f492c3ec3585e2693cb5f9bfcd1c784b0f3301a0 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue @@ -1,4 +1,5 @@ <script> +import { isEmpty } from 'lodash'; import { GlTableLite, GlLink, GlEmptyState, GlButton } from '@gitlab/ui'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; @@ -129,6 +130,9 @@ export default { hasItems() { return this.candidates.length > 0; }, + hasMetadata() { + return !isEmpty(this.experiment.metadata); + }, deleteButtonInfo() { return { deletePath: this.experiment.path, @@ -176,76 +180,94 @@ export default { }}</gl-button> <delete-button v-bind="deleteButtonInfo" /> </model-experiments-header> + <section> + <registry-search + :filters="filters" + :sorting="sorting" + :sortable-fields="sortableFields" + @sorting:changed="updateSortingAndEmitUpdate" + @filter:changed="updateFilters" + @filter:submit="submitFilters" + @filter:clear="filters = []" + /> + + <div v-if="hasItems" class="gl-overflow-x-auto"> + <gl-table-lite + :fields="fields" + :items="tableItems" + show-empty + small + class="gl-mt-0! ml-candidate-table" + > + <template #cell()="data"> + <div>{{ data.value }}</div> + </template> + + <template #cell(nameColumn)="data"> + <gl-link :href="data.value.details_path"> + <span v-if="data.value.name"> {{ data.value.name }}</span> + <span v-else class="gl-font-style-italic">{{ $options.i18n.NO_CANDIDATE_NAME }}</span> + </gl-link> + </template> + + <template #cell(artifact)="data"> + <gl-link v-if="data.value" :href="data.value" target="_blank">{{ + $options.i18n.ARTIFACTS_LABEL + }}</gl-link> + <div v-else class="gl-font-style-italic gl-text-gray-500"> + {{ $options.i18n.NO_ARTIFACT }} + </div> + </template> + + <template #cell(created_at)="data"> + <time-ago :time="data.value" /> + </template> + + <template #cell(user)="data"> + <gl-link v-if="data.value" :href="data.value.path">@{{ data.value.username }}</gl-link> + <div v-else>{{ $options.i18n.NO_DATA_CONTENT }}</div> + </template> + + <template #cell(ci_job)="data"> + <gl-link v-if="data.value" :href="data.value.path" target="_blank">{{ + data.value.name + }}</gl-link> + <div v-else class="gl-font-style-italic gl-text-gray-500"> + {{ $options.i18n.NO_JOB }} + </div> + </template> + </gl-table-lite> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.EMPTY_STATE_TITLE_LABEL" + :primary-button-text="$options.i18n.CREATE_NEW_LABEL" + :primary-button-link="$options.constants.CREATE_CANDIDATE_HELP_PATH" + :svg-path="emptyStateSvgPath" + :svg-height="null" + :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" + class="gl-py-8" + /> + + <keyset-pagination v-if="displayPagination" v-bind="pageInfo" /> + </section> + + <section> + <div class="experiment-metadata"> + <h3 :class="$options.HEADER_CLASSES">{{ $options.i18n.METADATA_LABEL }}</h3> + + <table v-if="hasMetadata"> + <tbody> + <tr v-for="item in experiment.metadata" :key="item.name"> + <td class="gl-font-weight-bold">{{ item.name }}</td> + <td>{{ item.value }}</td> + </tr> + </tbody> + </table> - <registry-search - :filters="filters" - :sorting="sorting" - :sortable-fields="sortableFields" - @sorting:changed="updateSortingAndEmitUpdate" - @filter:changed="updateFilters" - @filter:submit="submitFilters" - @filter:clear="filters = []" - /> - - <div v-if="hasItems" class="gl-overflow-x-auto"> - <gl-table-lite - :fields="fields" - :items="tableItems" - show-empty - small - class="gl-mt-0! ml-candidate-table" - > - <template #cell()="data"> - <div>{{ data.value }}</div> - </template> - - <template #cell(nameColumn)="data"> - <gl-link :href="data.value.details_path"> - <span v-if="data.value.name"> {{ data.value.name }}</span> - <span v-else class="gl-font-style-italic">{{ $options.i18n.NO_CANDIDATE_NAME }}</span> - </gl-link> - </template> - - <template #cell(artifact)="data"> - <gl-link v-if="data.value" :href="data.value" target="_blank">{{ - $options.i18n.ARTIFACTS_LABEL - }}</gl-link> - <div v-else class="gl-font-style-italic gl-text-gray-500"> - {{ $options.i18n.NO_ARTIFACT }} - </div> - </template> - - <template #cell(created_at)="data"> - <time-ago :time="data.value" /> - </template> - - <template #cell(user)="data"> - <gl-link v-if="data.value" :href="data.value.path">@{{ data.value.username }}</gl-link> - <div v-else>{{ $options.i18n.NO_DATA_CONTENT }}</div> - </template> - - <template #cell(ci_job)="data"> - <gl-link v-if="data.value" :href="data.value.path" target="_blank">{{ - data.value.name - }}</gl-link> - <div v-else class="gl-font-style-italic gl-text-gray-500"> - {{ $options.i18n.NO_JOB }} - </div> - </template> - </gl-table-lite> - </div> - - <gl-empty-state - v-else - :title="$options.i18n.EMPTY_STATE_TITLE_LABEL" - :primary-button-text="$options.i18n.CREATE_NEW_LABEL" - :primary-button-link="$options.constants.CREATE_CANDIDATE_HELP_PATH" - :svg-path="emptyStateSvgPath" - :svg-height="null" - :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL" - class="gl-py-8" - /> - - <keyset-pagination v-if="displayPagination" v-bind="pageInfo" /> + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METADATA_MESSAGE }}</div> + </div> + </section> </div> </template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js index 3af33f53fbd2fc8eae86e2e96fb084e0ccdb037d..1cf91ad2fc2d789c45215c6e42c4e085dce89873 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js @@ -22,3 +22,5 @@ export const DELETE_EXPERIMENT_CONFIRMATION_MESSAGE = s__( export const DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete experiment'); export const DELETE_EXPERIMENT_MODAL_TITLE = s__('MLExperimentTracking|Delete experiment?'); export const DOWNLOAD_AS_CSV_LABEL = s__('MlExperimentTracking|Download as CSV'); +export const METADATA_LABEL = s__('MlExperimentTracking|Experiment metadata'); +export const NO_METADATA_MESSAGE = s__('MlExperimentTracking|No logged experiment metadata'); diff --git a/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss index 82fb631036c3bec82d0b9dbcf2cc21e52eb648de..758bc5a72efac06208830915d00aeadf1ee8a25b 100644 --- a/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss +++ b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss @@ -13,6 +13,7 @@ table.ml-candidate-table { } } +.experiment-metadata table, table.candidate-details { td { padding: $gl-spacing-scale-3 $gl-spacing-scale-3 $gl-spacing-scale-3 0; diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb index 6c7b6eb6fbcbc8c230e8732fa376b17ab1bc8f7b..e858f85c0b31be781530677efb1de6d8a576888c 100644 --- a/app/helpers/projects/ml/experiments_helper.rb +++ b/app/helpers/projects/ml/experiments_helper.rb @@ -8,6 +8,7 @@ module ExperimentsHelper def experiment_as_data(experiment) data = { name: experiment.name, + metadata: experiment.metadata, path: link_to_experiment(experiment.project, experiment) } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d4bc0d4dc4e2a35f7624be4af811b45a6b31bc4a..41268fb9ff8a6f0c8cd0b44f304f2ed135637721 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -32212,6 +32212,9 @@ msgstr "" msgid "MlExperimentTracking|Experiment" msgstr "" +msgid "MlExperimentTracking|Experiment metadata" +msgstr "" + msgid "MlExperimentTracking|Experiment removed" msgstr "" @@ -32251,6 +32254,9 @@ msgstr "" msgid "MlExperimentTracking|No candidates logged for the query. Create new candidates using the MLflow client." msgstr "" +msgid "MlExperimentTracking|No logged experiment metadata" +msgstr "" + msgid "MlExperimentTracking|No name" msgstr "" diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js index 2dd178883054a4c2d1e4b002c07fbc36654cd81d..d62982adeb8b10674ec53b65d6efe261eb140070 100644 --- a/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/experiments/show/ml_experiments_show_spec.js @@ -7,7 +7,13 @@ import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue import Pagination from '~/vue_shared/components/incubation/pagination.vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import * as urlHelpers from '~/lib/utils/url_utility'; -import { MOCK_START_CURSOR, MOCK_PAGE_INFO, MOCK_CANDIDATES, MOCK_EXPERIMENT } from './mock_data'; +import { + MOCK_START_CURSOR, + MOCK_PAGE_INFO, + MOCK_CANDIDATES, + MOCK_EXPERIMENT, + MOCK_EXPERIMENT_METADATA, +} from './mock_data'; describe('MlExperimentsShow', () => { let wrapper; @@ -29,6 +35,17 @@ describe('MlExperimentsShow', () => { createWrapper(MOCK_CANDIDATES, ['rmse', 'auc', 'mae'], ['l1_ratio'], pageInfo); }; + const createWrapperWithExperimentMetadata = () => { + createWrapper( + [], + [], + [], + MOCK_PAGE_INFO, + { ...MOCK_EXPERIMENT, metadata: MOCK_EXPERIMENT_METADATA }, + 'path', + ); + }; + const findPagination = () => wrapper.findComponent(Pagination); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); @@ -40,6 +57,10 @@ describe('MlExperimentsShow', () => { const findExperimentHeader = () => wrapper.findComponent(ModelExperimentsHeader); const findDeleteButton = () => wrapper.findComponent(DeleteButton); const findDownloadButton = () => findExperimentHeader().findComponent(GlButton); + const findMetadataTableRow = (idx) => wrapper.findAll('.experiment-metadata tbody > tr').at(idx); + const findMetadataTableColumn = (row, col) => findMetadataTableRow(row).findAll('td').at(col); + const findMetadataHeader = () => wrapper.find('.experiment-metadata h3'); + const findMetadataEmptyState = () => wrapper.find('.experiment-metadata .gl-text-secondary'); const hrefInRowAndColumn = (row, col) => findColumnInRow(row, col).findComponent(GlLink).attributes().href; @@ -318,4 +339,27 @@ describe('MlExperimentsShow', () => { }); }); }); + + describe('Experiments metadata', () => { + it('has correct header', () => { + createWrapper(); + + expect(findMetadataHeader().text()).toBe('Experiment metadata'); + }); + + it('shows empty state if there is no metadata', () => { + createWrapper(); + + expect(findMetadataEmptyState().text()).toBe('No logged experiment metadata'); + }); + + it('shows the metadata', () => { + createWrapperWithExperimentMetadata(); + + MOCK_EXPERIMENT_METADATA.forEach((metadata, idx) => { + expect(findMetadataTableColumn(idx, 0).text()).toContain(metadata.name); + expect(findMetadataTableColumn(idx, 1).text()).toContain(metadata.value); + }); + }); + }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js index 4a606be8da6f82fc0a6d0585dbc318ec2780d262..33b3a343c7f3dfbb065ab58e06bb6bbf5f36fc96 100644 --- a/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js +++ b/spec/frontend/ml/experiment_tracking/routes/experiments/show/mock_data.js @@ -7,7 +7,30 @@ export const MOCK_PAGE_INFO = { hasPreviousPage: true, }; -export const MOCK_EXPERIMENT = { name: 'experiment', path: '/path/to/experiment' }; +export const MOCK_EXPERIMENT = { + name: 'experiment', + metadata: [], + path: '/path/to/experiment', +}; + +export const MOCK_EXPERIMENT_METADATA = [ + { + id: 1, + created_at: '2024-03-20T16:19:23.843Z', + updated_at: '2024-03-20T16:19:23.843Z', + experiment_id: 1, + name: 'metadata_1', + value: 'a', + }, + { + id: 2, + created_at: '2024-03-20T16:19:23.848Z', + updated_at: '2024-03-20T16:19:23.848Z', + experiment_id: 1, + name: 'metadata_2', + value: 'b', + }, +]; export const MOCK_CANDIDATES = [ { diff --git a/spec/helpers/projects/ml/experiments_helper_spec.rb b/spec/helpers/projects/ml/experiments_helper_spec.rb index 9ac518f664d4253df15b6d7b34d147124a73f09f..c7fc75c3d90cfa30ebb15cbc44612a10cf9a58c8 100644 --- a/spec/helpers/projects/ml/experiments_helper_spec.rb +++ b/spec/helpers/projects/ml/experiments_helper_spec.rb @@ -106,9 +106,11 @@ subject { Gitlab::Json.parse(helper.experiment_as_data(experiment)) } it do - is_expected.to eq( - { 'name' => experiment.name, 'path' => "/#{project.full_path}/-/ml/experiments/#{experiment.iid}" } - ) + is_expected.to eq({ + 'name' => experiment.name, + 'metadata' => experiment.metadata, + 'path' => "/#{project.full_path}/-/ml/experiments/#{experiment.iid}" + }) end end