Commit 82817de4 authored by Adam Hegyi's avatar Adam Hegyi Committed by Dmytro Zaporozhets (DZ)

Expose Instance Statistics measurements in GraphQL

This change exposes Instance Statistics measurements (recorded object
counts) for admin users only.
parent e83eacb1
# 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