Commit 0289d503 authored by Michael Kozono's avatar Michael Kozono

Merge branch '298748-agent-details-page' into 'master'

Add cluster agent show page

See merge request gitlab-org/gitlab!54106
parents 522fb0d3 235a9c09
...@@ -286,7 +286,7 @@ ...@@ -286,7 +286,7 @@
- if project_nav_tab? :clusters - if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project) - show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do = nav_link(controller: [:cluster_agents, :clusters]) do
= link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do = link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do
%span %span
= _('Kubernetes') = _('Kubernetes')
......
...@@ -903,6 +903,7 @@ Autogenerated return type of CiCdSettingsUpdate. ...@@ -903,6 +903,7 @@ Autogenerated return type of CiCdSettingsUpdate.
| `project` | Project | The project this cluster agent is associated with. | | `project` | Project | The project this cluster agent is associated with. |
| `tokens` | ClusterAgentTokenConnection | Tokens associated with the cluster agent. | | `tokens` | ClusterAgentTokenConnection | Tokens associated with the cluster agent. |
| `updatedAt` | Time | Timestamp the cluster agent was updated. | | `updatedAt` | Time | Timestamp the cluster agent was updated. |
| `webPath` | String | Web path of the cluster agent. |
### ClusterAgentDeletePayload ### ClusterAgentDeletePayload
......
<script>
import { GlAlert, GlBadge, GlLoadingIcon, GlSprintf, GlTab, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
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,
};
},
update: (data) => data?.project?.clusterAgent,
error() {
this.clusterAgent = null;
},
},
},
components: {
GlAlert,
GlBadge,
GlLoadingIcon,
GlSprintf,
GlTab,
GlTabs,
TimeAgoTooltip,
TokenTable,
},
props: {
agentName: {
required: true,
type: String,
},
projectPath: {
required: true,
type: String,
},
},
computed: {
createdAt() {
return this.clusterAgent?.createdAt;
},
createdBy() {
return this.clusterAgent?.createdByUser?.name || this.$options.i18n.unknownUser;
},
isLoading() {
return this.$apollo.queries.clusterAgent.loading;
},
tokenCount() {
return this.clusterAgent?.tokens?.count;
},
tokens() {
return this.clusterAgent?.tokens?.nodes || [];
},
},
};
</script>
<template>
<section>
<h2>{{ agentName }}</h2>
<gl-loading-icon v-if="isLoading" 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 slot="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>
<TokenTable :tokens="tokens" />
</gl-tab>
</gl-tabs>
</div>
<gl-alert v-else variant="danger" :dismissible="false">
{{ $options.i18n.loadingError }}
</gl-alert>
</section>
</template>
<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'),
learnMore: s__('ClusterAgents|Learn how to create an agent access token'),
noTokens: s__('ClusterAgents|This agent has no tokens'),
unknownUser: s__('ClusterAgents|Unknown user'),
},
props: {
tokens: {
required: true,
type: Array,
},
},
computed: {
fields() {
return [
{
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(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>
fragment Token on ClusterAgentToken {
id
createdAt
description
createdByUser {
name
}
}
#import "../fragments/cluster_agent_token.fragment.graphql"
query getClusterAgent($projectPath: ID!, $agentName: String!) {
project(fullPath: $projectPath) {
clusterAgent(name: $agentName) {
id
createdAt
createdByUser {
name
}
tokens {
count
nodes {
...Token
}
}
}
}
}
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,
},
});
},
});
};
import loadClusterAgentVues from 'ee/clusters/agents';
loadClusterAgentVues();
# frozen_string_literal: true
class Projects::ClusterAgentsController < Projects::ApplicationController
before_action :authorize_can_read_cluster_agent!
feature_category :kubernetes_management
def show
@agent_name = params[:name]
end
private
def authorize_can_read_cluster_agent!
return if can?(current_user, :admin_cluster, project) && project.feature_available?(:cluster_agents)
access_denied!
end
end
...@@ -43,9 +43,18 @@ module Types ...@@ -43,9 +43,18 @@ module Types
null: true, null: true,
description: 'Timestamp the cluster agent was updated.' description: 'Timestamp the cluster agent was updated.'
field :web_path,
GraphQL::STRING_TYPE,
null: true,
description: 'Web path of the cluster agent.'
def project def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
end end
def web_path
::Gitlab::Routing.url_helpers.project_cluster_agent_path(object.project, object.name)
end
end end
end end
end end
...@@ -19,6 +19,7 @@ module EE ...@@ -19,6 +19,7 @@ module EE
override :sidebar_operations_paths override :sidebar_operations_paths
def sidebar_operations_paths def sidebar_operations_paths
super + %w[ super + %w[
cluster_agents
oncall_schedules oncall_schedules
] ]
end end
......
# frozen_string_literal: true
module Projects::ClusterAgentsHelper
def js_cluster_agent_details_data(agent_name, project)
{
agent_name: agent_name,
project_path: project.full_path
}
end
end
- add_to_breadcrumbs _('Kubernetes'), project_clusters_path(@project)
- page_title @agent_name
#js-cluster-agent-details{ data: js_cluster_agent_details_data(@agent_name, @project) }
---
title: Added cluster agent details page
merge_request: 54106
author:
type: added
...@@ -127,6 +127,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -127,6 +127,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :incident_management, path: '' do namespace :incident_management, path: '' do
resources :oncall_schedules, only: [:index], path: 'oncall_schedules' resources :oncall_schedules, only: [:index], path: 'oncall_schedules'
end end
resources :cluster_agents, only: [:show], param: :name
end end
# End of the /-/ scope. # End of the /-/ scope.
......
...@@ -3,8 +3,9 @@ ...@@ -3,8 +3,9 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'ClusterAgents', :js do RSpec.describe 'ClusterAgents', :js do
let_it_be(:agent) { create(:cluster_agent) } let_it_be(:token) { create(:cluster_agent_token, description: 'feature test token')}
let(:agent) { token.agent }
let(:project) { agent.project } let(:project) { agent.project }
let(:user) { project.creator } let(:user) { project.creator }
...@@ -27,6 +28,17 @@ RSpec.describe 'ClusterAgents', :js do ...@@ -27,6 +28,17 @@ RSpec.describe 'ClusterAgents', :js do
expect(page).not_to have_content('GitLab Agent managed clusters') expect(page).not_to have_content('GitLab Agent managed clusters')
end end
end end
context 'when user visits agents show page' do
before do
visit project_cluster_agent_path(project, agent.name)
end
it 'displays not found' do
expect(page).to have_title('Not Found')
expect(page).to have_content('Page Not Found')
end
end
end end
context 'premium user' do context 'premium user' do
...@@ -44,20 +56,35 @@ RSpec.describe 'ClusterAgents', :js do ...@@ -44,20 +56,35 @@ RSpec.describe 'ClusterAgents', :js do
it 'displays empty state', :aggregate_failures do it 'displays empty state', :aggregate_failures do
click_link 'GitLab Agent managed clusters' click_link 'GitLab Agent managed clusters'
expect(page).to have_link('Integrate with the GitLab Agent') expect(page).to have_link('Integrate with the GitLab Agent')
expect(page).to have_selector('.empty-state') expect(page).to have_selector('.empty-state')
end end
end end
context 'when user has an agent and visits the index page' do context 'when user has an agent' do
before do context 'when visiting the index page' do
visit project_clusters_path(project) before do
visit project_clusters_path(project)
end
it 'displays a table with agent', :aggregate_failures do
click_link 'GitLab Agent managed clusters'
expect(page).to have_content(agent.name)
expect(page).to have_selector('[data-testid="cluster-agent-list-table"] tbody tr', count: 1)
end
end end
it 'displays a table with agent', :aggregate_failures do context 'when visiting the show page' do
click_link 'GitLab Agent managed clusters' before do
expect(page).to have_content(agent.name) visit project_cluster_agent_path(project, agent.name)
expect(page).to have_selector('[data-testid="cluster-agent-list-table"] tbody tr', count: 1) end
it 'displays agent and token information', :aggregate_failures do
expect(page).to have_content(agent.name)
expect(page).to have_content(token.description)
end
end end
end end
end end
......
import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import ClusterAgentShow from 'ee/clusters/agents/components/show.vue';
import TokenTable from 'ee/clusters/agents/components/token_table.vue';
import getAgentQuery from 'ee/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',
},
tokens: {
count: 1,
nodes: [],
},
};
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 },
});
};
const findCreatedText = () => wrapper.find('[data-testid="cluster-agent-create-info"]').text();
const findTokenCount = () => wrapper.find('[data-testid="cluster-agent-token-count"]').text();
beforeEach(() => {
return createWrapper({ clusterAgent: defaultClusterAgent });
});
afterEach(() => {
wrapper.destroy();
});
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');
});
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');
});
});
it('displays token count', () => {
expect(findTokenCount()).toMatchInterpolatedText(
`${ClusterAgentShow.i18n.tokens} ${defaultClusterAgent.tokens.count}`,
);
});
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}`);
});
});
it('renders token table', () => {
expect(wrapper.find(TokenTable).exists()).toBe(true);
});
describe('when the agent query is loading', () => {
beforeEach(() => {
return createWrapper({
clusterAgent: null,
queryResponse: jest.fn().mockReturnValue(new Promise(() => {})),
});
});
it('displays a loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
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);
});
});
});
import { GlEmptyState, GlLink, GlTooltip, GlTruncate } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import TokenTable from 'ee/clusters/agents/components/token_table.vue';
import { useFakeDate } from 'helpers/fake_date';
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',
},
},
{
id: '2',
createdAt: '2021-02-10T00:00:00Z',
description: null,
createdByUser: null,
},
];
const createComponent = (tokens) => {
wrapper = mount(TokenTable, { propsData: { tokens } });
return wrapper.vm.$nextTick();
};
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`
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);
});
});
});
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe GitlabSchema.types['ClusterAgent'] do RSpec.describe GitlabSchema.types['ClusterAgent'] do
let(:fields) { %i[created_at created_by_user id name project updated_at tokens] } let(:fields) { %i[created_at created_by_user id name project updated_at tokens web_path] }
it { expect(described_class.graphql_name).to eq('ClusterAgent') } it { expect(described_class.graphql_name).to eq('ClusterAgent') }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::ClusterAgentsHelper do
describe '#js_cluster_agent_details_data' do
let_it_be(:project) { create(:project) }
let(:agent_name) { 'agent-name' }
subject { helper.js_cluster_agent_details_data(agent_name, project) }
it 'returns name' do
expect(subject[:agent_name]).to eq(agent_name)
end
it 'returns project path' do
expect(subject[:project_path]).to eq(project.full_path)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::ClusterAgentsController do
let_it_be(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
describe 'GET #show' do
subject { get project_cluster_agent_path(project, cluster_agent.name) }
context 'when user is unauthorized' do
let_it_be(:user) { create(:user) }
before do
project.add_developer(user)
sign_in(user)
subject
end
it 'shows 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when user is authorized' do
let(:user) { project.creator }
context 'without premium plan' do
before do
sign_in(user)
subject
end
it 'shows 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with premium plan' do
before do
stub_licensed_features(cluster_agents: true)
sign_in(user)
subject
end
it 'renders content' do
expect(response).to be_successful
end
end
end
end
end
...@@ -6317,21 +6317,42 @@ msgstr "" ...@@ -6317,21 +6317,42 @@ msgstr ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter" msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr "" msgstr ""
msgid "ClusterAgents|Access tokens"
msgstr ""
msgid "ClusterAgents|An error occurred while loading your GitLab Agents" msgid "ClusterAgents|An error occurred while loading your GitLab Agents"
msgstr "" msgstr ""
msgid "ClusterAgents|An error occurred while loading your agent"
msgstr ""
msgid "ClusterAgents|Configuration" msgid "ClusterAgents|Configuration"
msgstr "" msgstr ""
msgid "ClusterAgents|Connect your cluster with the GitLab Agent" msgid "ClusterAgents|Connect your cluster with the GitLab Agent"
msgstr "" msgstr ""
msgid "ClusterAgents|Created by"
msgstr ""
msgid "ClusterAgents|Created by %{name} %{time}"
msgstr ""
msgid "ClusterAgents|Date created"
msgstr ""
msgid "ClusterAgents|Description"
msgstr ""
msgid "ClusterAgents|Integrate Kubernetes with a GitLab Agent" msgid "ClusterAgents|Integrate Kubernetes with a GitLab Agent"
msgstr "" msgstr ""
msgid "ClusterAgents|Integrate with the GitLab Agent" msgid "ClusterAgents|Integrate with the GitLab Agent"
msgstr "" msgstr ""
msgid "ClusterAgents|Learn how to create an agent access token"
msgstr ""
msgid "ClusterAgents|Name" msgid "ClusterAgents|Name"
msgstr "" msgstr ""
...@@ -6341,6 +6362,15 @@ msgstr "" ...@@ -6341,6 +6362,15 @@ msgstr ""
msgid "ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}" msgid "ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}"
msgstr "" msgstr ""
msgid "ClusterAgents|This agent has no tokens"
msgstr ""
msgid "ClusterAgents|Unknown user"
msgstr ""
msgid "ClusterAgents|You will need to create a token to connect to your agent"
msgstr ""
msgid "ClusterAgent|This feature is only available for premium plans" msgid "ClusterAgent|This feature is only available for premium plans"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment