diff options
Diffstat (limited to 'app/assets/javascripts/environments/components')
11 files changed, 449 insertions, 27 deletions
diff --git a/app/assets/javascripts/environments/components/commit.vue b/app/assets/javascripts/environments/components/commit.vue index 8577bf629a3..9ee716ccbab 100644 --- a/app/assets/javascripts/environments/components/commit.vue +++ b/app/assets/javascripts/environments/components/commit.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlAvatar, GlAvatarLink, GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { escape } from 'lodash'; diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index b2844ed5ad6..2186941e00c 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon } from '@gitlab/ui'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index 01b8208fd55..96d2a8d9ba2 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/multi-word-component-names --> <script> import { GlBadge, diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue index a2405d23924..f90a1dcd193 100644 --- a/app/assets/javascripts/environments/components/edit_environment.vue +++ b/app/assets/javascripts/environments/components/edit_environment.vue @@ -4,7 +4,7 @@ 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 getEnvironmentWithNamespace from '../graphql/queries/environment_with_namespace.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'; @@ -18,8 +18,8 @@ export default { apollo: { environment: { query() { - return this.glFeatures?.kubernetesNamespaceForEnvironment - ? getEnvironmentWithNamespace + return this.glFeatures?.fluxResourceForEnvironment + ? getEnvironmentWithFluxResource : getEnvironment; }, variables() { @@ -60,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 1bff013b9c2..d89dcf56b7c 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -21,6 +21,7 @@ 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: { @@ -32,6 +33,7 @@ export default { GlLink, GlSprintf, GlAlert, + EnvironmentFluxResourceSelector, }, mixins: [glFeatureFlagsMixin()], inject: { @@ -173,15 +175,20 @@ export default { item.text.toLowerCase().includes(lowerCasedSearchTerm), ); }, - isKasKubernetesNamespaceAvailable() { - return this.glFeatures?.kubernetesNamespaceForEnvironment; - }, showNamespaceSelector() { - return Boolean(this.isKasKubernetesNamespaceAvailable && this.selectedAgentId); + return Boolean(this.selectedAgentId); }, 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; @@ -201,6 +208,7 @@ export default { watch: { environment(change) { this.selectedAgentId = change.clusterAgentId; + this.selectedNamespace = change.kubernetesNamespace; }, }, methods: { @@ -229,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; @@ -348,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/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index a95b5b273f7..795cbf5327a 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -250,7 +250,6 @@ export default { v-if="canSetupReviewApp" v-model="isReviewAppModalVisible" :modal-id="$options.modalId" - data-testid="enable-review-app-modal" /> <stop-stale-environments-modal v-if="canCleanUpEnvs" diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index a1efeaac359..0e52a80c2c5 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -24,11 +24,20 @@ export default { required: true, type: Object, }, + environmentName: { + required: true, + type: String, + }, namespace: { required: false, type: String, default: '', }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, }, data() { return { @@ -96,7 +105,13 @@ export default { </p> <gl-collapse :visible="isVisible" class="gl-md-pl-7 gl-md-pr-5 gl-mt-4"> <template v-if="isVisible"> - <kubernetes-status-bar :cluster-health-status="clusterHealthStatus" class="gl-mb-4" /> + <kubernetes-status-bar + :cluster-health-status="clusterHealthStatus" + :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" /> <gl-alert v-if="error" variant="danger" :dismissible="false" 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 94cd7438e46..e8857dfe459 100644 --- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -1,12 +1,24 @@ <script> -import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { GlLoadingIcon, GlBadge, GlPopover, GlSprintf, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { HEALTH_BADGES } 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'; export default { components: { GlLoadingIcon, GlBadge, + GlPopover, + GlSprintf, + GlLink, }, props: { clusterHealthStatus: { @@ -17,23 +29,175 @@ export default { return ['error', 'success', ''].includes(val); }, }, + configuration: { + required: true, + type: Object, + }, + environmentName: { + required: true, + type: String, + }, + namespace: { + required: false, + type: String, + default: '', + }, + fluxResourcePath: { + required: false, + type: String, + default: '', + }, + }, + apollo: { + fluxKustomizationStatus: { + query: fluxKustomizationStatusQuery, + variables() { + return { + configuration: this.configuration, + namespace: this.namespace, + environmentName: this.environmentName.toLowerCase(), + fluxResourcePath: this.fluxResourcePath, + }; + }, + skip() { + return Boolean( + !this.namespace || this.fluxResourcePath?.includes(HELM_RELEASES_RESOURCE_TYPE), + ); + }, + error(err) { + this.fluxApiError = err.message; + }, + }, + fluxHelmReleaseStatus: { + query: fluxHelmReleaseStatusQuery, + variables() { + return { + 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.fluxResourcePath?.includes(KUSTOMIZATIONS_RESOURCE_TYPE), + ); + }, + error(err) { + this.fluxApiError = err.message; + }, + }, + }, + data() { + return { + fluxApiError: '', + }; }, computed: { healthBadge() { return HEALTH_BADGES[this.clusterHealthStatus]; }, + hasKustomizations() { + return this.fluxKustomizationStatus?.length; + }, + hasHelmReleases() { + return this.fluxHelmReleaseStatus?.length; + }, + isLoading() { + return ( + this.$apollo.queries.fluxKustomizationStatus.loading || + this.$apollo.queries.fluxHelmReleaseStatus.loading + ); + }, + fluxBadgeId() { + return `${this.environmentName}-flux-sync-badge`; + }, + fluxCRD() { + if (!this.hasKustomizations && !this.hasHelmReleases) { + return []; + } + + return this.hasKustomizations ? this.fluxKustomizationStatus : this.fluxHelmReleaseStatus; + }, + fluxAnyStalled() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Stalled'; + }); + }, + fluxAnyReconciling() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Reconciling'; + }); + }, + fluxAnyReconciled() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Ready'; + }); + }, + fluxAnyFailed() { + return this.fluxCRD.find((condition) => { + return condition.status === STATUS_FALSE && condition.type === 'Ready'; + }); + }, + syncStatusBadge() { + if (!this.fluxCRD.length && this.fluxApiError) { + return { ...SYNC_STATUS_BADGES.unavailable, popoverText: this.fluxApiError }; + } else if (!this.fluxCRD.length) { + return SYNC_STATUS_BADGES.unavailable; + } else if (this.fluxAnyFailed) { + return { ...SYNC_STATUS_BADGES.failed, popoverText: this.fluxAnyFailed.message }; + } else if (this.fluxAnyStalled) { + return { ...SYNC_STATUS_BADGES.stalled, popoverText: this.fluxAnyStalled.message }; + } else if (this.fluxAnyReconciling) { + return SYNC_STATUS_BADGES.reconciling; + } else if (this.fluxAnyReconciled) { + return SYNC_STATUS_BADGES.reconciled; + } + return SYNC_STATUS_BADGES.unknown; + }, }, i18n: { healthLabel: s__('Environment|Environment health'), + syncStatusLabel: s__('Environment|Sync status'), }, + badgeContainerClasses: 'gl-display-flex gl-align-items-center gl-flex-shrink-0 gl-mr-3 gl-mb-2', }; </script> <template> - <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-mb-2"> - <span class="gl-font-sm gl-font-monospace gl-mr-3">{{ $options.i18n.healthLabel }}</span> - <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline /> - <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant"> - {{ healthBadge.text }} - </gl-badge> + <div class="gl-display-flex gl-flex-wrap"> + <div :class="$options.badgeContainerClasses"> + <span class="gl-mr-3">{{ $options.i18n.healthLabel }}</span> + <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline /> + <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant" data-testid="health-badge"> + {{ healthBadge.text }} + </gl-badge> + </div> + + <div :class="$options.badgeContainerClasses"> + <span class="gl-mr-3">{{ $options.i18n.syncStatusLabel }}</span> + <gl-loading-icon v-if="isLoading" size="sm" inline /> + <template v-else-if="syncStatusBadge"> + <gl-badge + :id="fluxBadgeId" + :icon="syncStatusBadge.icon" + :variant="syncStatusBadge.variant" + data-testid="sync-badge" + tabindex="0" + >{{ syncStatusBadge.text }} + </gl-badge> + <gl-popover :target="fluxBadgeId" :title="syncStatusBadge.popoverTitle"> + <gl-sprintf :message="syncStatusBadge.popoverText"> + <template #link="{ content }"> + <gl-link :href="syncStatusBadge.popoverLink" class="gl-font-sm">{{ + content + }}</gl-link></template + > + </gl-sprintf> + </gl-popover> + </template> + </div> </div> </template> 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 fda1c85f739..2148343f690 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -14,7 +14,7 @@ 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 getEnvironmentClusterAgentWithNamespace from '../graphql/queries/environment_cluster_agent_with_namespace.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'; @@ -83,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() { @@ -165,8 +165,8 @@ export default { rolloutStatus() { return this.environment?.rolloutStatus; }, - isKubernetesNamespaceAvailable() { - return this.glFeatures?.kubernetesNamespaceForEnvironment; + isFluxResourceAvailable() { + return this.glFeatures?.fluxResourceForEnvironment; }, }, methods: { @@ -185,13 +185,14 @@ export default { return { environmentName: this.environment.name, projectFullPath: this.projectPath }; }, query() { - return this.isKubernetesNamespaceAvailable - ? getEnvironmentClusterAgentWithNamespace + 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 || ''; }, }); }, @@ -372,7 +373,12 @@ export default { </gl-sprintf> </div> <div v-if="clusterAgent" :class="$options.kubernetesOverviewClasses"> - <kubernetes-overview :cluster-agent="clusterAgent" :namespace="kubernetesNamespace" /> + <kubernetes-overview + :cluster-agent="clusterAgent" + :namespace="kubernetesNamespace" + :flux-resource-path="fluxResourcePath" + :environment-name="environment.name" + /> </div> <div v-if="rolloutStatus" :class="$options.deployBoardClasses"> <deploy-board-wrapper |