Expose namespace storage statistics with GraphQL
Root namespaces have storage statistics. This commit allows namespace owners to get those stats via GraphQL queries like the following one { namespace(fullPath: "a_namespace_path") { rootStorageStatistics { storageSize repositorySize lfsObjectsSize buildArtifactsSize packagesSize wikiSize } } }
This commit is contained in:
parent
c65ea080ba
commit
606a1d2d31
18 changed files with 256 additions and 4 deletions
|
@ -19,6 +19,11 @@ module Types
|
||||||
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
|
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
|
||||||
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
|
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
|
||||||
|
|
||||||
|
field :root_storage_statistics, Types::RootStorageStatisticsType,
|
||||||
|
null: true,
|
||||||
|
description: 'The aggregated storage statistics. Only available for root namespaces',
|
||||||
|
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(obj.id).find }
|
||||||
|
|
||||||
field :projects,
|
field :projects,
|
||||||
Types::ProjectType.connection_type,
|
Types::ProjectType.connection_type,
|
||||||
null: false,
|
null: false,
|
||||||
|
|
16
app/graphql/types/root_storage_statistics_type.rb
Normal file
16
app/graphql/types/root_storage_statistics_type.rb
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Types
|
||||||
|
class RootStorageStatisticsType < BaseObject
|
||||||
|
graphql_name 'RootStorageStatistics'
|
||||||
|
|
||||||
|
authorize :read_statistics
|
||||||
|
|
||||||
|
field :storage_size, GraphQL::INT_TYPE, null: false, description: 'The total storage in bytes'
|
||||||
|
field :repository_size, GraphQL::INT_TYPE, null: false, description: 'The git repository size in bytes'
|
||||||
|
field :lfs_objects_size, GraphQL::INT_TYPE, null: false, description: 'The LFS objects size in bytes'
|
||||||
|
field :build_artifacts_size, GraphQL::INT_TYPE, null: false, description: 'The CI artifacts size in bytes'
|
||||||
|
field :packages_size, GraphQL::INT_TYPE, null: false, description: 'The packages size in bytes'
|
||||||
|
field :wiki_size, GraphQL::INT_TYPE, null: false, description: 'The wiki size in bytes'
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,6 +8,8 @@ class Namespace::RootStorageStatistics < ApplicationRecord
|
||||||
belongs_to :namespace
|
belongs_to :namespace
|
||||||
has_one :route, through: :namespace
|
has_one :route, through: :namespace
|
||||||
|
|
||||||
|
scope :for_namespace_ids, ->(namespace_ids) { where(namespace_id: namespace_ids) }
|
||||||
|
|
||||||
delegate :all_projects, to: :namespace
|
delegate :all_projects, to: :namespace
|
||||||
|
|
||||||
def recalculate!
|
def recalculate!
|
||||||
|
|
|
@ -124,6 +124,8 @@ class GroupPolicy < BasePolicy
|
||||||
rule { developer & developer_maintainer_access }.enable :create_projects
|
rule { developer & developer_maintainer_access }.enable :create_projects
|
||||||
rule { create_projects_disabled }.prevent :create_projects
|
rule { create_projects_disabled }.prevent :create_projects
|
||||||
|
|
||||||
|
rule { owner | admin }.enable :read_statistics
|
||||||
|
|
||||||
def access_level
|
def access_level
|
||||||
return GroupMember::NO_ACCESS if @user.nil?
|
return GroupMember::NO_ACCESS if @user.nil?
|
||||||
|
|
||||||
|
|
5
app/policies/namespace/root_storage_statistics_policy.rb
Normal file
5
app/policies/namespace/root_storage_statistics_policy.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Namespace::RootStorageStatisticsPolicy < BasePolicy
|
||||||
|
delegate { @subject.namespace }
|
||||||
|
end
|
|
@ -11,6 +11,7 @@ class NamespacePolicy < BasePolicy
|
||||||
enable :create_projects
|
enable :create_projects
|
||||||
enable :admin_namespace
|
enable :admin_namespace
|
||||||
enable :read_namespace
|
enable :read_namespace
|
||||||
|
enable :read_statistics
|
||||||
end
|
end
|
||||||
|
|
||||||
rule { personal_project & ~can_create_personal_project }.prevent :create_projects
|
rule { personal_project & ~can_create_personal_project }.prevent :create_projects
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Expose namespace storage statistics with GraphQL
|
||||||
|
merge_request: 32012
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -109,6 +109,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
|
||||||
| `visibility` | String | |
|
| `visibility` | String | |
|
||||||
| `lfsEnabled` | Boolean | |
|
| `lfsEnabled` | Boolean | |
|
||||||
| `requestAccessEnabled` | Boolean | |
|
| `requestAccessEnabled` | Boolean | |
|
||||||
|
| `rootStorageStatistics` | RootStorageStatistics | The aggregated storage statistics. Only available if the namespace has no parent |
|
||||||
| `userPermissions` | GroupPermissions! | Permissions for the current user on the resource |
|
| `userPermissions` | GroupPermissions! | Permissions for the current user on the resource |
|
||||||
| `webUrl` | String! | |
|
| `webUrl` | String! | |
|
||||||
| `avatarUrl` | String | |
|
| `avatarUrl` | String | |
|
||||||
|
@ -453,6 +454,17 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
|
||||||
| `exists` | Boolean! | |
|
| `exists` | Boolean! | |
|
||||||
| `tree` | Tree | |
|
| `tree` | Tree | |
|
||||||
|
|
||||||
|
### RootStorageStatistics
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
| --- | ---- | ---------- |
|
||||||
|
| `storageSize` | Int! | The total storage in Bytes |
|
||||||
|
| `repositorySize` | Int! | The git repository size in Bytes |
|
||||||
|
| `lfsObjectsSize` | Int! | The LFS objects size in Bytes |
|
||||||
|
| `buildArtifactsSize` | Int! | The CI artifacts size in Bytes |
|
||||||
|
| `packagesSize` | Int! | The packages size in Bytes |
|
||||||
|
| `wikiSize` | Int! | The wiki size in Bytes |
|
||||||
|
|
||||||
### Submodule
|
### Submodule
|
||||||
|
|
||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Gitlab
|
||||||
|
module Graphql
|
||||||
|
module Loaders
|
||||||
|
class BatchRootStorageStatisticsLoader
|
||||||
|
attr_reader :namespace_id
|
||||||
|
|
||||||
|
def initialize(namespace_id)
|
||||||
|
@namespace_id = namespace_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def find
|
||||||
|
BatchLoader.for(namespace_id).batch do |namespace_ids, loader|
|
||||||
|
Namespace::RootStorageStatistics.for_namespace_ids(namespace_ids).each do |statistics|
|
||||||
|
loader.call(statistics.namespace_id, statistics)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,7 +8,7 @@ describe GitlabSchema.types['Namespace'] do
|
||||||
it 'has the expected fields' do
|
it 'has the expected fields' do
|
||||||
expected_fields = %w[
|
expected_fields = %w[
|
||||||
id name path full_name full_path description description_html visibility
|
id name path full_name full_path description description_html visibility
|
||||||
lfs_enabled request_access_enabled projects
|
lfs_enabled request_access_enabled projects root_storage_statistics
|
||||||
]
|
]
|
||||||
|
|
||||||
is_expected.to have_graphql_fields(*expected_fields)
|
is_expected.to have_graphql_fields(*expected_fields)
|
||||||
|
|
14
spec/graphql/types/root_storage_statistics_type_spec.rb
Normal file
14
spec/graphql/types/root_storage_statistics_type_spec.rb
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe GitlabSchema.types['RootStorageStatistics'] do
|
||||||
|
it { expect(described_class.graphql_name).to eq('RootStorageStatistics') }
|
||||||
|
|
||||||
|
it 'has all the required fields' do
|
||||||
|
is_expected.to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
|
||||||
|
:build_artifacts_size, :packages_size, :wiki_size)
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to require_graphql_authorizations(:read_statistics) }
|
||||||
|
end
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader do
|
||||||
|
describe '#find' do
|
||||||
|
it 'only queries once for project statistics' do
|
||||||
|
stats = create_list(:namespace_root_storage_statistics, 2)
|
||||||
|
namespace1 = stats.first.namespace
|
||||||
|
namespace2 = stats.last.namespace
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.new(namespace1.id).find
|
||||||
|
described_class.new(namespace2.id).find
|
||||||
|
end.not_to exceed_query_limit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,6 +8,19 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
|
||||||
|
|
||||||
it { is_expected.to delegate_method(:all_projects).to(:namespace) }
|
it { is_expected.to delegate_method(:all_projects).to(:namespace) }
|
||||||
|
|
||||||
|
context 'scopes' do
|
||||||
|
describe '.for_namespace_ids' do
|
||||||
|
it 'returns only requested namespaces' do
|
||||||
|
stats = create_list(:namespace_root_storage_statistics, 3)
|
||||||
|
namespace_ids = stats[0..1].map { |s| s.namespace_id }
|
||||||
|
|
||||||
|
requested_stats = described_class.for_namespace_ids(namespace_ids).pluck(:namespace_id)
|
||||||
|
|
||||||
|
expect(requested_stats).to eq(namespace_ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#recalculate!' do
|
describe '#recalculate!' do
|
||||||
let(:namespace) { create(:group) }
|
let(:namespace) { create(:group) }
|
||||||
let(:root_storage_statistics) { create(:namespace_root_storage_statistics, namespace: namespace) }
|
let(:root_storage_statistics) { create(:namespace_root_storage_statistics, namespace: namespace) }
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Namespace::RootStorageStatisticsPolicy do
|
||||||
|
using RSpec::Parameterized::TableSyntax
|
||||||
|
|
||||||
|
describe '#rules' do
|
||||||
|
let(:statistics) { create(:namespace_root_storage_statistics, namespace: namespace) }
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
subject { Ability.allowed?(user, :read_statistics, statistics) }
|
||||||
|
|
||||||
|
shared_examples 'deny anonymous users' do
|
||||||
|
context 'when the users is anonymous' do
|
||||||
|
let(:user) { nil }
|
||||||
|
|
||||||
|
it { is_expected.to be_falsey }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the namespace is a personal namespace' do
|
||||||
|
let(:owner) { create(:user) }
|
||||||
|
let(:namespace) { owner.namespace }
|
||||||
|
|
||||||
|
include_examples 'deny anonymous users'
|
||||||
|
|
||||||
|
context 'when the user is not the owner' do
|
||||||
|
it { is_expected.to be_falsey }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the user is the owner' do
|
||||||
|
let(:user) { owner }
|
||||||
|
|
||||||
|
it { is_expected.to be_truthy }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the namespace is a group' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:external) { create(:user, :external) }
|
||||||
|
|
||||||
|
shared_examples 'allows only owners' do |group_type|
|
||||||
|
let(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel.level_value(group_type.to_s)) }
|
||||||
|
let(:namespace) { group }
|
||||||
|
|
||||||
|
include_examples 'deny anonymous users'
|
||||||
|
|
||||||
|
where(:user_type, :outcome) do
|
||||||
|
[
|
||||||
|
[:non_member, false],
|
||||||
|
[:guest, false],
|
||||||
|
[:reporter, false],
|
||||||
|
[:developer, false],
|
||||||
|
[:maintainer, false],
|
||||||
|
[:owner, true]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
with_them do
|
||||||
|
before do
|
||||||
|
group.add_user(user, user_type) unless user_type == :non_member
|
||||||
|
end
|
||||||
|
|
||||||
|
it { is_expected.to eq(outcome) }
|
||||||
|
|
||||||
|
context 'when the user is external' do
|
||||||
|
let(:user) { external }
|
||||||
|
|
||||||
|
it { is_expected.to eq(outcome) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
include_examples 'allows only owners', :public
|
||||||
|
include_examples 'allows only owners', :private
|
||||||
|
include_examples 'allows only owners', :internal
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,7 +6,7 @@ describe NamespacePolicy do
|
||||||
let(:admin) { create(:admin) }
|
let(:admin) { create(:admin) }
|
||||||
let(:namespace) { create(:namespace, owner: owner) }
|
let(:namespace) { create(:namespace, owner: owner) }
|
||||||
|
|
||||||
let(:owner_permissions) { [:create_projects, :admin_namespace, :read_namespace] }
|
let(:owner_permissions) { [:create_projects, :admin_namespace, :read_namespace, :read_statistics] }
|
||||||
|
|
||||||
subject { described_class.new(current_user, namespace) }
|
subject { described_class.new(current_user, namespace) }
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe 'rendering namespace statistics' do
|
||||||
|
include GraphqlHelpers
|
||||||
|
|
||||||
|
let(:namespace) { user.namespace }
|
||||||
|
let!(:statistics) { create(:namespace_root_storage_statistics, namespace: namespace, packages_size: 5.megabytes) }
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
let(:query) do
|
||||||
|
graphql_query_for('namespace',
|
||||||
|
{ 'fullPath' => namespace.full_path },
|
||||||
|
"rootStorageStatistics { #{all_graphql_fields_for('RootStorageStatistics')} }")
|
||||||
|
end
|
||||||
|
|
||||||
|
shared_examples 'a working namespace with storage statistics query' do
|
||||||
|
it_behaves_like 'a working graphql query' do
|
||||||
|
before do
|
||||||
|
post_graphql(query, current_user: user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the packages size if the user can read the statistics' do
|
||||||
|
post_graphql(query, current_user: user)
|
||||||
|
|
||||||
|
expect(graphql_data['namespace']['rootStorageStatistics']).not_to be_blank
|
||||||
|
expect(graphql_data['namespace']['rootStorageStatistics']['packagesSize']).to eq(5.megabytes)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'a working namespace with storage statistics query'
|
||||||
|
|
||||||
|
context 'when the namespace is a group' do
|
||||||
|
let(:group) { create(:group) }
|
||||||
|
let(:namespace) { group }
|
||||||
|
|
||||||
|
before do
|
||||||
|
group.add_owner(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'a working namespace with storage statistics query'
|
||||||
|
|
||||||
|
context 'when the namespace is public' do
|
||||||
|
let(:group) { create(:group, :public)}
|
||||||
|
|
||||||
|
it 'hides statistics for unauthenticated requests' do
|
||||||
|
post_graphql(query, current_user: nil)
|
||||||
|
|
||||||
|
expect(graphql_data['namespace']).to be_blank
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
require 'spec_helper'
|
require 'spec_helper'
|
||||||
|
|
||||||
describe 'rendering namespace statistics' do
|
describe 'rendering project statistics' do
|
||||||
include GraphqlHelpers
|
include GraphqlHelpers
|
||||||
|
|
||||||
let(:project) { create(:project) }
|
let(:project) { create(:project) }
|
||||||
|
|
|
@ -31,7 +31,8 @@ RSpec.shared_context 'GroupPolicy context' do
|
||||||
:admin_group_member,
|
:admin_group_member,
|
||||||
:change_visibility_level,
|
:change_visibility_level,
|
||||||
:set_note_created_at,
|
:set_note_created_at,
|
||||||
:create_subgroup
|
:create_subgroup,
|
||||||
|
:read_statistics
|
||||||
].compact
|
].compact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue