Commit f9a41b17 authored by Simon Knox's avatar Simon Knox Committed by Douglas Barbosa Alexandre

Hide info for unlicensed projects on Ops Dashboard

Check for project group license
If needed, check if user can upgrade group
parent 526494dc
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import _ from 'underscore'; import _ from 'underscore';
import { GlTooltip } from '@gitlab/ui'; import { GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
...@@ -36,6 +36,28 @@ export default { ...@@ -36,6 +36,28 @@ export default {
triggerer: __('Triggerer'), triggerer: __('Triggerer'),
}, },
computed: { computed: {
unlicensedMessage() {
if (this.project.upgrade_path) {
return sprintf(
__(
"To see this project's operational details, %{linkStart}upgrade its group plan to Silver%{linkEnd}. You can also remove the project from the dashboard.",
),
{
linkStart: `<a href="${this.project.upgrade_path}" target="_blank" rel="noopener noreferrer">`,
linkEnd: '</a>',
},
false,
);
}
return sprintf(
__(
"To see this project's operational details, contact an owner of group %{groupName} to upgrade the plan. You can also remove the project from the dashboard.",
),
{
groupName: this.project.namespace.name,
},
);
},
hasPipelineFailed() { hasPipelineFailed() {
return ( return (
this.lastPipeline && this.lastPipeline &&
...@@ -105,7 +127,13 @@ export default { ...@@ -105,7 +127,13 @@ export default {
@remove="removeProject" @remove="removeProject"
/> />
<div :class="cardClasses" class="dashboard-card-body card-body"> <div
v-if="project.upgrade_required"
class="dashboard-card-body card-body bg-secondary"
v-html="unlicensedMessage"
></div>
<div v-else :class="cardClasses" class="dashboard-card-body card-body">
<div v-if="lastPipeline" class="row"> <div v-if="lastPipeline" class="row">
<div class="col-1 align-self-center"> <div class="col-1 align-self-center">
<user-avatar-link <user-avatar-link
......
# frozen_string_literal: true # frozen_string_literal: true
class DashboardOperationsProjectEntity < Grape::Entity class DashboardOperationsProjectEntity < Grape::Entity
include Gitlab::Utils::StrongMemoize
include RequestAwareEntity include RequestAwareEntity
expose :project, merge: true, using: API::Entities::BasicProjectDetails expose :project, merge: true, using: API::Entities::BasicProjectDetails
expose :remove_path do |dashboard_project_object| expose :remove_path do |_|
remove_operations_project_path(project_id: dashboard_project_object.project.id) remove_operations_project_path(project_id: project.id)
end end
expose :last_pipeline, if: -> (*) { last_pipeline } do |_, options| expose :upgrade_required
PipelineDetailsEntity.represent(last_pipeline, options.merge(request: new_request)) expose :upgrade_path, if: -> (*) { upgrade_required && user_can_upgrade? } do |_|
project&.group ? group_billings_path(project.group) : profile_billings_path
end end
expose :last_deployment, if: -> (*) { last_deployment? } do |dashboard_project_object, options| expose :last_pipeline, if: -> (*) { !upgrade_required && last_pipeline } do |_, options|
DeploymentEntity.represent(dashboard_project_object.last_deployment, PipelineDetailsEntity.represent(last_pipeline, options.merge(request: new_request))
options.merge(request: new_request))
end end
expose :alert_count expose :last_deployment, if: -> (*) { !upgrade_required && last_deployment } do |_, options|
expose :alert_path, if: -> (*) { last_deployment? } do |dashboard_project_object| DeploymentEntity.represent(last_deployment, options.merge(request: new_request))
project = dashboard_project_object.project end
environment = dashboard_project_object.last_deployment.environment
metrics_project_environment_path(project, environment) expose :alert_count, if: -> (*) { !upgrade_required }
expose :alert_path, if: -> (*) { !upgrade_required && last_deployment } do |_|
metrics_project_environment_path(project, last_deployment.environment)
end end
expose :last_alert, using: PrometheusAlertEntity, if: -> (*) { last_alert? } expose :last_alert, using: PrometheusAlertEntity, if: -> (*) { !upgrade_required && last_alert? }
private private
...@@ -33,20 +35,38 @@ class DashboardOperationsProjectEntity < Grape::Entity ...@@ -33,20 +35,38 @@ class DashboardOperationsProjectEntity < Grape::Entity
def new_request def new_request
EntityRequest.new( EntityRequest.new(
current_user: request.current_user, current_user: current_user,
project: dashboard_project.project project: project
) )
end end
def upgrade_required
strong_memoize(:upgrade_required) do
!project.feature_available?(:operations_dashboard)
end
end
def user_can_upgrade?
can?(current_user, :admin_namespace, project&.namespace)
end
def last_pipeline def last_pipeline
dashboard_project.project.last_pipeline project.last_pipeline
end end
def last_deployment? def last_deployment
dashboard_project.last_deployment dashboard_project.last_deployment
end end
def last_alert? def last_alert?
dashboard_project.last_alert dashboard_project.last_alert
end end
def current_user
request.current_user
end
def project
dashboard_project.project
end
end end
...@@ -27,7 +27,7 @@ module Dashboard ...@@ -27,7 +27,7 @@ module Dashboard
ProjectsService ProjectsService
.new(user) .new(user)
.execute(projects) .execute(projects, include_unavailable: true)
.to_a # 1 query .to_a # 1 query
end end
......
...@@ -7,18 +7,23 @@ module Dashboard ...@@ -7,18 +7,23 @@ module Dashboard
@user = user @user = user
end end
def execute(project_ids) def execute(project_ids, include_unavailable: false)
return [] unless License.feature_available?(:operations_dashboard) return [] unless License.feature_available?(:operations_dashboard)
find_projects(user, project_ids) projects = find_projects(user, project_ids).to_a
.to_a projects = available_projects(projects) unless include_unavailable
.select { |project| project.feature_available?(:operations_dashboard) }
projects
end end
private private
attr_reader :user, :project_ids attr_reader :user, :project_ids
def available_projects(projects)
projects.select { |project| project.feature_available?(:operations_dashboard) }
end
def find_projects(user, project_ids) def find_projects(user, project_ids)
ProjectsFinder.new( ProjectsFinder.new(
current_user: user, current_user: user,
......
---
title: Hide info for unlicensed projects on Ops Dashboard
merge_request: 15099
author:
type: fixed
...@@ -20,4 +20,66 @@ describe 'Dashboard operations', :js do ...@@ -20,4 +20,66 @@ describe 'Dashboard operations', :js do
expect(page).to have_text('Alerts') expect(page).to have_text('Alerts')
expect(page).to have_text(pipeline.status) expect(page).to have_text(pipeline.status)
end end
context 'when opened on gitlab.com' do
before do
stub_application_setting(check_namespace_plan: true)
stub_licensed_features(operations_dashboard: true)
end
it 'masks projects without valid license' do
user = create(:user)
gold_group = create(:group)
bronze_group = create(:group)
create(:gitlab_subscription, :gold, namespace: gold_group)
create(:gitlab_subscription, :bronze, namespace: bronze_group)
gold_project = create(:project, :repository, namespace: gold_group, name: 'Gold Project')
bronze_project = create(:project, :repository, namespace: bronze_group, name: 'Bronze Project')
public_project = create(:project, :repository, :public, namespace: bronze_group, name: 'Public Bronze Project')
gold_pipeline = create(:ci_pipeline, project: gold_project, sha: gold_project.commit.sha, status: :running)
bronze_pipeline = create(:ci_pipeline, project: bronze_project, sha: bronze_project.commit.sha, status: :running)
public_pipeline = create(:ci_pipeline, project: public_project, sha: public_project.commit.sha, status: :running)
gold_project.add_developer(user)
bronze_group.add_developer(user)
user.update(ops_dashboard_projects: [gold_project, bronze_project, public_project])
sign_in(user)
visit operations_path
bronze_card = project_card(bronze_project)
gold_card = project_card(gold_project)
public_card = project_card(public_project)
assert_masked(bronze_card, bronze_project, bronze_pipeline, bronze_group)
assert_available(gold_card, gold_project, gold_pipeline)
assert_available(public_card, public_project, public_pipeline)
end
def project_card(project)
page.find('.js-dashboard-project', text: "#{project.namespace.name} / #{project.name}")
end
def assert_available(card, project, pipeline)
expect(card).to have_text(project.name)
expect(card).to have_text(pipeline.ref)
expect(card).to have_text(pipeline.short_sha)
expect(card).to have_text('Alerts')
expect(card).to have_text(pipeline.status)
end
def assert_masked(card, project, pipeline, group)
expect(card).to have_text(project.name)
expect(card).to have_text("To see this project's operational details, contact an owner of group #{group.path} to upgrade the plan. You can also remove the project from the dashboard.")
expect(card).not_to have_text(pipeline.ref)
expect(card).not_to have_text(pipeline.short_sha)
expect(card).not_to have_text('Alerts')
expect(card).not_to have_text(pipeline.status)
end
end
end end
...@@ -27,6 +27,69 @@ describe('project component', () => { ...@@ -27,6 +27,69 @@ describe('project component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('with unlicensed project', () => {
let project;
describe('can upgrade project group', () => {
beforeEach(() => {
project = {
...mockOneProject,
upgrade_required: true,
upgrade_path: '/upgrade',
};
wrapper = shallowMount(ProjectComponent, {
sync: false,
store,
localVue,
propsData: { project },
});
});
it('shows project title', () => {
const header = wrapper.find(ProjectHeader);
expect(header.props('project')).toEqual(project);
});
it('styles card with gray background', () => {
expect(wrapper.find('.dashboard-card-body.bg-secondary').exists()).toBe(true);
});
it('shows upgrade license text', () => {
expect(wrapper.find('.dashboard-card-body').html()).toContain(wrapper.vm.unlicensedMessage);
expect(wrapper.vm.unlicensedMessage).toContain('upgrade its group plan to Silver');
});
it('hides commit info', () => {
expect(wrapper).not.toContain(Commit);
});
});
describe('cannot upgrade project group', () => {
beforeEach(() => {
project = {
...mockOneProject,
upgrade_required: true,
upgrade_path: '',
};
wrapper = shallowMount(ProjectComponent, {
sync: false,
store,
localVue,
propsData: { project },
});
});
it('shows upgrade license text', () => {
expect(wrapper.find('.dashboard-card-body').html()).toContain(wrapper.vm.unlicensedMessage);
expect(wrapper.vm.unlicensedMessage).not.toContain('upgrade its group plan to Silver');
expect(wrapper.vm.unlicensedMessage).toContain(
`contact an owner of group ${project.namespace.name}`,
);
});
});
});
describe('wrapped components', () => { describe('wrapped components', () => {
describe('project header', () => { describe('project header', () => {
it('binds project', () => { it('binds project', () => {
......
...@@ -107,5 +107,7 @@ export function mockProjectData( ...@@ -107,5 +107,7 @@ export function mockProjectData(
upstream_pipeline: mockPipelineData(upstreamStatus), upstream_pipeline: mockPipelineData(upstreamStatus),
downstream_pipelines: [], downstream_pipelines: [],
alert_count: alertCount, alert_count: alertCount,
upgrade_required: false,
upgrade_path: '/groups/test/-/billings',
})); }));
} }
...@@ -10,13 +10,18 @@ describe DashboardOperationsProjectEntity do ...@@ -10,13 +10,18 @@ describe DashboardOperationsProjectEntity do
subject { described_class.new(resource, request: request).as_json } subject { described_class.new(resource, request: request).as_json }
context 'the project supports the ops dashboard' do
before do
stub_licensed_features(operations_dashboard: true)
end
it 'has all required fields' do it 'has all required fields' do
expect(subject).to include(:remove_path, :alert_count) expect(subject).to include(:remove_path, :alert_count, :upgrade_required)
expect(subject.first).to include(:id) expect(subject.first).to include(:id)
end end
it 'does not have optional fields' do it 'does not have optional fields' do
expect(subject).not_to include(:last_pipeline, :upstream_pipeline, :downstream_pipelines) expect(subject).not_to include(:last_pipeline, :upstream_pipeline, :downstream_pipelines, :upgrade_path)
end end
context 'when there is a pipeline' do context 'when there is a pipeline' do
...@@ -80,4 +85,31 @@ describe DashboardOperationsProjectEntity do ...@@ -80,4 +85,31 @@ describe DashboardOperationsProjectEntity do
end end
end end
end end
end
context 'the project does not support the ops dashboard' do
it 'has the expected fields' do
expect(subject).to include(:remove_path, :upgrade_required)
expect(subject.first).to include(:id)
expect(subject).not_to include(:alert_count, :upgrade_path)
end
context 'the user has permission to upgrade plan' do
let(:user) { build(:user, :admin) }
it 'shows the profile upgrade path' do
expect(subject[:upgrade_path]).to eq '/profile/billings'
end
end
context 'the user has permission to upgrade group' do
let(:project) { build(:project, namespace: create(:group)) }
let(:user) { build(:user, :admin) }
it 'shows the group upgrade path' do
expect(subject[:upgrade_path]).to eq "/groups/#{project.namespace.path}/-/billings"
end
end
end
end end
...@@ -60,7 +60,9 @@ describe Dashboard::Operations::ListService do ...@@ -60,7 +60,9 @@ describe Dashboard::Operations::ListService do
user.ops_dashboard_projects << project user.ops_dashboard_projects << project
allow(projects_service) allow(projects_service)
.to receive(:execute).with([project]).and_return([project]) .to receive(:execute)
.with([project], include_unavailable: true)
.and_return([project])
end end
it 'returns a list of projects' do it 'returns a list of projects' do
...@@ -120,7 +122,7 @@ describe Dashboard::Operations::ListService do ...@@ -120,7 +122,7 @@ describe Dashboard::Operations::ListService do
allow(projects_service) allow(projects_service)
.to receive(:execute) .to receive(:execute)
.with([project, project2]) .with([project, project2], include_unavailable: true)
.and_return([project, project2]) .and_return([project, project2])
end end
...@@ -145,7 +147,9 @@ describe Dashboard::Operations::ListService do ...@@ -145,7 +147,9 @@ describe Dashboard::Operations::ListService do
context 'without added projects' do context 'without added projects' do
before do before do
allow(projects_service) allow(projects_service)
.to receive(:execute).with([]).and_return([]) .to receive(:execute)
.with([], include_unavailable: true)
.and_return([])
end end
it_behaves_like 'no projects' it_behaves_like 'no projects'
......
...@@ -116,6 +116,12 @@ describe Dashboard::Operations::ProjectsService do ...@@ -116,6 +116,12 @@ describe Dashboard::Operations::ProjectsService do
else else
it_behaves_like 'project not found' it_behaves_like 'project not found'
end end
context 'if :include_unavailable option is provided' do
let(:result) { service.execute(projects, include_unavailable: true) }
it_behaves_like 'project found'
end
end end
end end
......
...@@ -16217,6 +16217,12 @@ msgstr "" ...@@ -16217,6 +16217,12 @@ msgstr ""
msgid "To see all the user's personal access tokens you must impersonate them first." msgid "To see all the user's personal access tokens you must impersonate them first."
msgstr "" msgstr ""
msgid "To see this project's operational details, %{linkStart}upgrade its group plan to Silver%{linkEnd}. You can also remove the project from the dashboard."
msgstr ""
msgid "To see this project's operational details, contact an owner of group %{groupName} to upgrade the plan. You can also remove the project from the dashboard."
msgstr ""
msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:" msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
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