diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue new file mode 100644 index 0000000000000000000000000000000000000000..7b9efa29cb0238b4007bf897f7604efda8ff286b --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue @@ -0,0 +1,204 @@ +<script> +import { + GlAvatarLabeled, + GlButton, + GlLink, + GlTab, + GlTabs, + GlTableLite, + GlTooltipDirective, +} from '@gitlab/ui'; +import { isEmpty, maxBy, range } from 'lodash'; +import { __, s__, sprintf } from '~/locale'; + +export default { + name: 'CandidateDetail', + components: { + GlAvatarLabeled, + GlButton, + GlLink, + GlTab, + GlTabs, + GlTableLite, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + candidate: { + type: Object, + required: true, + }, + }, + computed: { + info() { + return this.candidate.info; + }, + ciJob() { + return this.info.ciJob; + }, + hasMetadata() { + return !isEmpty(this.candidate.metadata); + }, + hasParameters() { + return !isEmpty(this.candidate.params); + }, + hasMetrics() { + return !isEmpty(this.candidate.metrics); + }, + metricsTableFields() { + const maxStep = maxBy(this.candidate.metrics, 'step').step; + const rowClass = '!gl-p-3'; + + const cssClasses = { thClass: rowClass, tdClass: rowClass }; + + const fields = range(maxStep + 1).map((step) => ({ + key: step.toString(), + label: sprintf(s__('MlModelRegistry|Step %{step}'), { step }), + ...cssClasses, + })); + + return [{ key: 'name', label: s__('MlModelRegistry|Metric'), ...cssClasses }, ...fields]; + }, + metricsTableItems() { + const items = {}; + this.candidate.metrics.forEach((metric) => { + const metricRow = items[metric.name] || { name: metric.name }; + metricRow[metric.step] = metric.value; + items[metric.name] = metricRow; + }); + + return Object.values(items); + }, + parameterTableItems() { + return this.candidate.params.map((param) => ({ name: param.name, value: param.value })); + }, + parameterTableFields() { + return [ + { key: 'name', label: __('Name') }, + { key: 'value', label: __('Value') }, + ]; + }, + }, + methods: { + copyMlflowId() { + navigator.clipboard.writeText(this.info.eid); + }, + }, + i18n: { + detailsLabel: s__('MlModelRegistry|Details & Metadata'), + artifactsLabel: s__('MlModelRegistry|Artifacts'), + mlflowIdLabel: s__('MlModelRegistry|MLflow run ID'), + ciSectionLabel: s__('MlModelRegistry|CI Info'), + jobLabel: __('Job'), + ciUserLabel: s__('MlModelRegistry|Triggered by'), + ciMrLabel: __('Merge request'), + parametersLabel: s__('MlModelRegistry|Parameters'), + performanceLabel: s__('MlModelRegistry|Performance'), + noParametersMessage: s__('MlModelRegistry|No logged parameters'), + noMetricsMessage: s__('MlModelRegistry|No logged metrics'), + noMetadataMessage: s__('MlModelRegistry|No logged metadata'), + noCiMessage: s__('MlModelRegistry|Candidate not linked to a CI build'), + noArtifactsMessage: s__('MlModelRegistry|No logged artifacts.'), + copyMessage: __('Copy MLflow run ID'), + }, +}; +</script> + +<template> + <div> + <gl-tabs class="mt-5"> + <gl-tab :title="$options.i18n.detailsLabel" class="gl-pt-3" data-testid="details"> + <section> + <label class="gl-font-bold">{{ $options.i18n.mlflowIdLabel }}</label> + <div + class="gl-m-0 gl-w-fit gl-border-2 gl-border-solid gl-border-default gl-px-2 gl-py-0" + > + <span data-testid="mlflow-run-id"> + {{ info.eid }} + </span> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + :aria-label="$options.i18n.copyMessage" + :title="$options.i18n.copyMessage" + icon="copy-to-clipboard" + @click="copyMlflowId" + /> + </div> + <div + v-for="item in candidate.metadata" + :key="item.name" + class="mt-3" + data-testid="metadata" + > + <h5 class="gl-font-bold">{{ item.name }}</h5> + <p>{{ item.value }}</p> + </div> + </section> + <section class="gl-pt-3" data-testid="parameters"> + <h4>{{ $options.i18n.parametersLabel }}</h4> + <gl-table-lite + v-if="hasParameters" + :items="parameterTableItems" + :fields="parameterTableFields" + class="gl-w-100" + hover + data-testid="parameters-table" + /> + <div v-else class="gl-text-subtle">{{ $options.i18n.noParametersMessage }}</div> + </section> + <section data-testid="ci"> + <h4>{{ $options.i18n.ciSectionLabel }}</h4> + <div v-if="ciJob" class="pt-3"> + <div> + <h5 class="gl-font-bold">{{ $options.i18n.jobLabel }}</h5> + <gl-link :href="ciJob.path" data-testid="ci-job-path"> + {{ ciJob.name }} + </gl-link> + </div> + <div v-if="ciJob.user" class="pt-3"> + <h5 class="gl-font-bold">{{ $options.i18n.ciUserLabel }}</h5> + <gl-avatar-labeled label="" :size="24" :src="ciJob.user.avatar"> + <gl-link :href="ciJob.user.path"> + {{ ciJob.user.name }} + </gl-link> + </gl-avatar-labeled> + </div> + <div v-if="ciJob.mergeRequest" class="pt-3"> + <h5 class="gl-font-bold">{{ $options.i18n.ciMrLabel }}</h5> + <gl-link :href="ciJob.mergeRequest.path"> + !{{ ciJob.mergeRequest.iid }} {{ ciJob.mergeRequest.title }} + </gl-link> + </div> + </div> + <div v-else class="gl-text-subtle">{{ $options.i18n.noCiMessage }}</div> + </section> + </gl-tab> + <gl-tab :title="$options.i18n.artifactsLabel" class="pt-3" data-testid="artifacts"> + <gl-link + v-if="info.pathToArtifact" + :href="info.pathToArtifact" + data-testid="artifacts-link" + > + {{ $options.i18n.artifactsLabel }} + </gl-link> + <div v-else class="gl-text-subtle">{{ $options.i18n.noArtifactsMessage }}</div> + </gl-tab> + <gl-tab :title="$options.i18n.performanceLabel" class="pt-3" data-testid="metrics"> + <div v-if="hasMetrics" class="gl-overflow-x-auto"> + <gl-table-lite + :items="metricsTableItems" + :fields="metricsTableFields" + class="gl-w-100" + hover + data-testid="metrics-table" + /> + </div> + <div v-else class="gl-text-subtle">{{ $options.i18n.noMetricsMessage }}</div> + </gl-tab> + </gl-tabs> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue new file mode 100644 index 0000000000000000000000000000000000000000..947fba5f5be36a42453395618e8ad898225132d3 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/candidate_header.vue @@ -0,0 +1,91 @@ +<script> +import { GlBadge, GlIcon, GlLink } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import PageHeading from '~/vue_shared/components/page_heading.vue'; +import { s__, sprintf } from '~/locale'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; + +export default { + name: 'CandidateHeader', + components: { + DeleteButton, + GlBadge, + GlIcon, + GlLink, + PageHeading, + TimeAgoTooltip, + }, + mixins: [timeagoMixin], + props: { + info: { + type: Object, + required: true, + }, + }, + computed: { + title() { + return sprintf(s__('MlExperimentTracking|Candidate %{id}'), { id: this.info.iid }); + }, + authorInfo() { + return sprintf(s__('MlExperimentTracking|by %{author}'), { author: this.info.authorName }); + }, + statusVariant() { + return this.$options.statusVariants[this.info.status]; + }, + }, + i18n: { + deleteCandidateConfirmationMessage: s__( + 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', + ), + deleteCandidatePrimaryActionLabel: s__('MlExperimentTracking|Delete candidate'), + deleteCandidateModalTitle: s__('MlExperimentTracking|Delete candidate?'), + }, + statusVariants: { + running: 'success', + scheduled: 'info', + finished: 'muted', + failed: 'warning', + killed: 'danger', + }, +}; +</script> + +<template> + <div class="gl-flex gl-items-center gl-justify-between"> + <page-heading> + <template #heading> + <gl-link data-testid="experiment-link" :href="info.pathToExperiment"> + {{ info.experimentName }} / + </gl-link> + <span class="gl-inline-flex gl-items-center"> + {{ title }} + </span> + </template> + <template #description> + <div class="gl-flex gl-flex-wrap gl-items-center gl-gap-x-2"> + <gl-badge :variant="statusVariant"> + <gl-icon name="issue-type-test-case" /> + {{ info.status }} + </gl-badge> + <time-ago-tooltip :time="info.createdAt" /> + <gl-link + v-if="info.authorName" + data-testid="author-link" + class="js-user-link gl-font-bold !gl-text-subtle" + :href="info.authorWebUrl" + > + <span class="sm:gl-inline">{{ authorInfo }}</span> + </gl-link> + </div> + </template> + </page-heading> + + <delete-button + :delete-path="info.path" + :delete-confirmation-text="$options.i18n.deleteCandidateConfirmationMessage" + :action-primary-text="$options.i18n.deleteCandidatePrimaryActionLabel" + :modal-title="$options.i18n.deleteCandidateModalTitle" + /> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue index fdd7cad6d6581315608487eec0dcf18960c5e11c..75198327de122b289445e983357323972be21dce 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue @@ -1,14 +1,11 @@ <script> -import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; -import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; -import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue'; -import { s__ } from '~/locale'; +import CandidateHeader from './candidate_header.vue'; +import CandidateDetail from './candidate_detail.vue'; export default { name: 'MlCandidatesShow', components: { - ModelExperimentsHeader, - DeleteButton, + CandidateHeader, CandidateDetail, }, props: { @@ -22,28 +19,12 @@ export default { return Object.freeze(this.candidate.info); }, }, - i18n: { - TITLE_LABEL: s__('MlExperimentTracking|Model candidate details'), - DELETE_CANDIDATE_CONFIRMATION_MESSAGE: s__( - 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', - ), - DELETE_CANDIDATE_PRIMARY_ACTION_LABEL: s__('MlExperimentTracking|Delete candidate'), - DELETE_CANDIDATE_MODAL_TITLE: s__('MlExperimentTracking|Delete candidate?'), - }, }; </script> <template> <div> - <model-experiments-header :page-title="$options.i18n.TITLE_LABEL" hide-mlflow-usage> - <delete-button - :delete-path="info.path" - :delete-confirmation-text="$options.i18n.DELETE_CANDIDATE_CONFIRMATION_MESSAGE" - :action-primary-text="$options.i18n.DELETE_CANDIDATE_PRIMARY_ACTION_LABEL" - :modal-title="$options.i18n.DELETE_CANDIDATE_MODAL_TITLE" - /> - </model-experiments-header> - + <candidate-header :info="info" /> <candidate-detail :candidate="candidate" /> </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/components/candidate_detail.vue b/app/assets/javascripts/ml/model_registry/components/candidate_detail.vue index c61b75ae931b7c28f7f65859f71d5ea86a0ce469..83999bb5287a77dc32bb8b1cf9beccc083b79002 100644 --- a/app/assets/javascripts/ml/model_registry/components/candidate_detail.vue +++ b/app/assets/javascripts/ml/model_registry/components/candidate_detail.vue @@ -37,11 +37,6 @@ export default { type: Object, required: true, }, - showInfoSection: { - type: Boolean, - required: false, - default: true, - }, }, i18n: { INFO_LABEL, @@ -108,32 +103,6 @@ export default { <template> <div> - <section v-if="showInfoSection" class="gl-mb-6"> - <table class="candidate-details"> - <tbody> - <detail-row :label="$options.i18n.ID_LABEL"> - {{ info.iid }} - </detail-row> - - <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> - - <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> - - <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> - <gl-link :href="info.pathToExperiment"> - {{ info.experimentName }} - </gl-link> - </detail-row> - - <detail-row v-if="info.pathToArtifact" :label="$options.i18n.ARTIFACTS_LABEL"> - <gl-link :href="info.pathToArtifact"> - {{ $options.i18n.ARTIFACTS_LABEL }} - </gl-link> - </detail-row> - </tbody> - </table> - </section> - <section class="gl-mb-6"> <h3 :class="$options.HEADER_CLASSES">{{ $options.i18n.CI_SECTION_LABEL }}</h3> diff --git a/app/assets/javascripts/ml/model_registry/components/model_version_performance.vue b/app/assets/javascripts/ml/model_registry/components/model_version_performance.vue index 2eded91258271628618afa2fb2d271fd6b6a610a..a860375da68837dc2940197681d76b47135d63cf 100644 --- a/app/assets/javascripts/ml/model_registry/components/model_version_performance.vue +++ b/app/assets/javascripts/ml/model_registry/components/model_version_performance.vue @@ -53,7 +53,7 @@ export default { @click="copyMlflowId" /> </p> - <candidate-detail :candidate="candidate" :show-info-section="false" /> + <candidate-detail :candidate="candidate" /> </div> </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/translations.js b/app/assets/javascripts/ml/model_registry/translations.js index 14771a686acb1835128a901c932e2dc719dfb1eb..478af02ae6e026fde9ce4be72fc59a67a31723be 100644 --- a/app/assets/javascripts/ml/model_registry/translations.js +++ b/app/assets/javascripts/ml/model_registry/translations.js @@ -8,13 +8,15 @@ export const modelsCountLabel = (modelCount) => export const DESCRIPTION_LABEL = __('Description'); export const NO_DESCRIPTION_PROVIDED_LABEL = s__('MlModelRegistry|No description provided'); export const INFO_LABEL = s__('MlModelRegistry|Info'); +export const DETAILS_LABEL = s__('MlModelRegistry|Details & Metadata'); export const ID_LABEL = s__('MlModelRegistry|ID'); export const MLFLOW_ID_LABEL = s__('MlModelRegistry|MLflow run ID'); export const STATUS_LABEL = s__('MlModelRegistry|Status'); export const EXPERIMENT_LABEL = s__('MlModelRegistry|Experiment'); export const ARTIFACTS_LABEL = s__('MlModelRegistry|Artifacts'); +export const NO_ARTIFACTS_MESSAGE = s__('MlModelRegistry|No logged artifacts.'); export const PARAMETERS_LABEL = s__('MlModelRegistry|Parameters'); -export const PERFORMANCE_LABEL = s__('MlModelRegistry|Model performance'); +export const PERFORMANCE_LABEL = s__('MlModelRegistry|Performance'); export const METADATA_LABEL = s__('MlModelRegistry|Metadata'); export const NO_PARAMETERS_MESSAGE = s__('MlModelRegistry|No logged parameters'); export const NO_METRICS_MESSAGE = s__('MlModelRegistry|No logged metrics'); diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb index d881e8813c7a21baf03f29924e520e85383a3de6..d4f75cb447ff6b9064adb85775f8f61432f6798a 100644 --- a/app/presenters/ml/candidate_details_presenter.rb +++ b/app/presenters/ml/candidate_details_presenter.rb @@ -20,7 +20,10 @@ def present path_to_experiment: link_to_experiment, path: link_to_details, status: candidate.status, - ci_job: job_info + ci_job: job_info, + created_at: candidate.created_at, + authorWebUrl: candidate.user&.namespace&.web_url, + authorName: candidate.user&.name }, params: candidate.params, metrics: candidate.metrics, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0e655a1b99e18fc5a9dbb498f409fd7ee0983fe6..848d4edf77212d6cfe7b0d25a5f6dc51c2831015 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -35426,6 +35426,9 @@ msgstr "" msgid "MlExperimentTracking|CI Job" msgstr "" +msgid "MlExperimentTracking|Candidate %{id}" +msgstr "" + msgid "MlExperimentTracking|Candidate removed" msgstr "" @@ -35507,9 +35510,6 @@ msgstr "" msgid "MlExperimentTracking|Machine learning experiment tracking" msgstr "" -msgid "MlExperimentTracking|Model candidate details" -msgstr "" - msgid "MlExperimentTracking|Model experiments" msgstr "" @@ -35552,6 +35552,9 @@ msgstr "" msgid "MlExperimentTracking|Version" msgstr "" +msgid "MlExperimentTracking|by %{author}" +msgstr "" + msgid "MlModelRegistry|%d model" msgid_plural "MlModelRegistry|%d models" msgstr[0] "" @@ -35658,6 +35661,9 @@ msgstr "" msgid "MlModelRegistry|Description" msgstr "" +msgid "MlModelRegistry|Details & Metadata" +msgstr "" + msgid "MlModelRegistry|Drop or %{linkStart}select%{linkEnd} artifacts to attach" msgstr "" @@ -35748,6 +35754,9 @@ msgstr "" msgid "MlModelRegistry|Metadata" msgstr "" +msgid "MlModelRegistry|Metric" +msgstr "" + msgid "MlModelRegistry|Model card" msgstr "" @@ -35760,9 +35769,6 @@ msgstr "" msgid "MlModelRegistry|Model name" msgstr "" -msgid "MlModelRegistry|Model performance" -msgstr "" - msgid "MlModelRegistry|Model registry" msgstr "" @@ -35799,6 +35805,9 @@ msgstr "" msgid "MlModelRegistry|No description provided" msgstr "" +msgid "MlModelRegistry|No logged artifacts." +msgstr "" + msgid "MlModelRegistry|No logged metadata" msgstr "" @@ -35838,6 +35847,9 @@ msgstr "" msgid "MlModelRegistry|Status" msgstr "" +msgid "MlModelRegistry|Step %{step}" +msgstr "" + msgid "MlModelRegistry|Subfolder" msgstr "" diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_detail_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_detail_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..61ed60e72dda61296bb18601c71d7ef6ed590310 --- /dev/null +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_detail_spec.js @@ -0,0 +1,177 @@ +import { GlAvatarLabeled, GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CandidateDetail from '~/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue'; +import { newCandidate } from 'jest/ml/model_registry/mock_data'; + +describe('ml/experiment_tracking/routes/candidates/show/candidate_detail.vue', () => { + let wrapper; + + const defaultProps = { + candidate: newCandidate(), + }; + + const createWrapper = (props = {}) => { + return mountExtended(CandidateDetail, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findArtifactsTab = () => wrapper.findByTestId('artifacts'); + const findAllTabs = () => wrapper.findAll('.gl-tab-nav-item'); + const findMetricsTab = () => wrapper.findByTestId('metrics'); + const findMlflowIdButton = () => wrapper.findComponent(GlButton); + const findMetricsTable = () => wrapper.findByTestId('metrics-table'); + const findMetadata = () => wrapper.findByTestId('metadata'); + const findMlflowRunId = () => wrapper.findByTestId('mlflow-run-id'); + const findCiJobPathLink = () => wrapper.findByTestId('ci-job-path'); + const findArtifactLink = () => wrapper.findByTestId('artifacts-link'); + const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); + const findParametersSection = () => wrapper.findByTestId('parameters'); + const findParametersTable = () => wrapper.findByTestId('parameters-table'); + const findCiSection = () => wrapper.findByTestId('ci'); + + describe('Basic rendering', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders all three tabs', () => { + const tabs = findAllTabs(); + expect(tabs.at(0).text()).toBe('Details & Metadata'); + expect(tabs.at(1).text()).toBe('Artifacts'); + expect(tabs.at(2).text()).toBe('Performance'); + }); + + it('displays MLflow run ID', () => { + expect(findMlflowRunId().text()).toBe('abcdefg'); + }); + + it('renders metadata section', () => { + expect(findMetadata().text()).toContain('FileName'); + expect(findMetadata().text()).toContain('test.py'); + }); + }); + + describe('Parameters section', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders parameters table when parameters exist', () => { + expect(findParametersSection().text()).toContain('Parameters'); + }); + + it('renders metrics table with correct columns', () => { + const fields = findParametersTable().props('fields'); + expect(fields.map((e) => e.label)).toEqual(['Name', 'Value']); + }); + + it('formats metrics data correctly', () => { + expect(findParametersTable().vm.$attrs.items).toEqual([ + { name: 'Algorithm', value: 'Decision Tree' }, + { name: 'MaxDepth', value: '3' }, + ]); + }); + + it('shows no parameters message when parameters are empty', () => { + wrapper = createWrapper({ + candidate: { + ...defaultProps.candidate, + params: [], + }, + }); + expect(findParametersSection().text()).toContain('No logged parameters'); + }); + }); + + describe('CI information section', () => { + it('renders CI job information when available', () => { + wrapper = createWrapper(); + expect(findCiJobPathLink().text()).toContain('test'); + }); + + it('renders user information when available', () => { + wrapper = createWrapper(); + expect(findAvatarLabeled().text()).toContain('CI User'); + }); + + it('shows no CI message when CI information is missing', () => { + wrapper = createWrapper({ + candidate: { + ...defaultProps.candidate, + info: { ...defaultProps.candidate.info, ciJob: null }, + }, + }); + expect(findCiSection().text()).toContain('Candidate not linked to a CI build'); + }); + }); + + describe('Metrics table', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders metrics table with correct columns', () => { + const fields = findMetricsTable().props('fields'); + expect(fields.map((e) => e.label)).toEqual([ + 'Metric', + 'Step 0', + 'Step 1', + 'Step 2', + 'Step 3', + ]); + }); + + it('formats metrics data correctly', () => { + expect(findMetricsTable().vm.$attrs.items).toContainEqual({ + name: 'Accuracy', + 1: '.99', + 2: '.98', + 3: '.97', + }); + }); + + it('shows no metrics message when metrics are empty', () => { + wrapper = createWrapper({ + candidate: { + ...defaultProps.candidate, + metrics: [], + }, + }); + expect(findMetricsTab().text()).toContain('No logged metrics'); + }); + }); + + describe('MLflow ID copy button', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('copies MLflow ID to clipboard when clicked', async () => { + jest.spyOn(navigator.clipboard, 'writeText').mockImplementation(() => Promise.resolve()); + await findMlflowIdButton().vm.$emit('click'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abcdefg'); + jest.restoreAllMocks(); + }); + }); + + describe('Artifacts tab', () => { + it('renders artifact link when available', () => { + wrapper = createWrapper(); + expect(findArtifactLink().attributes('href')).toBe('path_to_artifact'); + }); + + it('shows no artifacts message when artifact path is missing', () => { + wrapper = createWrapper({ + candidate: { + ...defaultProps.candidate, + info: { ...defaultProps.candidate.info, pathToArtifact: null }, + }, + }); + expect(findArtifactsTab().text()).toContain('No logged artifacts'); + }); + }); +}); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b0a46d8bc3828233bc2a0758686350377a0cfe63 --- /dev/null +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/candidate_header_spec.js @@ -0,0 +1,129 @@ +import { GlBadge, GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CandidateHeader from '~/ml/experiment_tracking/routes/candidates/show/candidate_header.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import PageHeading from '~/vue_shared/components/page_heading.vue'; +import { newCandidate } from 'jest/ml/model_registry/mock_data'; + +describe('ml/experiment_tracking/routes/candidates/show/candidate_header.vue', () => { + let wrapper; + + const defaultProps = { + info: newCandidate().info, + }; + + const createWrapper = (props = {}) => { + return shallowMountExtended(CandidateHeader, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findPageHeading = () => wrapper.findComponent(PageHeading); + const findDeleteButton = () => wrapper.findComponent(DeleteButton); + const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); + const findBadge = () => wrapper.findComponent(GlBadge); + const findAuthorLink = () => wrapper.findByTestId('author-link'); + const findExperimentLink = () => wrapper.findByTestId('experiment-link'); + const findStatusIcon = () => wrapper.findComponent(GlIcon); + + describe('Basic rendering', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders the page heading with correct title', () => { + expect(findPageHeading().text()).toContain('Candidate candidate_iid'); + }); + + it('renders the experiment name with link', () => { + expect(findExperimentLink().text()).toBe('The Experiment /'); + expect(findExperimentLink().attributes('href')).toBe('path/to/experiment'); + }); + + it('renders the time ago component with correct timestamp', () => { + expect(findTimeAgo().props('time')).toBe('2024-01-01T00:00:00Z'); + }); + + it('renders the status badge with correct variant', () => { + expect(findBadge().props('variant')).toBe('muted'); + expect(findBadge().text()).toContain('SUCCESS'); + }); + + it('renders the status icon', () => { + expect(findStatusIcon().exists()).toBe(true); + expect(findStatusIcon().props('name')).toBe('issue-type-test-case'); + }); + + it('renders the author information', () => { + const authorLink = findAuthorLink(); + expect(authorLink.attributes('href')).toBe('/test-user'); + expect(authorLink.text()).toContain('by Test User'); + }); + + it('renders the delete button component', () => { + const deleteButton = findDeleteButton(); + expect(deleteButton.exists()).toBe(true); + expect(deleteButton.props('deletePath')).toBe('path_to_candidate'); + }); + }); + + describe('Status variants', () => { + const testCases = [ + { status: 'running', variant: 'success' }, + { status: 'scheduled', variant: 'info' }, + { status: 'finished', variant: 'muted' }, + { status: 'failed', variant: 'warning' }, + { status: 'killed', variant: 'danger' }, + ]; + + testCases.forEach(({ status, variant }) => { + it(`renders correct badge variant for ${status} status`, () => { + wrapper = createWrapper({ + info: { + ...defaultProps.info, + status, + }, + }); + + expect(findBadge().props('variant')).toBe(variant); + }); + }); + }); + + describe('When author is not provided', () => { + beforeEach(() => { + wrapper = createWrapper({ + info: { + ...defaultProps.info, + authorName: null, + authorWebUrl: null, + }, + }); + }); + + it('does not render author link', () => { + expect(findAuthorLink().exists()).toBe(false); + }); + }); + + describe('delete button configuration', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('passes correct props to delete button', () => { + const deleteButton = findDeleteButton(); + expect(deleteButton.props()).toMatchObject({ + deletePath: 'path_to_candidate', + deleteConfirmationText: + 'Deleting this candidate will delete the associated parameters, metrics, and metadata.', + actionPrimaryText: 'Delete candidate', + modalTitle: 'Delete candidate?', + }); + }); + }); +}); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js index 3999e906cec4ec78c36575e865b4366b8ede0247..8d95b43e7792584a7727cb106978b4d90fe22f2c 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js @@ -1,39 +1,29 @@ import { shallowMount } from '@vue/test-utils'; import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show'; -import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; -import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue'; -import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; +import CandidateHeader from '~/ml/experiment_tracking/routes/candidates/show/candidate_header.vue'; +import CandidateDetail from '~/ml/experiment_tracking/routes/candidates/show/candidate_detail.vue'; import { newCandidate } from 'jest/ml/model_registry/mock_data'; describe('MlCandidatesShow', () => { let wrapper; - const CANDIDATE = newCandidate(); + const candidate = newCandidate(); const createWrapper = () => { wrapper = shallowMount(MlCandidatesShow, { - propsData: { candidate: CANDIDATE }, + propsData: { candidate }, }); }; - const findDeleteButton = () => wrapper.findComponent(DeleteButton); - const findHeader = () => wrapper.findComponent(ModelExperimentsHeader); + const findCandidateHeader = () => wrapper.findComponent(CandidateHeader); const findCandidateDetail = () => wrapper.findComponent(CandidateDetail); beforeEach(() => createWrapper()); - it('shows delete button', () => { - expect(findDeleteButton().exists()).toBe(true); - }); - - it('passes the delete path to delete button', () => { - expect(findDeleteButton().props('deletePath')).toBe('path_to_candidate'); - }); - - it('passes the right title', () => { - expect(findHeader().props('pageTitle')).toBe('Model candidate details'); + it('creates the candidate header section', () => { + expect(findCandidateHeader().props('info')).toBe(candidate.info); }); it('creates the candidate detail section', () => { - expect(findCandidateDetail().props('candidate')).toBe(CANDIDATE); + expect(findCandidateDetail().props('candidate')).toBe(candidate); }); }); diff --git a/spec/frontend/ml/model_registry/components/candidate_detail_spec.js b/spec/frontend/ml/model_registry/components/candidate_detail_spec.js index c6dcb534be89ae85d36af2b8391f4e90bbf19d05..eaffc169c8c04ddb6cb36ae05a2265c65231c8f9 100644 --- a/spec/frontend/ml/model_registry/components/candidate_detail_spec.js +++ b/spec/frontend/ml/model_registry/components/candidate_detail_spec.js @@ -16,14 +16,13 @@ describe('ml/model_registry/components/candidate_detail.vue', () => { const CANDIDATE = newCandidate(); const USER_ROW = 1; - const INFO_SECTION = 0; - const CI_SECTION = 1; - const PARAMETER_SECTION = 2; - const METADATA_SECTION = 3; + const CI_SECTION = 0; + const PARAMETER_SECTION = 1; + const METADATA_SECTION = 2; - const createWrapper = (createCandidate = () => CANDIDATE, showInfoSection = true) => { + const createWrapper = (createCandidate = () => CANDIDATE) => { wrapper = shallowMountExtended(CandidateDetail, { - propsData: { candidate: createCandidate(), showInfoSection }, + propsData: { candidate: createCandidate() }, stubs: { GlTableLite: { ...stubComponent(GlTableLite), props: ['items', 'fields'] }, }, @@ -47,11 +46,6 @@ describe('ml/model_registry/components/candidate_detail.vue', () => { const mrText = `!${CANDIDATE.info.ciJob.mergeRequest.iid} ${CANDIDATE.info.ciJob.mergeRequest.title}`; const expectedTable = [ - [INFO_SECTION, 0, 'ID', CANDIDATE.info.iid], - [INFO_SECTION, 1, 'MLflow run ID', CANDIDATE.info.eid], - [INFO_SECTION, 2, 'Status', CANDIDATE.info.status], - [INFO_SECTION, 3, 'Experiment', CANDIDATE.info.experimentName], - [INFO_SECTION, 4, 'Artifacts', 'Artifacts'], [CI_SECTION, 0, 'Job', CANDIDATE.info.ciJob.name], [CI_SECTION, 1, 'Triggered by', 'CI User'], [CI_SECTION, 2, 'Merge request', mrText], @@ -71,8 +65,6 @@ describe('ml/model_registry/components/candidate_detail.vue', () => { describe('Table links', () => { const linkRows = [ - [INFO_SECTION, 3, CANDIDATE.info.pathToExperiment], - [INFO_SECTION, 4, CANDIDATE.info.pathToArtifact], [CI_SECTION, 0, CANDIDATE.info.ciJob.path], [CI_SECTION, 2, CANDIDATE.info.ciJob.mergeRequest.path], ]; @@ -181,12 +173,4 @@ describe('ml/model_registry/components/candidate_detail.vue', () => { expect(findLabel('Triggered by').exists()).toBe(false); }); }); - - describe('showInfoSection is set to false', () => { - beforeEach(() => createWrapper(() => CANDIDATE, false)); - - it('does not render the info section', () => { - expect(findLabel('MLflow run ID').exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js index d8bb6a8eedbca73dadfae7ddbd3f9be84c2d82d4..e30208751b798da40d0b780a9f867501b60a5c89 100644 --- a/spec/frontend/ml/model_registry/mock_data.js +++ b/spec/frontend/ml/model_registry/mock_data.js @@ -37,6 +37,9 @@ export const newCandidate = () => ({ avatar: '/img.png', }, }, + createdAt: '2024-01-01T00:00:00Z', + authorName: 'Test User', + authorWebUrl: '/test-user', }, }); diff --git a/spec/presenters/ml/candidate_details_presenter_spec.rb b/spec/presenters/ml/candidate_details_presenter_spec.rb index 98b152f727c9e6f454d49c890b9a8098eb9e4ad9..05b09d73221efe78ddd70ccf4ac25e551b5171e2 100644 --- a/spec/presenters/ml/candidate_details_presenter_spec.rb +++ b/spec/presenters/ml/candidate_details_presenter_spec.rb @@ -48,7 +48,7 @@ subject { described_class.new(candidate, user).present } it 'presents the candidate correctly' do - is_expected.to eq( + is_expected.to match( { candidate: { info: { @@ -73,7 +73,10 @@ path: "/#{pipeline.user.username}", username: pipeline.user.username } - } + }, + created_at: candidate.created_at, + authorWebUrl: nil, + authorName: candidate.user.name }, params: params, metrics: metrics,