diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-19 17:16:28 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-19 17:16:28 +0300 |
commit | e4384360a16dd9a19d4d2d25d0ef1f2b862ed2a6 (patch) | |
tree | 2fcdfa7dcdb9db8f5208b2562f4b4e803d671243 /app/assets/javascripts/ci | |
parent | ffda4e7bcac36987f936b4ba515995a6698698f0 (diff) |
Add latest changes from gitlab-org/gitlab@16-2-stable-eev16.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/ci')
47 files changed, 1039 insertions, 733 deletions
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue index 09b02068388..a25f871ac92 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue @@ -24,6 +24,10 @@ export default { type: Array, required: true, }, + hasEnvScopeQuery: { + type: Boolean, + required: true, + }, selectedEnvironmentScope: { type: String, required: false, @@ -32,6 +36,7 @@ export default { }, data() { return { + isDropdownShown: false, selectedEnvironment: '', searchTerm: '', }; @@ -46,17 +51,20 @@ export default { return environment.toLowerCase().includes(lowerCasedSearchTerm); }); }, - isEnvScopeLimited() { - return this.glFeatures?.ciLimitEnvironmentScope; + isDropdownLoading() { + return this.areEnvironmentsLoading && this.hasEnvScopeQuery && !this.isDropdownShown; + }, + isDropdownSearching() { + return this.areEnvironmentsLoading && this.hasEnvScopeQuery && this.isDropdownShown; }, searchedEnvironments() { - // If FF is enabled, search query will be fired so this component will already - // receive filtered environments during the refetch. - // If FF is disabled, search the existing list of environments in the frontend - let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments; + // If hasEnvScopeQuery (applies only to projects for now), search query will be fired so this + // component will already receive filtered environments during the refetch. + // Otherwise (applies to groups), search the existing list of environments in the frontend + let filtered = this.hasEnvScopeQuery ? this.environments : this.filteredEnvironments; // If there is no search term, make sure to include * - if (this.isEnvScopeLimited && !this.searchTerm) { + if (this.hasEnvScopeQuery && !this.searchTerm) { filtered = uniq([...filtered, '*']); } @@ -65,15 +73,12 @@ export default { text: environment, })); }, - shouldShowSearchLoading() { - return this.areEnvironmentsLoading && this.isEnvScopeLimited; - }, shouldRenderCreateButton() { return this.searchTerm && !this.environments.includes(this.searchTerm); }, shouldRenderDivider() { return ( - (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading + (this.hasEnvScopeQuery || this.shouldRenderCreateButton) && !this.areEnvironmentsLoading ); }, environmentScopeLabel() { @@ -84,7 +89,7 @@ export default { debouncedSearch: debounce(function debouncedSearch(searchTerm) { const newSearchTerm = searchTerm.trim(); this.searchTerm = newSearchTerm; - if (this.isEnvScopeLimited) { + if (this.hasEnvScopeQuery) { this.$emit('search-environment-scope', newSearchTerm); } }, 500), @@ -96,6 +101,9 @@ export default { this.$emit('create-environment-scope', this.searchTerm); this.selectEnvironment(this.searchTerm); }, + toggleDropdownShown(isShown) { + this.isDropdownShown = isShown; + }, }, ENVIRONMENT_QUERY_LIMIT, i18n: { @@ -111,14 +119,17 @@ export default { block searchable :items="searchedEnvironments" - :searching="shouldShowSearchLoading" + :loading="isDropdownLoading" + :searching="isDropdownSearching" :toggle-text="environmentScopeLabel" @search="debouncedSearch" @select="selectEnvironment" + @shown="toggleDropdownShown(true)" + @hidden="toggleDropdownShown(false)" > <template #footer> <gl-dropdown-divider v-if="shouldRenderDivider" /> - <div v-if="isEnvScopeLimited" data-testid="max-envs-notice"> + <div v-if="hasEnvScopeQuery" data-testid="max-envs-notice"> <gl-dropdown-item class="gl-list-style-none" disabled> <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm"> <template #limit> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue index 9c79adffdae..2045b127a82 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue @@ -3,6 +3,7 @@ import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; +import getGroupEnvironments from '../graphql/queries/group_environments.query.graphql'; import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; @@ -22,6 +23,15 @@ export default { graphqlId() { return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, + queriesAvailable() { + if (this.glFeatures.ciGroupEnvScopeGraphql) { + return this.$options.queryData; + } + + return { + ciVariables: this.$options.queryData.ciVariables, + }; + }, }, mutationData: { [ADD_MUTATION_ACTION]: addGroupVariable, @@ -33,6 +43,10 @@ export default { lookup: (data) => data?.group?.ciVariables, query: getGroupVariables, }, + environments: { + lookup: (data) => data?.group?.environmentScopes, + query: getGroupEnvironments, + }, }, }; </script> @@ -45,6 +59,6 @@ export default { entity="group" :full-path="groupPath" :mutation-data="$options.mutationData" - :query-data="$options.queryData" + :query-data="queriesAvailable" /> </template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 41514d2d2f1..3af48635f3f 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -93,6 +93,10 @@ export default { required: false, default: false, }, + hasEnvScopeQuery: { + type: Boolean, + required: true, + }, mode: { type: String, required: true, @@ -147,7 +151,7 @@ export default { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, environmentsList() { - if (this.glFeatures?.ciLimitEnvironmentScope) { + if (this.hasEnvScopeQuery) { return this.environments; } @@ -385,6 +389,7 @@ export default { <ci-environments-dropdown v-if="areScopedVariablesAvailable" :are-environments-loading="areEnvironmentsLoading" + :has-env-scope-query="hasEnvScopeQuery" :selected-environment-scope="variable.environmentScope" :environments="environmentsList" @select-environment="setEnvironmentScope" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index 26e20c690bc..b8a95f9081a 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -33,6 +33,10 @@ export default { required: false, default: false, }, + hasEnvScopeQuery: { + type: Boolean, + required: true, + }, isLoading: { type: Boolean, required: false, @@ -107,6 +111,7 @@ export default { :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :environments="environments" + :has-env-scope-query="hasEnvScopeQuery" :hide-environment-scope="hideEnvironmentScope" :variables="variables" :mode="mode" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index ee2c0a771cf..9786f25ed87 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -159,12 +159,13 @@ export default { return this.queryData?.environments?.query || {}; }, skip() { - return !this.queryData?.environments?.query; + return !this.hasEnvScopeQuery; }, variables() { return { + first: ENVIRONMENT_QUERY_LIMIT, fullPath: this.fullPath, - ...this.environmentQueryVariables, + search: '', }; }, update(data) { @@ -179,23 +180,12 @@ export default { areEnvironmentsLoading() { return this.$apollo.queries.environments.loading; }, - environmentQueryVariables() { - if (this.glFeatures?.ciLimitEnvironmentScope) { - return { - first: ENVIRONMENT_QUERY_LIMIT, - search: '', - }; - } - - return {}; + hasEnvScopeQuery() { + return Boolean(this.queryData?.environments?.query); }, isLoading() { - // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when - // environment query is loading and FF is enabled - // https://gitlab.com/gitlab-org/gitlab/-/issues/396990 return ( (this.$apollo.queries.ciVariables.loading && this.isInitialLoading) || - this.areEnvironmentsLoading || this.isLoadingMoreItems ); }, @@ -248,9 +238,7 @@ export default { this.variableMutation(UPDATE_MUTATION_ACTION, variable); }, async searchEnvironmentScope(searchTerm) { - if (this.glFeatures?.ciLimitEnvironmentScope) { - this.$apollo.queries.environments.refetch({ search: searchTerm }); - } + this.$apollo.queries.environments.refetch({ search: searchTerm }); }, async variableMutation(mutationAction, variable) { try { @@ -296,6 +284,7 @@ export default { :are-scoped-variables-available="areScopedVariablesAvailable" :entity="entity" :environments="environments" + :has-env-scope-query="hasEnvScopeQuery" :hide-environment-scope="hideEnvironmentScope" :is-loading="isLoading" :max-variable-limit="maxVariableLimit" diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql new file mode 100644 index 00000000000..5768d370474 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_environments.query.graphql @@ -0,0 +1,10 @@ +query getGroupEnvironments($fullPath: ID!, $first: Int, $search: String) { + group(fullPath: $fullPath) { + id + environmentScopes(first: $first, search: $search) { + nodes { + name + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue index 0b57433e894..8d670cb5389 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -2,6 +2,7 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; import { pipelineEditorTrackingOptions } from '../../../constants'; export default { @@ -34,7 +35,7 @@ export default { this.track(actions.helpDrawerLinks.runners, { label }); }, }, - RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', + RUNNER_HELP_URL: `${DOCS_URL}/runner/register/index.html`, }; </script> <template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue index 794763e0cd8..76db9613dc1 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue @@ -26,7 +26,7 @@ export default { return [ { key: 'artifacts.paths', - title: i18n.ARTIFACTS_AND_CACHE, + title: i18n.ARTIFACTS_PATHS, paths: this.job.artifacts.paths, generateInputDataTestId: (index) => `artifacts-paths-input-${index}`, generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`, diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue index d0f206e767f..460f508ee74 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue @@ -54,6 +54,13 @@ export default { `${this.startInNumber} ${this.startInUnit}${plural}`, ); }, + updateWhen(when) { + this.$emit('update-job', 'rules[0].when', when); + + if (when === JOB_RULES_WHEN.delayed.value) { + this.updateStartIn(); + } + }, }, }; </script> @@ -73,7 +80,7 @@ export default { :options="$options.whenOptions" data-testid="rules-when-select" :value="job.rules[0].when" - @input="$emit('update-job', 'rules[0].when', $event)" + @input="updateWhen" /> </gl-form-group> <gl-form-group diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index 6695c6179cf..0700d9e5439 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -16,6 +16,7 @@ import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline import playPipelineScheduleMutation from '../graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; +import { ALL_SCOPE } from '../constants'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; import TakeOwnershipModal from './take_ownership_modal.vue'; import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; @@ -58,6 +59,9 @@ export default { pipelinesPath: { default: '', }, + newSchedulePath: { + default: '', + }, }, apollo: { schedules: { @@ -65,7 +69,9 @@ export default { variables() { return { projectPath: this.fullPath, - status: this.scope, + // we need to ensure we send null to the API when + // the scope is 'ALL' + status: this.scope === ALL_SCOPE ? null : this.scope, }; }, update(data) { @@ -111,7 +117,7 @@ export default { { text: s__('PipelineSchedules|All'), count: limitedCounterWithDelimiter(this.count), - scope: null, + scope: ALL_SCOPE, showBadge: true, attrs: { 'data-testid': 'pipeline-schedules-all-tab' }, }, @@ -134,7 +140,7 @@ export default { // this watcher ensures that the count on the all tab // is not updated when switching to other tabs schedulesCount(newCount) { - if (!this.scope) { + if (!this.scope || this.scope === ALL_SCOPE) { this.count = newCount; } }, @@ -253,10 +259,10 @@ export default { </gl-alert> <gl-tabs - v-if="isLoading || count > 0" + v-if="isLoading || schedulesCount > 0" sync-active-tab-with-query-params query-param-name="scope" - nav-class="gl-flex-grow-1 gl-align-items-center" + nav-class="gl-flex-grow-1 gl-align-items-center gl-mt-2" > <gl-tab v-for="tab in tabs" @@ -289,13 +295,18 @@ export default { </gl-tab> <template #tabs-end> - <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button"> + <gl-button + :href="newSchedulePath" + variant="confirm" + class="gl-ml-auto" + data-testid="new-schedule-button" + > {{ $options.i18n.newSchedule }} </gl-button> </template> </gl-tabs> - <pipeline-schedule-empty-state v-else-if="!isLoading && count === 0" /> + <pipeline-schedule-empty-state v-else-if="!isLoading && schedulesCount === 0" /> <take-ownership-modal :visible="showTakeOwnershipModal" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue index 39ac55bb9c5..fbdb60f61f1 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue @@ -1,5 +1,5 @@ <script> -import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg?raw'; +import SCHEDULE_MD_SVG_URL from '@gitlab/svgs/dist/illustrations/schedule-md.svg?url'; import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; @@ -20,15 +20,18 @@ export default { ], createNew: s__('PipelineSchedules|Create a new pipeline schedule'), }, + SCHEDULE_MD_SVG_URL, components: { GlEmptyState, GlLink, GlSprintf, }, - computed: { - scheduleSvgPath() { - return `data:image/svg+xml;utf8,${encodeURIComponent(scheduleSvg)}`; + inject: { + newSchedulePath: { + default: '', }, + }, + computed: { schedulesHelpPath() { return helpPagePath('ci/pipelines/schedules'); }, @@ -37,9 +40,9 @@ export default { </script> <template> <gl-empty-state - :svg-path="scheduleSvgPath" + :svg-path="$options.SCHEDULE_MD_SVG_URL" :primary-button-text="$options.i18n.createNew" - primary-button-link="#" + :primary-button-link="newSchedulePath" > <template #title> <h3> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 367b1812a27..d84a9a4a4b5 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -8,18 +8,22 @@ import { GlFormGroup, GlFormInput, GlFormTextarea, - GlLink, - GlSprintf, + GlLoadingIcon, } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import Vue from 'vue'; import { __, s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import { visitUrl, queryToObject } from '~/lib/utils/url_utility'; import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; import RefSelector from '~/ref/components/ref_selector.vue'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import createPipelineScheduleMutation from '../graphql/mutations/create_pipeline_schedule.mutation.graphql'; +import updatePipelineScheduleMutation from '../graphql/mutations/update_pipeline_schedule.mutation.graphql'; +import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; +const scheduleId = queryToObject(window.location.search).id; + export default { components: { GlButton, @@ -30,21 +34,12 @@ export default { GlFormGroup, GlFormInput, GlFormTextarea, - GlLink, - GlSprintf, + GlLoadingIcon, RefSelector, TimezoneDropdown, IntervalPatternInput, }, - inject: [ - 'fullPath', - 'projectId', - 'defaultBranch', - 'cron', - 'cronTimezone', - 'dailyLimit', - 'settingsLink', - ], + inject: ['fullPath', 'projectId', 'defaultBranch', 'dailyLimit', 'settingsLink', 'schedulesPath'], props: { timezoneData: { type: Array, @@ -55,34 +50,79 @@ export default { required: false, default: '', }, + editing: { + type: Boolean, + required: true, + }, + }, + apollo: { + schedule: { + query: getPipelineSchedulesQuery, + variables() { + return { + projectPath: this.fullPath, + ids: scheduleId, + }; + }, + update(data) { + return data.project?.pipelineSchedules?.nodes[0] || {}; + }, + result({ data }) { + if (data) { + const { + project: { + pipelineSchedules: { nodes }, + }, + } = data; + + const schedule = nodes[0]; + const variables = schedule.variables?.nodes || []; + + this.description = schedule.description; + this.cron = schedule.cron; + this.cronTimezone = schedule.cronTimezone; + this.scheduleRef = schedule.ref; + this.variables = variables.map((variable) => { + return { + id: variable.id, + variableType: variable.variableType, + key: variable.key, + value: variable.value, + destroy: false, + }; + }); + this.addEmptyVariable(); + this.activated = schedule.active; + } + }, + skip() { + return !this.editing; + }, + error() { + createAlert({ message: this.$options.i18n.scheduleFetchError }); + }, + }, }, data() { return { - refValue: { - shortName: this.refParam, - // this is needed until we add support for ref type in url query strings - // ensure default branch is called with full ref on load - // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, - }, + cron: '', description: '', scheduleRef: this.defaultBranch, activated: true, - timezone: this.cronTimezone, - formCiVariables: {}, - // TODO: Add the GraphQL query to help populate the predefined variables - // app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue#131 - predefinedValueOptions: {}, + cronTimezone: '', + variables: [], + schedule: {}, }; }, i18n: { activated: __('Activated'), - cronTimezone: s__('PipelineSchedules|Cron timezone'), + cronTimezoneText: s__('PipelineSchedules|Cron timezone'), description: s__('PipelineSchedules|Description'), shortDescriptionPipeline: s__( 'PipelineSchedules|Provide a short description for this pipeline', ), - savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'), + editScheduleBtnText: s__('PipelineSchedules|Edit pipeline schedule'), + createScheduleBtnText: s__('PipelineSchedules|Create pipeline schedule'), cancel: __('Cancel'), targetBranchTag: __('Select target branch or tag'), intervalPattern: s__('PipelineSchedules|Interval Pattern'), @@ -91,6 +131,15 @@ export default { ), removeVariableLabel: s__('CiVariables|Remove variable'), variables: s__('Pipeline|Variables'), + scheduleCreateError: s__( + 'PipelineSchedules|An error occurred while creating the pipeline schedule.', + ), + scheduleUpdateError: s__( + 'PipelineSchedules|An error occurred while updating the pipeline schedule.', + ), + scheduleFetchError: s__( + 'PipelineSchedules|An error occurred while trying to fetch the pipeline schedule.', + ), }, typeOptions: { [VARIABLE_TYPE]: __('Variable'), @@ -103,15 +152,6 @@ export default { dropdownHeader: this.$options.i18n.targetBranchTag, }; }, - refFullName() { - return this.refValue.fullName; - }, - variables() { - return this.formCiVariables[this.refFullName]?.variables ?? []; - }, - descriptions() { - return this.formCiVariables[this.refFullName]?.descriptions ?? {}; - }, typeOptionsListbox() { return [ { @@ -127,52 +167,136 @@ export default { getEnabledRefTypes() { return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; }, + preparedVariablesUpdate() { + return this.variables.filter((variable) => variable.key !== ''); + }, + preparedVariablesCreate() { + return this.preparedVariablesUpdate.map((variable) => { + return { + key: variable.key, + value: variable.value, + variableType: variable.variableType, + }; + }); + }, + loading() { + return this.$apollo.queries.schedule.loading; + }, + buttonText() { + return this.editing + ? this.$options.i18n.editScheduleBtnText + : this.$options.i18n.createScheduleBtnText; + }, }, created() { - Vue.set(this.formCiVariables, this.refFullName, { - variables: [], - descriptions: {}, - }); - - this.addEmptyVariable(this.refFullName); + this.addEmptyVariable(); }, methods: { - addEmptyVariable(refValue) { - const { variables } = this.formCiVariables[refValue]; + addEmptyVariable() { + const lastVar = this.variables[this.variables.length - 1]; - const lastVar = variables[variables.length - 1]; if (lastVar?.key === '' && lastVar?.value === '') { return; } - variables.push({ - uniqueId: uniqueId(`var-${refValue}`), - variable_type: VARIABLE_TYPE, + this.variables.push({ + variableType: VARIABLE_TYPE, key: '', value: '', + destroy: false, }); }, setVariableAttribute(key, attribute, value) { - const { variables } = this.formCiVariables[this.refFullName]; - const variable = variables.find((v) => v.key === key); + const variable = this.variables.find((v) => v.key === key); variable[attribute] = value; }, - shouldShowValuesDropdown(key) { - return this.predefinedValueOptions[key]?.length > 1; - }, removeVariable(index) { - this.variables.splice(index, 1); + this.variables[index].destroy = true; }, canRemove(index) { return index < this.variables.length - 1; }, + async createPipelineSchedule() { + try { + const { + data: { + pipelineScheduleCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: createPipelineScheduleMutation, + variables: { + input: { + description: this.description, + cron: this.cron, + cronTimezone: this.cronTimezone, + ref: this.scheduleRef, + variables: this.preparedVariablesCreate, + active: this.activated, + projectPath: this.fullPath, + }, + }, + }); + + if (errors.length > 0) { + createAlert({ message: errors[0] }); + } else { + visitUrl(this.schedulesPath); + } + } catch { + createAlert({ message: this.$options.i18n.scheduleCreateError }); + } + }, + async updatePipelineSchedule() { + try { + const { + data: { + pipelineScheduleUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updatePipelineScheduleMutation, + variables: { + input: { + id: this.schedule.id, + description: this.description, + cron: this.cron, + cronTimezone: this.cronTimezone, + ref: this.scheduleRef, + variables: this.preparedVariablesUpdate, + active: this.activated, + }, + }, + }); + + if (errors.length > 0) { + createAlert({ message: errors[0] }); + } else { + visitUrl(this.schedulesPath); + } + } catch { + createAlert({ message: this.$options.i18n.scheduleUpdateError }); + } + }, + scheduleHandler() { + if (this.editing) { + this.updatePipelineSchedule(); + } else { + this.createPipelineSchedule(); + } + }, + setCronValue(cron) { + this.cron = cron; + }, + setTimezone(timezone) { + this.cronTimezone = timezone.identifier || ''; + }, }, }; </script> <template> - <div class="col-lg-8"> - <gl-form> + <div class="col-lg-8 gl-pl-0"> + <gl-loading-icon v-if="loading && editing" size="lg" /> + <gl-form v-else> <!--Description--> <gl-form-group :label="$options.i18n.description" label-for="schedule-description"> <gl-form-input @@ -181,6 +305,7 @@ export default { type="text" :placeholder="$options.i18n.shortDescriptionPipeline" data-testid="schedule-description" + required /> </gl-form-group> <!--Interval Pattern--> @@ -190,21 +315,24 @@ export default { :initial-cron-interval="cron" :daily-limit="dailyLimit" :send-native-errors="false" + @cronValue="setCronValue" /> </gl-form-group> <!--Timezone--> - <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone"> + <gl-form-group :label="$options.i18n.cronTimezoneText" label-for="schedule-timezone"> <timezone-dropdown id="schedule-timezone" - :value="timezone" + :value="cronTimezone" :timezone-data="timezoneData" name="schedule-timezone" + @input="setTimezone" /> </gl-form-group> <!--Branch/Tag Selector--> <gl-form-group :label="$options.i18n.targetBranchTag" label-for="schedule-target-branch-tag"> <ref-selector id="schedule-target-branch-tag" + v-model="scheduleRef" :enabled-ref-types="getEnabledRefTypes" :project-id="projectId" :value="scheduleRef" @@ -217,23 +345,23 @@ export default { <gl-form-group :label="$options.i18n.variables"> <div v-for="(variable, index) in variables" - :key="variable.uniqueId" - class="gl-mb-3 gl-pb-2" - data-testid="ci-variable-row" + :key="`var-${index}`" data-qa-selector="ci_variable_row_container" > <div - class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + v-if="!variable.destroy" + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row gl-mb-3 gl-pb-2" + data-testid="ci-variable-row" > <gl-dropdown - :text="$options.typeOptions[variable.variable_type]" + :text="$options.typeOptions[variable.variableType]" :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-type" > <gl-dropdown-item v-for="type in Object.keys($options.typeOptions)" :key="type" - @click="setVariableAttribute(variable.key, 'variable_type', type)" + @click="setVariableAttribute(variable.key, 'variableType', type)" > {{ $options.typeOptions[type] }} </gl-dropdown-item> @@ -244,26 +372,10 @@ export default { :class="$options.formElementClasses" data-testid="pipeline-form-ci-variable-key" data-qa-selector="ci_variable_key_field" - @change="addEmptyVariable(refFullName)" + @change="addEmptyVariable()" /> - <gl-dropdown - v-if="shouldShowValuesDropdown(variable.key)" - :text="variable.value" - :class="$options.formElementClasses" - class="gl-flex-grow-1 gl-mr-0!" - data-testid="pipeline-form-ci-variable-value-dropdown" - > - <gl-dropdown-item - v-for="value in predefinedValueOptions[variable.key]" - :key="value" - data-testid="pipeline-form-ci-variable-value-dropdown-items" - @click="setVariableAttribute(variable.key, 'value', value)" - > - {{ value }} - </gl-dropdown-item> - </gl-dropdown> + <gl-form-textarea - v-else v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" class="gl-mb-3 gl-h-7!" @@ -292,30 +404,19 @@ export default { /> </template> </div> - <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> - {{ descriptions[variable.key] }} - </div> </div> - - <template #description - ><gl-sprintf :message="$options.i18n.variablesDescription"> - <template #link="{ content }"> - <gl-link :href="settingsLink">{{ content }}</gl-link> - </template> - </gl-sprintf></template - > </gl-form-group> <!--Activated--> - <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">{{ - $options.i18n.activated - }}</gl-form-checkbox> + <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3"> + {{ $options.i18n.activated }} + </gl-form-checkbox> - <gl-button type="submit" variant="confirm" data-testid="schedule-submit-button">{{ - $options.i18n.savePipelineSchedule - }}</gl-button> - <gl-button type="reset" data-testid="schedule-cancel-button">{{ - $options.i18n.cancel - }}</gl-button> + <gl-button variant="confirm" data-testid="schedule-submit-button" @click="scheduleHandler"> + {{ buttonText }} + </gl-button> + <gl-button :href="schedulesPath" data-testid="schedule-cancel-button"> + {{ $options.i18n.cancel }} + </gl-button> </gl-form> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue index 5bd58bfd95d..a56da06f5da 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlButtonGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; export const i18n = { playTooltip: s__('PipelineSchedules|Run pipeline schedule'), @@ -44,6 +45,11 @@ export default { canRemove() { return this.schedule.userPermissions.adminPipelineSchedule; }, + editPathWithIdParam() { + const id = getIdFromGraphQLId(this.schedule.id); + + return `${this.schedule.editPath}?id=${id}`; + }, }, }; </script> @@ -67,7 +73,14 @@ export default { data-testid="take-ownership-pipeline-schedule-btn" @click="$emit('showTakeOwnershipModal', schedule.id)" /> - <gl-button v-if="canUpdate" v-gl-tooltip :title="$options.i18n.editTooltip" icon="pencil" /> + <gl-button + v-if="canUpdate" + v-gl-tooltip + :href="editPathWithIdParam" + :title="$options.i18n.editTooltip" + icon="pencil" + data-testid="edit-pipeline-schedule-btn" + /> <gl-button v-if="canRemove" v-gl-tooltip diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js index b4ab1143f60..16dab33ce29 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/constants.js +++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js @@ -1,2 +1,3 @@ -export const VARIABLE_TYPE = 'env_var'; -export const FILE_TYPE = 'file'; +export const VARIABLE_TYPE = 'ENV_VAR'; +export const FILE_TYPE = 'FILE'; +export const ALL_SCOPE = 'ALL'; diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..0bea1bb8360 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/create_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation createPipelineSchedule($input: PipelineScheduleCreateInput!) { + pipelineScheduleCreate(input: $input) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..a6a937af74a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/update_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation updatePipelineSchedule($input: PipelineScheduleUpdateInput!) { + pipelineScheduleUpdate(input: $input) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql index 6167c7dc577..29a26be0344 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql @@ -1,16 +1,24 @@ -query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) { +query getPipelineSchedulesQuery( + $projectPath: ID! + $status: PipelineScheduleStatus + $ids: [ID!] = null +) { currentUser { id username } project(fullPath: $projectPath) { id - pipelineSchedules(status: $status) { + pipelineSchedules(status: $status, ids: $ids) { count nodes { id description + cron + cronTimezone + ref forTag + editPath refPath refForDisplay lastPipeline { @@ -34,6 +42,14 @@ query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStat name webPath } + variables { + nodes { + id + variableType + key + value + } + } userPermissions { playPipelineSchedule updatePipelineSchedule diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js index 8bca4f85e9f..71db9400909 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js @@ -18,7 +18,7 @@ export default () => { return false; } - const { fullPath, pipelinesPath } = containerEl.dataset; + const { fullPath, pipelinesPath, newSchedulePath, schedulesPath } = containerEl.dataset; return new Vue({ el: containerEl, @@ -27,6 +27,8 @@ export default () => { provide: { fullPath, pipelinesPath, + newSchedulePath, + schedulesPath, }, render(createElement) { return createElement(PipelineSchedules); diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js index 445161f99cb..6bf121d39b6 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -9,7 +9,7 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); -export default (selector) => { +export default (selector, editing = false) => { const containerEl = document.querySelector(selector); if (!containerEl) { @@ -18,13 +18,12 @@ export default (selector) => { const { fullPath, - cron, dailyLimit, timezoneData, - cronTimezone, projectId, defaultBranch, settingsLink, + schedulesPath, } = containerEl.dataset; return new Vue({ @@ -36,15 +35,15 @@ export default (selector) => { projectId, defaultBranch, dailyLimit: dailyLimit ?? '', - cronTimezone: cronTimezone ?? '', - cron: cron ?? '', settingsLink, + schedulesPath, }, render(createElement) { return createElement(PipelineSchedulesForm, { props: { timezoneData: JSON.parse(timezoneData), refParam: defaultBranch, + editing, }, }); }, diff --git a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue deleted file mode 100644 index b21a486e259..00000000000 --- a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import ReportItem from '~/ci/reports/components/report_item.vue'; -import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; - -export default { - components: { - ReportItem, - SmartVirtualList, - }, - props: { - component: { - type: String, - required: false, - default: '', - }, - nestedLevel: { - type: Number, - required: false, - default: 0, - validator: (value) => [0, 1, 2].includes(value), - }, - resolvedIssues: { - type: Array, - required: false, - default: () => [], - }, - unresolvedIssues: { - type: Array, - required: false, - default: () => [], - }, - resolvedHeading: { - type: String, - required: false, - default: s__('ciReport|Fixed'), - }, - unresolvedHeading: { - type: String, - required: false, - default: s__('ciReport|New'), - }, - }, - groups: ['unresolved', 'resolved'], - typicalReportItemHeight: 32, - maxShownReportItems: 20, - computed: { - groups() { - return this.$options.groups - .map((group) => ({ - name: group, - issues: this[`${group}Issues`], - heading: this[`${group}Heading`], - })) - .filter(({ issues }) => issues.length > 0); - }, - listLength() { - // every group has a header which is rendered as a list item - const groupsCount = this.groups.length; - const issuesCount = this.groups.reduce( - (totalIssues, { issues }) => totalIssues + issues.length, - 0, - ); - - return groupsCount + issuesCount; - }, - listClasses() { - return { - 'gl-pl-9': this.nestedLevel === 1, - 'gl-pl-11-5': this.nestedLevel === 2, - }; - }, - }, -}; -</script> - -<template> - <smart-virtual-list - :length="listLength" - :remain="$options.maxShownReportItems" - :size="$options.typicalReportItemHeight" - :class="listClasses" - class="report-block-container" - wtag="ul" - wclass="report-block-list" - > - <template v-for="(group, groupIndex) in groups"> - <h2 - :key="group.name" - :data-testid="`${group.name}Heading`" - :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']" - class="h5 mb-1" - > - {{ group.heading }} - </h2> - <report-item - v-for="(issue, issueIndex) in group.issues" - :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`" - :issue="issue" - :show-report-section-status-icon="false" - :component="component" - status="none" - /> - </template> - </smart-virtual-list> -</template> diff --git a/app/assets/javascripts/ci/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue deleted file mode 100644 index ee55368c829..00000000000 --- a/app/assets/javascripts/ci/reports/components/summary_row.vue +++ /dev/null @@ -1,93 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import { ICON_WARNING } from '../constants'; - -/** - * Renders the summary row for each report - * - * Used both in MR widget and Pipeline's view for: - * - Unit tests reports - * - Security reports - */ - -export default { - name: 'ReportSummaryRow', - components: { - CiIcon, - HelpPopover, - GlLoadingIcon, - }, - props: { - nestedSummary: { - type: Boolean, - required: false, - default: false, - }, - summary: { - type: String, - required: false, - default: '', - }, - statusIcon: { - type: String, - required: true, - }, - popoverOptions: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - iconStatus() { - return { - group: this.statusIcon, - icon: `status_${this.statusIcon}`, - }; - }, - rowClasses() { - if (!this.nestedSummary) { - return ['gl-px-5']; - } - return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }]; - }, - statusIconSize() { - if (!this.nestedSummary) { - return 24; - } - return 16; - }, - }, -}; -</script> -<template> - <div - class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center" - :class="rowClasses" - > - <div class="gl-mr-3"> - <gl-loading-icon - v-if="statusIcon === 'loading'" - css-class="report-block-list-loading-icon" - size="lg" - /> - <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" /> - </div> - <div class="report-block-list-issue-description"> - <div class="report-block-list-issue-description-text" data-testid="summary-row-description"> - <slot name="summary">{{ summary }}</slot - ><span v-if="popoverOptions" class="text-nowrap" - > <help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> - </span> - </div> - </div> - <div - v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */" - class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row" - > - <slot></slot> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js index 1137236d355..3968f8db752 100644 --- a/app/assets/javascripts/ci/reports/constants.js +++ b/app/assets/javascripts/ci/reports/constants.js @@ -7,8 +7,6 @@ export const STATUS_SUCCESS = 'success'; export const STATUS_NEUTRAL = 'neutral'; export const STATUS_NOT_FOUND = 'not_found'; -export const ICON_WARNING = 'warning'; - export const status = { LOADING, ERROR, @@ -22,3 +20,6 @@ export const status = { export const SLOT_SUCCESS = 'success'; export const SLOT_LOADING = 'loading'; export const SLOT_ERROR = 'error'; + +export const CODE_QUALITY_SCALE_KEY = 'codeQuality'; +export const SAST_SCALE_KEY = 'sast'; diff --git a/app/assets/javascripts/ci/reports/sast/constants.js b/app/assets/javascripts/ci/reports/sast/constants.js new file mode 100644 index 00000000000..3800065917b --- /dev/null +++ b/app/assets/javascripts/ci/reports/sast/constants.js @@ -0,0 +1,44 @@ +export const SEVERITY_CLASSES = { + info: 'gl-text-blue-400', + low: 'gl-text-orange-300', + medium: 'gl-text-orange-400', + high: 'gl-text-red-600', + critical: 'gl-text-red-800', + unknown: 'gl-text-gray-400', +}; + +export const SEVERITY_ICONS = { + info: 'severity-info', + low: 'severity-low', + medium: 'severity-medium', + high: 'severity-high', + critical: 'severity-critical', + unknown: 'severity-unknown', +}; + +export const SEVERITIES = { + info: { + class: SEVERITY_CLASSES.info, + name: SEVERITY_ICONS.info, + }, + low: { + class: SEVERITY_CLASSES.low, + name: SEVERITY_ICONS.low, + }, + medium: { + class: SEVERITY_CLASSES.medium, + name: SEVERITY_ICONS.medium, + }, + high: { + class: SEVERITY_CLASSES.high, + name: SEVERITY_ICONS.high, + }, + critical: { + class: SEVERITY_CLASSES.critical, + name: SEVERITY_ICONS.critical, + }, + unknown: { + class: SEVERITY_CLASSES.unknown, + name: SEVERITY_ICONS.unknown, + }, +}; diff --git a/app/assets/javascripts/ci/reports/utils.js b/app/assets/javascripts/ci/reports/utils.js new file mode 100644 index 00000000000..bb6eddf2cce --- /dev/null +++ b/app/assets/javascripts/ci/reports/utils.js @@ -0,0 +1,20 @@ +import { SEVERITIES as SEVERITIES_CODE_QUALITY } from '~/ci/reports/codequality_report/constants'; +import { SEVERITIES as SEVERITIES_SAST } from '~/ci/reports/sast/constants'; +import { SAST_SCALE_KEY } from './constants'; + +function mapSeverity(findings) { + const severityInfo = + findings.scale === SAST_SCALE_KEY ? SEVERITIES_SAST : SEVERITIES_CODE_QUALITY; + return { + ...findings, + class: severityInfo[findings.severity].class, + name: severityInfo[findings.severity].name, + }; +} + +export function getSeverity(findings) { + if (Array.isArray(findings)) { + return findings.map((finding) => mapSeverity(finding)); + } + return mapSeverity(findings); +} diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue index d385d32fd9d..c2ec8462a0e 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -4,10 +4,8 @@ import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import RunnerDeleteButton from '../components/runner_delete_button.vue'; -import RunnerEditButton from '../components/runner_edit_button.vue'; -import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; +import RunnerHeaderActions from '../components/runner_header_actions.vue'; import RunnerDetailsTabs from '../components/runner_details_tabs.vue'; import { I18N_FETCH_ERROR } from '../constants'; @@ -18,10 +16,8 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo export default { name: 'AdminRunnerShowApp', components: { - RunnerDeleteButton, - RunnerEditButton, - RunnerPauseButton, RunnerHeader, + RunnerHeaderActions, RunnerDetailsTabs, }, props: { @@ -80,9 +76,11 @@ export default { <div> <runner-header v-if="runner" :runner="runner"> <template #actions> - <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> - <runner-pause-button v-if="canUpdate" :runner="runner" /> - <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + <runner-header-actions + :runner="runner" + :edit-path="runner.editAdminUrl" + @deleted="onDeleted" + /> </template> </runner-header> <runner-details-tabs v-if="runner" :runner="runner" /> diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index 4d88feebe53..2168685e703 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -126,10 +126,6 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, - shouldShowCreateRunnerWorkflow() { - // create_runner_workflow_for_admin feature flag - return this.glFeatures.createRunnerWorkflowForAdmin; - }, }, watch: { search: { @@ -193,14 +189,14 @@ export default { /> <div class="gl-w-full gl-md-w-auto gl-display-flex"> - <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm"> + <gl-button :href="newRunnerPath" variant="confirm"> {{ s__('Runners|New instance runner') }} </gl-button> <registration-dropdown class="gl-ml-3" :registration-token="registrationToken" :type="$options.INSTANCE_TYPE" - right + placement="right" /> </div> </div> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 9f4ce14f704..cc31afea88c 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { sprintf, __ } from '~/locale'; +import { sprintf, __, formatNumber } from '~/locale'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; @@ -49,6 +49,12 @@ export default { managersCount() { return this.runner.managers?.count || 0; }, + firstIpAddress() { + return this.runner.managers?.nodes?.[0]?.ipAddress || null; + }, + additionalIpAddressCount() { + return this.managersCount - 1; + }, jobCount() { return formatJobCount(this.runner.jobCount); }, @@ -63,6 +69,9 @@ export default { return null; }, }, + methods: { + formatNumber, + }, i18n: { I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, @@ -120,8 +129,11 @@ export default { </gl-sprintf> </runner-summary-field> - <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')"> - {{ runner.ipAddress }} + <runner-summary-field v-if="firstIpAddress" icon="disk" :tooltip="__('IP Address')"> + {{ firstIpAddress }} + <template v-if="additionalIpAddressCount" + >(+{{ formatNumber(additionalIpAddressCount) }})</template + > </runner-summary-field> <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')"> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue index 2fdf8456615..0154cd2a3ec 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider, GlIcon } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDropdownForm, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlIcon, +} from '@gitlab/ui'; import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; @@ -20,12 +26,15 @@ export default { showInstallationInstructions: s__( 'Runners|Show runner installation and registration instructions', ), + supportForRegistrationTokensDeprecated: s__( + 'Runners|Support for registration tokens is deprecated', + ), }, components: { - GlDropdown, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, GlDropdownForm, - GlDropdownItem, - GlDropdownDivider, GlIcon, RegistrationToken, RunnerInstructionsModal, @@ -51,14 +60,6 @@ export default { }; }, computed: { - isDeprecated() { - // Show a compact version when used as secondary option - // create_runner_workflow_for_admin or create_runner_workflow_for_namespace - return ( - this.glFeatures?.createRunnerWorkflowForAdmin || - this.glFeatures?.createRunnerWorkflowForNamespace - ); - }, actionText() { switch (this.type) { case INSTANCE_TYPE: @@ -71,30 +72,6 @@ export default { return I18N_REGISTER_RUNNER; } }, - dropdownText() { - if (this.isDeprecated) { - return ''; - } - return this.actionText; - }, - dropdownToggleClass() { - if (this.isDeprecated) { - return ['gl-px-3!']; - } - return []; - }, - dropdownCategory() { - if (this.isDeprecated) { - return 'tertiary'; - } - return 'primary'; - }, - dropdownVariant() { - if (this.isDeprecated) { - return 'default'; - } - return 'confirm'; - }, }, methods: { onShowInstructionsClick() { @@ -103,46 +80,51 @@ export default { onTokenReset(token) { this.currentRegistrationToken = token; - this.$refs.runnerRegistrationDropdown.hide(true); + this.$refs.runnerRegistrationDropdown.close(); + }, + onCopy() { + this.$refs.runnerRegistrationDropdown.close(); }, }, }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown ref="runnerRegistrationDropdown" - menu-class="gl-w-auto!" - :text="dropdownText" - :toggle-class="dropdownToggleClass" - :variant="dropdownVariant" - :category="dropdownCategory" + :toggle-text="actionText" + toggle-class="gl-px-3!" + variant="default" + category="tertiary" v-bind="$attrs" + icon="ellipsis_v" + text-sr-only + no-caret > - <template v-if="isDeprecated" #button-content> - <span class="gl-sr-only">{{ actionText }}</span> - <gl-icon name="ellipsis_v" /> - </template> <gl-dropdown-form class="gl-p-4!"> - <registration-token input-id="token-value" :value="currentRegistrationToken"> - <template v-if="isDeprecated" #label-description> + <registration-token input-id="token-value" :value="currentRegistrationToken" @copy="onCopy"> + <template #label-description> <gl-icon name="warning" class="gl-text-orange-500" /> <span class="gl-text-secondary"> - {{ s__('Runners|Support for registration tokens is deprecated') }} + {{ $options.i18n.supportForRegistrationTokensDeprecated }} </span> </template> </registration-token> </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick"> - {{ $options.i18n.showInstallationInstructions }} - <runner-instructions-modal - ref="runnerInstructionsModal" - :registration-token="currentRegistrationToken" - data-testid="runner-instructions-modal" - /> - </gl-dropdown-item> - <gl-dropdown-divider /> - <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> - </gl-dropdown> + <gl-disclosure-dropdown-group bordered> + <gl-disclosure-dropdown-item @action="onShowInstructionsClick"> + <template #list-item> + {{ $options.i18n.showInstallationInstructions }} + <runner-instructions-modal + ref="runnerInstructionsModal" + :registration-token="currentRegistrationToken" + data-testid="runner-instructions-modal" + /> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> + <gl-disclosure-dropdown-group bordered> + <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue index b196bccf66f..339c92a427f 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue @@ -31,6 +31,7 @@ export default { onCopy() { // value already in the clipboard, simply notify the user this.$toast?.show(s__('Runners|Registration token copied!')); + this.$emit('copy'); }, }, I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'), diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue index 6ce88fc54de..47ca3ed6227 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -19,7 +19,7 @@ export default { name: 'RunnerRegistrationTokenReset', i18n, components: { - GlDropdownItem, + GlDisclosureDropdownItem, GlLoadingIcon, GlModal, }, @@ -124,18 +124,20 @@ export default { }; </script> <template> - <gl-dropdown-item v-gl-modal="$options.modalId"> - {{ __('Reset registration token') }} - <gl-modal - size="sm" - :modal-id="$options.modalId" - :action-primary="actionPrimary" - :action-secondary="actionSecondary" - :title="$options.i18n.modalTitle" - @primary="handleModalPrimary" - > - <p>{{ $options.i18n.modalCopy }}</p> - </gl-modal> - <gl-loading-icon v-if="loading" inline /> - </gl-dropdown-item> + <gl-disclosure-dropdown-item v-gl-modal="$options.modalId"> + <template #list-item> + {{ __('Reset registration token') }} + <gl-modal + size="sm" + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-secondary="actionSecondary" + :title="$options.i18n.modalTitle" + @primary="handleModalPrimary" + > + <p>{{ $options.i18n.modalCopy }}</p> + </gl-modal> + <gl-loading-icon v-if="loading" inline /> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_action.vue b/app/assets/javascripts/ci/runner/components/runner_delete_action.vue new file mode 100644 index 00000000000..db8133c1ccb --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_action.vue @@ -0,0 +1,126 @@ +<script> +import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; +import { createAlert } from '~/alert'; +import { sprintf, s__ } from '~/locale'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { I18N_DELETED_TOAST } from '../constants'; +import RunnerDeleteModal from './runner_delete_modal.vue'; + +/** + * Component that wraps a delete GraphQL mutation for the + * runner, given its id. + * + * You can use the slot to define a presentation for the + * delete action, like a button or dropdown item. + * + * Usage: + * + * ```vue + * <runner-delete-action + * #default="{ loading, onClick }" + * :runner="runner" + * @done="onDeleted" + * > + * <button :disabled="loading" @click="onClick"> Delete! </button> + * </runner-pause-action> + * ``` + * + */ +export default { + name: 'RunnerDeleteAction', + components: { + RunnerDeleteModal, + }, + props: { + runner: { + type: Object, + required: true, + validator: (runner) => { + return runner?.id && runner?.shortSha; + }, + }, + }, + emits: ['done'], + data() { + return { + loading: false, + }; + }, + computed: { + runnerId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerName() { + return `#${this.runnerId} (${this.runner.shortSha})`; + }, + runnerManagersCount() { + return this.runner.managers?.count || 0; + }, + runnerDeleteModalId() { + return `delete-runner-modal-${this.runnerId}`; + }, + }, + methods: { + onClick() { + this.$refs.modal.show(); + }, + async onDelete() { + // "loading" stays "true" until this row is removed, + // should only change back if the operation fails. + this.loading = true; + try { + await this.$apollo.mutate({ + mutation: runnerDeleteMutation, + variables: { + input: { + id: this.runner.id, + }, + }, + update: (cache, { data }) => { + const { errors } = data.runnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + return; + } + + this.$emit('done', { + message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), + }); + + // Remove deleted runner from the cache + const cacheId = cache.identify(this.runner); + cache.evict({ id: cacheId }); + cache.gc(); + }, + }); + } catch (e) { + this.onError(e); + } + }, + onError(error) { + this.loading = false; + const { message } = error; + const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), { + runnerName: this.runnerName, + }); + + createAlert({ title, message }); + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> +<template> + <div> + <slot :loading="loading" :on-click="onClick"></slot> + <runner-delete-modal + ref="modal" + :modal-id="runnerDeleteModalId" + :runner-name="runnerName" + :managers-count="runnerManagersCount" + @primary="onDelete" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue index 3560521e8d7..d228a022032 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue @@ -1,30 +1,21 @@ <script> -import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; -import { createAlert } from '~/alert'; -import { sprintf, s__ } from '~/locale'; -import { captureException } from '~/ci/runner/sentry_utils'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; -import RunnerDeleteModal from './runner_delete_modal.vue'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_DELETE_RUNNER } from '../constants'; +import RunnerDeleteAction from './runner_delete_action.vue'; export default { name: 'RunnerDeleteButton', components: { GlButton, - RunnerDeleteModal, + RunnerDeleteAction, }, directives: { GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, }, props: { runner: { type: Object, required: true, - validator: (runner) => { - return runner?.id && runner?.shortSha; - }, }, compact: { type: Boolean, @@ -39,17 +30,11 @@ export default { }; }, computed: { - runnerId() { - return getIdFromGraphQLId(this.runner.id); - }, - runnerName() { - return `#${this.runnerId} (${this.runner.shortSha})`; - }, - runnerManagersCount() { - return this.runner.managers?.count || 0; - }, - runnerDeleteModalId() { - return `delete-runner-modal-${this.runnerId}`; + buttonContent() { + if (this.compact) { + return null; + } + return I18N_DELETE_RUNNER; }, icon() { if (this.compact) { @@ -57,12 +42,6 @@ export default { } return ''; }, - buttonContent() { - if (this.compact) { - return null; - } - return I18N_DELETE_RUNNER; - }, buttonClass() { // Ensure a square button is shown when compact: true. // Without this class we will have distorted/rectangular button. @@ -78,83 +57,36 @@ export default { return null; }, tooltip() { - // Only show basic "delete" tooltip when compact. - // Also prevent a "sticky" tooltip: If this button is - // loading, mouseout listeners don't run leaving the tooltip stuck - if (this.compact && !this.deleting) { + if (this.compact) { return I18N_DELETE_RUNNER; } return ''; }, }, methods: { - async onDelete() { - // Deleting stays "true" until this row is removed, - // should only change back if the operation fails. - this.deleting = true; - try { - await this.$apollo.mutate({ - mutation: runnerDeleteMutation, - variables: { - input: { - id: this.runner.id, - }, - }, - update: (cache, { data }) => { - const { errors } = data.runnerDelete; - - if (errors?.length) { - this.onError(new Error(errors.join(' '))); - return; - } - - this.$emit('deleted', { - message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), - }); - - // Remove deleted runner from the cache - const cacheId = cache.identify(this.runner); - cache.evict({ id: cacheId }); - cache.gc(); - }, - }); - } catch (e) { - this.onError(e); - } - }, - onError(error) { - this.deleting = false; - const { message } = error; - const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), { - runnerName: this.runnerName, - }); - - createAlert({ title, message }); - captureException({ error, component: this.$options.name }); + onDone(event) { + this.$emit('deleted', event); }, }, }; </script> <template> - <div v-gl-tooltip="tooltip" class="btn-group"> - <gl-button - v-gl-modal="runnerDeleteModalId" - :aria-label="ariaLabel" - :icon="icon" - :class="buttonClass" - :loading="deleting" - variant="danger" - category="secondary" - v-bind="$attrs" - > - {{ buttonContent }} - </gl-button> - <runner-delete-modal - :modal-id="runnerDeleteModalId" - :runner-name="runnerName" - :managers-count="runnerManagersCount" - @primary="onDelete" - /> - </div> + <runner-delete-action class="btn-group" :runner="runner" @done="onDone"> + <template #default="{ loading, onClick }"> + <gl-button + v-gl-tooltip="loading ? '' : tooltip" + :aria-label="ariaLabel" + :icon="icon" + :class="buttonClass" + :loading="loading" + variant="danger" + category="secondary" + v-bind="$attrs" + @click="onClick" + > + {{ buttonContent }} + </gl-button> + </template> + </runner-delete-action> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue new file mode 100644 index 00000000000..0a81974a6d0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_disclosure_dropdown_item.vue @@ -0,0 +1,38 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import { I18N_DELETE } from '../constants'; +import RunnerDeleteAction from './runner_delete_action.vue'; + +export default { + name: 'RunnerDeleteDisclosureDropdownItem', + components: { + GlDisclosureDropdownItem, + RunnerDeleteAction, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + emits: ['deleted'], + methods: { + onDone(event) { + this.$emit('deleted', event); + }, + }, + I18N_DELETE, +}; +</script> + +<template> + <runner-delete-action :runner="runner" @done="onDone"> + <template #default="{ onClick }"> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <span class="gl-text-red-500">{{ $options.I18N_DELETE }}</span> + </template> + </gl-disclosure-dropdown-item> + </template> + </runner-delete-action> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue index 93f79fd67ea..124ac0b4e73 100644 --- a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue +++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue @@ -52,6 +52,9 @@ export default { }, }, methods: { + show() { + this.$refs.modal.show(); + }, onPrimary() { this.$refs.modal.hide(); }, diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue index 9e8055a8432..496985ff7ac 100644 --- a/app/assets/javascripts/ci/runner/components/runner_detail.vue +++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue @@ -40,12 +40,12 @@ export default { <template> <div class="gl-display-contents"> - <dt class="gl-mb-5 gl-mr-6 gl-max-w-26"> + <dt class="gl-mb-5 gl-mr-6 gl-max-w-26" data-testid="label-slot"> <template v-if="label || $scopedSlots.label"> <slot name="label">{{ label }}</slot> </template> </dt> - <dd class="gl-mb-5"> + <dd class="gl-mb-5" data-testid="value-slot"> <template v-if="value || $scopedSlots.value"> <slot name="value">{{ value }}</slot> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue index 33e0acaf5c0..b4efd72b082 100644 --- a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue @@ -9,15 +9,23 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + props: { + href: { + type: String, + required: false, + default: null, + }, + }, I18N_EDIT, }; </script> <template> <gl-button + v-if="href" v-gl-tooltip="$options.I18N_EDIT" - v-bind="$attrs" :aria-label="$options.I18N_EDIT" + :href="href" icon="pencil" v-on="$listeners" /> diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue new file mode 100644 index 00000000000..d0dcc04c3dc --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_edit_disclosure_dropdown_item.vue @@ -0,0 +1,29 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; + +import { I18N_EDIT } from '../constants'; + +export default { + name: 'RunnerEditDisclosureDropdownItem', + components: { + GlDisclosureDropdownItem, + }, + props: { + href: { + type: String, + required: false, + default: null, + }, + }, + computed: { + item() { + return { text: I18N_EDIT, href: this.href }; + }, + }, + I18N_EDIT, +}; +</script> + +<template> + <gl-disclosure-dropdown-item v-if="href" :item="item" /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue index f46e894bf2e..55a33ef2074 100644 --- a/app/assets/javascripts/ci/runner/components/runner_header.vue +++ b/app/assets/javascripts/ci/runner/components/runner_header.vue @@ -32,31 +32,29 @@ export default { }; </script> <template> - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-py-5" - > - <div> + <div class="gl-py-5"> + <div class="gl-display-flex gl-justify-content-space-between"> <h1 class="gl-font-size-h-display gl-my-0">{{ name }}</h1> - <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3"> - <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" /> - <runner-type-badge :type="runner.runnerType" /> - <span v-if="runner.createdAt"> - <gl-sprintf :message="__('%{locked} created %{timeago}')"> - <template #locked> - <gl-icon - v-if="runner.locked" - v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - name="lock" - :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - /> - </template> - <template #timeago> - <time-ago :time="runner.createdAt" /> - </template> - </gl-sprintf> - </span> - </div> + <slot name="actions"></slot> + </div> + <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3"> + <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" /> + <runner-type-badge :type="runner.runnerType" /> + <span v-if="runner.createdAt"> + <gl-sprintf :message="__('%{locked} created %{timeago}')"> + <template #locked> + <gl-icon + v-if="runner.locked" + v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + /> + </template> + <template #timeago> + <time-ago :time="runner.createdAt" /> + </template> + </gl-sprintf> + </span> </div> - <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_header_actions.vue b/app/assets/javascripts/ci/runner/components/runner_header_actions.vue new file mode 100644 index 00000000000..bc6f184bd4d --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_header_actions.vue @@ -0,0 +1,80 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; + +import RunnerDeleteButton from './runner_delete_button.vue'; +import RunnerEditButton from './runner_edit_button.vue'; +import RunnerPauseButton from './runner_pause_button.vue'; + +import RunnerEditDisclosureDropdownItem from './runner_edit_disclosure_dropdown_item.vue'; +import RunnerPauseDisclosureDropdownItem from './runner_pause_disclosure_dropdown_item.vue'; +import RunnerDeleteDisclosureDropdownItem from './runner_delete_disclosure_dropdown_item.vue'; + +export default { + name: 'RunnerHeaderActions', + components: { + GlDisclosureDropdown, + + RunnerDeleteButton, + RunnerEditButton, + RunnerPauseButton, + + RunnerEditDisclosureDropdownItem, + RunnerPauseDisclosureDropdownItem, + RunnerDeleteDisclosureDropdownItem, + }, + props: { + runner: { + type: Object, + required: true, + }, + editPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, + }, + methods: { + onDeleted(event) { + this.$emit('deleted', event); + }, + }, +}; +</script> + +<template> + <div v-if="canUpdate || canDelete"> + <!-- sm and up screens --> + <div class="gl-display-none gl-sm-display-flex gl-gap-3"> + <runner-edit-button v-if="canUpdate" :href="editPath" /> + <runner-pause-button v-if="canUpdate" :runner="runner" /> + <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + </div> + + <!-- xs screens --> + <div class="gl-sm-display-none"> + <gl-disclosure-dropdown + icon="ellipsis_v" + :toggle-text="s__('Runner|Runner actions')" + text-sr-only + category="tertiary" + no-caret + > + <runner-edit-disclosure-dropdown-item v-if="canUpdate" :href="editPath" /> + <runner-pause-disclosure-dropdown-item v-if="canUpdate" :runner="runner" /> + <runner-delete-disclosure-dropdown-item + v-if="canDelete" + :runner="runner" + @deleted="onDeleted" + /> + </gl-disclosure-dropdown> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue index d2836962a97..a4a489074c3 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue @@ -11,7 +11,6 @@ import { I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS, I18N_CONTACT_ADMIN_TO_REGISTER, - I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, I18N_NO_RESULTS, I18N_EDIT_YOUR_SEARCH, } from '~/ci/runner/constants'; @@ -44,15 +43,6 @@ export default { default: null, }, }, - computed: { - shouldShowCreateRunnerWorkflow() { - // create_runner_workflow_for_admin or create_runner_workflow_for_namespace - return ( - this.glFeatures?.createRunnerWorkflowForAdmin || - this.glFeatures?.createRunnerWorkflowForNamespace - ); - }, - }, modalId: 'runners-empty-state-instructions-modal', svgHeight: 145, EMPTY_STATE_SVG_URL, @@ -63,7 +53,6 @@ export default { I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS, I18N_CONTACT_ADMIN_TO_REGISTER, - I18N_FOLLOW_REGISTRATION_INSTRUCTIONS, I18N_NO_RESULTS, I18N_EDIT_YOUR_SEARCH, }; @@ -85,39 +74,22 @@ export default { > <template #description> {{ $options.I18N_RUNNERS_ARE_AGENTS }} - <template v-if="shouldShowCreateRunnerWorkflow"> - <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK"> - <template #link="{ content }"> - <gl-link :href="newRunnerPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - <template v-if="registrationToken"> - <br /> - <gl-link v-gl-modal="$options.modalId">{{ - $options.I18N_STILL_USING_REGISTRATION_TOKENS - }}</gl-link> - <runner-instructions-modal - :modal-id="$options.modalId" - :registration-token="registrationToken" - /> - </template> - <template v-if="!newRunnerPath && !registrationToken"> - {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} - </template> - </template> - <gl-sprintf - v-else-if="registrationToken" - :message="$options.I18N_FOLLOW_REGISTRATION_INSTRUCTIONS" - > + <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK"> <template #link="{ content }"> - <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> - <runner-instructions-modal - :modal-id="$options.modalId" - :registration-token="registrationToken" - /> + <gl-link :href="newRunnerPath">{{ content }}</gl-link> </template> </gl-sprintf> - <template v-else> + <template v-if="registrationToken"> + <br /> + <gl-link v-gl-modal="$options.modalId">{{ + $options.I18N_STILL_USING_REGISTRATION_TOKENS + }}</gl-link> + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="registrationToken" + /> + </template> + <template v-if="!newRunnerPath && !registrationToken"> {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }} </template> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_action.vue b/app/assets/javascripts/ci/runner/components/runner_pause_action.vue new file mode 100644 index 00000000000..184d6a83381 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pause_action.vue @@ -0,0 +1,89 @@ +<script> +import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; +import { createAlert } from '~/alert'; +import { captureException } from '~/ci/runner/sentry_utils'; + +/** + * Renderless component that wraps a GraphQL pause mutation for the + * runner, given its id and current "paused" value. + * + * You can use the slot to define a presentation for the delete action, + * like a button or dropdown item. + + * Usage: + * + * ```vue + * <runner-pause-action + * #default="{ loading, onClick }" + * :runner="runner" + * @done="onToggled" + * > + * <button :disabled="loading" @click="onClick">{{ runner.paused ? 'Go!' : 'Stop!' }}</button> + * </runner-pause-action> + * ``` + * + */ +export default { + name: 'RunnerPauseAction', + props: { + runner: { + type: Object, + required: true, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['done'], + data() { + return { + loading: false, + }; + }, + methods: { + async onClick() { + this.loading = true; + try { + const input = { + id: this.runner.id, + paused: !this.runner.paused, + }; + + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerTogglePausedMutation, + variables: { + input, + }, + }); + + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + this.$emit('done'); + } catch (e) { + this.onError(e); + } finally { + this.loading = false; + } + }, + onError(error) { + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + }, + render() { + return this.$scopedSlots.default({ + onClick: this.onClick, + loading: this.loading, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue index d16c8f98bad..15bb54027c7 100644 --- a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue +++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue @@ -1,14 +1,14 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql'; -import { createAlert } from '~/alert'; -import { captureException } from '~/ci/runner/sentry_utils'; -import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants'; + +import { I18N_RESUME, I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME_TOOLTIP } from '../constants'; +import RunnerPauseAction from './runner_pause_action.vue'; export default { name: 'RunnerPauseButton', components: { GlButton, + RunnerPauseAction, }, directives: { GlTooltip: GlTooltipDirective, @@ -25,96 +25,47 @@ export default { }, }, emits: ['toggledPaused'], - data() { - return { - updating: false, - }; - }, computed: { isPaused() { return this.runner.paused; }, + tooltip() { + return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP; + }, icon() { return this.isPaused ? 'play' : 'pause'; }, label() { return this.isPaused ? I18N_RESUME : I18N_PAUSE; }, - buttonContent() { - if (this.compact) { - return null; - } - return this.label; - }, ariaLabel() { if (this.compact) { return this.label; } return null; }, - tooltip() { - // Prevent a "sticky" tooltip: If this button is disabled, - // mouseout listeners don't run leaving the tooltip stuck - if (!this.updating) { - return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP; - } - return ''; - }, - }, - methods: { - async onToggle() { - this.updating = true; - try { - const input = { - id: this.runner.id, - paused: !this.isPaused, - }; - - const { - data: { - runnerUpdate: { errors }, - }, - } = await this.$apollo.mutate({ - mutation: runnerTogglePausedMutation, - variables: { - input, - }, - }); - - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } - this.$emit('toggledPaused'); - } catch (e) { - this.onError(e); - } finally { - this.updating = false; + buttonContent() { + if (this.compact) { + return null; } - }, - onError(error) { - const { message } = error; - - createAlert({ message }); - captureException({ error, component: this.$options.name }); + return this.label; }, }, }; </script> <template> - <gl-button - v-gl-tooltip="tooltip" - v-bind="$attrs" - :aria-label="ariaLabel" - :icon="icon" - :loading="updating" - @click="onToggle" - v-on="$listeners" - > - <!-- - Use <template v-if> to ensure a square button is shown when compact: true. - Sending empty content will still show a distorted/rectangular button. - --> - <template v-if="buttonContent">{{ buttonContent }}</template> - </gl-button> + <runner-pause-action :runner="runner" @done="$emit('toggledPaused')"> + <template #default="{ loading, onClick }"> + <gl-button + v-gl-tooltip="loading ? '' : tooltip" + :icon="icon" + :aria-label="ariaLabel" + :loading="loading" + @click="onClick" + > + <template v-if="buttonContent">{{ buttonContent }}</template> + </gl-button> + </template> + </runner-pause-action> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue new file mode 100644 index 00000000000..3dd5e227a4a --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pause_disclosure_dropdown_item.vue @@ -0,0 +1,34 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; + +import { I18N_RESUME, I18N_PAUSE } from '../constants'; +import RunnerPauseAction from './runner_pause_action.vue'; + +export default { + name: 'RunnerPauseDisclosureDropdownItem', + components: { + GlDisclosureDropdownItem, + RunnerPauseAction, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + emits: ['toggledPaused'], + computed: { + item() { + return { text: this.runner.paused ? I18N_RESUME : I18N_PAUSE }; + }, + }, +}; +</script> + +<template> + <runner-pause-action :runner="runner" @done="$emit('toggledPaused')"> + <template #default="{ onClick }"> + <gl-disclosure-dropdown-item :item="item" @action="onClick" /> + </template> + </runner-pause-action> +</template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 40841696ead..203f97876de 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -1,4 +1,5 @@ import { __, s__ } from '~/locale'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; export const RUNNER_TYPENAME = 'CiRunner'; // __typename @@ -90,6 +91,7 @@ export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs'); export const I18N_RESUME = __('Resume'); export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs'); +export const I18N_DELETE = s__('Runners|Delete'); export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); @@ -117,9 +119,6 @@ export const I18N_STILL_USING_REGISTRATION_TOKENS = s__('Runners|Still using reg export const I18N_CONTACT_ADMIN_TO_REGISTER = s__( 'Runners|To register new runners, contact your administrator.', ); -export const I18N_FOLLOW_REGISTRATION_INSTRUCTIONS = s__( - 'Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', -); // No runners found export const I18N_NO_RESULTS = s__('Runners|No results found'); @@ -271,12 +270,10 @@ export const DEFAULT_PLATFORM = LINUX_PLATFORM; // Runner docs are in a separate repository and are not shipped with GitLab // they are rendered as external URLs. -export const INSTALL_HELP_URL = 'https://docs.gitlab.com/runner/install'; -export const EXECUTORS_HELP_URL = 'https://docs.gitlab.com/runner/executors/'; -export const SERVICE_COMMANDS_HELP_URL = - 'https://docs.gitlab.com/runner/commands/#service-related-commands'; -export const CHANGELOG_URL = 'https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md'; -export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html'; -export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html'; -export const RUNNER_MANAGERS_HELP_URL = - 'https://docs.gitlab.com/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities'; +export const INSTALL_HELP_URL = `${DOCS_URL}/runner/install`; +export const EXECUTORS_HELP_URL = `${DOCS_URL}/runner/executors/`; +export const SERVICE_COMMANDS_HELP_URL = `${DOCS_URL}/runner/commands/#service-related-commands`; +export const CHANGELOG_URL = `https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md`; +export const DOCKER_HELP_URL = `${DOCS_URL}/runner/install/docker.html`; +export const KUBERNETES_HELP_URL = `${DOCS_URL}/runner/install/kubernetes.html`; +export const RUNNER_MANAGERS_HELP_URL = `${DOCS_URL}/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities`; diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index c0b888e758b..7ad9605d0a4 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -6,7 +6,6 @@ fragment ListItemShared on CiRunner { runnerType shortSha version - ipAddress paused locked jobCount @@ -22,8 +21,11 @@ fragment ListItemShared on CiRunner { updateRunner deleteRunner } - managers { + managers(first: 1) { count + nodes { + ipAddress + } } groups(first: 1) { nodes { diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue index e885cf45c5a..4b570db772f 100644 --- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue @@ -4,10 +4,8 @@ import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import RunnerDeleteButton from '../components/runner_delete_button.vue'; -import RunnerEditButton from '../components/runner_edit_button.vue'; -import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; +import RunnerHeaderActions from '../components/runner_header_actions.vue'; import RunnerDetailsTabs from '../components/runner_details_tabs.vue'; import { I18N_FETCH_ERROR } from '../constants'; @@ -18,10 +16,8 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo export default { name: 'GroupRunnerShowApp', components: { - RunnerDeleteButton, - RunnerEditButton, - RunnerPauseButton, RunnerHeader, + RunnerHeaderActions, RunnerDetailsTabs, }, props: { @@ -85,9 +81,11 @@ export default { <div> <runner-header v-if="runner" :runner="runner"> <template #actions> - <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" /> - <runner-pause-button v-if="canUpdate" :runner="runner" /> - <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + <runner-header-actions + :runner="runner" + :edit-path="editGroupRunnerPath" + @deleted="onDeleted" + /> </template> </runner-header> diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 74523bc335f..71584c40a38 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -155,10 +155,6 @@ export default { isSearchFiltered() { return isSearchFiltered(this.search); }, - shouldShowCreateRunnerWorkflow() { - // create_runner_workflow_for_namespace feature flag - return this.glFeatures.createRunnerWorkflowForNamespace; - }, }, watch: { search: { @@ -231,11 +227,7 @@ export default { /> <div class="gl-w-full gl-md-w-auto gl-display-flex"> - <gl-button - v-if="shouldShowCreateRunnerWorkflow && newRunnerPath" - :href="newRunnerPath" - variant="confirm" - > + <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm"> {{ s__('Runners|New group runner') }} </gl-button> <registration-dropdown @@ -243,7 +235,7 @@ export default { class="gl-ml-3" :registration-token="registrationToken" :type="$options.GROUP_TYPE" - right + placement="right" /> </div> </div> |