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 /app/assets
parentf2ac9ee99ac6b1afc0edbc8621a05176dddd6a14 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets')
-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
26 files changed, 1524 insertions, 13 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.');