diff options
Diffstat (limited to 'app')
26 files changed, 536 insertions, 58 deletions
diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index cfb18cc4f82..736eaa7062d 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -1,14 +1,20 @@ <script> -import { GlCollapse, GlButton } from '@gitlab/ui'; +import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import KubernetesAgentInfo from './kubernetes_agent_info.vue'; +import KubernetesPods from './kubernetes_pods.vue'; export default { components: { GlCollapse, GlButton, + GlAlert, KubernetesAgentInfo, + KubernetesPods, }, + inject: ['kasTunnelUrl'], props: { agentName: { required: true, @@ -22,10 +28,16 @@ export default { required: true, type: String, }, + namespace: { + required: false, + type: String, + default: '', + }, }, data() { return { isVisible: false, + error: '', }; }, computed: { @@ -35,11 +47,26 @@ export default { label() { return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand; }, + gitlabAgentId() { + const id = isGid(this.agentId) ? getIdFromGraphQLId(this.agentId) : this.agentId; + return id.toString(); + }, + k8sAccessConfiguration() { + return { + basePath: this.kasTunnelUrl, + baseOptions: { + headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers }, + }, + }; + }, }, methods: { toggleCollapse() { this.isVisible = !this.isVisible; }, + onClusterError(message) { + this.error = message; + }, }, i18n: { collapse: __('Collapse'), @@ -66,7 +93,17 @@ export default { :agent-name="agentName" :agent-id="agentId" :agent-project-path="agentProjectPath" + class="gl-mb-5" /> + + <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5"> + {{ error }} + </gl-alert> + + <kubernetes-pods + :configuration="k8sAccessConfiguration" + :namespace="namespace" class="gl-mb-5" + @cluster-error="onClusterError" /></template> </gl-collapse> </div> diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue new file mode 100644 index 00000000000..e43bc838708 --- /dev/null +++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue @@ -0,0 +1,111 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { s__ } from '~/locale'; +import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql'; + +export default { + components: { + GlLoadingIcon, + GlSingleStat, + }, + apollo: { + k8sPods: { + query: k8sPodsQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + update(data) { + return data?.k8sPods || []; + }, + error(error) { + this.error = error; + this.$emit('cluster-error', this.error); + }, + }, + }, + props: { + configuration: { + required: true, + type: Object, + }, + namespace: { + required: true, + type: String, + }, + }, + data() { + return { + error: '', + }; + }, + + computed: { + podStats() { + if (!this.k8sPods) return null; + + return [ + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Running'), + title: this.$options.i18n.runningPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Pending'), + title: this.$options.i18n.pendingPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Succeeded'), + title: this.$options.i18n.succeededPods, + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + value: this.getPodsByPhase('Failed'), + title: this.$options.i18n.failedPods, + }, + ]; + }, + loading() { + return this.$apollo.queries.k8sPods.loading; + }, + }, + methods: { + getPodsByPhase(phase) { + const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase); + return filteredPods.length; + }, + }, + i18n: { + podsTitle: s__('Environment|Pods'), + runningPods: s__('Environment|Running'), + pendingPods: s__('Environment|Pending'), + succeededPods: s__('Environment|Succeeded'), + failedPods: s__('Environment|Failed'), + }, +}; +</script> +<template> + <div> + <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p> + + <gl-loading-icon v-if="loading" /> + + <div + v-else-if="podStats && !error" + class="gl-display-flex gl-flex-wrap-wrap gl-sm-flex-wrap-nowrap gl-mx-n3 gl-mt-n3" + > + <gl-single-stat + v-for="(stat, index) in podStats" + :key="index" + class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3" + :value="stat.value" + :title="stat.title" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index f46bd0e3780..b5ef7d00cc3 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -173,7 +173,8 @@ export default { return this.glFeatures?.kasUserAccessProject; }, hasRequiredAgentData() { - return this.agent.project && this.agent.id && this.agent.name; + const { project, id, name } = this.agent || {}; + return project && id && name; }, showKubernetesOverview() { return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData; @@ -367,6 +368,7 @@ export default { :agent-project-path="agent.project" :agent-name="agent.name" :agent-id="agent.id" + :namespace="agent.kubernetesNamespace" /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 26514b59995..0482741979b 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -5,6 +5,7 @@ import pageInfoQuery from './queries/page_info.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; +import k8sPodsQuery from './queries/k8s_pods.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -82,6 +83,14 @@ export const apolloProvider = (endpoint) => { }, }, }); + cache.writeQuery({ + query: k8sPodsQuery, + data: { + status: { + phase: '', + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql new file mode 100644 index 00000000000..818bca24d51 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql @@ -0,0 +1,7 @@ +query getK8sPods($configuration: Object, $namespace: String) { + k8sPods(configuration: $configuration, namespace: $namespace) @client { + status { + phase + } + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index e21670870b8..39e05825cf0 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,3 +1,4 @@ +import { CoreV1Api, Configuration } from '@gitlab/cluster-client'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { @@ -71,6 +72,19 @@ export const resolvers = (endpoint) => ({ isLastDeployment(_, { environment }) { return environment?.lastDeployment?.isLast; }, + k8sPods(_, { configuration, namespace }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + const podsApi = namespace + ? coreV1Api.listCoreV1NamespacedPod(namespace) + : coreV1Api.listCoreV1PodForAllNamespaces(); + + return podsApi + .then((res) => res?.data?.items || []) + .catch((err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; + }); + }, }, Mutation: { stopEnvironment(_, { environment }, { client }) { diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index b4d1f7326f6..7c102fd04d8 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -62,6 +62,19 @@ type LocalPageInfo { previousPage: Int! } +type k8sPodStatus { + phase: String +} + +type LocalK8sPods { + status: k8sPodStatus +} + +input LocalConfiguration { + basePath: String + baseOptions: JSON +} + extend type Query { environmentApp(page: Int, scope: String): LocalEnvironmentApp folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder @@ -71,6 +84,7 @@ extend type Query { environmentToStop: LocalEnvironment isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean isLastDeployment(environment: LocalEnvironmentInput): Boolean + k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods] } extend type Mutation { diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d9a523fd806..3f746bc5383 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility'; import { parseBoolean } from '../lib/utils/common_utils'; import { apolloProvider } from './graphql/client'; import EnvironmentsApp from './components/environments_app.vue'; @@ -16,6 +17,7 @@ export default (el) => { projectPath, defaultBranchName, projectId, + kasTunnelUrl, } = el.dataset; return new Vue({ @@ -28,6 +30,7 @@ export default (el) => { newEnvironmentPath, helpPagePath, projectId, + kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl), canCreateEnvironment: parseBoolean(canCreateEnvironment), }, render(h) { 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 3c765de92a2..23b58543f11 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 @@ -2,6 +2,7 @@ import { GlLink } from '@gitlab/ui'; import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants'; import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue'; +import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; import { TITLE_LABEL, INFO_LABEL, @@ -12,12 +13,16 @@ import { PARAMETERS_LABEL, METRICS_LABEL, METADATA_LABEL, + DELETE_CANDIDATE_CONFIRMATION_MESSAGE, + DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, + DELETE_CANDIDATE_MODAL_TITLE, } from './translations'; export default { name: 'MlCandidatesShow', components: { IncubationAlert, + DeleteButton, GlLink, }, props: { @@ -36,6 +41,9 @@ export default { PARAMETERS_LABEL, METRICS_LABEL, METADATA_LABEL, + DELETE_CANDIDATE_CONFIRMATION_MESSAGE, + DELETE_CANDIDATE_PRIMARY_ACTION_LABEL, + DELETE_CANDIDATE_MODAL_TITLE, }, computed: { sections() { @@ -67,11 +75,22 @@ export default { :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE" /> - <h3> - {{ $options.i18n.TITLE_LABEL }} - </h3> + <div class="detail-page-header gl-flex-wrap"> + <div class="detail-page-header-body"> + <h1 class="page-title gl-font-size-h-display flex-fill"> + {{ $options.i18n.TITLE_LABEL }} + </h1> - <table class="candidate-details"> + <delete-button + :delete-path="candidate.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" + /> + </div> + </div> + + <table class="candidate-details gl-w-full"> <tbody> <tr class="divider"></tr> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js index caad145873e..5f7714aa0c0 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js @@ -9,3 +9,8 @@ export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts'); export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters'); export const METRICS_LABEL = s__('MlExperimentTracking|Metrics'); export const METADATA_LABEL = s__('MlExperimentTracking|Metadata'); +export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__( + 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', +); +export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate'); +export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?'); diff --git a/app/assets/javascripts/pages/admin/jobs/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js index cfde1fc0a2b..84be895e194 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/constants.js +++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { DEFAULT_FIELDS } from '~/jobs/components/table/constants'; export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal'; export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?'); @@ -10,3 +11,11 @@ export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed'); export const CANCEL_JOBS_WARNING = s__( "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?", ); + +/* Admin Table constants */ +export const DEFAULT_FIELDS_ADMIN = [ + ...DEFAULT_FIELDS.slice(0, 2), + { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' }, + { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' }, + ...DEFAULT_FIELDS.slice(2), +]; diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue index c5a0509b625..6d5b4f24119 100644 --- a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue +++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue @@ -1,5 +1,16 @@ <script> +import { queryToObject } from '~/lib/utils/url_utility'; +import { validateQueryString } from '~/jobs/components/filtered_search/utils'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; +import { DEFAULT_FIELDS_ADMIN } from '../constants'; +import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql'; + export default { + components: { + JobsTable, + JobsTableTabs, + }, inject: { jobStatuses: { default: null, @@ -11,9 +22,69 @@ export default { default: '', }, }, + apollo: { + jobs: { + query: GetAllJobs, + variables() { + return this.variables; + }, + update(data) { + const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data || {}; + return { + list, + pageInfo, + count, + }; + }, + error() { + this.hasError = true; + }, + }, + }, + data() { + return { + jobs: { + list: [], + }, + hasError: false, + count: 0, + scope: null, + infiniteScrollingTriggered: false, + DEFAULT_FIELDS_ADMIN, + }; + }, + computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, + variables() { + return { ...this.validatedQueryString }; + }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, + jobsCount() { + return this.jobs.count; + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount) { + if (this.scope) return; + + this.count = newCount; + }, + }, }; </script> <template> - <div>{{ __('Jobs') }}</div> + <div> + <jobs-table-tabs :all-jobs-count="count" :loading="loading" /> + + <jobs-table :jobs="jobs.list" :table-fields="DEFAULT_FIELDS_ADMIN" /> + </div> </template> diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js new file mode 100644 index 00000000000..fd7ee2a6f8c --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js @@ -0,0 +1,62 @@ +import { isEqual } from 'lodash'; + +export default { + typePolicies: { + Query: { + fields: { + jobs: { + keyArgs: ['statuses'], + }, + }, + }, + CiJobConnection: { + merge(existing = {}, incoming, { args = {} }) { + if (incoming.nodes) { + let nodes; + + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } + } else { + nodes = [...incoming.nodes]; + } + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + return { + nodes: existing.nodes, + pageInfo: existing.pageInfo, + statuses: args.statuses, + }; + }, + }, + }, +}; diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql new file mode 100644 index 00000000000..374009efa15 --- /dev/null +++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql @@ -0,0 +1,81 @@ +query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) { + jobs(after: $after, first: $first, statuses: $statuses) { + count + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + nodes { + artifacts { + nodes { + id + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + pipeline { + id + project { + id + fullPath + webUrl + } + path + user { + id + webPath + avatarUrl + } + } + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } +} diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 2297efd805e..9c2a255a1a3 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,11 +1,21 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; +import createDefaultClient from '~/lib/graphql'; import { CANCEL_JOBS_MODAL_ID } from '../components/constants'; import CancelJobsModal from '../components/cancel_jobs_modal.vue'; import AdminJobsTableApp from '../components/table/admin_jobs_table_app.vue'; +import cacheConfig from '../components/table/graphql/cache_config'; Vue.use(Translate); +Vue.use(VueApollo); + +const client = createDefaultClient({}, { cacheConfig }); + +const apolloProvider = new VueApollo({ + defaultClient: client, +}); function initJobs() { const buttonId = 'js-stop-jobs-button'; @@ -44,6 +54,7 @@ export function initAdminJobsApp() { return new Vue({ el: containerEl, + apolloProvider, provide: { url, emptyStateSvgPath, diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb index ed465860004..e534000f494 100644 --- a/app/controllers/projects/ml/candidates_controller.rb +++ b/app/controllers/projects/ml/candidates_controller.rb @@ -10,9 +10,10 @@ module Projects def show; end def destroy + @experiment = @candidate.experiment @candidate.destroy! - redirect_to project_ml_experiments_path(@project), + redirect_to project_ml_experiment_path(@project, @experiment.iid), status: :found, notice: s_("MlExperimentTracking|Candidate removed") end diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb index 927337da6bb..1f044ebed3b 100644 --- a/app/helpers/projects/ml/experiments_helper.rb +++ b/app/helpers/projects/ml/experiments_helper.rb @@ -15,6 +15,7 @@ module Projects path_to_artifact: link_to_artifact(candidate), experiment_name: candidate.experiment.name, path_to_experiment: link_to_experiment(candidate.project, candidate.experiment), + path: link_to_details(candidate), status: candidate.status }, metadata: candidate.metadata diff --git a/app/models/issue.rb b/app/models/issue.rb index d7b72fa9dad..77dcaf9336c 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -39,6 +39,8 @@ class Issue < ApplicationRecord DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze + IssueTypeOutOfSyncError = Class.new(StandardError) + SORTING_PREFERENCE_FIELD = :issues_sort MAX_BRANCH_TEMPLATE = 255 @@ -233,6 +235,7 @@ class Issue < ApplicationRecord scope :with_projects_matching_search_data, -> { where('issue_search_data.project_id = issues.project_id') } before_validation :ensure_namespace_id, :ensure_work_item_type + before_save :check_issue_type_in_sync! after_save :ensure_metrics!, unless: :importing? after_commit :expire_etag_cache, unless: :importing? @@ -724,6 +727,33 @@ class Issue < ApplicationRecord private + def check_issue_type_in_sync! + # We might have existing records out of sync, so we need to skip this check unless the value is changed + # so those records can still be updated until we fix them and remove the issue_type column + # https://gitlab.com/gitlab-org/gitlab/-/work_items/403158?iid_path=true + return unless (changes.keys & %w[issue_type work_item_type_id]).any? + + if issue_type != work_item_type.base_type + error = IssueTypeOutOfSyncError.new( + <<~ERROR + Issue `issue_type` out of sync with `work_item_type_id` column. + `issue_type` must be equal to `work_item.base_type`. + You can assign the correct work_item_type like this for example: + + Issue.new(issue_type: :incident, work_item_type: WorkItems::Type.default_by_type(:incident)) + + More details in https://gitlab.com/gitlab-org/gitlab/-/issues/338005 + ERROR + ) + + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + error, + issue_type: issue_type, + work_item_type_id: work_item_type_id + ) + end + end + def due_date_after_start_date return unless start_date.present? && due_date.present? diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 42807b4d7cf..05090efe260 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -124,11 +124,6 @@ module Issues def update_project_counter_caches?(issue) super || issue.confidential_changed? end - - override :allowed_create_params - def allowed_create_params(params) - super(params).except(:work_item_type_id, :work_item_type) - end end end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 1b87c42be69..cb90aca5800 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -70,18 +70,6 @@ module Issues def issue_params @issue_params ||= build_issue_params - - if @issue_params[:work_item_type].present? - @issue_params[:issue_type] = @issue_params[:work_item_type].base_type - else - # If :issue_type is nil then params[:issue_type] was either nil - # or not permitted. Either way, the :issue_type will default - # to the column default of `issue`. And that means we need to - # ensure the work_item_type_id is set - @issue_params[:work_item_type_id] = get_work_item_type_id(@issue_params[:issue_type]) - end - - @issue_params end private @@ -98,11 +86,7 @@ module Issues :confidential ] - params[:work_item_type] = WorkItems::Type.find_by(id: params[:work_item_type_id]) if params[:work_item_type_id].present? # rubocop: disable CodeReuse/ActiveRecord - public_issue_params << :issue_type if create_issue_type_allowed?(container, params[:issue_type]) - base_type = params[:work_item_type]&.base_type - public_issue_params << :work_item_type if create_issue_type_allowed?(container, base_type) params.slice(*public_issue_params) end @@ -113,10 +97,6 @@ module Issues .merge(public_params) .with_indifferent_access end - - def get_work_item_type_id(issue_type = :issue) - find_work_item_type_id(issue_type) - end end end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index e652fec02e5..06977dec04b 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -27,6 +27,7 @@ module Issues # We should not initialize the callback classes during the build service execution because these will be # initialized when we call #create below @issue = @build_service.execute(initialize_callbacks: false) + set_work_item_type(@issue) # issue_type is set in BuildService, so we can delete it from params, in later phase # it can be set also from quick actions - in that case work_item_id is synced later again @@ -76,7 +77,6 @@ module Issues handle_escalation_status_change(issue) create_timeline_event(issue) try_to_associate_contacts(issue) - change_additional_attributes(issue) super end @@ -105,12 +105,24 @@ module Issues private - def handle_quick_actions(issue) - # Do not handle quick actions unless the work item is the default Issue. - # The available quick actions for a work item depend on its type and widgets. - return if @params[:work_item_type].present? && @params[:work_item_type] != WorkItems::Type.default_by_type(:issue) + def set_work_item_type(issue) + work_item_type = if params[:work_item_type_id].present? + params.delete(:work_item_type) + WorkItems::Type.find_by(id: params.delete(:work_item_type_id)) # rubocop: disable CodeReuse/ActiveRecord + else + params.delete(:work_item_type) + end + + base_type = work_item_type&.base_type + if create_issue_type_allowed?(container, base_type) + issue.work_item_type = work_item_type + # Up to this point issue_type might be set to the default, so we need to sync if a work item type is provided + issue.issue_type = work_item_type.base_type + end - super + # If no work item type was provided, we need to set it to whatever issue_type was up to this point, + # and that includes the column default + issue.work_item_type = WorkItems::Type.default_by_type(issue.issue_type) end def authorization_action @@ -144,15 +156,6 @@ module Issues set_crm_contacts(issue, contacts) end - - override :change_additional_attributes - def change_additional_attributes(issue) - super - - # issue_type can be still set through quick actions, in that case - # we have to make sure to re-sync work_item_type with it - issue.work_item_type_id = find_work_item_type_id(params[:issue_type]) if params[:issue_type] - end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b64826d5357..877f0a73c82 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -115,14 +115,6 @@ module Issues attr_reader :spam_params - def handle_quick_actions(issue) - # Do not handle quick actions unless the work item is the default Issue. - # The available quick actions for a work item depend on its type and widgets. - return unless issue.work_item_type.default_issue? - - super - end - def handle_date_changes(issue) return unless issue.previous_changes.slice('due_date', 'start_date').any? diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb index eff2132039f..ae355dc6d96 100644 --- a/app/services/work_items/create_service.rb +++ b/app/services/work_items/create_service.rb @@ -2,6 +2,7 @@ module WorkItems class CreateService < Issues::CreateService + extend ::Gitlab::Utils::Override include WidgetableService def initialize(container:, spam_params:, current_user: nil, params: {}, widget_params: {}) @@ -48,6 +49,15 @@ module WorkItems private + override :handle_quick_actions + def handle_quick_actions(work_item) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return if work_item.work_item_type != WorkItems::Type.default_by_type(:issue) + + super + end + def authorization_action :create_work_item end diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb index d4acadbc851..defdeebfed8 100644 --- a/app/services/work_items/update_service.rb +++ b/app/services/work_items/update_service.rb @@ -2,6 +2,7 @@ module WorkItems class UpdateService < ::Issues::UpdateService + extend Gitlab::Utils::Override include WidgetableService def initialize(container:, current_user: nil, params: {}, spam_params: nil, widget_params: {}) @@ -26,6 +27,15 @@ module WorkItems private + override :handle_quick_actions + def handle_quick_actions(work_item) + # Do not handle quick actions unless the work item is the default Issue. + # The available quick actions for a work item depend on its type and widgets. + return unless work_item.work_item_type.default_issue? + + super + end + def prepare_update_params(work_item) execute_widgets( work_item: work_item, diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index e4b8750b96c..7ddaf868a35 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -8,4 +8,5 @@ "help-page-path" => help_page_path("ci/environments/index.md"), "project-path" => @project.full_path, "project-id" => @project.id, - "default-branch-name" => @project.default_branch_or_main } } + "default-branch-name" => @project.default_branch_or_main, + "kas-tunnel-url" => ::Gitlab::Kas.tunnel_url } } diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml index 92b0a5a0b90..b8ee62055f0 100644 --- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -1,7 +1,7 @@ - display_issuable_type = issuable_display_type(@merge_request) .btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full - = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Merge request actions'), testid: 'merge-request-actions', 'aria-label': _('Merge request actions') } do + = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret gl-display-none! gl-md-display-inline-flex!", title: _('Merge request actions'), 'aria-label': _('Merge request actions'), data: { toggle: 'dropdown', testid: 'merge-request-actions' } do = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon" = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do %span.gl-dropdown-button-text= _('Merge request actions') |