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 @@
- if project_nav_tab? :clusters
- 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
%span
= _('Kubernetes')
......
......@@ -903,6 +903,7 @@ Autogenerated return type of CiCdSettingsUpdate.
| `project` | Project | The project this cluster agent is associated with. |
| `tokens` | ClusterAgentTokenConnection | Tokens associated with the cluster agent. |
| `updatedAt` | Time | Timestamp the cluster agent was updated. |
| `webPath` | String | Web path of the cluster agent. |
### 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
null: true,
description: 'Timestamp the cluster agent was updated.'
field :web_path,
GraphQL::STRING_TYPE,
null: true,
description: 'Web path of the cluster agent.'
def project
Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
end
def web_path
::Gitlab::Routing.url_helpers.project_cluster_agent_path(object.project, object.name)
end
end
end
end
......@@ -19,6 +19,7 @@ module EE
override :sidebar_operations_paths
def sidebar_operations_paths
super + %w[
cluster_agents
oncall_schedules
]
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
namespace :incident_management, path: '' do
resources :oncall_schedules, only: [:index], path: 'oncall_schedules'
end
resources :cluster_agents, only: [:show], param: :name
end
# End of the /-/ scope.
......
......@@ -3,8 +3,9 @@
require 'spec_helper'
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(:user) { project.creator }
......@@ -27,6 +28,17 @@ RSpec.describe 'ClusterAgents', :js do
expect(page).not_to have_content('GitLab Agent managed clusters')
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
context 'premium user' do
......@@ -44,20 +56,35 @@ RSpec.describe 'ClusterAgents', :js do
it 'displays empty state', :aggregate_failures do
click_link 'GitLab Agent managed clusters'
expect(page).to have_link('Integrate with the GitLab Agent')
expect(page).to have_selector('.empty-state')
end
end
context 'when user has an agent and visits the index page' do
before do
visit project_clusters_path(project)
context 'when user has an agent' do
context 'when visiting the index page' do
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
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)
context 'when visiting the show page' do
before do
visit project_cluster_agent_path(project, agent.name)
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
......
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 @@
require 'spec_helper'
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') }
......
# 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 ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr ""
msgid "ClusterAgents|Access tokens"
msgstr ""
msgid "ClusterAgents|An error occurred while loading your GitLab Agents"
msgstr ""
msgid "ClusterAgents|An error occurred while loading your agent"
msgstr ""
msgid "ClusterAgents|Configuration"
msgstr ""
msgid "ClusterAgents|Connect your cluster with the GitLab Agent"
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"
msgstr ""
msgid "ClusterAgents|Integrate with the GitLab Agent"
msgstr ""
msgid "ClusterAgents|Learn how to create an agent access token"
msgstr ""
msgid "ClusterAgents|Name"
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}"
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"
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