Commit 884a1e61 authored by Dmytro Zaporozhets (DZ)'s avatar Dmytro Zaporozhets (DZ)

Merge branch '241673-expose-instance-statistics-via-graphql' into 'master'

Expose instance statistics measurements via GraphQL

Closes #241673

See merge request gitlab-org/gitlab!40871
parents 104051d7 82817de4
# frozen_string_literal: true
module Resolvers
module Admin
module Analytics
module InstanceStatistics
class MeasurementsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::Admin::Analytics::InstanceStatistics::MeasurementType, null: true
argument :identifier, Types::Admin::Analytics::InstanceStatistics::MeasurementIdentifierEnum,
required: true,
description: 'The type of measurement/statistics to retrieve'
def resolve(identifier:)
authorize!
::Analytics::InstanceStatistics::Measurement
.with_identifier(identifier)
.order_by_latest
end
private
def authorize!
admin? || raise_resource_not_available_error!
end
def admin?
context[:current_user].present? && context[:current_user].admin?
end
end
end
end
end
end
# frozen_string_literal: true
module Types
module Admin
module Analytics
module InstanceStatistics
class MeasurementIdentifierEnum < BaseEnum
graphql_name 'MeasurementIdentifier'
description 'Possible identifier types for a measurement'
value 'PROJECTS', 'Project count', value: :projects
value 'USERS', 'User count', value: :users
value 'ISSUES', 'Issue count', value: :issues
value 'MERGE_REQUESTS', 'Merge request count', value: :merge_requests
value 'GROUPS', 'Group count', value: :groups
value 'PIPELINES', 'Pipeline count', value: :pipelines
end
end
end
end
end
# frozen_string_literal: true
# rubocop:disable Graphql/AuthorizeTypes
module Types
module Admin
module Analytics
module InstanceStatistics
class MeasurementType < BaseObject
graphql_name 'InstanceStatisticsMeasurement'
description 'Represents a recorded measurement (object count) for the Admins'
field :recorded_at, Types::TimeType, null: true,
description: 'The time the measurement was recorded'
field :count, GraphQL::INT_TYPE, null: false,
description: 'Object count'
field :identifier, Types::Admin::Analytics::InstanceStatistics::MeasurementIdentifierEnum, null: false,
description: 'The type of objects being measured'
end
end
end
end
end
......@@ -76,6 +76,11 @@ module Types
argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'The global ID of the Issue'
end
field :instance_statistics_measurements, Types::Admin::Analytics::InstanceStatistics::MeasurementType.connection_type,
null: true,
description: 'Get statistics on the instance',
resolver: Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver
def design_management
DesignManagementObject.new(nil)
end
......
......@@ -3,10 +3,20 @@
module Analytics
module InstanceStatistics
class Measurement < ApplicationRecord
enum identifier: { projects: 1, users: 2 }
enum identifier: {
projects: 1,
users: 2,
issues: 3,
merge_requests: 4,
groups: 5,
pipelines: 6
}
validates :recorded_at, :identifier, :count, presence: true
validates :recorded_at, uniqueness: { scope: :identifier }
scope :order_by_latest, -> { order(recorded_at: :desc) }
scope :with_identifier, -> (identifier) { where(identifier: identifier) }
end
end
end
---
title: Expose Instance Statistics measurements (object counts) via GraphQL
merge_request: 40871
author:
type: added
......@@ -7410,6 +7410,61 @@ type InstanceSecurityDashboard {
): VulnerabilitySeveritiesCount
}
"""
Represents a recorded measurement (object count) for the Admins
"""
type InstanceStatisticsMeasurement {
"""
Object count
"""
count: Int!
"""
The type of objects being measured
"""
identifier: MeasurementIdentifier!
"""
The time the measurement was recorded
"""
recordedAt: Time
}
"""
The connection type for InstanceStatisticsMeasurement.
"""
type InstanceStatisticsMeasurementConnection {
"""
A list of edges.
"""
edges: [InstanceStatisticsMeasurementEdge]
"""
A list of nodes.
"""
nodes: [InstanceStatisticsMeasurement]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type InstanceStatisticsMeasurementEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: InstanceStatisticsMeasurement
}
"""
Incident severity
"""
......@@ -9044,6 +9099,41 @@ type MarkAsSpamSnippetPayload {
snippet: Snippet
}
"""
Possible identifier types for a measurement
"""
enum MeasurementIdentifier {
"""
Group count
"""
GROUPS
"""
Issue count
"""
ISSUES
"""
Merge request count
"""
MERGE_REQUESTS
"""
Pipeline count
"""
PIPELINES
"""
Project count
"""
PROJECTS
"""
User count
"""
USERS
}
interface MemberInterface {
"""
GitLab::Access level
......@@ -13510,6 +13600,36 @@ type Query {
"""
instanceSecurityDashboard: InstanceSecurityDashboard
"""
Get statistics on the instance
"""
instanceStatisticsMeasurements(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
The type of measurement/statistics to retrieve
"""
identifier: MeasurementIdentifier!
"""
Returns the last _n_ elements from the list.
"""
last: Int
): InstanceStatisticsMeasurementConnection
"""
Find an issue
"""
......
......@@ -20447,6 +20447,181 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "InstanceStatisticsMeasurement",
"description": "Represents a recorded measurement (object count) for the Admins",
"fields": [
{
"name": "count",
"description": "Object count",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "identifier",
"description": "The type of objects being measured",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "MeasurementIdentifier",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "recordedAt",
"description": "The time the measurement was recorded",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "InstanceStatisticsMeasurementConnection",
"description": "The connection type for InstanceStatisticsMeasurement.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "InstanceStatisticsMeasurementEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "InstanceStatisticsMeasurement",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "InstanceStatisticsMeasurementEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "InstanceStatisticsMeasurement",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Int",
......@@ -25093,6 +25268,53 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "MeasurementIdentifier",
"description": "Possible identifier types for a measurement",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "PROJECTS",
"description": "Project count",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "USERS",
"description": "User count",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ISSUES",
"description": "Issue count",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MERGE_REQUESTS",
"description": "Merge request count",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "GROUPS",
"description": "Group count",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "PIPELINES",
"description": "Pipeline count",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "INTERFACE",
"name": "MemberInterface",
......@@ -39660,6 +39882,73 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "instanceStatisticsMeasurements",
"description": "Get statistics on the instance",
"args": [
{
"name": "identifier",
"description": "The type of measurement/statistics to retrieve",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "MeasurementIdentifier",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "InstanceStatisticsMeasurementConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "Find an issue",
......@@ -1100,6 +1100,16 @@ Represents a Group Membership
| `vulnerabilityGrades` | VulnerableProjectsByGrade! => Array | Represents vulnerable project counts for each grade |
| `vulnerabilitySeveritiesCount` | VulnerabilitySeveritiesCount | Counts for each vulnerability severity from projects selected in Instance Security Dashboard |
## InstanceStatisticsMeasurement
Represents a recorded measurement (object count) for the Admins
| Name | Type | Description |
| --- | ---- | ---------- |
| `count` | Int! | Object count |
| `identifier` | MeasurementIdentifier! | The type of objects being measured |
| `recordedAt` | Time | The time the measurement was recorded |
## Issue
| Name | Type | Description |
......
......@@ -3,7 +3,15 @@
FactoryBot.define do
factory :instance_statistics_measurement, class: 'Analytics::InstanceStatistics::Measurement' do
recorded_at { Time.now }
identifier { Analytics::InstanceStatistics::Measurement.identifiers[:projects] }
identifier { :projects }
count { 1_000 }
trait :project_count do
identifier { :projects }
end
trait :group_count do
identifier { :groups }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:user) { create(:user) }
let_it_be(:admin_user) { create(:user, :admin) }
let_it_be(:project_measurement_new) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
let_it_be(:project_measurement_old) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
subject { resolve_measurements({ identifier: 'projects' }, { current_user: current_user }) }
context 'when requesting project count measurements' do
context 'as an admin user' do
let(:current_user) { admin_user }
it 'returns the records, latest first' do
expect(subject).to eq([project_measurement_new, project_measurement_old])
end
end
context 'as a non-admin user' do
let(:current_user) { user }
it 'raises ResourceNotAvailable error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'as an unauthenticated user' do
let(:current_user) { nil }
it 'raises ResourceNotAvailable error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
def resolve_measurements(args = {}, context = {})
resolve(described_class, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['MeasurementIdentifier'] do
specify { expect(described_class.graphql_name).to eq('MeasurementIdentifier') }
it 'exposes all the existing identifier values' do
identifiers = Analytics::InstanceStatistics::Measurement.identifiers.keys.map(&:upcase)
expect(described_class.values.keys).to match_array(identifiers)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do
subject { described_class }
it { is_expected.to have_graphql_field(:recorded_at) }
it { is_expected.to have_graphql_field(:identifier) }
it { is_expected.to have_graphql_field(:count) }
end
......@@ -21,6 +21,7 @@ RSpec.describe GitlabSchema.types['Query'] do
user
users
issue
instance_statistics_measurements
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
......@@ -62,4 +63,12 @@ RSpec.describe GitlabSchema.types['Query'] do
is_expected.to have_graphql_type(Types::IssueType)
end
end
describe 'instance_statistics_measurements field' do
subject { described_class.fields['instanceStatisticsMeasurements'] }
it 'returns issue' do
is_expected.to have_graphql_type(Types::Admin::Analytics::InstanceStatistics::MeasurementType.connection_type)
end
end
end
......@@ -11,4 +11,35 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
it { is_expected.to validate_presence_of(:count) }
it { is_expected.to validate_uniqueness_of(:recorded_at).scoped_to(:identifier) }
end
describe 'identifiers enum' do
it 'maps to the correct values' do
expect(described_class.identifiers).to eq({
projects: 1,
users: 2,
issues: 3,
merge_requests: 4,
groups: 5,
pipelines: 6
}.with_indifferent_access)
end
end
describe 'scopes' do
let_it_be(:measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
let_it_be(:measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
let_it_be(:measurement_3) { create(:instance_statistics_measurement, :group_count, recorded_at: 5.days.ago) }
describe '.order_by_latest' do
subject { described_class.order_by_latest }
it { is_expected.to eq([measurement_2, measurement_3, measurement_1]) }
end
describe '.with_identifier' do
subject { described_class.with_identifier(:projects) }
it { is_expected.to match_array([measurement_1, measurement_2]) }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'InstanceStatisticsMeasurements' do
include GraphqlHelpers
let(:current_user) { create(:user, :admin) }
let!(:instance_statistics_measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 20.days.ago, count: 5) }
let!(:instance_statistics_measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago, count: 10) }
let(:query) { graphql_query_for(:instanceStatisticsMeasurements, 'identifier: PROJECTS', 'nodes { count }') }
before do
post_graphql(query, current_user: current_user)
end
it 'returns measurement objects' do
expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([{ "count" => 10 }, { "count" => 5 }])
end
end
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