Commit 235a9c09 authored by Emily Ring's avatar Emily Ring Committed by Michael Kozono

Add cluster agent details page

Added route, controller, and views for cluster agent show page.
Updated associated tests and translations.
parent 95a1c067
......@@ -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