Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-10-08 21:13:02 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-10-08 21:13:02 +0300
commite7527f548681e4f9efd32f9c3da937ad74c68948 (patch)
treeef51227ccbbeb1accc8e3886870dbc6a168e8bb8
parentf2ac9ee99ac6b1afc0edbc8621a05176dddd6a14 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue159
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue122
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js1
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql11
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql34
-rw-r--r--app/assets/javascripts/clusters/agents/index.js30
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js8
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_empty_state.vue119
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue152
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue156
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue83
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue259
-rw-r--r--app/assets/javascripts/clusters_list/constants.js85
-rw-r--r--app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql8
-rw-r--r--app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql9
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql15
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql47
-rw-r--r--app/assets/javascripts/clusters_list/index.js5
-rw-r--r--app/assets/javascripts/clusters_list/load_agents.js44
-rw-r--r--app/assets/javascripts/pages/projects/cluster_agents/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js2
-rw-r--r--app/assets/javascripts/repository/commits_service.js65
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue39
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue55
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue22
-rw-r--r--app/assets/javascripts/repository/constants.js4
-rw-r--r--app/controllers/concerns/notes_actions.rb2
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/views/groups/edit.html.haml5
-rw-r--r--app/views/groups/settings/_general.html.haml3
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml1
-rw-r--r--config/feature_flags/development/lazy_load_commits.yml8
-rw-r--r--config/mail_room.yml1
-rw-r--r--doc/administration/auth/ldap/ldap-troubleshooting.md22
-rw-r--r--doc/administration/gitaly/praefect.md32
-rw-r--r--doc/administration/instance_limits.md2
-rw-r--r--doc/administration/lfs/index.md9
-rw-r--r--doc/api/commits.md13
-rw-r--r--doc/api/members.md7
-rw-r--r--doc/api/merge_request_approvals.md25
-rw-r--r--doc/development/documentation/styleguide/index.md72
-rw-r--r--doc/push_rules/push_rules.md8
-rw-r--r--doc/subscriptions/bronze_starter.md2
-rw-r--r--doc/user/project/repository/branches/index.md5
-rw-r--r--doc/user/project/repository/mirror/pull.md149
-rw-r--r--doc/user/project/repository/reducing_the_repo_size_using_git.md8
-rw-r--r--lib/api/notes.rb2
-rw-r--r--lib/gitlab/mail_room.rb3
-rw-r--r--lib/gitlab/redis/wrapper.rb6
-rw-r--r--locale/gitlab.pot15
-rw-r--r--spec/frontend/clusters/agents/components/show_spec.js195
-rw-r--r--spec/frontend/clusters/agents/components/token_table_spec.js135
-rw-r--r--spec/frontend/clusters_list/components/agent_empty_state_spec.js77
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js117
-rw-r--r--spec/frontend/clusters_list/components/agents_spec.js246
-rw-r--r--spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js129
-rw-r--r--spec/frontend/clusters_list/components/install_agent_modal_spec.js190
-rw-r--r--spec/frontend/clusters_list/components/mock_data.js12
-rw-r--r--spec/frontend/clusters_list/mocks/apollo.js45
-rw-r--r--spec/frontend/clusters_list/stubs.js14
-rw-r--r--spec/frontend/repository/commits_service_spec.js84
-rw-r--r--spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap75
-rw-r--r--spec/frontend/repository/components/table/index_spec.js33
-rw-r--r--spec/frontend/repository/components/table/row_spec.js34
-rw-r--r--spec/frontend/repository/components/tree_content_spec.js22
-rw-r--r--spec/lib/gitlab/mail_room/mail_room_spec.rb3
-rw-r--r--spec/lib/gitlab/redis/queues_spec.rb20
-rw-r--r--spec/models/pages_domain_spec.rb6
-rw-r--r--spec/support/redis/redis_shared_examples.rb22
70 files changed, 3201 insertions, 197 deletions
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
new file mode 100644
index 00000000000..5c672d288c5
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -0,0 +1,159 @@
+<script>
+import {
+ GlAlert,
+ GlBadge,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTab,
+ GlTabs,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { MAX_LIST_COUNT } from '../constants';
+import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
+import TokenTable from './token_table.vue';
+
+export default {
+ i18n: {
+ installedInfo: s__('ClusterAgents|Created by %{name} %{time}'),
+ loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
+ tokens: s__('ClusterAgents|Access tokens'),
+ unknownUser: s__('ClusterAgents|Unknown user'),
+ },
+ apollo: {
+ clusterAgent: {
+ query: getClusterAgentQuery,
+ variables() {
+ return {
+ agentName: this.agentName,
+ projectPath: this.projectPath,
+ ...this.cursor,
+ };
+ },
+ update: (data) => data?.project?.clusterAgent,
+ error() {
+ this.clusterAgent = null;
+ },
+ },
+ },
+ components: {
+ GlAlert,
+ GlBadge,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTab,
+ GlTabs,
+ TimeAgoTooltip,
+ TokenTable,
+ },
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ projectPath: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ cursor: {
+ first: MAX_LIST_COUNT,
+ last: null,
+ },
+ };
+ },
+ computed: {
+ createdAt() {
+ return this.clusterAgent?.createdAt;
+ },
+ createdBy() {
+ return this.clusterAgent?.createdByUser?.name || this.$options.i18n.unknownUser;
+ },
+ isLoading() {
+ return this.$apollo.queries.clusterAgent.loading;
+ },
+ showPagination() {
+ return this.tokenPageInfo.hasPreviousPage || this.tokenPageInfo.hasNextPage;
+ },
+ tokenCount() {
+ return this.clusterAgent?.tokens?.count;
+ },
+ tokenPageInfo() {
+ return this.clusterAgent?.tokens?.pageInfo || {};
+ },
+ tokens() {
+ return this.clusterAgent?.tokens?.nodes || [];
+ },
+ },
+ methods: {
+ nextPage() {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ afterToken: this.tokenPageInfo.endCursor,
+ };
+ },
+ prevPage() {
+ this.cursor = {
+ first: null,
+ last: MAX_LIST_COUNT,
+ beforeToken: this.tokenPageInfo.startCursor,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <h2>{{ agentName }}</h2>
+
+ <gl-loading-icon v-if="isLoading && clusterAgent == null" size="lg" class="gl-m-3" />
+
+ <div v-else-if="clusterAgent">
+ <p data-testid="cluster-agent-create-info">
+ <gl-sprintf :message="$options.i18n.installedInfo">
+ <template #name>
+ {{ createdBy }}
+ </template>
+
+ <template #time>
+ <time-ago-tooltip :time="createdAt" />
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-tabs>
+ <gl-tab>
+ <template #title>
+ <span data-testid="cluster-agent-token-count">
+ {{ $options.i18n.tokens }}
+
+ <gl-badge v-if="tokenCount" size="sm" class="gl-tab-counter-badge">{{
+ tokenCount
+ }}</gl-badge>
+ </span>
+ </template>
+
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" />
+
+ <div v-else>
+ <TokenTable :tokens="tokens" />
+
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" />
+ </div>
+ </div>
+ </gl-tab>
+ </gl-tabs>
+ </div>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ $options.i18n.loadingError }}
+ </gl-alert>
+ </section>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
new file mode 100644
index 00000000000..70ed2566134
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlLink,
+ GlTable,
+ GlTooltip,
+ GlTruncate,
+ TimeAgoTooltip,
+ },
+ i18n: {
+ createdBy: s__('ClusterAgents|Created by'),
+ createToken: s__('ClusterAgents|You will need to create a token to connect to your agent'),
+ dateCreated: s__('ClusterAgents|Date created'),
+ description: s__('ClusterAgents|Description'),
+ lastUsed: s__('ClusterAgents|Last contact'),
+ learnMore: s__('ClusterAgents|Learn how to create an agent access token'),
+ name: s__('ClusterAgents|Name'),
+ neverUsed: s__('ClusterAgents|Never'),
+ noTokens: s__('ClusterAgents|This agent has no tokens'),
+ unknownUser: s__('ClusterAgents|Unknown user'),
+ },
+ props: {
+ tokens: {
+ required: true,
+ type: Array,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: this.$options.i18n.name,
+ tdAttr: { 'data-testid': 'agent-token-name' },
+ },
+ {
+ key: 'lastUsed',
+ label: this.$options.i18n.lastUsed,
+ tdAttr: { 'data-testid': 'agent-token-used' },
+ },
+ {
+ key: 'createdAt',
+ label: this.$options.i18n.dateCreated,
+ tdAttr: { 'data-testid': 'agent-token-created-time' },
+ },
+ {
+ key: 'createdBy',
+ label: this.$options.i18n.createdBy,
+ tdAttr: { 'data-testid': 'agent-token-created-user' },
+ },
+ {
+ key: 'description',
+ label: this.$options.i18n.description,
+ tdAttr: { 'data-testid': 'agent-token-description' },
+ },
+ ];
+ },
+ learnMoreUrl() {
+ return helpPagePath('user/clusters/agent/index.md', {
+ anchor: 'create-an-agent-record-in-gitlab',
+ });
+ },
+ },
+ methods: {
+ createdByName(token) {
+ return token?.createdByUser?.name || this.$options.i18n.unknownUser;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="tokens.length">
+ <div class="gl-text-right gl-my-5">
+ <gl-link target="_blank" :href="learnMoreUrl">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </div>
+
+ <gl-table :items="tokens" :fields="fields" fixed stacked="md">
+ <template #cell(lastUsed)="{ item }">
+ <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" />
+ <span v-else>{{ $options.i18n.neverUsed }}</span>
+ </template>
+
+ <template #cell(createdAt)="{ item }">
+ <time-ago-tooltip :time="item.createdAt" />
+ </template>
+
+ <template #cell(createdBy)="{ item }">
+ <span>{{ createdByName(item) }}</span>
+ </template>
+
+ <template #cell(description)="{ item }">
+ <div v-if="item.description" :id="`tooltip-description-container-${item.id}`">
+ <gl-truncate :id="`tooltip-description-${item.id}`" :text="item.description" />
+
+ <gl-tooltip
+ :container="`tooltip-description-container-${item.id}`"
+ :target="`tooltip-description-${item.id}`"
+ placement="top"
+ >
+ {{ item.description }}
+ </gl-tooltip>
+ </div>
+ </template>
+ </gl-table>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.noTokens"
+ :primary-button-link="learnMoreUrl"
+ :primary-button-text="$options.i18n.learnMore"
+ />
+</template>
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
new file mode 100644
index 00000000000..bbc4630f83b
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -0,0 +1 @@
+export const MAX_LIST_COUNT = 25;
diff --git a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
new file mode 100644
index 00000000000..1e9187e8ad1
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
@@ -0,0 +1,11 @@
+fragment Token on ClusterAgentToken {
+ id
+ createdAt
+ description
+ lastUsedAt
+ name
+
+ createdByUser {
+ name
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
new file mode 100644
index 00000000000..d01db8f0a6a
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -0,0 +1,34 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/cluster_agent_token.fragment.graphql"
+
+query getClusterAgent(
+ $projectPath: ID!
+ $agentName: String!
+ $first: Int
+ $last: Int
+ $afterToken: String
+ $beforeToken: String
+) {
+ project(fullPath: $projectPath) {
+ clusterAgent(name: $agentName) {
+ id
+ createdAt
+
+ createdByUser {
+ name
+ }
+
+ tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
+ count
+
+ nodes {
+ ...Token
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
new file mode 100644
index 00000000000..bcb5b271203
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import AgentShowPage from './components/show.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.querySelector('#js-cluster-agent-details');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient();
+ const { agentName, projectPath } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ render(createElement) {
+ return createElement(AgentShowPage, {
+ props: {
+ agentName,
+ projectPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
new file mode 100644
index 00000000000..9b870134512
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -0,0 +1,8 @@
+export function generateAgentRegistrationCommand(agentToken, kasAddress) {
+ return `docker run --pull=always --rm \\
+ registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate \\
+ --agent-token=${agentToken} \\
+ --kas-address=${kasAddress} \\
+ --agent-version stable \\
+ --namespace gitlab-kubernetes-agent | kubectl apply -f -`;
+}
diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
new file mode 100644
index 00000000000..405339b3d36
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui';
+import { INSTALL_AGENT_MODAL_ID } from '../constants';
+
+export default {
+ modalId: INSTALL_AGENT_MODAL_ID,
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ GlAlert,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ inject: [
+ 'emptyStateImage',
+ 'projectPath',
+ 'agentDocsUrl',
+ 'installDocsUrl',
+ 'getStartedDocsUrl',
+ 'integrationDocsUrl',
+ ],
+ props: {
+ hasConfigurations: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ repositoryPath() {
+ return `/${this.projectPath}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :svg-path="emptyStateImage"
+ :title="s__('ClusterAgents|Integrate Kubernetes with a GitLab Agent')"
+ class="empty-state--agent"
+ >
+ <template #description>
+ <p class="mw-460 gl-mx-auto">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="agentDocsUrl" target="_blank" data-testid="agent-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p class="mw-460 gl-mx-auto">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="installDocsUrl" target="_blank" data-testid="install-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-alert
+ v-if="!hasConfigurations"
+ variant="warning"
+ class="gl-mb-5 text-left"
+ :dismissible="false"
+ >
+ {{
+ s__(
+ 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.',
+ )
+ }}
+
+ <template #actions>
+ <gl-button
+ category="primary"
+ variant="info"
+ :href="getStartedDocsUrl"
+ target="_blank"
+ class="gl-ml-0!"
+ >
+ {{ s__('ClusterAgents|Read more about getting started') }}
+ </gl-button>
+ <gl-button category="secondary" variant="info" :href="repositoryPath">
+ {{ s__('ClusterAgents|Go to the repository') }}
+ </gl-button>
+ </template>
+ </gl-alert>
+ </template>
+
+ <template #actions>
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ :disabled="!hasConfigurations"
+ data-testid="integration-primary-button"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('ClusterAgents|Integrate with the GitLab Agent') }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
new file mode 100644
index 00000000000..487e512c06d
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -0,0 +1,152 @@
+<script>
+import {
+ GlButton,
+ GlLink,
+ GlModalDirective,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlPopover,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, TROUBLESHOOTING_LINK } from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlPopover,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ mixins: [timeagoMixin],
+ inject: ['integrationDocsUrl'],
+ INSTALL_AGENT_MODAL_ID,
+ AGENT_STATUSES,
+ TROUBLESHOOTING_LINK,
+ props: {
+ agents: {
+ required: true,
+ type: Array,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: s__('ClusterAgents|Name'),
+ },
+ {
+ key: 'status',
+ label: s__('ClusterAgents|Connection status'),
+ },
+ {
+ key: 'lastContact',
+ label: s__('ClusterAgents|Last contact'),
+ },
+ {
+ key: 'configuration',
+ label: s__('ClusterAgents|Configuration'),
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-block gl-text-right gl-my-3">
+ <gl-button
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ variant="confirm"
+ category="primary"
+ >{{ s__('ClusterAgents|Install a new GitLab Agent') }}
+ </gl-button>
+ </div>
+
+ <gl-table
+ :items="agents"
+ :fields="fields"
+ stacked="md"
+ head-variant="white"
+ thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
+ data-testid="cluster-agent-list-table"
+ >
+ <template #cell(name)="{ item }">
+ <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">
+ {{ item.name }}
+ </gl-link>
+ </template>
+
+ <template #cell(status)="{ item }">
+ <span
+ :id="`connection-status-${item.name}`"
+ class="gl-pr-5"
+ data-testid="cluster-agent-connection-status"
+ >
+ <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
+ <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
+ >{{ $options.AGENT_STATUSES[item.status].name }}
+ </span>
+ <gl-tooltip
+ v-if="item.status === 'active'"
+ :target="`connection-status-${item.name}`"
+ placement="right"
+ >
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
+ </gl-sprintf>
+ </gl-tooltip>
+ <gl-popover
+ v-else
+ :target="`connection-status-${item.name}`"
+ :title="$options.AGENT_STATUSES[item.status].tooltip.title"
+ placement="right"
+ container="viewport"
+ >
+ <p>
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
+ >
+ </p>
+ <p class="gl-mb-0">
+ {{ s__('ClusterAgents|For more troubleshooting information go to') }}
+ <gl-link :href="$options.TROUBLESHOOTING_LINK" target="_blank" class="gl-font-sm">
+ {{ $options.TROUBLESHOOTING_LINK }}</gl-link
+ >
+ </p>
+ </gl-popover>
+ </template>
+
+ <template #cell(lastContact)="{ item }">
+ <span data-testid="cluster-agent-last-contact">
+ <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
+ <span v-else>{{ s__('ClusterAgents|Never') }}</span>
+ </span>
+ </template>
+
+ <template #cell(configuration)="{ item }">
+ <span data-testid="cluster-agent-configuration-link">
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
+ .gitlab/agents/{{ item.name }}
+ </gl-link>
+
+ <span v-else>.gitlab/agents/{{ item.name }}</span>
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
+ </span>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
new file mode 100644
index 00000000000..ed44c1f5fa7
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -0,0 +1,156 @@
+<script>
+import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui';
+import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants';
+import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
+import AgentEmptyState from './agent_empty_state.vue';
+import AgentTable from './agent_table.vue';
+import InstallAgentModal from './install_agent_modal.vue';
+
+export default {
+ apollo: {
+ agents: {
+ query: getAgentsQuery,
+ variables() {
+ return {
+ defaultBranchName: this.defaultBranchName,
+ projectPath: this.projectPath,
+ ...this.cursor,
+ };
+ },
+ update(data) {
+ this.updateTreeList(data);
+ return data;
+ },
+ },
+ },
+ components: {
+ AgentEmptyState,
+ AgentTable,
+ InstallAgentModal,
+ GlAlert,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ },
+ inject: ['projectPath'],
+ props: {
+ defaultBranchName: {
+ default: '.noBranch',
+ required: false,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ cursor: {
+ first: MAX_LIST_COUNT,
+ last: null,
+ },
+ folderList: {},
+ };
+ },
+ computed: {
+ agentList() {
+ let list = this.agents?.project?.clusterAgents?.nodes;
+
+ if (list) {
+ list = list.map((agent) => {
+ const configFolder = this.folderList[agent.name];
+ const lastContact = this.getLastContact(agent);
+ const status = this.getStatus(lastContact);
+ return { ...agent, configFolder, lastContact, status };
+ });
+ }
+
+ return list;
+ },
+ agentPageInfo() {
+ return this.agents?.project?.clusterAgents?.pageInfo || {};
+ },
+ isLoading() {
+ return this.$apollo.queries.agents.loading;
+ },
+ showPagination() {
+ return this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage;
+ },
+ treePageInfo() {
+ return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
+ },
+ hasConfigurations() {
+ return Boolean(this.agents?.project?.repository?.tree?.trees?.nodes?.length);
+ },
+ },
+ methods: {
+ reloadAgents() {
+ this.$apollo.queries.agents.refetch();
+ },
+ nextPage() {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ afterAgent: this.agentPageInfo.endCursor,
+ afterTree: this.treePageInfo.endCursor,
+ };
+ },
+ prevPage() {
+ this.cursor = {
+ first: null,
+ last: MAX_LIST_COUNT,
+ beforeAgent: this.agentPageInfo.startCursor,
+ beforeTree: this.treePageInfo.endCursor,
+ };
+ },
+ updateTreeList(data) {
+ const configFolders = data?.project?.repository?.tree?.trees?.nodes;
+
+ if (configFolders) {
+ configFolders.forEach((folder) => {
+ this.folderList[folder.name] = folder;
+ });
+ }
+ },
+ getLastContact(agent) {
+ const tokens = agent?.tokens?.nodes;
+ let lastContact = null;
+ if (tokens?.length) {
+ tokens.forEach((token) => {
+ const lastContactToDate = new Date(token.lastUsedAt).getTime();
+ if (lastContactToDate > lastContact) {
+ lastContact = lastContactToDate;
+ }
+ });
+ }
+ return lastContact;
+ },
+ getStatus(lastContact) {
+ if (lastContact) {
+ const now = new Date().getTime();
+ const diff = now - lastContact;
+
+ return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
+ }
+ return 'unused';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" />
+
+ <section v-else-if="agentList" class="gl-mt-3">
+ <div v-if="agentList.length">
+ <AgentTable :agents="agentList" />
+
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" />
+ </div>
+ </div>
+
+ <AgentEmptyState v-else :has-configurations="hasConfigurations" />
+ <InstallAgentModal @agentRegistered="reloadAgents" />
+ </section>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ s__('ClusterAgents|An error occurred while loading your GitLab Agents') }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
new file mode 100644
index 00000000000..9fb020d2f4f
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants';
+import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
+
+export default {
+ name: 'AvailableAgentsDropdown',
+ i18n: I18N_AVAILABLE_AGENTS_DROPDOWN,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ inject: ['projectPath'],
+ props: {
+ isRegistering: {
+ required: true,
+ type: Boolean,
+ },
+ },
+ apollo: {
+ agents: {
+ query: agentConfigurations,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ this.populateAvailableAgents(data);
+ },
+ },
+ },
+ data() {
+ return {
+ availableAgents: [],
+ selectedAgent: null,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.agents.loading;
+ },
+ dropdownText() {
+ if (this.isRegistering) {
+ return this.$options.i18n.registeringAgent;
+ } else if (this.selectedAgent === null) {
+ return this.$options.i18n.selectAgent;
+ }
+
+ return this.selectedAgent;
+ },
+ },
+ methods: {
+ selectAgent(agent) {
+ this.$emit('agentSelected', agent);
+ this.selectedAgent = agent;
+ },
+ isSelected(agent) {
+ return this.selectedAgent === agent;
+ },
+ populateAvailableAgents(data) {
+ const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? [];
+ const configuredAgents =
+ data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? [];
+
+ this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent));
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="dropdownText" :loading="isLoading || isRegistering">
+ <gl-dropdown-item
+ v-for="agent in availableAgents"
+ :key="agent"
+ :is-checked="isSelected(agent)"
+ is-check-item
+ @click="selectAgent(agent)"
+ >
+ {{ agent }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
new file mode 100644
index 00000000000..5f192fe4d5a
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -0,0 +1,259 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlLink,
+ GlModal,
+ GlSprintf,
+} from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+import { generateAgentRegistrationCommand } from '../clusters_util';
+import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants';
+import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
+import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
+import AvailableAgentsDropdown from './available_agents_dropdown.vue';
+
+export default {
+ modalId: INSTALL_AGENT_MODAL_ID,
+ i18n: I18N_INSTALL_AGENT_MODAL,
+ components: {
+ AvailableAgentsDropdown,
+ ClipboardButton,
+ CodeBlock,
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ },
+ inject: ['projectPath', 'kasAddress'],
+ data() {
+ return {
+ registering: false,
+ agentName: null,
+ agentToken: null,
+ error: null,
+ };
+ },
+ computed: {
+ registered() {
+ return Boolean(this.agentToken);
+ },
+ nextButtonDisabled() {
+ return !this.registering && this.agentName !== null;
+ },
+ canCancel() {
+ return !this.registered && !this.registering;
+ },
+ agentRegistrationCommand() {
+ return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
+ },
+ basicInstallPath() {
+ return helpPagePath('user/clusters/agent/index', {
+ anchor: 'install-the-agent-into-the-cluster',
+ });
+ },
+ advancedInstallPath() {
+ return helpPagePath('user/clusters/agent/index', { anchor: 'advanced-installation' });
+ },
+ },
+ methods: {
+ setAgentName(name) {
+ this.agentName = name;
+ },
+ cancelClicked() {
+ this.$refs.modal.hide();
+ },
+ doneClicked() {
+ this.$emit('agentRegistered');
+ this.$refs.modal.hide();
+ },
+ resetModal() {
+ this.registering = null;
+ this.agentName = null;
+ this.agentToken = null;
+ this.error = null;
+ },
+ createAgentMutation() {
+ return this.$apollo
+ .mutate({
+ mutation: createAgent,
+ variables: {
+ input: {
+ name: this.agentName,
+ projectPath: this.projectPath,
+ },
+ },
+ })
+ .then(({ data: { createClusterAgent } }) => createClusterAgent);
+ },
+ createAgentTokenMutation(agendId) {
+ return this.$apollo
+ .mutate({
+ mutation: createAgentToken,
+ variables: {
+ input: {
+ clusterAgentId: agendId,
+ name: this.agentName,
+ },
+ },
+ })
+ .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
+ },
+ async registerAgent() {
+ this.registering = true;
+ this.error = null;
+
+ try {
+ const { errors: agentErrors, clusterAgent } = await this.createAgentMutation();
+
+ if (agentErrors?.length > 0) {
+ throw new Error(agentErrors[0]);
+ }
+
+ const { errors: tokenErrors, secret } = await this.createAgentTokenMutation(
+ clusterAgent.id,
+ );
+
+ if (tokenErrors?.length > 0) {
+ throw new Error(tokenErrors[0]);
+ }
+
+ this.agentToken = secret;
+ } catch (error) {
+ if (error) {
+ this.error = error.message;
+ } else {
+ this.error = this.$options.i18n.unknownError;
+ }
+ } finally {
+ this.registering = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalTitle"
+ static
+ lazy
+ @hidden="resetModal"
+ >
+ <template v-if="!registered">
+ <p>
+ <strong>{{ $options.i18n.selectAgentTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.selectAgentBody">
+ <template #link="{ content }">
+ <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <form>
+ <gl-form-group label-for="agent-name">
+ <available-agents-dropdown
+ class="gl-w-70p"
+ :is-registering="registering"
+ @agentSelected="setAgentName"
+ />
+ </gl-form-group>
+ </form>
+
+ <p v-if="error">
+ <gl-alert
+ :title="$options.i18n.registrationErrorTitle"
+ variant="danger"
+ :dismissible="false"
+ >
+ {{ error }}
+ </gl-alert>
+ </p>
+ </template>
+
+ <template v-else>
+ <p>
+ <strong>{{ $options.i18n.tokenTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.tokenBody">
+ <template #link="{ content }">
+ <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-alert
+ :title="$options.i18n.tokenSingleUseWarningTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ {{ $options.i18n.tokenSingleUseWarningBody }}
+ </gl-alert>
+ </p>
+
+ <p>
+ <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
+ <template #append>
+ <clipboard-button :text="agentToken" :title="$options.i18n.copyToken" />
+ </template>
+ </gl-form-input-group>
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.basicInstallTitle }}</strong>
+ </p>
+
+ <p>
+ {{ $options.i18n.basicInstallBody }}
+ </p>
+
+ <p>
+ <code-block :code="agentRegistrationCommand" />
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.advancedInstallTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.advancedInstallBody">
+ <template #link="{ content }">
+ <gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+
+ <template #modal-footer>
+ <gl-button v-if="canCancel" @click="cancelClicked">{{ $options.i18n.cancel }} </gl-button>
+
+ <gl-button v-if="registered" variant="confirm" category="primary" @click="doneClicked"
+ >{{ $options.i18n.done }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ :disabled="!nextButtonDisabled"
+ variant="confirm"
+ category="primary"
+ @click="registerAgent"
+ >{{ $options.i18n.next }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index f39678b73dc..0bade1fc281 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -1,4 +1,10 @@
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
+
+export const MAX_LIST_COUNT = 25;
+export const INSTALL_AGENT_MODAL_ID = 'install-agent';
+export const ACTIVE_CONNECTION_TIME = 480000;
+export const TROUBLESHOOTING_LINK =
+ 'https://docs.gitlab.com/ee/user/clusters/agent/#troubleshooting';
export const CLUSTER_ERRORS = {
default: {
@@ -58,3 +64,80 @@ export const STATUSES = {
deleting: { title: __('Deleting') },
creating: { title: __('Creating') },
};
+
+export const I18N_INSTALL_AGENT_MODAL = {
+ next: __('Next'),
+ done: __('Done'),
+ cancel: __('Cancel'),
+
+ modalTitle: s__('ClusterAgents|Install new Agent'),
+
+ selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'),
+ selectAgentBody: s__(
+ `ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`,
+ ),
+
+ copyToken: s__('ClusterAgents|Copy token'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`,
+ ),
+
+ tokenSingleUseWarningTitle: s__(
+ 'ClusterAgents|The token value will not be shown again after you close this window.',
+ ),
+ tokenSingleUseWarningBody: s__(
+ `ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`,
+ ),
+
+ basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
+ basicInstallBody: s__(
+ `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
+ ),
+
+ advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'),
+ advancedInstallBody: s__(
+ 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.',
+ ),
+
+ registrationErrorTitle: s__('Failed to register Agent'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+};
+
+export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
+ selectAgent: s__('ClusterAgents|Select an Agent'),
+ registeringAgent: s__('ClusterAgents|Registering Agent'),
+};
+
+export const AGENT_STATUSES = {
+ active: {
+ name: s__('ClusterAgents|Connected'),
+ icon: 'status-success',
+ class: 'text-success-500',
+ tooltip: {
+ title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')),
+ },
+ },
+ inactive: {
+ name: s__('ClusterAgents|Not connected'),
+ icon: 'severity-critical',
+ class: 'text-danger-800',
+ tooltip: {
+ title: s__('ClusterAgents|Agent might not be connected to GitLab'),
+ body: sprintf(
+ s__(
+ 'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.',
+ ),
+ ),
+ },
+ },
+ unused: {
+ name: s__('ClusterAgents|Never connected'),
+ icon: 'status-neutral',
+ class: 'text-secondary-400',
+ tooltip: {
+ title: s__('ClusterAgents|Agent never connected to GitLab'),
+ body: s__('ClusterAgents|Make sure you are using a valid token.'),
+ },
+ },
+};
diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
new file mode 100644
index 00000000000..c29756159f5
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
@@ -0,0 +1,8 @@
+mutation createClusterAgent($input: CreateClusterAgentInput!) {
+ createClusterAgent(input: $input) {
+ clusterAgent {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql
new file mode 100644
index 00000000000..e93580cf416
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql
@@ -0,0 +1,9 @@
+mutation createClusterAgentToken($input: ClusterAgentTokenCreateInput!) {
+ clusterAgentTokenCreate(input: $input) {
+ secret
+ token {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
new file mode 100644
index 00000000000..40b61337024
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
@@ -0,0 +1,15 @@
+query agentConfigurations($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ agentConfigurations {
+ nodes {
+ agentName
+ }
+ }
+
+ clusterAgents {
+ nodes {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
new file mode 100644
index 00000000000..61989e00d9e
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -0,0 +1,47 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getAgents(
+ $defaultBranchName: String!
+ $projectPath: ID!
+ $first: Int
+ $last: Int
+ $afterAgent: String
+ $afterTree: String
+ $beforeAgent: String
+ $beforeTree: String
+) {
+ project(fullPath: $projectPath) {
+ clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) {
+ nodes {
+ id
+ name
+ webPath
+ tokens {
+ nodes {
+ lastUsedAt
+ }
+ }
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+
+ repository {
+ tree(path: ".gitlab/agents", ref: $defaultBranchName) {
+ trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) {
+ nodes {
+ name
+ path
+ webPath
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index daa82892773..de18965abbd 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,6 +1,11 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import loadClusters from './load_clusters';
+import loadAgents from './load_agents';
+
+Vue.use(VueApollo);
export default () => {
loadClusters(Vue);
+ loadAgents(Vue, VueApollo);
};
diff --git a/app/assets/javascripts/clusters_list/load_agents.js b/app/assets/javascripts/clusters_list/load_agents.js
new file mode 100644
index 00000000000..b77d386df20
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/load_agents.js
@@ -0,0 +1,44 @@
+import createDefaultClient from '~/lib/graphql';
+import Agents from './components/agents.vue';
+
+export default (Vue, VueApollo) => {
+ const el = document.querySelector('#js-cluster-agents-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
+
+ const {
+ emptyStateImage,
+ defaultBranchName,
+ projectPath,
+ agentDocsUrl,
+ installDocsUrl,
+ getStartedDocsUrl,
+ integrationDocsUrl,
+ kasAddress,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ provide: {
+ emptyStateImage,
+ projectPath,
+ agentDocsUrl,
+ installDocsUrl,
+ getStartedDocsUrl,
+ integrationDocsUrl,
+ kasAddress,
+ },
+ render(createElement) {
+ return createElement(Agents, {
+ props: {
+ defaultBranchName,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/cluster_agents/show/index.js b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js
new file mode 100644
index 00000000000..4ed3e2f7bea
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js
@@ -0,0 +1,3 @@
+import loadClusterAgentVues from '~/clusters/agents';
+
+loadClusterAgentVues();
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 2b5451bd18b..a1ba920b322 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -1,4 +1,4 @@
-import initClustersListApp from 'ee_else_ce/clusters_list';
+import initClustersListApp from '~/clusters_list';
import PersistentUserCallout from '~/persistent_user_callout';
const callout = document.querySelector('.gcp-signup-offer');
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
new file mode 100644
index 00000000000..504efaea8cc
--- /dev/null
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -0,0 +1,65 @@
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { normalizeData } from 'ee_else_ce/repository/utils/commit';
+import createFlash from '~/flash';
+import { COMMIT_BATCH_SIZE, I18N_COMMIT_DATA_FETCH_ERROR } from './constants';
+
+let requestedOffsets = [];
+let fetchedBatches = [];
+
+export const isRequested = (offset) => requestedOffsets.includes(offset);
+
+export const resetRequestedCommits = () => {
+ requestedOffsets = [];
+ fetchedBatches = [];
+};
+
+const addRequestedOffset = (offset) => {
+ if (isRequested(offset) || offset < 0) {
+ return;
+ }
+
+ requestedOffsets.push(offset);
+};
+
+const removeLeadingSlash = (path) => path.replace(/^\//, '');
+
+const fetchData = (projectPath, path, ref, offset) => {
+ if (fetchedBatches.includes(offset) || offset < 0) {
+ return [];
+ }
+
+ fetchedBatches.push(offset);
+
+ const url = joinPaths(
+ gon.relative_url_root || '/',
+ projectPath,
+ '/-/refs/',
+ ref,
+ '/logs_tree/',
+ encodeURIComponent(removeLeadingSlash(path)),
+ );
+
+ return axios
+ .get(url, { params: { format: 'json', offset } })
+ .then(({ data }) => normalizeData(data, path))
+ .catch(() => createFlash({ message: I18N_COMMIT_DATA_FETCH_ERROR }));
+};
+
+export const loadCommits = async (projectPath, path, ref, offset) => {
+ if (isRequested(offset)) {
+ return [];
+ }
+
+ // We fetch in batches of 25, so this ensures we don't refetch
+ Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => {
+ addRequestedOffset(offset - i);
+ addRequestedOffset(offset + i);
+ });
+
+ // Since a user could scroll either up or down, we want to support lazy loading in both directions
+ const commitsBatchUp = await fetchData(projectPath, path, ref, offset - COMMIT_BATCH_SIZE);
+ const commitsBatchDown = await fetchData(projectPath, path, ref, offset);
+
+ return commitsBatchUp.concat(commitsBatchDown);
+};
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 4d1e550d13e..44621fc34d1 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -1,5 +1,6 @@
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '../../../locale';
import getRefMixin from '../../mixins/get_ref';
import projectPathQuery from '../../queries/project_path.query.graphql';
@@ -15,13 +16,18 @@ export default {
ParentRow,
GlButton,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
apollo: {
projectPath: {
query: projectPathQuery,
},
},
props: {
+ commits: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
path: {
type: String,
required: true,
@@ -48,6 +54,7 @@ export default {
data() {
return {
projectPath: '',
+ rowNumbers: {},
};
},
computed: {
@@ -73,10 +80,37 @@ export default {
return ['', '/'].indexOf(this.path) === -1;
},
},
+ watch: {
+ $route: function routeChange() {
+ this.$options.totalRowsLoaded = -1;
+ },
+ },
+ totalRowsLoaded: -1,
methods: {
showMore() {
this.$emit('showMore');
},
+ generateRowNumber(id) {
+ if (!this.glFeatures.lazyLoadCommits) {
+ return 0;
+ }
+
+ if (!this.rowNumbers[id] && this.rowNumbers[id] !== 0) {
+ this.$options.totalRowsLoaded += 1;
+ this.rowNumbers[id] = this.$options.totalRowsLoaded;
+ }
+
+ return this.rowNumbers[id];
+ },
+ getCommit(fileName, type) {
+ if (!this.glFeatures.lazyLoadCommits) {
+ return {};
+ }
+
+ return this.commits.find(
+ (commitEntry) => commitEntry.fileName === fileName && commitEntry.type === type,
+ );
+ },
},
};
</script>
@@ -116,6 +150,9 @@ export default {
:lfs-oid="entry.lfsOid"
:loading-path="loadingPath"
:total-entries="totalEntries"
+ :row-number="generateRowNumber(entry.id)"
+ :commit-info="getCommit(entry.name, entry.type)"
+ v-on="$listeners"
/>
</template>
<template v-if="isLoading">
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 09b80a8ef8e..5010d60f374 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -8,6 +8,7 @@ import {
GlIcon,
GlHoverLoadDirective,
GlSafeHtmlDirective,
+ GlIntersectionObserver,
} from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
import filesQuery from 'shared_queries/repository/files.query.graphql';
@@ -30,6 +31,7 @@ export default {
GlIcon,
TimeagoTooltip,
FileIcon,
+ GlIntersectionObserver,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -48,10 +50,23 @@ export default {
maxOffset: this.totalEntries,
};
},
+ skip() {
+ return this.glFeatures.lazyLoadCommits;
+ },
},
},
mixins: [getRefMixin, glFeatureFlagMixin()],
props: {
+ commitInfo: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ rowNumber: {
+ type: Number,
+ required: false,
+ default: null,
+ },
totalEntries: {
type: Number,
required: true,
@@ -113,9 +128,13 @@ export default {
data() {
return {
commit: null,
+ hasRowAppeared: false,
};
},
computed: {
+ commitData() {
+ return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit;
+ },
refactorBlobViewerEnabled() {
return this.glFeatures.refactorBlobViewer;
},
@@ -148,7 +167,10 @@ export default {
return this.sha.slice(0, 8);
},
hasLockLabel() {
- return this.commit && this.commit.lockLabel;
+ return this.commitData && this.commitData.lockLabel;
+ },
+ showSkeletonLoader() {
+ return !this.commitData && this.hasRowAppeared;
},
},
methods: {
@@ -179,6 +201,19 @@ export default {
apolloQuery(query, variables) {
this.$apollo.query({ query, variables });
},
+ rowAppeared() {
+ this.hasRowAppeared = true;
+
+ if (this.glFeatures.lazyLoadCommits) {
+ this.$emit('row-appear', {
+ rowNumber: this.rowNumber,
+ hasCommit: Boolean(this.commitInfo),
+ });
+ }
+ },
+ rowDisappeared() {
+ this.hasRowAppeared = false;
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
@@ -222,7 +257,7 @@ export default {
<gl-icon
v-if="hasLockLabel"
v-gl-tooltip
- :title="commit.lockLabel"
+ :title="commitData.lockLabel"
name="lock"
:size="12"
class="ml-1"
@@ -230,17 +265,19 @@ export default {
</td>
<td class="d-none d-sm-table-cell tree-commit cursor-default">
<gl-link
- v-if="commit"
- v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml"
- :href="commit.commitPath"
- :title="commit.message"
+ v-if="commitData"
+ v-safe-html:[$options.safeHtmlConfig]="commitData.titleHtml"
+ :href="commitData.commitPath"
+ :title="commitData.message"
class="str-truncated-100 tree-commit-link"
/>
- <gl-skeleton-loading v-else :lines="1" class="h-auto" />
+ <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared">
+ <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="h-auto" />
+ </gl-intersection-observer>
</td>
<td class="tree-time-ago text-right cursor-default">
- <timeago-tooltip v-if="commit" :time="commit.committedDate" />
- <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" />
+ <timeago-tooltip v-if="commitData" :time="commitData.committedDate" />
+ <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="ml-auto h-auto w-50" />
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 5a8ead9ae8f..16dfe3cfb14 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -8,6 +8,7 @@ import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../co
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import { readmeFile } from '../utils/readme';
+import { loadCommits, isRequested, resetRequestedCommits } from '../commits_service';
import FilePreview from './preview/index.vue';
import FileTable from './table/index.vue';
@@ -36,6 +37,7 @@ export default {
},
data() {
return {
+ commits: [],
projectPath: '',
nextPageCursor: '',
pagesLoaded: 1,
@@ -81,12 +83,16 @@ export default {
this.entries.submodules = [];
this.entries.blobs = [];
this.nextPageCursor = '';
+ resetRequestedCommits();
this.fetchFiles();
},
},
mounted() {
// We need to wait for `ref` and `projectPath` to be set
- this.$nextTick(() => this.fetchFiles());
+ this.$nextTick(() => {
+ resetRequestedCommits();
+ this.fetchFiles();
+ });
},
methods: {
fetchFiles() {
@@ -152,6 +158,18 @@ export default {
.concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
.find(({ hasNextPage }) => hasNextPage);
},
+ loadCommitData({ rowNumber = 0, hasCommit } = {}) {
+ if (!this.glFeatures.lazyLoadCommits || hasCommit || isRequested(rowNumber)) {
+ return;
+ }
+
+ loadCommits(this.projectPath, this.path, this.ref, rowNumber)
+ .then(this.setCommitData)
+ .catch(() => {});
+ },
+ setCommitData(data) {
+ this.commits = this.commits.concat(data);
+ },
handleShowMore() {
this.clickedShowMore = true;
this.pagesLoaded += 1;
@@ -169,7 +187,9 @@ export default {
:is-loading="isLoadingFiles"
:loading-path="loadingPath"
:has-more="hasShowMore"
+ :commits="commits"
@showMore="handleShowMore"
+ @row-appear="loadCommitData"
/>
<file-preview v-if="readme" :blob="readme" />
</div>
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 93032bf17e2..70952c8413b 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -4,6 +4,8 @@ export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
+export const COMMIT_BATCH_SIZE = 25; // we request commit data in batches of 25
+
export const SECONDARY_OPTIONS_TEXT = __('Cancel');
export const COMMIT_LABEL = __('Commit message');
export const TARGET_BRANCH_LABEL = __('Target branch');
@@ -13,3 +15,5 @@ export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52;
export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72;
export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width';
+
+export const I18N_COMMIT_DATA_FETCH_ERROR = __('An error occurred while fetching commit data.');
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 2d8168af2e3..c2ee735a2b5 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -62,7 +62,7 @@ module NotesActions
json.merge!(note_json(@note))
end
- if @note.errors.present? && @note.errors.keys != [:commands_only]
+ if @note.errors.present? && @note.errors.attribute_names != [:commands_only]
render json: json, status: :unprocessable_entity
else
render json: json
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 6fd4c632dd3..cb0e1900e48 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -16,6 +16,7 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_edit_tree!, only: [:create_dir]
before_action do
+ push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index a293bdac28c..7c7e6457020 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -33,6 +33,7 @@ class ProjectsController < Projects::ApplicationController
before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
before_action do
+ push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 0b4c600fdb1..420771257c9 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title _("General Settings")
-- page_title _("General Settings")
+- breadcrumb_title _("General settings")
+- page_title _("General settings")
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
@@ -13,6 +13,7 @@
= _('Collapse')
%p
= _('Update your group name, description, avatar, and visibility.')
+ = link_to s_('Learn more about groups.'), help_page_path('user/group/index')
.settings-content
= render 'groups/settings/general'
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 7a2d5c91af6..ed76a9fe253 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -14,8 +14,9 @@
.row.gl-mt-3
.form-group.col-md-9
- = f.label :description, _('Group description (optional)'), class: 'label-bold'
+ = f.label :description, _('Group description'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
+ .form-text.text-muted= _('Optional.')
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 652da4b396a..e049afbc40b 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -4,7 +4,6 @@
= s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}').html_safe % { link_start: group_deploy_tokens_help_link_start, link_end: '</a>'.html_safe }
= form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: Feature.enabled?(:ajax_new_deploy_token, group_or_project) do |f|
- = form_errors(token)
.form-group
= f.label :name, class: 'label-bold'
diff --git a/config/feature_flags/development/lazy_load_commits.yml b/config/feature_flags/development/lazy_load_commits.yml
new file mode 100644
index 00000000000..d4764907211
--- /dev/null
+++ b/config/feature_flags/development/lazy_load_commits.yml
@@ -0,0 +1,8 @@
+---
+name: lazy_load_commits
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71633
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/342497
+milestone: '14.4'
+type: development
+group: group::source code
+default_enabled: false
diff --git a/config/mail_room.yml b/config/mail_room.yml
index 25bda294a13..895438dcc4e 100644
--- a/config/mail_room.yml
+++ b/config/mail_room.yml
@@ -29,6 +29,7 @@
:delivery_method: sidekiq
:delivery_options:
:redis_url: <%= config[:redis_url].to_json %>
+ :redis_db: <%= config[:redis_db] %>
:namespace: <%= Gitlab::Redis::Queues::SIDEKIQ_NAMESPACE %>
:queue: <%= config[:queue] %>
:worker: <%= config[:worker] %>
diff --git a/doc/administration/auth/ldap/ldap-troubleshooting.md b/doc/administration/auth/ldap/ldap-troubleshooting.md
index 784a18d2b29..4757725d0bd 100644
--- a/doc/administration/auth/ldap/ldap-troubleshooting.md
+++ b/doc/administration/auth/ldap/ldap-troubleshooting.md
@@ -55,9 +55,8 @@ main: # 'main' is the GitLab 'provider ID' of this LDAP server
#### Query LDAP **(PREMIUM SELF)**
The following allows you to perform a search in LDAP using the rails console.
-Depending on what you're trying to do, it may make more sense to query [a
-user](#query-a-user-in-ldap) or [a group](#query-a-group-in-ldap) directly, or
-even [use `ldapsearch`](#ldapsearch) instead.
+Depending on what you're trying to do, it may make more sense to query [a user](#query-a-user-in-ldap)
+or [a group](#query-a-group-in-ldap) directly, or even [use `ldapsearch`](#ldapsearch) instead.
```ruby
adapter = Gitlab::Auth::Ldap::Adapter.new('ldapmain')
@@ -355,11 +354,10 @@ things to check to debug the situation.
1. Select the **Identities** tab. There should be an LDAP identity with
an LDAP DN as the 'Identifier'. If not, this user hasn't signed in with
LDAP yet and must do so first.
-- You've waited an hour or [the configured
- interval](index.md#adjust-ldap-group-sync-schedule) for the group to
- sync. To speed up the process, either go to the GitLab group **Group information > Members**
- and press **Sync now** (sync one group) or [run the group sync Rake
- task](../../raketasks/ldap.md#run-a-group-sync) (sync all groups).
+- You've waited an hour or [the configured interval](index.md#adjust-ldap-group-sync-schedule) for
+ the group to sync. To speed up the process, either go to the GitLab group **Group information > Members**
+ and press **Sync now** (sync one group) or [run the group sync Rake task](../../raketasks/ldap.md#run-a-group-sync)
+ (sync all groups).
If all of the above looks good, jump in to a little more advanced debugging in
the rails console.
@@ -371,8 +369,8 @@ the rails console.
1. Look through the output of the sync. See [example log
output](#example-console-output-after-a-group-sync)
for how to read the output.
-1. If you still aren't able to see why the user isn't being added, [query the
- LDAP group directly](#query-a-group-in-ldap) to see what members are listed.
+1. If you still aren't able to see why the user isn't being added, [query the LDAP group directly](#query-a-group-in-ldap)
+ to see what members are listed.
1. Is the user's DN or UID in one of the lists from the above output? One of the DNs or
UIDs here should match the 'Identifier' from the LDAP identity checked earlier. If it doesn't,
the user does not appear to be in the LDAP group.
@@ -398,8 +396,8 @@ GitLab syncs the `admin_group`.
#### Sync all groups
NOTE:
-To sync all groups manually when debugging is unnecessary, [use the Rake
-task](../../raketasks/ldap.md#run-a-group-sync) instead.
+To sync all groups manually when debugging is unnecessary,
+[use the Rake task](../../raketasks/ldap.md#run-a-group-sync) instead.
The output from a manual [group sync](index.md#group-sync) can show you what happens
when GitLab syncs its LDAP group memberships against LDAP.
diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md
index 0d85bb76855..4376aa13f80 100644
--- a/doc/administration/gitaly/praefect.md
+++ b/doc/administration/gitaly/praefect.md
@@ -265,8 +265,8 @@ praefect['database_direct_dbname'] = 'praefect_production'
#praefect['database_direct_sslrootcert'] = '...'
```
-We recommend using PgBouncer with `session` pool mode instead. You can use the [bundled
-PgBouncer](../postgresql/pgbouncer.md) or use an external PgBouncer and [configure it
+We recommend using PgBouncer with `session` pool mode instead. You can use the
+[bundled PgBouncer](../postgresql/pgbouncer.md) or use an external PgBouncer and [configure it
manually](https://www.pgbouncer.org/config.html).
The following example uses the bundled PgBouncer and sets up two separate connection pools,
@@ -475,8 +475,8 @@ On the **Praefect** node:
1. [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/2013) in GitLab 13.1 and later, enable [distribution of reads](index.md#distributed-reads).
-1. Save the changes to `/etc/gitlab/gitlab.rb` and [reconfigure
- Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure):
+1. Save the changes to `/etc/gitlab/gitlab.rb` and
+ [reconfigure Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure):
```shell
gitlab-ctl reconfigure
@@ -499,16 +499,16 @@ On the **Praefect** node:
running reconfigure automatically when running commands such as `apt-get update`. This way any
additional configuration changes can be done and then reconfigure can be run manually.
-1. Save the changes to `/etc/gitlab/gitlab.rb` and [reconfigure
- Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure):
+1. Save the changes to `/etc/gitlab/gitlab.rb` and
+ [reconfigure Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure):
```shell
gitlab-ctl reconfigure
```
1. To ensure that Praefect [has updated its Prometheus listen
- address](https://gitlab.com/gitlab-org/gitaly/-/issues/2734), [restart
- Praefect](../restart_gitlab.md#omnibus-gitlab-restart):
+ address](https://gitlab.com/gitlab-org/gitaly/-/issues/2734),
+ [restart Praefect](../restart_gitlab.md#omnibus-gitlab-restart):
```shell
gitlab-ctl restart praefect
@@ -695,8 +695,8 @@ Particular attention should be shown to:
was set in the [previous section](#praefect). This document uses `gitaly-1`,
`gitaly-2`, and `gitaly-3` as Gitaly storage names.
-For more information on Gitaly server configuration, see our [Gitaly
-documentation](configure_gitaly.md#configure-gitaly-servers).
+For more information on Gitaly server configuration, see our
+[Gitaly documentation](configure_gitaly.md#configure-gitaly-servers).
1. SSH into the **Gitaly** node and login as root:
@@ -803,16 +803,16 @@ documentation](configure_gitaly.md#configure-gitaly-servers).
})
```
-1. Save the changes to `/etc/gitlab/gitlab.rb` and [reconfigure
- Gitaly](../restart_gitlab.md#omnibus-gitlab-reconfigure):
+1. Save the changes to `/etc/gitlab/gitlab.rb` and
+ [reconfigure Gitaly](../restart_gitlab.md#omnibus-gitlab-reconfigure):
```shell
gitlab-ctl reconfigure
```
1. To ensure that Gitaly [has updated its Prometheus listen
- address](https://gitlab.com/gitlab-org/gitaly/-/issues/2734), [restart
- Gitaly](../restart_gitlab.md#omnibus-gitlab-restart):
+ address](https://gitlab.com/gitlab-org/gitaly/-/issues/2734),
+ [restart Gitaly](../restart_gitlab.md#omnibus-gitlab-restart):
```shell
gitlab-ctl restart gitaly
@@ -1044,8 +1044,8 @@ To get started quickly:
grafana['disable_login_form'] = false
```
-1. Save the changes to `/etc/gitlab/gitlab.rb` and [reconfigure
- GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure):
+1. Save the changes to `/etc/gitlab/gitlab.rb` and
+ [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure):
```shell
gitlab-ctl reconfigure
diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md
index ea9eb8f33fb..b3357548ca8 100644
--- a/doc/administration/instance_limits.md
+++ b/doc/administration/instance_limits.md
@@ -266,7 +266,7 @@ Set the limit to `0` to disable it.
The [minimum wait time between pull refreshes](../user/project/repository/mirror/index.md)
defaults to 300 seconds (5 minutes). For example, by default a pull refresh will only run once in a given 300 second period regardless of how many times you try to trigger it.
-This setting applies in the context of pull refreshes invoked via the [projects API](../api/projects.md#start-the-pull-mirroring-process-for-a-project), or when forcing an update by selecting the **Update now** (**{retry}**) button within **Settings > Repository > Mirroring repositories**. This setting has no effect on the automatic 30 minute interval schedule used by Sidekiq for [pull mirroring](../user/project/repository/mirror/pull.md#how-it-works).
+This setting applies in the context of pull refreshes invoked via the [projects API](../api/projects.md#start-the-pull-mirroring-process-for-a-project), or when forcing an update by selecting the **Update now** (**{retry}**) button within **Settings > Repository > Mirroring repositories**. This setting has no effect on the automatic 30 minute interval schedule used by Sidekiq for [pull mirroring](../user/project/repository/mirror/pull.md).
To change this limit for a self-managed installation, run the following in the
[GitLab Rails console](operations/rails_console.md#starting-a-rails-console-session):
diff --git a/doc/administration/lfs/index.md b/doc/administration/lfs/index.md
index 682352d8f59..d2f220e3795 100644
--- a/doc/administration/lfs/index.md
+++ b/doc/administration/lfs/index.md
@@ -2,18 +2,15 @@
stage: Create
group: Source Code
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments"
-type: reference, howto
disqus_identifier: 'https://docs.gitlab.com/ee/workflow/lfs/lfs_administration.html'
---
# GitLab Git Large File Storage (LFS) Administration **(FREE SELF)**
-> - Git LFS is supported in GitLab starting with version 8.2.
-> - Support for object storage, such as AWS S3, was introduced in 10.0.
-> - LFS is enabled in GitLab self-managed instances by default.
-
Documentation about how to use Git LFS are under [Managing large binary files with Git LFS doc](../../topics/git/lfs/index.md).
+LFS is enabled in GitLab self-managed instances by default.
+
## Requirements
- Users need to install [Git LFS client](https://git-lfs.github.com) version 1.0.1 or later.
@@ -346,8 +343,6 @@ git lfs version
## Known limitations
-- Support for removing unreferenced LFS objects was added in 8.14 onward.
-- LFS authentications via SSH was added with GitLab 8.12.
- Only compatible with the Git LFS client versions 1.1.0 and later, or 1.0.2.
- The storage statistics count each LFS object for
every project linking to it.
diff --git a/doc/api/commits.md b/doc/api/commits.md
index e91da23596f..94d1ced181c 100644
--- a/doc/api/commits.md
+++ b/doc/api/commits.md
@@ -2,7 +2,6 @@
stage: Create
group: Source Code
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments"
-type: reference, api
---
# Commits API **(FREE)**
@@ -75,8 +74,6 @@ Example response:
## Create a commit with multiple files and actions
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/6096) in GitLab 8.13.
-
Create a commit by posting a JSON payload
```plaintext
@@ -256,8 +253,6 @@ Example response:
## Get references a commit is pushed to
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/15026) in GitLab 10.6
-
Get all references (from branches or tags) a commit is pushed to.
The pagination parameters `page` and `per_page` can be used to restrict the list of references.
@@ -291,8 +286,6 @@ Example response:
## Cherry-pick a commit
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/8047) in GitLab 8.15.
-
Cherry-picks a commit to a given branch.
```plaintext
@@ -366,8 +359,6 @@ dry run.
## Revert a commit
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/22919) in GitLab 11.5.
-
Reverts a commit in a given branch.
```plaintext
@@ -622,7 +613,7 @@ Example response:
## Commit status
-In GitLab 8.1 and later, this is the new commit status API.
+This is the commit status API for use with GitLab.
### List the statuses of a commit
@@ -752,8 +743,6 @@ Example response:
## List Merge Requests associated with a commit
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/18004) in GitLab 10.7.
-
Get a list of Merge Requests related to the specified commit.
```plaintext
diff --git a/doc/api/members.md b/doc/api/members.md
index a30e2745ce2..3c54665408a 100644
--- a/doc/api/members.md
+++ b/doc/api/members.md
@@ -561,7 +561,12 @@ Example response:
## Remove a member from a group or project
-Removes a user from a group or project.
+Removes a user from a group or project where the user has been explicitly assigned a role.
+
+The user needs to be a group member to qualify for removal.
+For example, if the user was added directly to a project within the group but not this
+group explicitly, you cannot use this API to remove them. See
+[Remove a billable member from a group](#remove-a-billable-member-from-a-group) for an alternative approach.
```plaintext
DELETE /groups/:id/members/:user_id
diff --git a/doc/api/merge_request_approvals.md b/doc/api/merge_request_approvals.md
index 998fb534b7b..4ede95ea189 100644
--- a/doc/api/merge_request_approvals.md
+++ b/doc/api/merge_request_approvals.md
@@ -2,7 +2,6 @@
stage: Create
group: Source Code
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments"
-type: reference, api
---
# Merge request approvals API **(PREMIUM)**
@@ -15,8 +14,7 @@ in the project. Must be authenticated for all endpoints.
### Get Configuration
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/183) in GitLab 10.6.
-> - Moved to GitLab Premium in 13.9.
+> Moved to GitLab Premium in 13.9.
You can request information about a project's approval configuration using the
following endpoint:
@@ -44,8 +42,7 @@ GET /projects/:id/approvals
### Change configuration
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/183) in GitLab 10.6.
-> - Moved to GitLab Premium in 13.9.
+> Moved to GitLab Premium in 13.9.
If you are allowed to, you can change approval configuration using the following
endpoint:
@@ -180,7 +177,7 @@ GET /projects/:id/approval_rules
### Get a single project-level rule
-> - Introduced 13.7.
+> Introduced in GitLab 13.7.
You can request information about a single project approval rules using the following endpoint:
@@ -297,7 +294,7 @@ POST /projects/:id/approval_rules
| `rule_type` | string | no | The type of rule. `any_approver` is a pre-configured default rule with `approvals_required` at `0`. Other rules are `regular`.
| `user_ids` | Array | no | The ids of users as approvers |
| `group_ids` | Array | no | The ids of groups as approvers |
-| `protected_branch_ids` | Array | no | **(PREMIUM)** The ids of protected branches to scope the rule by |
+| `protected_branch_ids` | Array | no | **(PREMIUM)** The ids of protected branches to scope the rule by. To identify the ID, [use the API](protected_branches.md#list-protected-branches). |
```json
{
@@ -420,7 +417,7 @@ PUT /projects/:id/approval_rules/:approval_rule_id
| `approvals_required` | integer | yes | The number of required approvals for this rule |
| `user_ids` | Array | no | The ids of users as approvers |
| `group_ids` | Array | no | The ids of groups as approvers |
-| `protected_branch_ids` | Array | no | **(PREMIUM)** The ids of protected branches to scope the rule by |
+| `protected_branch_ids` | Array | no | **(PREMIUM)** The ids of protected branches to scope the rule by. To identify the ID, [use the API](protected_branches.md#list-protected-branches). |
```json
{
@@ -527,8 +524,7 @@ Configuration for approvals on a specific Merge Request. Must be authenticated f
### Get Configuration
-> - Introduced in GitLab 8.9.
-> - Moved to GitLab Premium in 13.9.
+> Moved to GitLab Premium in 13.9.
You can request information about a merge request's approval status using the
following endpoint:
@@ -574,8 +570,7 @@ GET /projects/:id/merge_requests/:merge_request_iid/approvals
### Change approval configuration
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/183) in GitLab 10.6.
-> - Moved to GitLab Premium in 13.9.
+> Moved to GitLab Premium in 13.9.
If you are allowed to, you can change `approvals_required` using the following
endpoint:
@@ -955,8 +950,7 @@ These are system generated rules.
## Approve Merge Request
-> - Introduced in GitLab 8.9.
-> - Moved to GitLab Premium in 13.9.
+> Moved to GitLab Premium in 13.9.
If you are allowed to, you can approve a merge request using the following
endpoint:
@@ -1019,8 +1013,7 @@ does not match, the response code is `409`.
## Unapprove Merge Request
-> - Introduced in GitLab 9.0.
-> - Moved to GitLab Premium in 13.9.
+> Moved to GitLab Premium in 13.9.
If you did approve a merge request, you can unapprove it using the following
endpoint:
diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md
index 324ce6d35e7..df63cec546d 100644
--- a/doc/development/documentation/styleguide/index.md
+++ b/doc/development/documentation/styleguide/index.md
@@ -1374,6 +1374,8 @@ Alert boxes are generated when one of these words is followed by a line break:
- `FLAG:`
- `NOTE:`
- `WARNING:`
+- `INFO:` (Marketing only)
+- `DISCLAIMER:`
For example:
@@ -1430,6 +1432,58 @@ It renders on the GitLab documentation site as:
WARNING:
This is something to be warned about.
+### Info
+
+The Marketing team uses the `INFO` alert to add information relating
+to sales and marketing efforts.
+
+The text in an `INFO:` alert always renders in a floating text box to the right of the text around it.
+To view the rendered GitLab docs site, check the review app in the MR. You might need to move the text up or down
+in the surrounding text, depending on where you'd like to floating box to appear.
+
+For example, if your page has text like this:
+
+```markdown
+This is an introductory paragraph. GitLab uses the SSH protocol to securely communicate with Git.
+When you use SSH keys to authenticate to the GitLab remote server,
+you don't need to supply your username and password each time.
+
+INFO:
+Here is some information. This information is an important addition to how you
+work with GitLab and you might want to consider it.
+
+And here is another paragraph. GitLab uses the SSH protocol to securely communicate with Git.
+When you use SSH keys to authenticate to the GitLab remote server,
+you don't need to supply your username and password each time.
+
+And here is another paragraph. GitLab uses the SSH protocol to securely communicate with Git.
+When you use SSH keys to authenticate to the GitLab remote server,
+you don't need to supply your username and password each time.
+```
+
+It renders on the GitLab documentation site as:
+
+This is an introductory paragraph. GitLab uses the SSH protocol to securely communicate with Git.
+When you use SSH keys to authenticate to the GitLab remote server,
+you don't need to supply your username and password each time.
+
+INFO:
+Here is some information. This information is an important addition to how you
+work with GitLab and you might want to consider it.
+
+And here is another paragraph. GitLab uses the SSH protocol to securely communicate with Git.
+When you use SSH keys to authenticate to the GitLab remote server,
+you don't need to supply your username and password each time.
+
+And here is another paragraph. GitLab uses the SSH protocol to securely communicate with Git.
+When you use SSH keys to authenticate to the GitLab remote server,
+you don't need to supply your username and password each time.
+
+### Disclaimer
+
+Use to describe future functionality only.
+For more information, see [Legal disclaimer for future features](#legal-disclaimer-for-future-features).
+
## Blockquotes
For highlighting a text inside a blockquote, use this format:
@@ -1623,6 +1677,24 @@ For example:
You can say that we plan to remove a feature.
+#### Legal disclaimer for future features
+
+If you **must** write about features we have not yet delivered, put this exact disclaimer near the content it applies to.
+
+```markdown
+DISCLAIMER:
+This page contains information related to upcoming products, features, and functionality.
+It is important to note that the information presented is for informational purposes only.
+Please do not rely on this information for purchasing or planning purposes.
+As with all projects, the items mentioned on this page are subject to change or delay.
+The development, release, and timing of any products, features, or functionality remain at the
+sole discretion of GitLab Inc.
+```
+
+If all of the content on the page is not available, use the disclaimer once at the top of the page.
+
+If the content in a topic is not ready, use the disclaimer in the topic.
+
### Removing versions after each major release
Whenever a major GitLab release occurs, we remove all version references
diff --git a/doc/push_rules/push_rules.md b/doc/push_rules/push_rules.md
index 5c656b2b5a7..3d0e4fdbe49 100644
--- a/doc/push_rules/push_rules.md
+++ b/doc/push_rules/push_rules.md
@@ -73,7 +73,7 @@ Some example regular expressions you can use in push rules:
By default, GitLab restricts certain formats of branch names for security purposes.
40-character hexadecimal names, similar to Git commit hashes, are prohibited.
-### Custom Push Rules **(FREE SELF)**
+### Custom Push Rules **(PREMIUM SELF)**
It's possible to create custom push rules rather than the push rules available in
**Admin Area > Push Rules** by using more advanced server hooks.
@@ -104,8 +104,8 @@ The following options are available:
|---------------------------------|-------------|
| Removal of tags with `git push` | Forbid users to remove Git tags with `git push`. Tags can be deleted through the web UI. |
| Check whether the commit author is a GitLab user | Restrict commits to existing GitLab users (checked against their emails). |
-| Reject unverified users **(PREMIUM)** | GitLab rejects any commit that was not committed by an authenticated user. |
-| Check whether commit is signed through GPG **(PREMIUM)** | Reject commit when it is not signed through GPG. Read [signing commits with GPG](../user/project/repository/gpg_signed_commits/index.md). |
+| Reject unverified users | GitLab rejects any commit that was not committed by an authenticated user. |
+| Check whether commit is signed through GPG | Reject commit when it is not signed through GPG. Read [signing commits with GPG](../user/project/repository/gpg_signed_commits/index.md). |
| Prevent pushing secret files | GitLab rejects any files that are likely to contain secrets. See the [forbidden file names](#prevent-pushing-secrets-to-the-repository). |
| Require expression in commit messages | Only commit messages that match this regular expression are allowed to be pushed. Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. |
| Reject expression in commit messages | Only commit messages that do not match this regular expression are allowed to be pushed. Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. |
@@ -117,7 +117,7 @@ The following options are available:
NOTE:
GitLab uses [RE2 syntax](https://github.com/google/re2/wiki/Syntax) for regular expressions in push rules, and you can test them at the [regex101 regex tester](https://regex101.com/).
-### Caveat to "Reject unsigned commits" push rule **(PREMIUM)**
+### Caveat to "Reject unsigned commits" push rule
This push rule ignores commits that are authenticated and created by GitLab
(either through the UI or API). When the **Reject unsigned commits** push rule is
diff --git a/doc/subscriptions/bronze_starter.md b/doc/subscriptions/bronze_starter.md
index b311653eef7..bb2a2303d29 100644
--- a/doc/subscriptions/bronze_starter.md
+++ b/doc/subscriptions/bronze_starter.md
@@ -93,7 +93,7 @@ the tiers are no longer mentioned in GitLab documentation:
- [Overwrite diverged branches](../user/project/repository/mirror/pull.md#overwrite-diverged-branches)
- [Trigger pipelines for mirror updates](../user/project/repository/mirror/pull.md#trigger-pipelines-for-mirror-updates)
- [Hard failures](../user/project/repository/mirror/pull.md#hard-failure) when mirroring fails
- - [Trigger pull mirroring from the API](../user/project/repository/mirror/pull.md#trigger-an-update-using-the-api)
+ - [Trigger pull mirroring from the API](../user/project/repository/mirror/pull.md#trigger-an-update-by-using-the-api)
- [Mirror only protected branches](../user/project/repository/mirror/index.md#mirror-only-protected-branches)
- [Bidirectional mirroring](../user/project/repository/mirror/bidirectional.md)
- [Mirror with Perforce Helix via Git Fusion](../user/project/repository/mirror/bidirectional.md#mirror-with-perforce-helix-via-git-fusion)
diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md
index 91858ff9a65..351daaaca3b 100644
--- a/doc/user/project/repository/branches/index.md
+++ b/doc/user/project/repository/branches/index.md
@@ -2,7 +2,6 @@
stage: Create
group: Source Code
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments"
-type: concepts, howto
---
# Branches **(FREE)**
@@ -57,8 +56,6 @@ To compare branches in a repository:
## Delete merged branches
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/6449) in GitLab 8.14.
-
![Delete merged branches](img/delete_merged_branches.png)
This feature allows merged branches to be deleted in bulk. Only branches that
@@ -83,8 +80,6 @@ Search results appear in the following order:
## Branch filter search box
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/22166) in GitLab 11.5.
-
![Branch filter search box](img/branch_filter_search_box_v13_12.png)
This feature allows you to search and select branches quickly. Search results appear in the following order:
diff --git a/doc/user/project/repository/mirror/pull.md b/doc/user/project/repository/mirror/pull.md
index b75ae079ba0..1b16263547f 100644
--- a/doc/user/project/repository/mirror/pull.md
+++ b/doc/user/project/repository/mirror/pull.md
@@ -7,82 +7,101 @@ disqus_identifier: 'https://docs.gitlab.com/ee/workflow/repository_mirroring.htm
# Pull from a remote repository **(PREMIUM)**
-> - [Added Git LFS support](https://gitlab.com/gitlab-org/gitlab/-/issues/10871) in GitLab 11.11.
-> - Moved to GitLab Premium in 13.9.
-
-You can set up a repository to automatically have its branches, tags, and commits updated from an
-upstream repository.
-
-If a repository you're interested in is located on a different server, and you want
-to browse its content and its activity using the GitLab interface, you can configure
-mirror pulling:
-
-1. If your remote repository is on GitHub and you have
- [two-factor authentication (2FA) configured](https://docs.github.com/en/github/authenticating-to-github/securing-your-account-with-two-factor-authentication-2fa),
- create a [personal access token for GitHub](https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token).
- with the `repo` scope. If 2FA is enabled, this personal access
- token serves as your GitHub password.
-1. In your project, go to **Settings > Repository**, and then expand the
- **Mirroring repositories** section.
-1. In the **Git repository URL** field, enter a repository URL. Include the username
- in the URL if required: `https://MYUSERNAME@github.com/group/PROJECTNAME.git`
-1. In the **Mirror direction** dropdown, select **Pull**.
-1. In the **Authentication method** dropdown, select your authentication method.
-1. Select from the following checkboxes, if needed:
- - **Overwrite diverged branches**
- - **Trigger pipelines for mirror updates**
- - **Only mirror protected branches**
-1. Select **Mirror repository** to save the configuration.
-
-Because GitLab is now set to pull changes from the upstream repository, you should not push commits
-directly to the repository on GitLab. Instead, any commits should be pushed to the remote repository.
-Changes pushed to the remote repository are pulled into the GitLab repository, either:
-
-- Automatically in a certain period of time.
-- When a [forced update](index.md#force-an-update) is initiated.
-
-WARNING:
-If you do manually update a branch in the GitLab repository, the branch becomes diverged from
-upstream, and GitLab no longer automatically updates this branch to prevent any changes from being lost.
-Deleted branches and tags in the upstream repository are not reflected in the GitLab repository.
-
-## How it works
-
-After the pull mirroring feature has been enabled for a repository, the repository is added to a queue.
-
-Once per minute, a Sidekiq cron job schedules repository mirrors to update, based on:
-
-- The capacity available. This is determined by Sidekiq settings. For GitLab.com, see [GitLab.com Sidekiq settings](../../../gitlab_com/index.md#sidekiq).
-- The number of repository mirrors already in the queue that are due to be updated. Being due depends on when the repository mirror was last updated and how many times it's been retried.
-
-Repository mirrors are updated as Sidekiq becomes available to process them. If the process of updating the repository mirror:
+> Moved to GitLab Premium in 13.9.
-- **Succeeds**: An update is enqueued again with at least a 30 minute wait.
-- **Fails**: (For example, a branch diverged from upstream.), The update attempted again later. Mirrors can fail
- up to 14 times before they are no longer enqueued for updates.
+You can use the GitLab interface to browse the content and activity of a repository,
+even if it isn't hosted on GitLab. Create a pull [mirror](index.md) to copy the
+branches, tags, and commits from an upstream repository to yours.
+
+Unlike [push mirrors](push.md), pull mirrors retrieve changes from an upstream (remote)
+repository on a scheduled basis. To prevent the mirror from diverging from the upstream
+repository, don't push commits directly to the downstream mirror. Push commits to
+the upstream repository instead. Changes in the remote repository are pulled into the GitLab repository, either:
+
+- Automatically in a certain period of time. Self-managed instances can
+ configure [pull mirroring intervals](../../../../administration/instance_limits.md#pull-mirroring-interval).
+- When an administrator [force-updates the mirror](index.md#force-an-update).
+- When an [API call triggers an update](#trigger-an-update-by-using-the-api).
+
+By default, if any branch or tag on the downstream pull mirror diverges from the
+local repository, GitLab stops updating the branch. This prevents data loss.
+Deleted branches and tags in the upstream repository are not reflected in the
+downstream repository.
+
+## How pull mirroring works
+
+After you configure a GitLab repository as a pull mirror:
+
+1. GitLab adds the repository to a queue.
+1. Once per minute, a Sidekiq cron job schedules repository mirrors to update, based on:
+ - Available capacity, determined by Sidekiq settings. For GitLab.com, read
+ [GitLab.com Sidekiq settings](../../../gitlab_com/index.md#sidekiq).
+ - How many mirrors are already in the queue and due for updates. Being due depends
+ on when the repository mirror was last updated, and how many times updates have been retried.
+1. Sidekiq becomes available to process updates, mirrors are updated. If the update process:
+ - **Succeeds**: An update is enqueued again with at least a 30 minute wait.
+ - **Fails**: The update is attempted again later. After 14 failures, a mirror is marked as a
+ [hard failure](#hard-failure) and is no longer enqueued for updates. A branch diverging
+ from its upstream counterpart can cause failures. To prevent branches from
+ diverging, configure [Overwrite diverged branches](#overwrite-diverged-branches) when
+ you create your mirror.
+
+## Configure pull mirroring
+
+Prerequisite:
+
+- If your remote repository is on GitHub and you have
+ [two-factor authentication (2FA) configured](https://docs.github.com/en/github/authenticating-to-github/securing-your-account-with-two-factor-authentication-2fa),
+ create a [personal access token for GitHub](https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token)
+ with the `repo` scope. If 2FA is enabled, this personal access
+ token serves as your GitHub password.
+
+1. On the top bar, select **Menu > Projects** and find your project.
+1. On the left sidebar, select **Settings > Repository**.
+1. Expand **Mirroring repositories**.
+1. Enter the **Git repository URL**. Include the username
+ in the URL, if required: `https://MYUSERNAME@github.com/GROUPNAME/PROJECTNAME.git`
+1. In **Mirror direction**, select **Pull**.
+1. In **Authentication method**, select your authentication method.
+1. Select any of the options you need:
+ - [**Overwrite diverged branches**](#overwrite-diverged-branches)
+ - [**Trigger pipelines for mirror updates**](#trigger-pipelines-for-mirror-updates)
+ - **Only mirror protected branches**
+1. To save the configuration, select **Mirror repository**.
-## Overwrite diverged branches
+### Overwrite diverged branches
> Moved to GitLab Premium in 13.9.
-You can choose to always update your local branches with remote versions, even if they have
-diverged from the remote.
+To always update your local branches with remote versions, even if they have
+diverged from the remote, select **Overwrite diverged branches** when you
+create a mirror.
WARNING:
For mirrored branches, enabling this option results in the loss of local changes.
-To use this option, check the **Overwrite diverged branches** box when creating a repository mirror.
-
-## Trigger pipelines for mirror updates
+### Trigger pipelines for mirror updates
> Moved to GitLab Premium in 13.9.
If this option is enabled, pipelines trigger when branches or tags are
updated from the remote repository. Depending on the activity of the remote
repository, this may greatly increase the load on your CI runners. Only enable
-this if you know they can handle the load. CI uses the credentials
+this feature if you know they can handle the load. CI uses the credentials
assigned when you set up pull mirroring.
+## Trigger an update by using the API
+
+> Moved to GitLab Premium in 13.9.
+
+Pull mirroring uses polling to detect new branches and commits added upstream,
+often minutes afterwards. If you notify GitLab by
+[API](../../../../api/projects.md#start-the-pull-mirroring-process-for-a-project),
+updates are pulled immediately.
+
+For more information, read
+[Start the pull mirroring process for a project](../../../../api/projects.md#start-the-pull-mirroring-process-for-a-project).
+
## Hard failure
> Moved to GitLab Premium in 13.9.
@@ -95,12 +114,8 @@ and mirroring attempts stop. This failure is visible in either the:
You can resume the project mirroring again by [forcing an update](index.md#force-an-update).
-## Trigger an update using the API
-
-> Moved to GitLab Premium in 13.9.
-
-Pull mirroring uses polling to detect new branches and commits added upstream, often minutes
-afterwards. If you notify GitLab by [API](../../../../api/projects.md#start-the-pull-mirroring-process-for-a-project),
-updates are pulled immediately.
+## Related topics
-For more information, see [Start the pull mirroring process for a Project](../../../../api/projects.md#start-the-pull-mirroring-process-for-a-project).
+- Configure [pull mirroring intervals](../../../../administration/instance_limits.md#pull-mirroring-interval)
+ on self-managed instances.
+- Configure [pull mirroring through the API](../../../../api/projects.md#configure-pull-mirroring-for-a-project).
diff --git a/doc/user/project/repository/reducing_the_repo_size_using_git.md b/doc/user/project/repository/reducing_the_repo_size_using_git.md
index 81429ea5384..b853ef4ba67 100644
--- a/doc/user/project/repository/reducing_the_repo_size_using_git.md
+++ b/doc/user/project/repository/reducing_the_repo_size_using_git.md
@@ -18,7 +18,7 @@ Such problems can be detected with [git-sizer](https://github.com/github/git-siz
Rewriting a repository can remove unwanted history to make the repository smaller.
We **recommend [`git filter-repo`](https://github.com/newren/git-filter-repo/blob/main/README.md)**
over [`git filter-branch`](https://git-scm.com/docs/git-filter-branch) and
-[BFG](https://rtyley.github.io/bfg-repo-cleaner/).
+[BFG](https://rtyley.github.io/bfg-repo-cleaner/).
WARNING:
Rewriting repository history is a destructive operation. Make sure to back up your repository before
@@ -63,6 +63,12 @@ To purge files from a GitLab repository:
git clone --bare --mirror /path/to/project.bundle
```
+1. Navigate to the `project.git` directory:
+
+ ```shell
+ cd project.git
+ ```
+
1. Using `git filter-repo`, purge any files from the history of your repository. Because we are
trying to remove internal refs, we rely on the `commit-map` produced by each run to tell us
which internal refs to remove.
diff --git a/lib/api/notes.rb b/lib/api/notes.rb
index 418efe3d1a7..953c7feefbc 100644
--- a/lib/api/notes.rb
+++ b/lib/api/notes.rb
@@ -88,7 +88,7 @@ module API
note = create_note(noteable, opts)
- if note.errors.keys == [:commands_only]
+ if note.errors.attribute_names == [:commands_only]
status 202
present note, with: Entities::NoteCommands
elsif note.valid?
diff --git a/lib/gitlab/mail_room.rb b/lib/gitlab/mail_room.rb
index 0633efc6b0c..75d27ed8cc1 100644
--- a/lib/gitlab/mail_room.rb
+++ b/lib/gitlab/mail_room.rb
@@ -71,7 +71,8 @@ module Gitlab
def redis_config
gitlab_redis_queues = Gitlab::Redis::Queues.new(rails_env)
- config = { redis_url: gitlab_redis_queues.url }
+
+ config = { redis_url: gitlab_redis_queues.url, redis_db: gitlab_redis_queues.db }
if gitlab_redis_queues.sentinels?
config[:sentinels] = gitlab_redis_queues.sentinels
diff --git a/lib/gitlab/redis/wrapper.rb b/lib/gitlab/redis/wrapper.rb
index db1e24a3201..7b804038146 100644
--- a/lib/gitlab/redis/wrapper.rb
+++ b/lib/gitlab/redis/wrapper.rb
@@ -96,6 +96,8 @@ module Gitlab
end
def instrumentation_class
+ return unless defined?(::Gitlab::Instrumentation::Redis)
+
"::Gitlab::Instrumentation::Redis::#{store_name}".constantize
end
end
@@ -112,6 +114,10 @@ module Gitlab
raw_config_hash[:url]
end
+ def db
+ redis_store_options[:db]
+ end
+
def sentinels
raw_config_hash[:sentinels]
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index b11c0629d20..8e73a4ae1ee 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3611,6 +3611,9 @@ msgstr ""
msgid "An error occurred while fetching codequality mr diff reports."
msgstr ""
+msgid "An error occurred while fetching commit data."
+msgstr ""
+
msgid "An error occurred while fetching commits. Retry the search."
msgstr ""
@@ -14950,6 +14953,9 @@ msgstr ""
msgid "General pipelines"
msgstr ""
+msgid "General settings"
+msgstr ""
+
msgid "Generate a default set of labels"
msgstr ""
@@ -16012,6 +16018,9 @@ msgstr ""
msgid "Group by"
msgstr ""
+msgid "Group description"
+msgstr ""
+
msgid "Group description (optional)"
msgstr ""
@@ -20106,6 +20115,9 @@ msgstr ""
msgid "Learn more about group-level project templates"
msgstr ""
+msgid "Learn more about groups."
+msgstr ""
+
msgid "Learn more about shards and replicas in the %{configuration_link_start}Advanced search configuration%{configuration_link_end} documentation. Changes won't take place until the index is %{recreated_link_start}recreated%{recreated_link_end}."
msgstr ""
@@ -23971,6 +23983,9 @@ msgstr ""
msgid "Optional parameter \"variables\" must be a Hash. Ex: variables[key1]=value1"
msgstr ""
+msgid "Optional."
+msgstr ""
+
msgid "Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab."
msgstr ""
diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js
new file mode 100644
index 00000000000..fd04ff8b3e7
--- /dev/null
+++ b/spec/frontend/clusters/agents/components/show_spec.js
@@ -0,0 +1,195 @@
+import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlSprintf, GlTab } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import ClusterAgentShow from '~/clusters/agents/components/show.vue';
+import TokenTable from '~/clusters/agents/components/token_table.vue';
+import getAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
+import { useFakeDate } from 'helpers/fake_date';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('ClusterAgentShow', () => {
+ let wrapper;
+ useFakeDate([2021, 2, 15]);
+
+ const propsData = {
+ agentName: 'cluster-agent',
+ projectPath: 'path/to/project',
+ };
+
+ const defaultClusterAgent = {
+ id: '1',
+ createdAt: '2021-02-13T00:00:00Z',
+ createdByUser: {
+ name: 'user-1',
+ },
+ name: 'token-1',
+ tokens: {
+ count: 1,
+ nodes: [],
+ pageInfo: null,
+ },
+ };
+
+ const createWrapper = ({ clusterAgent, queryResponse = null }) => {
+ const agentQueryResponse =
+ queryResponse || jest.fn().mockResolvedValue({ data: { project: { clusterAgent } } });
+ const apolloProvider = createMockApollo([[getAgentQuery, agentQueryResponse]]);
+
+ wrapper = shallowMount(ClusterAgentShow, {
+ localVue,
+ apolloProvider,
+ propsData,
+ stubs: { GlSprintf, TimeAgoTooltip, GlTab },
+ });
+ };
+
+ const createWrapperWithoutApollo = ({ clusterAgent, loading = false }) => {
+ const $apollo = { queries: { clusterAgent: { loading } } };
+
+ wrapper = shallowMount(ClusterAgentShow, {
+ propsData,
+ mocks: { $apollo, clusterAgent },
+ stubs: { GlTab },
+ });
+ };
+
+ const findCreatedText = () => wrapper.find('[data-testid="cluster-agent-create-info"]').text();
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findPaginationButtons = () => wrapper.find(GlKeysetPagination);
+ const findTokenCount = () => wrapper.find('[data-testid="cluster-agent-token-count"]').text();
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default behaviour', () => {
+ beforeEach(() => {
+ return createWrapper({ clusterAgent: defaultClusterAgent });
+ });
+
+ it('displays the agent name', () => {
+ expect(wrapper.text()).toContain(propsData.agentName);
+ });
+
+ it('displays agent create information', () => {
+ expect(findCreatedText()).toMatchInterpolatedText('Created by user-1 2 days ago');
+ });
+
+ it('displays token count', () => {
+ expect(findTokenCount()).toMatchInterpolatedText(
+ `${ClusterAgentShow.i18n.tokens} ${defaultClusterAgent.tokens.count}`,
+ );
+ });
+
+ it('renders token table', () => {
+ expect(wrapper.find(TokenTable).exists()).toBe(true);
+ });
+
+ it('should not render pagination buttons when there are no additional pages', () => {
+ expect(findPaginationButtons().exists()).toBe(false);
+ });
+ });
+
+ describe('when create user is unknown', () => {
+ const missingUser = {
+ ...defaultClusterAgent,
+ createdByUser: null,
+ };
+
+ beforeEach(() => {
+ return createWrapper({ clusterAgent: missingUser });
+ });
+
+ it('displays agent create information with unknown user', () => {
+ expect(findCreatedText()).toMatchInterpolatedText('Created by Unknown user 2 days ago');
+ });
+ });
+
+ describe('when token count is missing', () => {
+ const missingTokens = {
+ ...defaultClusterAgent,
+ tokens: null,
+ };
+
+ beforeEach(() => {
+ return createWrapper({ clusterAgent: missingTokens });
+ });
+
+ it('displays token header with no count', () => {
+ expect(findTokenCount()).toMatchInterpolatedText(`${ClusterAgentShow.i18n.tokens}`);
+ });
+ });
+
+ describe('when the token list has additional pages', () => {
+ const pageInfo = {
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'prev',
+ endCursor: 'next',
+ };
+
+ const tokenPagination = {
+ ...defaultClusterAgent,
+ tokens: {
+ ...defaultClusterAgent.tokens,
+ pageInfo,
+ },
+ };
+
+ beforeEach(() => {
+ return createWrapper({ clusterAgent: tokenPagination });
+ });
+
+ it('should render pagination buttons', () => {
+ expect(findPaginationButtons().exists()).toBe(true);
+ });
+
+ it('should pass pageInfo to the pagination component', () => {
+ expect(findPaginationButtons().props()).toMatchObject(pageInfo);
+ });
+ });
+
+ describe('when the agent query is loading', () => {
+ describe('when the clusterAgent is missing', () => {
+ beforeEach(() => {
+ return createWrapper({
+ clusterAgent: null,
+ queryResponse: jest.fn().mockReturnValue(new Promise(() => {})),
+ });
+ });
+
+ it('displays a loading icon and hides the token tab', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(wrapper.text()).not.toContain(ClusterAgentShow.i18n.tokens);
+ });
+ });
+
+ describe('when the clusterAgent is present', () => {
+ beforeEach(() => {
+ createWrapperWithoutApollo({ clusterAgent: defaultClusterAgent, loading: true });
+ });
+
+ it('displays a loading icon and token tab', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(wrapper.text()).toContain(ClusterAgentShow.i18n.tokens);
+ });
+ });
+ });
+
+ describe('when the agent query has errored', () => {
+ beforeEach(() => {
+ createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() });
+ return waitForPromises();
+ });
+
+ it('displays an alert message', () => {
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
+ expect(wrapper.text()).toContain(ClusterAgentShow.i18n.loadingError);
+ });
+ });
+});
diff --git a/spec/frontend/clusters/agents/components/token_table_spec.js b/spec/frontend/clusters/agents/components/token_table_spec.js
new file mode 100644
index 00000000000..47ff944dd84
--- /dev/null
+++ b/spec/frontend/clusters/agents/components/token_table_spec.js
@@ -0,0 +1,135 @@
+import { GlEmptyState, GlLink, GlTooltip, GlTruncate } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import TokenTable from '~/clusters/agents/components/token_table.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+
+describe('ClusterAgentTokenTable', () => {
+ let wrapper;
+ useFakeDate([2021, 2, 15]);
+
+ const defaultTokens = [
+ {
+ id: '1',
+ createdAt: '2021-02-13T00:00:00Z',
+ description: 'Description of token 1',
+ createdByUser: {
+ name: 'user-1',
+ },
+ lastUsedAt: '2021-02-13T00:00:00Z',
+ name: 'token-1',
+ },
+ {
+ id: '2',
+ createdAt: '2021-02-10T00:00:00Z',
+ description: null,
+ createdByUser: null,
+ lastUsedAt: null,
+ name: 'token-2',
+ },
+ ];
+
+ const createComponent = (tokens) => {
+ wrapper = extendedWrapper(mount(TokenTable, { propsData: { tokens } }));
+ };
+
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+ const findLink = () => wrapper.find(GlLink);
+
+ beforeEach(() => {
+ return createComponent(defaultTokens);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays a learn more link', () => {
+ const learnMoreLink = findLink();
+
+ expect(learnMoreLink.exists()).toBe(true);
+ expect(learnMoreLink.text()).toBe(TokenTable.i18n.learnMore);
+ });
+
+ it.each`
+ name | lineNumber
+ ${'token-1'} | ${0}
+ ${'token-2'} | ${1}
+ `('displays token name "$name" for line "$lineNumber"', ({ name, lineNumber }) => {
+ const tokens = wrapper.findAll('[data-testid="agent-token-name"]');
+ const token = tokens.at(lineNumber);
+
+ expect(token.text()).toBe(name);
+ });
+
+ it.each`
+ lastContactText | lineNumber
+ ${'2 days ago'} | ${0}
+ ${'Never'} | ${1}
+ `(
+ 'displays last contact information "$lastContactText" for line "$lineNumber"',
+ ({ lastContactText, lineNumber }) => {
+ const tokens = wrapper.findAllByTestId('agent-token-used');
+ const token = tokens.at(lineNumber);
+
+ expect(token.text()).toBe(lastContactText);
+ },
+ );
+
+ it.each`
+ createdText | lineNumber
+ ${'2 days ago'} | ${0}
+ ${'5 days ago'} | ${1}
+ `(
+ 'displays created information "$createdText" for line "$lineNumber"',
+ ({ createdText, lineNumber }) => {
+ const tokens = wrapper.findAll('[data-testid="agent-token-created-time"]');
+ const token = tokens.at(lineNumber);
+
+ expect(token.text()).toBe(createdText);
+ },
+ );
+
+ it.each`
+ createdBy | lineNumber
+ ${'user-1'} | ${0}
+ ${'Unknown user'} | ${1}
+ `(
+ 'displays creator information "$createdBy" for line "$lineNumber"',
+ ({ createdBy, lineNumber }) => {
+ const tokens = wrapper.findAll('[data-testid="agent-token-created-user"]');
+ const token = tokens.at(lineNumber);
+
+ expect(token.text()).toBe(createdBy);
+ },
+ );
+
+ it.each`
+ description | truncatesText | hasTooltip | lineNumber
+ ${'Description of token 1'} | ${true} | ${true} | ${0}
+ ${''} | ${false} | ${false} | ${1}
+ `(
+ 'displays description information "$description" for line "$lineNumber"',
+ ({ description, truncatesText, hasTooltip, lineNumber }) => {
+ const tokens = wrapper.findAll('[data-testid="agent-token-description"]');
+ const token = tokens.at(lineNumber);
+
+ expect(token.text()).toContain(description);
+ expect(token.find(GlTruncate).exists()).toBe(truncatesText);
+ expect(token.find(GlTooltip).exists()).toBe(hasTooltip);
+ },
+ );
+
+ describe('when there are no tokens', () => {
+ beforeEach(() => {
+ return createComponent([]);
+ });
+
+ it('displays an empty state', () => {
+ const emptyState = findEmptyState();
+
+ expect(emptyState.exists()).toBe(true);
+ expect(emptyState.text()).toContain(TokenTable.i18n.noTokens);
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
new file mode 100644
index 00000000000..a548721588e
--- /dev/null
+++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
@@ -0,0 +1,77 @@
+import { GlAlert, GlEmptyState, GlSprintf } from '@gitlab/ui';
+import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+const emptyStateImage = '/path/to/image';
+const projectPath = 'path/to/project';
+const agentDocsUrl = 'path/to/agentDocs';
+const installDocsUrl = 'path/to/installDocs';
+const getStartedDocsUrl = 'path/to/getStartedDocs';
+const integrationDocsUrl = 'path/to/integrationDocs';
+
+describe('AgentEmptyStateComponent', () => {
+ let wrapper;
+
+ const propsData = {
+ hasConfigurations: false,
+ };
+ const provideData = {
+ emptyStateImage,
+ projectPath,
+ agentDocsUrl,
+ installDocsUrl,
+ getStartedDocsUrl,
+ integrationDocsUrl,
+ };
+
+ const findConfigurationsAlert = () => wrapper.findComponent(GlAlert);
+ const findAgentDocsLink = () => wrapper.findByTestId('agent-docs-link');
+ const findInstallDocsLink = () => wrapper.findByTestId('install-docs-link');
+ const findIntegrationButton = () => wrapper.findByTestId('integration-primary-button');
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(AgentEmptyState, {
+ propsData,
+ provide: provideData,
+ stubs: { GlEmptyState, GlSprintf },
+ });
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ it('renders correct href attributes for the links', () => {
+ expect(findAgentDocsLink().attributes('href')).toBe(agentDocsUrl);
+ expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl);
+ });
+
+ describe('when there are no agent configurations in repository', () => {
+ it('should render notification message box', () => {
+ expect(findConfigurationsAlert().exists()).toBe(true);
+ });
+
+ it('should disable integration button', () => {
+ expect(findIntegrationButton().attributes('disabled')).toBe('true');
+ });
+ });
+
+ describe('when there is a list of agent configurations', () => {
+ beforeEach(() => {
+ propsData.hasConfigurations = true;
+ wrapper = shallowMountExtended(AgentEmptyState, {
+ propsData,
+ provide: provideData,
+ });
+ });
+ it('should render content without notification message box', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findConfigurationsAlert().exists()).toBe(false);
+ expect(findIntegrationButton().attributes('disabled')).toBeUndefined();
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
new file mode 100644
index 00000000000..e3b90584f29
--- /dev/null
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -0,0 +1,117 @@
+import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
+import AgentTable from '~/clusters_list/components/agent_table.vue';
+import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+const connectedTimeNow = new Date();
+const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME);
+
+const propsData = {
+ agents: [
+ {
+ name: 'agent-1',
+ configFolder: {
+ webPath: '/agent/full/path',
+ },
+ webPath: '/agent-1',
+ status: 'unused',
+ lastContact: null,
+ tokens: null,
+ },
+ {
+ name: 'agent-2',
+ webPath: '/agent-2',
+ status: 'active',
+ lastContact: connectedTimeNow.getTime(),
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeNow,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-3',
+ webPath: '/agent-3',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ ],
+};
+const provideData = { integrationDocsUrl: 'path/to/integrationDocs' };
+
+describe('AgentTable', () => {
+ let wrapper;
+
+ const findAgentLink = (at) => wrapper.findAllByTestId('cluster-agent-name-link').at(at);
+ const findStatusIcon = (at) => wrapper.findAllComponents(GlIcon).at(at);
+ const findStatusText = (at) => wrapper.findAllByTestId('cluster-agent-connection-status').at(at);
+ const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at);
+ const findConfiguration = (at) =>
+ wrapper.findAllByTestId('cluster-agent-configuration-link').at(at);
+
+ beforeEach(() => {
+ wrapper = mountExtended(AgentTable, { propsData, provide: provideData });
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ it('displays header button', () => {
+ expect(wrapper.find(GlButton).text()).toBe('Install a new GitLab Agent');
+ });
+
+ describe('agent table', () => {
+ it.each`
+ agentName | link | lineNumber
+ ${'agent-1'} | ${'/agent-1'} | ${0}
+ ${'agent-2'} | ${'/agent-2'} | ${1}
+ `('displays agent link', ({ agentName, link, lineNumber }) => {
+ expect(findAgentLink(lineNumber).text()).toBe(agentName);
+ expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
+ });
+
+ it.each`
+ status | iconName | lineNumber
+ ${'Never connected'} | ${'status-neutral'} | ${0}
+ ${'Connected'} | ${'status-success'} | ${1}
+ ${'Not connected'} | ${'severity-critical'} | ${2}
+ `('displays agent connection status', ({ status, iconName, lineNumber }) => {
+ expect(findStatusText(lineNumber).text()).toBe(status);
+ expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
+ });
+
+ it.each`
+ lastContact | lineNumber
+ ${'Never'} | ${0}
+ ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
+ ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
+ `('displays agent last contact time', ({ lastContact, lineNumber }) => {
+ expect(findLastContactText(lineNumber).text()).toBe(lastContact);
+ });
+
+ it.each`
+ agentPath | hasLink | lineNumber
+ ${'.gitlab/agents/agent-1'} | ${true} | ${0}
+ ${'.gitlab/agents/agent-2'} | ${false} | ${1}
+ `('displays config file path', ({ agentPath, hasLink, lineNumber }) => {
+ const findLink = findConfiguration(lineNumber).find(GlLink);
+
+ expect(findLink.exists()).toBe(hasLink);
+ expect(findConfiguration(lineNumber).text()).toBe(agentPath);
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js
new file mode 100644
index 00000000000..54d5ae94172
--- /dev/null
+++ b/spec/frontend/clusters_list/components/agents_spec.js
@@ -0,0 +1,246 @@
+import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue';
+import AgentTable from '~/clusters_list/components/agent_table.vue';
+import Agents from '~/clusters_list/components/agents.vue';
+import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants';
+import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('Agents', () => {
+ let wrapper;
+
+ const propsData = {
+ defaultBranchName: 'default',
+ };
+ const provideData = {
+ projectPath: 'path/to/project',
+ kasAddress: 'kas.example.com',
+ };
+
+ const createWrapper = ({ agents = [], pageInfo = null, trees = [] }) => {
+ const provide = provideData;
+ const apolloQueryResponse = {
+ data: {
+ project: {
+ clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] } },
+ repository: { tree: { trees: { nodes: trees, pageInfo } } },
+ },
+ },
+ };
+
+ const apolloProvider = createMockApollo([
+ [getAgentsQuery, jest.fn().mockResolvedValue(apolloQueryResponse, provide)],
+ ]);
+
+ wrapper = shallowMount(Agents, {
+ localVue,
+ apolloProvider,
+ propsData,
+ provide: provideData,
+ });
+
+ return wrapper.vm.$nextTick();
+ };
+
+ const findAgentTable = () => wrapper.find(AgentTable);
+ const findEmptyState = () => wrapper.find(AgentEmptyState);
+ const findPaginationButtons = () => wrapper.find(GlKeysetPagination);
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('when there is a list of agents', () => {
+ let testDate = new Date();
+ const agents = [
+ {
+ id: '1',
+ name: 'agent-1',
+ webPath: '/agent-1',
+ tokens: null,
+ },
+ {
+ id: '2',
+ name: 'agent-2',
+ webPath: '/agent-2',
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: testDate,
+ },
+ ],
+ },
+ },
+ ];
+
+ const trees = [
+ {
+ name: 'agent-2',
+ path: '.gitlab/agents/agent-2',
+ webPath: '/project/path/.gitlab/agents/agent-2',
+ },
+ ];
+
+ const expectedAgentsList = [
+ {
+ id: '1',
+ name: 'agent-1',
+ webPath: '/agent-1',
+ configFolder: undefined,
+ status: 'unused',
+ lastContact: null,
+ tokens: null,
+ },
+ {
+ id: '2',
+ name: 'agent-2',
+ configFolder: {
+ name: 'agent-2',
+ path: '.gitlab/agents/agent-2',
+ webPath: '/project/path/.gitlab/agents/agent-2',
+ },
+ webPath: '/agent-2',
+ status: 'active',
+ lastContact: new Date(testDate).getTime(),
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: testDate,
+ },
+ ],
+ },
+ },
+ ];
+
+ beforeEach(() => {
+ return createWrapper({ agents, trees });
+ });
+
+ it('should render agent table', () => {
+ expect(findAgentTable().exists()).toBe(true);
+ expect(findEmptyState().exists()).toBe(false);
+ });
+
+ it('should pass agent and folder info to table component', () => {
+ expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
+ });
+
+ describe('when the agent has recently connected tokens', () => {
+ it('should set agent status to active', () => {
+ expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
+ });
+ });
+
+ describe('when the agent has tokens connected more then 8 minutes ago', () => {
+ const now = new Date();
+ testDate = new Date(now.getTime() - ACTIVE_CONNECTION_TIME);
+ it('should set agent status to inactive', () => {
+ expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
+ });
+ });
+
+ describe('when the agent has no connected tokens', () => {
+ testDate = null;
+ it('should set agent status to unused', () => {
+ expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
+ });
+ });
+
+ it('should not render pagination buttons when there are no additional pages', () => {
+ expect(findPaginationButtons().exists()).toBe(false);
+ });
+
+ describe('when the list has additional pages', () => {
+ const pageInfo = {
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'prev',
+ endCursor: 'next',
+ };
+
+ beforeEach(() => {
+ return createWrapper({
+ agents,
+ pageInfo,
+ });
+ });
+
+ it('should render pagination buttons', () => {
+ expect(findPaginationButtons().exists()).toBe(true);
+ });
+
+ it('should pass pageInfo to the pagination component', () => {
+ expect(findPaginationButtons().props()).toMatchObject(pageInfo);
+ });
+ });
+ });
+
+ describe('when the agent list is empty', () => {
+ beforeEach(() => {
+ return createWrapper({ agents: [] });
+ });
+
+ it('should render empty state', () => {
+ expect(findAgentTable().exists()).toBe(false);
+ expect(findEmptyState().exists()).toBe(true);
+ });
+ });
+
+ describe('when the agent configurations are present', () => {
+ const trees = [
+ {
+ name: 'agent-1',
+ path: '.gitlab/agents/agent-1',
+ webPath: '/project/path/.gitlab/agents/agent-1',
+ },
+ ];
+
+ beforeEach(() => {
+ return createWrapper({ agents: [], trees });
+ });
+
+ it('should pass the correct hasConfigurations boolean value to empty state component', () => {
+ expect(findEmptyState().props('hasConfigurations')).toEqual(true);
+ });
+ });
+
+ describe('when agents query has errored', () => {
+ beforeEach(() => {
+ return createWrapper({ agents: null });
+ });
+
+ it('displays an alert message', () => {
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
+ });
+ });
+
+ describe('when agents query is loading', () => {
+ const mocks = {
+ $apollo: {
+ queries: {
+ agents: {
+ loading: true,
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(Agents, { mocks, propsData, provide: provideData });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
new file mode 100644
index 00000000000..40c2c59e187
--- /dev/null
+++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
@@ -0,0 +1,129 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { createLocalVue, mount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue';
+import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants';
+import agentConfigurationsQuery from '~/clusters_list/graphql/queries/agent_configurations.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { agentConfigurationsResponse } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('AvailableAgentsDropdown', () => {
+ let wrapper;
+
+ const i18n = I18N_AVAILABLE_AGENTS_DROPDOWN;
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findConfiguredAgentItem = () => findDropdownItems().at(0);
+
+ const createWrapper = ({ propsData = {}, isLoading = false }) => {
+ const provide = {
+ projectPath: 'path/to/project',
+ };
+
+ wrapper = (() => {
+ if (isLoading) {
+ const mocks = {
+ $apollo: {
+ queries: {
+ agents: {
+ loading: true,
+ },
+ },
+ },
+ };
+
+ return mount(AvailableAgentsDropdown, { mocks, provide, propsData });
+ }
+
+ const apolloProvider = createMockApollo([
+ [agentConfigurationsQuery, jest.fn().mockResolvedValue(agentConfigurationsResponse)],
+ ]);
+
+ return mount(AvailableAgentsDropdown, {
+ localVue,
+ apolloProvider,
+ provide,
+ propsData,
+ });
+ })();
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('there are agents available', () => {
+ const propsData = {
+ isRegistering: false,
+ };
+
+ beforeEach(() => {
+ createWrapper({ propsData });
+ });
+
+ it('prompts to select an agent', () => {
+ expect(findDropdown().props('text')).toBe(i18n.selectAgent);
+ });
+
+ it('shows only agents that are not yet installed', () => {
+ expect(findDropdownItems()).toHaveLength(1);
+ expect(findConfiguredAgentItem().text()).toBe('configured-agent');
+ expect(findConfiguredAgentItem().props('isChecked')).toBe(false);
+ });
+
+ describe('click events', () => {
+ beforeEach(() => {
+ findConfiguredAgentItem().vm.$emit('click');
+ });
+
+ it('emits agentSelected with the name of the clicked agent', () => {
+ expect(wrapper.emitted('agentSelected')).toEqual([['configured-agent']]);
+ });
+
+ it('marks the clicked item as selected', () => {
+ expect(findDropdown().props('text')).toBe('configured-agent');
+ expect(findConfiguredAgentItem().props('isChecked')).toBe(true);
+ });
+ });
+ });
+
+ describe('registration in progress', () => {
+ const propsData = {
+ isRegistering: true,
+ };
+
+ beforeEach(() => {
+ createWrapper({ propsData });
+ });
+
+ it('updates the text in the dropdown', () => {
+ expect(findDropdown().props('text')).toBe(i18n.registeringAgent);
+ });
+
+ it('displays a loading icon', () => {
+ expect(findDropdown().props('loading')).toBe(true);
+ });
+ });
+
+ describe('agents query is loading', () => {
+ const propsData = {
+ isRegistering: false,
+ };
+
+ beforeEach(() => {
+ createWrapper({ propsData, isLoading: true });
+ });
+
+ it('updates the text in the dropdown', () => {
+ expect(findDropdown().text()).toBe(i18n.selectAgent);
+ });
+
+ it('displays a loading icon', () => {
+ expect(findDropdown().props('loading')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
new file mode 100644
index 00000000000..98ca5e05b3f
--- /dev/null
+++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
@@ -0,0 +1,190 @@
+import { GlAlert, GlButton, GlFormInputGroup } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue';
+import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue';
+import { I18N_INSTALL_AGENT_MODAL } from '~/clusters_list/constants';
+import createAgentMutation from '~/clusters_list/graphql/mutations/create_agent.mutation.graphql';
+import createAgentTokenMutation from '~/clusters_list/graphql/mutations/create_agent_token.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+import {
+ createAgentResponse,
+ createAgentErrorResponse,
+ createAgentTokenResponse,
+ createAgentTokenErrorResponse,
+} from '../mocks/apollo';
+import ModalStub from '../stubs';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('InstallAgentModal', () => {
+ let wrapper;
+ let apolloProvider;
+
+ const i18n = I18N_INSTALL_AGENT_MODAL;
+ const findModal = () => wrapper.findComponent(ModalStub);
+ const findAgentDropdown = () => findModal().findComponent(AvailableAgentsDropdown);
+ const findAlert = () => findModal().findComponent(GlAlert);
+ const findButtonByVariant = (variant) =>
+ findModal()
+ .findAll(GlButton)
+ .wrappers.find((button) => button.props('variant') === variant);
+ const findActionButton = () => findButtonByVariant('confirm');
+ const findCancelButton = () => findButtonByVariant('default');
+
+ const expectDisabledAttribute = (element, disabled) => {
+ if (disabled) {
+ expect(element.attributes('disabled')).toBe('true');
+ } else {
+ expect(element.attributes('disabled')).toBeUndefined();
+ }
+ };
+
+ const createWrapper = () => {
+ const provide = {
+ projectPath: 'path/to/project',
+ kasAddress: 'kas.example.com',
+ };
+
+ wrapper = shallowMount(InstallAgentModal, {
+ attachTo: document.body,
+ stubs: {
+ GlModal: ModalStub,
+ },
+ localVue,
+ apolloProvider,
+ provide,
+ });
+ };
+
+ const mockSelectedAgentResponse = () => {
+ createWrapper();
+
+ wrapper.vm.setAgentName('agent-name');
+ findActionButton().vm.$emit('click');
+
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ apolloProvider = null;
+ });
+
+ describe('initial state', () => {
+ it('renders the dropdown for available agents', () => {
+ expect(findAgentDropdown().isVisible()).toBe(true);
+ expect(findModal().text()).not.toContain(i18n.basicInstallTitle);
+ expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false);
+ expect(findModal().findComponent(GlAlert).exists()).toBe(false);
+ expect(findModal().findComponent(CodeBlock).exists()).toBe(false);
+ });
+
+ it('renders a cancel button', () => {
+ expect(findCancelButton().isVisible()).toBe(true);
+ expectDisabledAttribute(findCancelButton(), false);
+ });
+
+ it('renders a disabled next button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expect(findActionButton().text()).toBe(i18n.next);
+ expectDisabledAttribute(findActionButton(), true);
+ });
+ });
+
+ describe('an agent is selected', () => {
+ beforeEach(() => {
+ findAgentDropdown().vm.$emit('agentSelected');
+ });
+
+ it('enables the next button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expectDisabledAttribute(findActionButton(), false);
+ });
+ });
+
+ describe('registering an agent', () => {
+ const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse);
+ const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse);
+
+ beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [createAgentMutation, createAgentHandler],
+ [createAgentTokenMutation, createAgentTokenHandler],
+ ]);
+
+ return mockSelectedAgentResponse(apolloProvider);
+ });
+
+ it('creates an agent and token', () => {
+ expect(createAgentHandler).toHaveBeenCalledWith({
+ input: { name: 'agent-name', projectPath: 'path/to/project' },
+ });
+
+ expect(createAgentTokenHandler).toHaveBeenCalledWith({
+ input: { clusterAgentId: 'agent-id', name: 'agent-name' },
+ });
+ });
+
+ it('renders a done button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expect(findActionButton().text()).toBe(i18n.done);
+ expectDisabledAttribute(findActionButton(), false);
+ });
+
+ it('shows agent instructions', () => {
+ const modalText = findModal().text();
+ expect(modalText).toContain(i18n.basicInstallTitle);
+ expect(modalText).toContain(i18n.basicInstallBody);
+
+ const token = findModal().findComponent(GlFormInputGroup);
+ expect(token.props('value')).toBe('mock-agent-token');
+
+ const alert = findModal().findComponent(GlAlert);
+ expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle);
+
+ const code = findModal().findComponent(CodeBlock).props('code');
+ expect(code).toContain('--agent-token=mock-agent-token');
+ expect(code).toContain('--kas-address=kas.example.com');
+ });
+
+ describe('error creating agent', () => {
+ beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)],
+ ]);
+
+ return mockSelectedAgentResponse();
+ });
+
+ it('displays the error message', () => {
+ expect(findAlert().text()).toBe(createAgentErrorResponse.data.createClusterAgent.errors[0]);
+ });
+ });
+
+ describe('error creating token', () => {
+ beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)],
+ [createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)],
+ ]);
+
+ return mockSelectedAgentResponse();
+ });
+
+ it('displays the error message', () => {
+ expect(findAlert().text()).toBe(
+ createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js
new file mode 100644
index 00000000000..e388d791b89
--- /dev/null
+++ b/spec/frontend/clusters_list/components/mock_data.js
@@ -0,0 +1,12 @@
+export const agentConfigurationsResponse = {
+ data: {
+ project: {
+ agentConfigurations: {
+ nodes: [{ agentName: 'installed-agent' }, { agentName: 'configured-agent' }],
+ },
+ clusterAgents: {
+ nodes: [{ name: 'installed-agent' }],
+ },
+ },
+ },
+};
diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js
new file mode 100644
index 00000000000..27b71a0d4b5
--- /dev/null
+++ b/spec/frontend/clusters_list/mocks/apollo.js
@@ -0,0 +1,45 @@
+export const createAgentResponse = {
+ data: {
+ createClusterAgent: {
+ clusterAgent: {
+ id: 'agent-id',
+ },
+ errors: [],
+ },
+ },
+};
+
+export const createAgentErrorResponse = {
+ data: {
+ createClusterAgent: {
+ clusterAgent: {
+ id: 'agent-id',
+ },
+ errors: ['could not create agent'],
+ },
+ },
+};
+
+export const createAgentTokenResponse = {
+ data: {
+ clusterAgentTokenCreate: {
+ token: {
+ id: 'token-id',
+ },
+ secret: 'mock-agent-token',
+ errors: [],
+ },
+ },
+};
+
+export const createAgentTokenErrorResponse = {
+ data: {
+ clusterAgentTokenCreate: {
+ token: {
+ id: 'token-id',
+ },
+ secret: 'mock-agent-token',
+ errors: ['could not create agent token'],
+ },
+ },
+};
diff --git a/spec/frontend/clusters_list/stubs.js b/spec/frontend/clusters_list/stubs.js
new file mode 100644
index 00000000000..5769d6190f6
--- /dev/null
+++ b/spec/frontend/clusters_list/stubs.js
@@ -0,0 +1,14 @@
+const ModalStub = {
+ name: 'glmodal-stub',
+ template: `
+ <div>
+ <slot></slot>
+ <slot name="modal-footer"></slot>
+ </div>
+ `,
+ methods: {
+ hide: jest.fn(),
+ },
+};
+
+export default ModalStub;
diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js
new file mode 100644
index 00000000000..d924974aede
--- /dev/null
+++ b/spec/frontend/repository/commits_service_spec.js
@@ -0,0 +1,84 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service';
+import httpStatus from '~/lib/utils/http_status';
+import createFlash from '~/flash';
+import { I18N_COMMIT_DATA_FETCH_ERROR } from '~/repository/constants';
+
+jest.mock('~/flash');
+
+describe('commits service', () => {
+ let mock;
+ const url = `${gon.relative_url_root || ''}/my-project/-/refs/main/logs_tree/`;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ mock.onGet(url).reply(httpStatus.OK, [], {});
+
+ jest.spyOn(axios, 'get');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ resetRequestedCommits();
+ });
+
+ const requestCommits = (offset, project = 'my-project', path = '', ref = 'main') =>
+ loadCommits(project, path, ref, offset);
+
+ it('calls axios get', async () => {
+ const offset = 10;
+ const project = 'my-project';
+ const path = 'my-path';
+ const ref = 'my-ref';
+ const testUrl = `${gon.relative_url_root || ''}/${project}/-/refs/${ref}/logs_tree/${path}`;
+
+ await requestCommits(offset, project, path, ref);
+
+ expect(axios.get).toHaveBeenCalledWith(testUrl, { params: { format: 'json', offset } });
+ });
+
+ it('encodes the path correctly', async () => {
+ await requestCommits(1, 'some-project', 'with $peci@l ch@rs/');
+
+ const encodedUrl = '/some-project/-/refs/main/logs_tree/with%20%24peci%40l%20ch%40rs%2F';
+ expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything());
+ });
+
+ it('calls axios get once per batch', async () => {
+ await Promise.all([requestCommits(0), requestCommits(1), requestCommits(23)]);
+
+ expect(axios.get.mock.calls.length).toEqual(1);
+ });
+
+ it('calls axios get twice if an offset is larger than 25', async () => {
+ await requestCommits(100);
+
+ expect(axios.get.mock.calls[0][1]).toEqual({ params: { format: 'json', offset: 75 } });
+ expect(axios.get.mock.calls[1][1]).toEqual({ params: { format: 'json', offset: 100 } });
+ });
+
+ it('updates the list of requested offsets', async () => {
+ await requestCommits(200);
+
+ expect(isRequested(200)).toBe(true);
+ });
+
+ it('resets the list of requested offsets', async () => {
+ await requestCommits(300);
+
+ resetRequestedCommits();
+ expect(isRequested(300)).toBe(false);
+ });
+
+ it('calls `createFlash` when the request fails', async () => {
+ const invalidPath = '/#@ some/path';
+ const invalidUrl = `${url}${invalidPath}`;
+ mock.onGet(invalidUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, [], {});
+
+ await requestCommits(1, 'my-project', invalidPath);
+
+ expect(createFlash).toHaveBeenCalledWith({ message: I18N_COMMIT_DATA_FETCH_ERROR });
+ });
+});
diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
index 6f461f4c69b..26064e9b248 100644
--- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
+++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
@@ -31,25 +31,36 @@ exports[`Repository table row component renders a symlink table row 1`] = `
<!---->
- <!---->
+ <gl-icon-stub
+ class="ml-1"
+ name="lock"
+ size="12"
+ title="Locked by Root"
+ />
</td>
<td
class="d-none d-sm-table-cell tree-commit cursor-default"
>
- <gl-skeleton-loading-stub
- class="h-auto"
- lines="1"
+ <gl-link-stub
+ class="str-truncated-100 tree-commit-link"
/>
+
+ <gl-intersection-observer-stub>
+ <!---->
+ </gl-intersection-observer-stub>
</td>
<td
class="tree-time-ago text-right cursor-default"
>
- <gl-skeleton-loading-stub
- class="ml-auto h-auto w-50"
- lines="1"
+ <timeago-tooltip-stub
+ cssclass=""
+ time="2019-01-01"
+ tooltipplacement="top"
/>
+
+ <!---->
</td>
</tr>
`;
@@ -85,25 +96,36 @@ exports[`Repository table row component renders table row 1`] = `
<!---->
- <!---->
+ <gl-icon-stub
+ class="ml-1"
+ name="lock"
+ size="12"
+ title="Locked by Root"
+ />
</td>
<td
class="d-none d-sm-table-cell tree-commit cursor-default"
>
- <gl-skeleton-loading-stub
- class="h-auto"
- lines="1"
+ <gl-link-stub
+ class="str-truncated-100 tree-commit-link"
/>
+
+ <gl-intersection-observer-stub>
+ <!---->
+ </gl-intersection-observer-stub>
</td>
<td
class="tree-time-ago text-right cursor-default"
>
- <gl-skeleton-loading-stub
- class="ml-auto h-auto w-50"
- lines="1"
+ <timeago-tooltip-stub
+ cssclass=""
+ time="2019-01-01"
+ tooltipplacement="top"
/>
+
+ <!---->
</td>
</tr>
`;
@@ -139,25 +161,36 @@ exports[`Repository table row component renders table row for path with special
<!---->
- <!---->
+ <gl-icon-stub
+ class="ml-1"
+ name="lock"
+ size="12"
+ title="Locked by Root"
+ />
</td>
<td
class="d-none d-sm-table-cell tree-commit cursor-default"
>
- <gl-skeleton-loading-stub
- class="h-auto"
- lines="1"
+ <gl-link-stub
+ class="str-truncated-100 tree-commit-link"
/>
+
+ <gl-intersection-observer-stub>
+ <!---->
+ </gl-intersection-observer-stub>
</td>
<td
class="tree-time-ago text-right cursor-default"
>
- <gl-skeleton-loading-stub
- class="ml-auto h-auto w-50"
- lines="1"
+ <timeago-tooltip-stub
+ cssclass=""
+ time="2019-01-01"
+ tooltipplacement="top"
/>
+
+ <!---->
</td>
</tr>
`;
diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js
index e9e51abaf0f..c8dddefc4f2 100644
--- a/spec/frontend/repository/components/table/index_spec.js
+++ b/spec/frontend/repository/components/table/index_spec.js
@@ -34,17 +34,45 @@ const MOCK_BLOBS = [
},
];
-function factory({ path, isLoading = false, hasMore = true, entries = {} }) {
+const MOCK_COMMITS = [
+ {
+ fileName: 'blob.md',
+ type: 'blob',
+ commit: {
+ message: 'Updated blob.md',
+ },
+ },
+ {
+ fileName: 'blob2.md',
+ type: 'blob',
+ commit: {
+ message: 'Updated blob2.md',
+ },
+ },
+ {
+ fileName: 'blob3.md',
+ type: 'blob',
+ commit: {
+ message: 'Updated blob3.md',
+ },
+ },
+];
+
+function factory({ path, isLoading = false, hasMore = true, entries = {}, commits = [] }) {
vm = shallowMount(Table, {
propsData: {
path,
isLoading,
entries,
hasMore,
+ commits,
},
mocks: {
$apollo,
},
+ provide: {
+ glFeatures: { lazyLoadCommits: true },
+ },
});
}
@@ -82,12 +110,15 @@ describe('Repository table component', () => {
entries: {
blobs: MOCK_BLOBS,
},
+ commits: MOCK_COMMITS,
});
const rows = vm.findAll(TableRow);
expect(rows.length).toEqual(3);
expect(rows.at(2).attributes().mode).toEqual('120000');
+ expect(rows.at(2).props().rowNumber).toBe(2);
+ expect(rows.at(2).props().commitInfo).toEqual(MOCK_COMMITS[2]);
});
describe('Show more button', () => {
diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js
index da28c9873d9..76e9f7da011 100644
--- a/spec/frontend/repository/components/table/row_spec.js
+++ b/spec/frontend/repository/components/table/row_spec.js
@@ -1,10 +1,12 @@
-import { GlBadge, GlLink, GlIcon } from '@gitlab/ui';
+import { GlBadge, GlLink, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import TableRow from '~/repository/components/table/row.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { FILE_SYMLINK_MODE } from '~/vue_shared/constants';
+const COMMIT_MOCK = { lockLabel: 'Locked by Root', committedDate: '2019-01-01' };
+
let vm;
let $router;
@@ -20,12 +22,14 @@ function factory(propsData = {}) {
projectPath: 'gitlab-org/gitlab-ce',
url: `https://test.com`,
totalEntries: 10,
+ commitInfo: COMMIT_MOCK,
+ rowNumber: 123,
},
directives: {
GlHoverLoad: createMockDirective(),
},
provide: {
- glFeatures: { refactorBlobViewer: true },
+ glFeatures: { refactorBlobViewer: true, lazyLoadCommits: true },
},
mocks: {
$router,
@@ -40,6 +44,7 @@ function factory(propsData = {}) {
describe('Repository table row component', () => {
const findRouterLink = () => vm.find(RouterLinkStub);
+ const findIntersectionObserver = () => vm.findComponent(GlIntersectionObserver);
afterEach(() => {
vm.destroy();
@@ -226,8 +231,6 @@ describe('Repository table row component', () => {
currentPath: '/',
});
- vm.setData({ commit: { lockLabel: 'Locked by Root', committedDate: '2019-01-01' } });
-
return vm.vm.$nextTick().then(() => {
expect(vm.find(GlIcon).exists()).toBe(true);
expect(vm.find(GlIcon).props('name')).toBe('lock');
@@ -246,4 +249,27 @@ describe('Repository table row component', () => {
expect(vm.find(FileIcon).props('loading')).toBe(true);
});
+
+ describe('row visibility', () => {
+ beforeEach(() => {
+ factory({
+ id: '1',
+ sha: '1',
+ path: 'test',
+ type: 'tree',
+ currentPath: '/',
+ });
+ });
+ it('emits a `row-appear` event', () => {
+ findIntersectionObserver().vm.$emit('appear');
+ expect(vm.emitted('row-appear')).toEqual([
+ [
+ {
+ hasCommit: true,
+ rowNumber: 123,
+ },
+ ],
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js
index e36287eff29..49397c77215 100644
--- a/spec/frontend/repository/components/tree_content_spec.js
+++ b/spec/frontend/repository/components/tree_content_spec.js
@@ -3,6 +3,13 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.g
import FilePreview from '~/repository/components/preview/index.vue';
import FileTable from '~/repository/components/table/index.vue';
import TreeContent from '~/repository/components/tree_content.vue';
+import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service';
+
+jest.mock('~/repository/commits_service', () => ({
+ loadCommits: jest.fn(() => Promise.resolve()),
+ isRequested: jest.fn(),
+ resetRequestedCommits: jest.fn(),
+}));
let vm;
let $apollo;
@@ -23,6 +30,7 @@ function factory(path, data = () => ({})) {
glFeatures: {
increasePageSizeExponentially: true,
paginatedTreeGraphqlQuery: true,
+ lazyLoadCommits: true,
},
},
});
@@ -45,7 +53,7 @@ describe('Repository table component', () => {
expect(vm.find(FilePreview).exists()).toBe(true);
});
- it('trigger fetchFiles when mounted', async () => {
+ it('trigger fetchFiles and resetRequestedCommits when mounted', async () => {
factory('/');
jest.spyOn(vm.vm, 'fetchFiles').mockImplementation(() => {});
@@ -53,6 +61,7 @@ describe('Repository table component', () => {
await vm.vm.$nextTick();
expect(vm.vm.fetchFiles).toHaveBeenCalled();
+ expect(resetRequestedCommits).toHaveBeenCalled();
});
describe('normalizeData', () => {
@@ -180,4 +189,15 @@ describe('Repository table component', () => {
});
});
});
+
+ it('loads commit data when row-appear event is emitted', () => {
+ const path = 'some/path';
+ const rowNumber = 1;
+
+ factory(path);
+ findFileTable().vm.$emit('row-appear', { hasCommit: false, rowNumber });
+
+ expect(isRequested).toHaveBeenCalledWith(rowNumber);
+ expect(loadCommits).toHaveBeenCalledWith('', path, '', rowNumber);
+ });
});
diff --git a/spec/lib/gitlab/mail_room/mail_room_spec.rb b/spec/lib/gitlab/mail_room/mail_room_spec.rb
index a42da4ad3e0..0bd1a27c65e 100644
--- a/spec/lib/gitlab/mail_room/mail_room_spec.rb
+++ b/spec/lib/gitlab/mail_room/mail_room_spec.rb
@@ -93,7 +93,7 @@ RSpec.describe Gitlab::MailRoom do
end
describe 'setting up redis settings' do
- let(:fake_redis_queues) { double(url: "localhost", sentinels: "yes, them", sentinels?: true) }
+ let(:fake_redis_queues) { double(url: "localhost", db: 99, sentinels: "yes, them", sentinels?: true) }
before do
allow(Gitlab::Redis::Queues).to receive(:new).and_return(fake_redis_queues)
@@ -103,6 +103,7 @@ RSpec.describe Gitlab::MailRoom do
config = described_class.enabled_configs.first
expect(config[:redis_url]).to eq('localhost')
+ expect(config[:redis_db]).to eq(99)
expect(config[:sentinels]).to eq('yes, them')
end
end
diff --git a/spec/lib/gitlab/redis/queues_spec.rb b/spec/lib/gitlab/redis/queues_spec.rb
index 2e396cde3bf..a0f73a654e7 100644
--- a/spec/lib/gitlab/redis/queues_spec.rb
+++ b/spec/lib/gitlab/redis/queues_spec.rb
@@ -9,10 +9,24 @@ RSpec.describe Gitlab::Redis::Queues do
include_examples "redis_shared_examples"
describe '#raw_config_hash' do
- it 'has a legacy default URL' do
- expect(subject).to receive(:fetch_config) { false }
+ before do
+ expect(subject).to receive(:fetch_config) { config }
+ end
+
+ context 'when the config url is blank' do
+ let(:config) { nil }
+
+ it 'has a legacy default URL' do
+ expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6381' )
+ end
+ end
+
+ context 'when the config url is present' do
+ let(:config) { { url: 'redis://localhost:1111' } }
- expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:6381' )
+ it 'sets the configured url' do
+ expect(subject.send(:raw_config_hash)).to eq(url: 'redis://localhost:1111' )
+ end
end
end
end
diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb
index b584f011972..2b6ed9a9927 100644
--- a/spec/models/pages_domain_spec.rb
+++ b/spec/models/pages_domain_spec.rb
@@ -94,7 +94,7 @@ RSpec.describe PagesDomain do
with_them do
it "is adds the expected errors" do
- expect(pages_domain.errors.keys).to eq errors_on
+ expect(pages_domain.errors.attribute_names).to eq errors_on
end
end
end
@@ -155,7 +155,7 @@ RSpec.describe PagesDomain do
it "adds error to certificate" do
domain.valid?
- expect(domain.errors.keys).to contain_exactly(:key, :certificate)
+ expect(domain.errors.attribute_names).to contain_exactly(:key, :certificate)
end
end
@@ -165,7 +165,7 @@ RSpec.describe PagesDomain do
domain.valid?
- expect(domain.errors.keys).to contain_exactly(:key)
+ expect(domain.errors.attribute_names).to contain_exactly(:key)
end
end
end
diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb
index 09bb3363205..dd916aea3e8 100644
--- a/spec/support/redis/redis_shared_examples.rb
+++ b/spec/support/redis/redis_shared_examples.rb
@@ -255,6 +255,28 @@ RSpec.shared_examples "redis_shared_examples" do
end
end
+ describe '#db' do
+ let(:rails_env) { 'development' }
+
+ subject { described_class.new(rails_env).db }
+
+ context 'with old format' do
+ let(:config_file_name) { config_old_format_host }
+
+ it 'returns the correct db' do
+ expect(subject).to eq(redis_database)
+ end
+ end
+
+ context 'with new format' do
+ let(:config_file_name) { config_new_format_host }
+
+ it 'returns the correct db' do
+ expect(subject).to eq(redis_database)
+ end
+ end
+ end
+
describe '#sentinels' do
subject { described_class.new(rails_env).sentinels }