diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-16 15:09:08 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-16 15:09:08 +0300 |
commit | 036cfe846472ee1cca9f7b8c43af28cd344ad66a (patch) | |
tree | 6712787dc2499e8ef8b9d887b8deed0c8a01dac6 /app/assets/javascripts/environments | |
parent | 06672560caf7701c357eb468ca17cce817b57239 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/environments')
19 files changed, 802 insertions, 372 deletions
diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue index a8e8e9a59e3..f90a1dcd193 100644 --- a/app/assets/javascripts/environments/components/edit_environment.vue +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -2,7 +2,9 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { visitUrl } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getEnvironment from '../graphql/queries/environment.query.graphql'; +import getEnvironmentWithFluxResource from '../graphql/queries/environment_with_flux_resource.query.graphql'; import updateEnvironment from '../graphql/mutations/update_environment.mutation.graphql'; import EnvironmentForm from './environment_form.vue'; @@ -11,11 +13,14 @@ export default { GlLoadingIcon, EnvironmentForm, }, + mixins: [glFeatureFlagsMixin()], inject: ['projectEnvironmentsPath', 'projectPath', 'environmentName'], apollo: { environment: { query() { - return getEnvironment; + return this.glFeatures?.fluxResourceForEnvironment + ? getEnvironmentWithFluxResource + : getEnvironment; }, variables() { return { @@ -55,6 +60,7 @@ export default { externalUrl: this.formEnvironment.externalUrl, clusterAgentId: this.formEnvironment.clusterAgentId, kubernetesNamespace: this.formEnvironment.kubernetesNamespace, + fluxResourcePath: this.formEnvironment.fluxResourcePath, }, }, }); diff --git a/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue b/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue new file mode 100644 index 00000000000..cad6752da94 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_flux_resource_selector.vue @@ -0,0 +1,210 @@ +<script> +import { GlFormGroup, GlCollapsibleListbox, GlAlert } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import fluxKustomizationsQuery from '../graphql/queries/flux_kustomizations.query.graphql'; +import fluxHelmReleasesQuery from '../graphql/queries/flux_helm_releases.query.graphql'; +import { + HELM_RELEASES_RESOURCE_TYPE, + KUSTOMIZATIONS_RESOURCE_TYPE, + KUSTOMIZATION, + HELM_RELEASE, +} from '../constants'; + +export default { + components: { + GlFormGroup, + GlCollapsibleListbox, + GlAlert, + }, + props: { + configuration: { + required: true, + type: Object, + }, + namespace: { + required: true, + type: String, + }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, + }, + i18n: { + fluxResourceLabel: s__('Environments|Select Flux resource (optional)'), + kustomizationsGroupLabel: s__('Environments|Kustomizations'), + helmReleasesGroupLabel: s__('Environments|HelmReleases'), + fluxResourcesHelpText: s__('Environments|Select Flux resource'), + errorTitle: s__( + 'Environments|Unable to access the following resources from this environment. Check your authorization on the following and try again:', + ), + reset: __('Reset'), + }, + data() { + return { + fluxResourceSearchTerm: '', + kustomizationsError: '', + helmReleasesError: '', + }; + }, + apollo: { + fluxKustomizations: { + query: fluxKustomizationsQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + skip() { + return !this.namespace; + }, + update(data) { + return data?.fluxKustomizations || []; + }, + error() { + this.kustomizationsError = KUSTOMIZATION; + }, + result(result) { + if (!result?.error && !result.errors?.length) { + this.kustomizationsError = ''; + } + }, + }, + fluxHelmReleases: { + query: fluxHelmReleasesQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + skip() { + return !this.namespace; + }, + update(data) { + return data?.fluxHelmReleases || []; + }, + error() { + this.helmReleasesError = HELM_RELEASE; + }, + result(result) { + if (!result?.error && !result.errors?.length) { + this.helmReleasesError = ''; + } + }, + }, + }, + computed: { + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + }; + }, + loadingFluxResourcesList() { + return this.$apollo.loading; + }, + kubernetesErrors() { + const errors = []; + if (this.kustomizationsError) { + errors.push(this.kustomizationsError); + } + if (this.helmReleasesError) { + errors.push(this.helmReleasesError); + } + return errors; + }, + fluxResourcesDropdownToggleText() { + const selectedResourceParts = this.fluxResourcePath ? this.fluxResourcePath.split('/') : []; + return selectedResourceParts.length + ? selectedResourceParts.at(-1) + : this.$options.i18n.fluxResourcesHelpText; + }, + fluxKustomizationsList() { + return ( + this.fluxKustomizations?.map((item) => { + return { + value: `${item.apiVersion}/namespaces/${item.metadata.namespace}/${KUSTOMIZATIONS_RESOURCE_TYPE}/${item.metadata.name}`, + text: item.metadata.name, + }; + }) || [] + ); + }, + fluxHelmReleasesList() { + return ( + this.fluxHelmReleases?.map((item) => { + return { + value: `${item.apiVersion}/namespaces/${item.metadata.namespace}/${HELM_RELEASES_RESOURCE_TYPE}/${item.metadata.name}`, + text: item.metadata.name, + }; + }) || [] + ); + }, + filteredKustomizationsList() { + const lowerCasedSearchTerm = this.fluxResourceSearchTerm.toLowerCase(); + return this.fluxKustomizationsList.filter((item) => + item.text.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + filteredHelmResourcesList() { + const lowerCasedSearchTerm = this.fluxResourceSearchTerm.toLowerCase(); + return this.fluxHelmReleasesList.filter((item) => + item.text.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + fluxResourcesList() { + const list = []; + if (this.filteredKustomizationsList?.length) { + list.push({ + text: this.$options.i18n.kustomizationsGroupLabel, + options: this.filteredKustomizationsList, + }); + } + + if (this.filteredHelmResourcesList?.length) { + list.push({ + text: this.$options.i18n.helmReleasesGroupLabel, + options: this.filteredHelmResourcesList, + }); + } + return list; + }, + }, + methods: { + onChange(event) { + this.$emit('change', event); + }, + onSearch(search) { + this.fluxResourceSearchTerm = search; + }, + }, +}; +</script> +<template> + <gl-form-group :label="$options.i18n.fluxResourceLabel" label-for="environment_flux_resource"> + <gl-alert v-if="kubernetesErrors.length" variant="warning" :dismissible="false" class="gl-mb-5"> + {{ $options.i18n.errorTitle }} + <ul class="gl-mb-0 gl-pl-6"> + <li v-for="(error, index) of kubernetesErrors" :key="index">{{ error }}</li> + </ul> + </gl-alert> + + <gl-collapsible-listbox + id="environment_flux_resource_path" + class="gl-w-full" + block + :selected="fluxResourcePath" + :items="fluxResourcesList" + :loading="loadingFluxResourcesList" + :toggle-text="fluxResourcesDropdownToggleText" + :header-text="$options.i18n.fluxResourcesHelpText" + :reset-button-label="$options.i18n.reset" + searchable + @search="onSearch" + @select="onChange" + @reset="onChange(null)" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 745a8a1d3ed..d89dcf56b7c 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -17,9 +17,11 @@ import { ENVIRONMENT_EDIT_HELP_TEXT, } from 'ee_else_ce/environments/constants'; import csrf from '~/lib/utils/csrf'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import getNamespacesQuery from '../graphql/queries/k8s_namespaces.query.graphql'; import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql'; +import EnvironmentFluxResourceSelector from './environment_flux_resource_selector.vue'; export default { components: { @@ -31,7 +33,9 @@ export default { GlLink, GlSprintf, GlAlert, + EnvironmentFluxResourceSelector, }, + mixins: [glFeatureFlagsMixin()], inject: { protectedEnvironmentSettingsPath: { default: '' }, projectPath: { default: '' }, @@ -177,6 +181,14 @@ export default { namespaceDropdownToggleText() { return this.selectedNamespace || this.$options.i18n.namespaceHelpText; }, + isKasFluxResourceAvailable() { + return this.glFeatures?.fluxResourceForEnvironment; + }, + showFluxResourceSelector() { + return Boolean( + this.isKasFluxResourceAvailable && this.selectedNamespace && this.selectedAgentId, + ); + }, k8sAccessConfiguration() { if (!this.showNamespaceSelector) { return null; @@ -196,6 +208,7 @@ export default { watch: { environment(change) { this.selectedAgentId = change.clusterAgentId; + this.selectedNamespace = change.kubernetesNamespace; }, }, methods: { @@ -224,7 +237,12 @@ export default { }, onAgentChange($event) { this.selectedNamespace = null; - this.onChange({ ...this.environment, clusterAgentId: $event, kubernetesNamespace: null }); + this.onChange({ + ...this.environment, + clusterAgentId: $event, + kubernetesNamespace: null, + fluxResourcePath: null, + }); }, onNamespaceSearch(search) { this.namespaceSearchTerm = search; @@ -343,11 +361,21 @@ export default { :reset-button-label="$options.i18n.reset" :searchable="true" @search="onNamespaceSearch" - @select="onChange({ ...environment, kubernetesNamespace: $event })" + @select=" + onChange({ ...environment, kubernetesNamespace: $event, fluxResourcePath: null }) + " @reset="onChange({ ...environment, kubernetesNamespace: null })" /> </gl-form-group> + <environment-flux-resource-selector + v-if="showFluxResourceSelector" + :namespace="selectedNamespace" + :configuration="k8sAccessConfiguration" + :flux-resource-path="environment.fluxResourcePath" + @change="onChange({ ...environment, fluxResourcePath: $event })" + /> + <div class="gl-mr-6"> <gl-button :loading="loading" diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index 8c30520ebec..0e52a80c2c5 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -33,6 +33,11 @@ export default { type: String, default: '', }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, }, data() { return { @@ -105,6 +110,7 @@ export default { :configuration="k8sAccessConfiguration" :namespace="namespace" :environment-name="environmentName" + :flux-resource-path="fluxResourcePath" class="gl-mb-3" /> <kubernetes-agent-info :cluster-agent="clusterAgent" class="gl-mb-5" /> diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue index b8859e65e38..c61ca4d749e 100644 --- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -1,7 +1,14 @@ <script> import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { HEALTH_BADGES, SYNC_STATUS_BADGES, STATUS_TRUE, STATUS_FALSE } from '../constants'; +import { + HEALTH_BADGES, + SYNC_STATUS_BADGES, + STATUS_TRUE, + STATUS_FALSE, + HELM_RELEASES_RESOURCE_TYPE, + KUSTOMIZATIONS_RESOURCE_TYPE, +} from '../constants'; import fluxKustomizationStatusQuery from '../graphql/queries/flux_kustomization_status.query.graphql'; import fluxHelmReleaseStatusQuery from '../graphql/queries/flux_helm_release_status.query.graphql'; @@ -32,6 +39,11 @@ export default { type: String, default: '', }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, }, apollo: { fluxKustomizationStatus: { @@ -41,10 +53,13 @@ export default { configuration: this.configuration, namespace: this.namespace, environmentName: this.environmentName.toLowerCase(), + fluxResourcePath: this.fluxResourcePath, }; }, skip() { - return !this.namespace; + return Boolean( + !this.namespace || this.fluxResourcePath?.includes(HELM_RELEASES_RESOURCE_TYPE), + ); }, }, fluxHelmReleaseStatus: { @@ -54,13 +69,15 @@ export default { configuration: this.configuration, namespace: this.namespace, environmentName: this.environmentName.toLowerCase(), + fluxResourcePath: this.fluxResourcePath, }; }, skip() { return Boolean( !this.namespace || this.$apollo.queries.fluxKustomizationStatus.loading || - this.hasKustomizations, + this.hasKustomizations || + this.fluxResourcePath?.includes(KUSTOMIZATIONS_RESOURCE_TYPE), ); }, }, diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue index c6bc94b0b80..6a4ed34989f 100644 --- a/app/assets/javascripts/environments/components/new_environment.vue +++ b/app/assets/javascripts/environments/components/new_environment.vue @@ -35,6 +35,7 @@ export default { projectPath: this.projectPath, clusterAgentId: this.environment.clusterAgentId, kubernetesNamespace: this.environment.kubernetesNamespace, + fluxResourcePath: this.environment.fluxResourcePath, }, }, }); diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index 48a3281c16f..2148343f690 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -11,8 +11,10 @@ import { import { __, s__ } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql'; import getEnvironmentClusterAgent from '../graphql/queries/environment_cluster_agent.query.graphql'; +import getEnvironmentClusterAgentWithFluxResource from '../graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql'; import ExternalUrl from './environment_external_url.vue'; import Actions from './environment_actions.vue'; import StopComponent from './environment_stop.vue'; @@ -50,6 +52,7 @@ export default { directives: { GlTooltip, }, + mixins: [glFeatureFlagsMixin()], inject: ['helpPagePath', 'projectPath'], props: { environment: { @@ -80,7 +83,7 @@ export default { tierTooltip: s__('Environment|Deployment tier'), }, data() { - return { visible: false, clusterAgent: null, kubernetesNamespace: '' }; + return { visible: false, clusterAgent: null, kubernetesNamespace: '', fluxResourcePath: '' }; }, computed: { icon() { @@ -162,6 +165,9 @@ export default { rolloutStatus() { return this.environment?.rolloutStatus; }, + isFluxResourceAvailable() { + return this.glFeatures?.fluxResourceForEnvironment; + }, }, methods: { toggleEnvironmentCollapse() { @@ -179,11 +185,14 @@ export default { return { environmentName: this.environment.name, projectFullPath: this.projectPath }; }, query() { - return getEnvironmentClusterAgent; + return this.isFluxResourceAvailable + ? getEnvironmentClusterAgentWithFluxResource + : getEnvironmentClusterAgent; }, update(data) { this.clusterAgent = data?.project?.environment?.clusterAgent; - this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace || ''; + this.kubernetesNamespace = data?.project?.environment?.kubernetesNamespace; + this.fluxResourcePath = data?.project?.environment?.fluxResourcePath || ''; }, }); }, @@ -367,6 +376,7 @@ export default { <kubernetes-overview :cluster-agent="clusterAgent" :namespace="kubernetesNamespace" + :flux-resource-path="fluxResourcePath" :environment-name="environment.name" /> </div> diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 921be902bdc..6afb143d2c1 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -160,3 +160,16 @@ export const CLUSTER_AGENT_ERROR_MESSAGES = { [ERROR_NOT_FOUND]: s__('Environment|Cluster agent not found.'), [ERROR_OTHER]: s__('Environment|There was an error connecting to the cluster agent.'), }; + +export const CLUSTER_FLUX_RECOURSES_ERROR_MESSAGES = { + [ERROR_UNAUTHORIZED]: s__( + 'Environment|Unauthorized to access %{resourceType} from this environment.', + ), + [ERROR_OTHER]: s__('Environment|There was an error fetching %{resourceType}.'), +}; + +export const HELM_RELEASES_RESOURCE_TYPE = 'helmreleases'; +export const KUSTOMIZATIONS_RESOURCE_TYPE = 'kustomizations'; + +export const KUSTOMIZATION = 'Kustomization'; +export const HELM_RELEASE = 'HelmRelease'; diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql new file mode 100644 index 00000000000..80363a06d42 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql @@ -0,0 +1,21 @@ +query getEnvironmentClusterAgentWithFluxResource($projectFullPath: ID!, $environmentName: String) { + project(fullPath: $projectFullPath) { + id + environment(name: $environmentName) { + id + kubernetesNamespace + fluxResourcePath + clusterAgent { + id + name + webPath + tokens { + nodes { + id + lastUsedAt + } + } + } + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql new file mode 100644 index 00000000000..166cd64189f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_with_flux_resource.query.graphql @@ -0,0 +1,16 @@ +query getEnvironmentWithFluxResource($projectFullPath: ID!, $environmentName: String) { + project(fullPath: $projectFullPath) { + id + environment(name: $environmentName) { + id + name + externalUrl + kubernetesNamespace + fluxResourcePath + clusterAgent { + id + name + } + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql index 0857f6f57df..fdccb087d28 100644 --- a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql @@ -2,11 +2,13 @@ query getFluxHelmReleaseStatusQuery( $configuration: LocalConfiguration $namespace: String $environmentName: String + $fluxResourcePath: String ) { fluxHelmReleaseStatus( configuration: $configuration namespace: $namespace environmentName: $environmentName + fluxResourcePath: $fluxResourcePath ) @client { status type diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql new file mode 100644 index 00000000000..fb37aba5adb --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_releases.query.graphql @@ -0,0 +1,9 @@ +query getFluxHelmReleasesQuery($configuration: LocalConfiguration, $namespace: String) { + fluxHelmReleases(configuration: $configuration, namespace: $namespace) @client { + apiVersion + metadata { + name + namespace + } + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql index 4eaea014718..bd00b8e9695 100644 --- a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql @@ -2,11 +2,13 @@ query getFluxHelmKustomizationStatusQuery( $configuration: LocalConfiguration $namespace: String $environmentName: String + $fluxResourcePath: String ) { fluxKustomizationStatus( configuration: $configuration namespace: $namespace environmentName: $environmentName + fluxResourcePath: $fluxResourcePath ) @client { status type diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql new file mode 100644 index 00000000000..ea7966560c3 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomizations.query.graphql @@ -0,0 +1,9 @@ +query getFluxKustomizationsQuery($configuration: LocalConfiguration, $namespace: String) { + fluxKustomizations(configuration: $configuration, namespace: $namespace) @client { + apiVersion + metadata { + name + namespace + } + } +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index fcc17cea4e1..017e3ccb45b 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,369 +1,14 @@ -import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; -import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; -import { - convertObjectPropsToCamelCase, - parseIntPagination, - normalizeHeaders, -} from '~/lib/utils/common_utils'; -import { humanizeClusterErrors } from '../helpers/k8s_integration_helper'; - -import pollIntervalQuery from './queries/poll_interval.query.graphql'; -import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; -import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; -import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; -import environmentToChangeCanaryQuery from './queries/environment_to_change_canary.query.graphql'; -import isEnvironmentStoppingQuery from './queries/is_environment_stopping.query.graphql'; -import pageInfoQuery from './queries/page_info.query.graphql'; - -const helmReleasesResourceType = 'helmreleases'; -const kustomizationsResourceType = 'kustomizations'; -const helmReleasesApiVersion = 'helm.toolkit.fluxcd.io/v2beta1'; -const kustomizationsApiVersion = 'kustomize.toolkit.fluxcd.io/v1beta1'; - -const buildErrors = (errors = []) => ({ - errors, - __typename: 'LocalEnvironmentErrors', -}); - -const mapNestedEnvironment = (env) => ({ - ...convertObjectPropsToCamelCase(env, { deep: true }), - __typename: 'NestedLocalEnvironment', -}); -const mapEnvironment = (env) => ({ - ...convertObjectPropsToCamelCase(env, { deep: true }), - __typename: 'LocalEnvironment', -}); - -const mapWorkloadItems = (items, kind) => { - return items.map((item) => { - const updatedItem = { - status: {}, - spec: {}, - }; - - switch (kind) { - case 'DeploymentList': - updatedItem.status.conditions = item.status.conditions || []; - break; - case 'DaemonSetList': - updatedItem.status = { - numberMisscheduled: item.status.numberMisscheduled || 0, - numberReady: item.status.numberReady || 0, - desiredNumberScheduled: item.status.desiredNumberScheduled || 0, - }; - break; - case 'StatefulSetList': - case 'ReplicaSetList': - updatedItem.status.readyReplicas = item.status.readyReplicas || 0; - updatedItem.spec.replicas = item.spec.replicas || 0; - break; - case 'JobList': - updatedItem.status.failed = item.status.failed || 0; - updatedItem.status.succeeded = item.status.succeeded || 0; - updatedItem.spec.completions = item.spec.completions || 0; - break; - case 'CronJobList': - updatedItem.status.active = item.status.active || 0; - updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || ''; - updatedItem.spec.suspend = item.spec.suspend || 0; - break; - default: - updatedItem.status = item?.status; - updatedItem.spec = item?.spec; - break; - } - - return updatedItem; - }); -}; - -const handleClusterError = (err) => { - const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; - throw error; -}; - -const buildFluxResourceUrl = ({ - basePath, - namespace, - apiVersion, - resourceType, - environmentName, -}) => { - return `${basePath}/apis/${apiVersion}/namespaces/${namespace}/${resourceType}/${environmentName}`; -}; - -const getFluxResourceStatus = (configuration, url) => { - const { headers } = configuration.baseOptions; - const withCredentials = true; - - return axios - .get(url, { withCredentials, headers }) - .then((res) => { - return res?.data?.status?.conditions || []; - }) - .catch((err) => { - handleClusterError(err); - }); -}; +import { baseQueries, baseMutations } from './resolvers/base'; +import kubernetesQueries from './resolvers/kubernetes'; +import fluxQueries from './resolvers/flux'; export const resolvers = (endpoint) => ({ Query: { - environmentApp(_context, { page, scope, search }, { cache }) { - return axios.get(endpoint, { params: { nested: true, page, scope, search } }).then((res) => { - const headers = normalizeHeaders(res.headers); - const interval = headers['POLL-INTERVAL']; - const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' }; - - if (interval) { - cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } }); - } else { - cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); - } - - cache.writeQuery({ - query: pageInfoQuery, - data: { pageInfo }, - }); - - return { - availableCount: res.data.available_count, - environments: res.data.environments.map(mapNestedEnvironment), - reviewApp: { - ...convertObjectPropsToCamelCase(res.data.review_app), - __typename: 'ReviewApp', - }, - canStopStaleEnvironments: res.data.can_stop_stale_environments, - stoppedCount: res.data.stopped_count, - __typename: 'LocalEnvironmentApp', - }; - }); - }, - folder(_, { environment: { folderPath }, scope, search }) { - return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({ - availableCount: res.data.available_count, - environments: res.data.environments.map(mapEnvironment), - stoppedCount: res.data.stopped_count, - __typename: 'LocalEnvironmentFolder', - })); - }, - 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) => { - handleClusterError(err); - }); - }, - k8sServices(_, { configuration }) { - const coreV1Api = new CoreV1Api(new Configuration(configuration)); - return coreV1Api - .listCoreV1ServiceForAllNamespaces() - .then((res) => { - const items = res?.data?.items || []; - return items.map((item) => { - const { type, clusterIP, externalIP, ports } = item.spec; - return { - metadata: item.metadata, - spec: { - type, - clusterIP: clusterIP || '-', - externalIP: externalIP || '-', - ports, - }, - }; - }); - }) - .catch((err) => { - handleClusterError(err); - }); - }, - k8sWorkloads(_, { configuration, namespace }) { - const appsV1api = new AppsV1Api(configuration); - const batchV1api = new BatchV1Api(configuration); - - let promises; - - if (namespace) { - promises = [ - appsV1api.listAppsV1NamespacedDeployment(namespace), - appsV1api.listAppsV1NamespacedDaemonSet(namespace), - appsV1api.listAppsV1NamespacedStatefulSet(namespace), - appsV1api.listAppsV1NamespacedReplicaSet(namespace), - batchV1api.listBatchV1NamespacedJob(namespace), - batchV1api.listBatchV1NamespacedCronJob(namespace), - ]; - } else { - promises = [ - appsV1api.listAppsV1DeploymentForAllNamespaces(), - appsV1api.listAppsV1DaemonSetForAllNamespaces(), - appsV1api.listAppsV1StatefulSetForAllNamespaces(), - appsV1api.listAppsV1ReplicaSetForAllNamespaces(), - batchV1api.listBatchV1JobForAllNamespaces(), - batchV1api.listBatchV1CronJobForAllNamespaces(), - ]; - } - - const summaryList = { - DeploymentList: [], - DaemonSetList: [], - StatefulSetList: [], - ReplicaSetList: [], - JobList: [], - CronJobList: [], - }; - - return Promise.allSettled(promises).then((results) => { - if (results.every((res) => res.status === 'rejected')) { - const error = results[0].reason; - const errorMessage = error?.response?.data?.message ?? error; - throw new Error(errorMessage); - } - for (const promiseResult of results) { - if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) { - const { kind, items } = promiseResult.value.data; - - if (items?.length > 0) { - summaryList[kind] = mapWorkloadItems(items, kind); - } - } - } - - return summaryList; - }); - }, - k8sNamespaces(_, { configuration }) { - const coreV1Api = new CoreV1Api(new Configuration(configuration)); - const namespacesApi = coreV1Api.listCoreV1Namespace(); - - return namespacesApi - .then((res) => { - return res?.data?.items || []; - }) - .catch((err) => { - const error = err?.response?.data?.reason || err; - throw new Error(humanizeClusterErrors(error)); - }); - }, - fluxKustomizationStatus(_, { configuration, namespace, environmentName }) { - const url = buildFluxResourceUrl({ - basePath: configuration.basePath, - resourceType: kustomizationsResourceType, - apiVersion: kustomizationsApiVersion, - namespace, - environmentName, - }); - return getFluxResourceStatus(configuration, url); - }, - fluxHelmReleaseStatus(_, { configuration, namespace, environmentName }) { - const url = buildFluxResourceUrl({ - basePath: configuration.basePath, - resourceType: helmReleasesResourceType, - apiVersion: helmReleasesApiVersion, - namespace, - environmentName, - }); - return getFluxResourceStatus(configuration, url); - }, + ...baseQueries(endpoint), + ...kubernetesQueries, + ...fluxQueries, }, Mutation: { - stopEnvironmentREST(_, { environment }, { client }) { - client.writeQuery({ - query: isEnvironmentStoppingQuery, - variables: { environment }, - data: { isEnvironmentStopping: true }, - }); - return axios - .post(environment.stopPath) - .then(() => buildErrors()) - .catch(() => { - client.writeQuery({ - query: isEnvironmentStoppingQuery, - variables: { environment }, - data: { isEnvironmentStopping: false }, - }); - return buildErrors([ - s__('Environments|An error occurred while stopping the environment, please try again'), - ]); - }); - }, - deleteEnvironment(_, { environment: { deletePath } }) { - return axios - .delete(deletePath) - .then(() => buildErrors()) - .catch(() => - buildErrors([ - s__( - 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', - ), - ]), - ); - }, - rollbackEnvironment(_, { environment, isLastDeployment }) { - return axios - .post(environment?.retryUrl) - .then(() => buildErrors()) - .catch(() => { - buildErrors([ - isLastDeployment - ? s__( - 'Environments|An error occurred while re-deploying the environment, please try again', - ) - : s__( - 'Environments|An error occurred while rolling back the environment, please try again', - ), - ]); - }); - }, - setEnvironmentToStop(_, { environment }, { client }) { - client.writeQuery({ - query: environmentToStopQuery, - data: { environmentToStop: environment }, - }); - }, - action(_, { action: { playPath } }) { - return axios - .post(playPath) - .then(() => buildErrors()) - .catch(() => - buildErrors([s__('Environments|An error occurred while making the request.')]), - ); - }, - setEnvironmentToDelete(_, { environment }, { client }) { - client.writeQuery({ - query: environmentToDeleteQuery, - data: { environmentToDelete: environment }, - }); - }, - setEnvironmentToRollback(_, { environment }, { client }) { - client.writeQuery({ - query: environmentToRollbackQuery, - data: { environmentToRollback: environment }, - }); - }, - setEnvironmentToChangeCanary(_, { environment, weight }, { client }) { - client.writeQuery({ - query: environmentToChangeCanaryQuery, - data: { environmentToChangeCanary: environment, weight }, - }); - }, - cancelAutoStop(_, { autoStopUrl }) { - return axios - .post(autoStopUrl) - .then(() => buildErrors()) - .catch((err) => - buildErrors([ - err?.response?.data?.message || - s__('Environments|An error occurred while canceling the auto stop, please try again'), - ]), - ); - }, + ...baseMutations, }, }); diff --git a/app/assets/javascripts/environments/graphql/resolvers/base.js b/app/assets/javascripts/environments/graphql/resolvers/base.js new file mode 100644 index 00000000000..9752a3a6634 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/resolvers/base.js @@ -0,0 +1,165 @@ +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import { + convertObjectPropsToCamelCase, + parseIntPagination, + normalizeHeaders, +} from '~/lib/utils/common_utils'; + +import pollIntervalQuery from '../queries/poll_interval.query.graphql'; +import environmentToRollbackQuery from '../queries/environment_to_rollback.query.graphql'; +import environmentToStopQuery from '../queries/environment_to_stop.query.graphql'; +import environmentToDeleteQuery from '../queries/environment_to_delete.query.graphql'; +import environmentToChangeCanaryQuery from '../queries/environment_to_change_canary.query.graphql'; +import isEnvironmentStoppingQuery from '../queries/is_environment_stopping.query.graphql'; +import pageInfoQuery from '../queries/page_info.query.graphql'; + +const buildErrors = (errors = []) => ({ + errors, + __typename: 'LocalEnvironmentErrors', +}); + +const mapNestedEnvironment = (env) => ({ + ...convertObjectPropsToCamelCase(env, { deep: true }), + __typename: 'NestedLocalEnvironment', +}); +const mapEnvironment = (env) => ({ + ...convertObjectPropsToCamelCase(env, { deep: true }), + __typename: 'LocalEnvironment', +}); + +export const baseQueries = (endpoint) => ({ + environmentApp(_context, { page, scope, search }, { cache }) { + return axios.get(endpoint, { params: { nested: true, page, scope, search } }).then((res) => { + const headers = normalizeHeaders(res.headers); + const interval = headers['POLL-INTERVAL']; + const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' }; + + if (interval) { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } }); + } else { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); + } + + cache.writeQuery({ + query: pageInfoQuery, + data: { pageInfo }, + }); + + return { + availableCount: res.data.available_count, + environments: res.data.environments.map(mapNestedEnvironment), + reviewApp: { + ...convertObjectPropsToCamelCase(res.data.review_app), + __typename: 'ReviewApp', + }, + canStopStaleEnvironments: res.data.can_stop_stale_environments, + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentApp', + }; + }); + }, + folder(_, { environment: { folderPath }, scope, search }) { + return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({ + availableCount: res.data.available_count, + environments: res.data.environments.map(mapEnvironment), + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentFolder', + })); + }, + isLastDeployment(_, { environment }) { + return environment?.lastDeployment?.isLast; + }, +}); + +export const baseMutations = { + stopEnvironmentREST(_, { environment }, { client }) { + client.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment }, + data: { isEnvironmentStopping: true }, + }); + return axios + .post(environment.stopPath) + .then(() => buildErrors()) + .catch(() => { + client.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment }, + data: { isEnvironmentStopping: false }, + }); + return buildErrors([ + s__('Environments|An error occurred while stopping the environment, please try again'), + ]); + }); + }, + deleteEnvironment(_, { environment: { deletePath } }) { + return axios + .delete(deletePath) + .then(() => buildErrors()) + .catch(() => + buildErrors([ + s__( + 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', + ), + ]), + ); + }, + rollbackEnvironment(_, { environment, isLastDeployment }) { + return axios + .post(environment?.retryUrl) + .then(() => buildErrors()) + .catch(() => { + buildErrors([ + isLastDeployment + ? s__( + 'Environments|An error occurred while re-deploying the environment, please try again', + ) + : s__( + 'Environments|An error occurred while rolling back the environment, please try again', + ), + ]); + }); + }, + setEnvironmentToStop(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToStopQuery, + data: { environmentToStop: environment }, + }); + }, + action(_, { action: { playPath } }) { + return axios + .post(playPath) + .then(() => buildErrors()) + .catch(() => buildErrors([s__('Environments|An error occurred while making the request.')])); + }, + setEnvironmentToDelete(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToDeleteQuery, + data: { environmentToDelete: environment }, + }); + }, + setEnvironmentToRollback(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToRollbackQuery, + data: { environmentToRollback: environment }, + }); + }, + setEnvironmentToChangeCanary(_, { environment, weight }, { client }) { + client.writeQuery({ + query: environmentToChangeCanaryQuery, + data: { environmentToChangeCanary: environment, weight }, + }); + }, + cancelAutoStop(_, { autoStopUrl }) { + return axios + .post(autoStopUrl) + .then(() => buildErrors()) + .catch((err) => + buildErrors([ + err?.response?.data?.message || + s__('Environments|An error occurred while canceling the auto stop, please try again'), + ]), + ); + }, +}; diff --git a/app/assets/javascripts/environments/graphql/resolvers/flux.js b/app/assets/javascripts/environments/graphql/resolvers/flux.js new file mode 100644 index 00000000000..f9ca35a3165 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/resolvers/flux.js @@ -0,0 +1,115 @@ +import axios from '~/lib/utils/axios_utils'; +import { + HELM_RELEASES_RESOURCE_TYPE, + KUSTOMIZATIONS_RESOURCE_TYPE, +} from '~/environments/constants'; + +const helmReleasesApiVersion = 'helm.toolkit.fluxcd.io/v2beta1'; +const kustomizationsApiVersion = 'kustomize.toolkit.fluxcd.io/v1beta1'; + +const handleClusterError = (err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; +}; + +const buildFluxResourceUrl = ({ + basePath, + namespace, + apiVersion, + resourceType, + environmentName = '', +}) => { + return `${basePath}/apis/${apiVersion}/namespaces/${namespace}/${resourceType}/${environmentName}`; +}; + +const getFluxResourceStatus = (configuration, url) => { + const { headers } = configuration.baseOptions; + const withCredentials = true; + + return axios + .get(url, { withCredentials, headers }) + .then((res) => { + return res?.data?.status?.conditions || []; + }) + .catch((err) => { + handleClusterError(err); + }); +}; + +const getFluxResources = (configuration, url) => { + const { headers } = configuration.baseOptions; + const withCredentials = true; + + return axios + .get(url, { withCredentials, headers }) + .then((res) => { + const items = res?.data?.items || []; + const result = items.map((item) => { + return { + apiVersion: item.apiVersion, + metadata: { + name: item.metadata?.name, + namespace: item.metadata?.namespace, + }, + }; + }); + return result || []; + }) + .catch((err) => { + const error = err?.response?.data?.reason || err; + throw new Error(error); + }); +}; + +export default { + fluxKustomizationStatus(_, { configuration, namespace, environmentName, fluxResourcePath = '' }) { + let url; + + if (fluxResourcePath) { + url = `${configuration.basePath}/apis/${fluxResourcePath}`; + } else { + url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: KUSTOMIZATIONS_RESOURCE_TYPE, + apiVersion: kustomizationsApiVersion, + namespace, + environmentName, + }); + } + return getFluxResourceStatus(configuration, url); + }, + fluxHelmReleaseStatus(_, { configuration, namespace, environmentName, fluxResourcePath }) { + let url; + + if (fluxResourcePath) { + url = `${configuration.basePath}/apis/${fluxResourcePath}`; + } else { + url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: HELM_RELEASES_RESOURCE_TYPE, + apiVersion: helmReleasesApiVersion, + namespace, + environmentName, + }); + } + return getFluxResourceStatus(configuration, url); + }, + fluxKustomizations(_, { configuration, namespace }) { + const url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: KUSTOMIZATIONS_RESOURCE_TYPE, + apiVersion: kustomizationsApiVersion, + namespace, + }); + return getFluxResources(configuration, url); + }, + fluxHelmReleases(_, { configuration, namespace }) { + const url = buildFluxResourceUrl({ + basePath: configuration.basePath, + resourceType: HELM_RELEASES_RESOURCE_TYPE, + apiVersion: helmReleasesApiVersion, + namespace, + }); + return getFluxResources(configuration, url); + }, +}; diff --git a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js new file mode 100644 index 00000000000..9ab65d0bb7f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js @@ -0,0 +1,155 @@ +import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; +import { humanizeClusterErrors } from '../../helpers/k8s_integration_helper'; + +const mapWorkloadItems = (items, kind) => { + return items.map((item) => { + const updatedItem = { + status: {}, + spec: {}, + }; + + switch (kind) { + case 'DeploymentList': + updatedItem.status.conditions = item.status.conditions || []; + break; + case 'DaemonSetList': + updatedItem.status = { + numberMisscheduled: item.status.numberMisscheduled || 0, + numberReady: item.status.numberReady || 0, + desiredNumberScheduled: item.status.desiredNumberScheduled || 0, + }; + break; + case 'StatefulSetList': + case 'ReplicaSetList': + updatedItem.status.readyReplicas = item.status.readyReplicas || 0; + updatedItem.spec.replicas = item.spec.replicas || 0; + break; + case 'JobList': + updatedItem.status.failed = item.status.failed || 0; + updatedItem.status.succeeded = item.status.succeeded || 0; + updatedItem.spec.completions = item.spec.completions || 0; + break; + case 'CronJobList': + updatedItem.status.active = item.status.active || 0; + updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || ''; + updatedItem.spec.suspend = item.spec.suspend || 0; + break; + default: + updatedItem.status = item?.status; + updatedItem.spec = item?.spec; + break; + } + + return updatedItem; + }); +}; + +const handleClusterError = (err) => { + const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; + throw error; +}; + +export default { + 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) => { + handleClusterError(err); + }); + }, + k8sServices(_, { configuration }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + return coreV1Api + .listCoreV1ServiceForAllNamespaces() + .then((res) => { + const items = res?.data?.items || []; + return items.map((item) => { + const { type, clusterIP, externalIP, ports } = item.spec; + return { + metadata: item.metadata, + spec: { + type, + clusterIP: clusterIP || '-', + externalIP: externalIP || '-', + ports, + }, + }; + }); + }) + .catch((err) => { + handleClusterError(err); + }); + }, + k8sWorkloads(_, { configuration, namespace }) { + const appsV1api = new AppsV1Api(configuration); + const batchV1api = new BatchV1Api(configuration); + + let promises; + + if (namespace) { + promises = [ + appsV1api.listAppsV1NamespacedDeployment(namespace), + appsV1api.listAppsV1NamespacedDaemonSet(namespace), + appsV1api.listAppsV1NamespacedStatefulSet(namespace), + appsV1api.listAppsV1NamespacedReplicaSet(namespace), + batchV1api.listBatchV1NamespacedJob(namespace), + batchV1api.listBatchV1NamespacedCronJob(namespace), + ]; + } else { + promises = [ + appsV1api.listAppsV1DeploymentForAllNamespaces(), + appsV1api.listAppsV1DaemonSetForAllNamespaces(), + appsV1api.listAppsV1StatefulSetForAllNamespaces(), + appsV1api.listAppsV1ReplicaSetForAllNamespaces(), + batchV1api.listBatchV1JobForAllNamespaces(), + batchV1api.listBatchV1CronJobForAllNamespaces(), + ]; + } + + const summaryList = { + DeploymentList: [], + DaemonSetList: [], + StatefulSetList: [], + ReplicaSetList: [], + JobList: [], + CronJobList: [], + }; + + return Promise.allSettled(promises).then((results) => { + if (results.every((res) => res.status === 'rejected')) { + const error = results[0].reason; + const errorMessage = error?.response?.data?.message ?? error; + throw new Error(errorMessage); + } + for (const promiseResult of results) { + if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) { + const { kind, items } = promiseResult.value.data; + + if (items?.length > 0) { + summaryList[kind] = mapWorkloadItems(items, kind); + } + } + } + + return summaryList; + }); + }, + k8sNamespaces(_, { configuration }) { + const coreV1Api = new CoreV1Api(new Configuration(configuration)); + const namespacesApi = coreV1Api.listCoreV1Namespace(); + + return namespacesApi + .then((res) => { + return res?.data?.items || []; + }) + .catch((err) => { + const error = err?.response?.data?.reason || err; + throw new Error(humanizeClusterErrors(error)); + }); + }, +}; diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index e49f1451759..164a2d98e90 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -142,7 +142,7 @@ export function getCronJobsStatuses(items) { } export function humanizeClusterErrors(reason) { - const errorReason = reason.toLowerCase(); + const errorReason = String(reason).toLowerCase(); const errorMessage = CLUSTER_AGENT_ERROR_MESSAGES[errorReason]; return errorMessage || CLUSTER_AGENT_ERROR_MESSAGES.other; } |