From b041321a355b507cd9329e80935e960c2b9114eb Mon Sep 17 00:00:00 2001 From: Manoj MJ Date: Mon, 9 Sep 2019 03:38:42 +0000 Subject: Application Statistics API This change implements Application Statistics API --- .../admin/statistics_panel/components/app.vue | 45 ++++++++ .../admin/statistics_panel/constants.js | 14 +++ .../javascripts/admin/statistics_panel/index.js | 22 ++++ .../admin/statistics_panel/store/actions.js | 28 +++++ .../admin/statistics_panel/store/getters.js | 17 +++ .../admin/statistics_panel/store/index.js | 16 +++ .../admin/statistics_panel/store/mutation_types.js | 3 + .../admin/statistics_panel/store/mutations.js | 16 +++ .../admin/statistics_panel/store/state.js | 5 + app/assets/javascripts/api.js | 6 ++ app/assets/javascripts/pages/admin/index.js | 7 +- app/controllers/admin/dashboard_controller.rb | 3 +- app/views/admin/dashboard/index.html.haml | 36 +------ ...page-due-to-statement-timeout-on-counting-t.yml | 5 + doc/api/api_resources.md | 1 + doc/api/statistics.md | 35 +++++++ lib/api/api.rb | 1 + lib/api/entities.rb | 49 +++++++++ lib/api/statistics.rb | 18 ++++ locale/gitlab.pot | 30 ++++++ spec/features/admin/dashboard_spec.rb | 2 +- spec/fixtures/api/schemas/statistics.json | 29 ++++++ .../admin/statistics_panel/components/app_spec.js | 73 +++++++++++++ spec/frontend/admin/statistics_panel/mock_data.js | 15 +++ .../admin/statistics_panel/store/actions_spec.js | 115 +++++++++++++++++++++ .../admin/statistics_panel/store/getters_spec.js | 48 +++++++++ .../admin/statistics_panel/store/mutations_spec.js | 41 ++++++++ spec/requests/api/statistics_spec.rb | 91 ++++++++++++++++ 28 files changed, 732 insertions(+), 39 deletions(-) create mode 100644 app/assets/javascripts/admin/statistics_panel/components/app.vue create mode 100644 app/assets/javascripts/admin/statistics_panel/constants.js create mode 100644 app/assets/javascripts/admin/statistics_panel/index.js create mode 100644 app/assets/javascripts/admin/statistics_panel/store/actions.js create mode 100644 app/assets/javascripts/admin/statistics_panel/store/getters.js create mode 100644 app/assets/javascripts/admin/statistics_panel/store/index.js create mode 100644 app/assets/javascripts/admin/statistics_panel/store/mutation_types.js create mode 100644 app/assets/javascripts/admin/statistics_panel/store/mutations.js create mode 100644 app/assets/javascripts/admin/statistics_panel/store/state.js create mode 100644 changelogs/unreleased/51123-error-500-viewing-admin-page-due-to-statement-timeout-on-counting-t.yml create mode 100644 doc/api/statistics.md create mode 100644 lib/api/statistics.rb create mode 100644 spec/fixtures/api/schemas/statistics.json create mode 100644 spec/frontend/admin/statistics_panel/components/app_spec.js create mode 100644 spec/frontend/admin/statistics_panel/mock_data.js create mode 100644 spec/frontend/admin/statistics_panel/store/actions_spec.js create mode 100644 spec/frontend/admin/statistics_panel/store/getters_spec.js create mode 100644 spec/frontend/admin/statistics_panel/store/mutations_spec.js create mode 100644 spec/requests/api/statistics_spec.rb diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue new file mode 100644 index 00000000000..29077d926cf --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/assets/javascripts/admin/statistics_panel/constants.js b/app/assets/javascripts/admin/statistics_panel/constants.js new file mode 100644 index 00000000000..2dce19a3894 --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/constants.js @@ -0,0 +1,14 @@ +import { s__ } from '~/locale'; + +const statisticsLabels = { + forks: s__('AdminStatistics|Forks'), + issues: s__('AdminStatistics|Issues'), + mergeRequests: s__('AdminStatistics|Merge Requests'), + notes: s__('AdminStatistics|Notes'), + snippets: s__('AdminStatistics|Snippets'), + sshKeys: s__('AdminStatistics|SSH Keys'), + milestones: s__('AdminStatistics|Milestones'), + activeUsers: s__('AdminStatistics|Active Users'), +}; + +export default statisticsLabels; diff --git a/app/assets/javascripts/admin/statistics_panel/index.js b/app/assets/javascripts/admin/statistics_panel/index.js new file mode 100644 index 00000000000..39112e3ddc0 --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import StatisticsPanelApp from './components/app.vue'; +import createStore from './store'; + +export default function(el) { + if (!el) { + return false; + } + + const store = createStore(); + + return new Vue({ + el, + store, + components: { + StatisticsPanelApp, + }, + render(h) { + return h(StatisticsPanelApp); + }, + }); +} diff --git a/app/assets/javascripts/admin/statistics_panel/store/actions.js b/app/assets/javascripts/admin/statistics_panel/store/actions.js new file mode 100644 index 00000000000..537025f524c --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/store/actions.js @@ -0,0 +1,28 @@ +import Api from '~/api'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +export const requestStatistics = ({ commit }) => commit(types.REQUEST_STATISTICS); + +export const fetchStatistics = ({ dispatch }) => { + dispatch('requestStatistics'); + + Api.adminStatistics() + .then(({ data }) => { + dispatch('receiveStatisticsSuccess', convertObjectPropsToCamelCase(data, { deep: true })); + }) + .catch(error => dispatch('receiveStatisticsError', error)); +}; + +export const receiveStatisticsSuccess = ({ commit }, statistics) => + commit(types.RECEIVE_STATISTICS_SUCCESS, statistics); + +export const receiveStatisticsError = ({ commit }, error) => { + commit(types.RECEIVE_STATISTICS_ERROR, error); + createFlash(s__('AdminDashboard|Error loading the statistics. Please try again')); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/admin/statistics_panel/store/getters.js b/app/assets/javascripts/admin/statistics_panel/store/getters.js new file mode 100644 index 00000000000..24437bc76bf --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/store/getters.js @@ -0,0 +1,17 @@ +/** + * Merges the statisticsLabels with the state's data + * and returns an array of the following form: + * [{ key: "forks", label: "Forks", value: 50 }] + */ +export const getStatistics = state => labels => + Object.keys(labels).map(key => { + const result = { + key, + label: labels[key], + value: state.statistics && state.statistics[key] ? state.statistics[key] : null, + }; + return result; + }); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/admin/statistics_panel/store/index.js b/app/assets/javascripts/admin/statistics_panel/store/index.js new file mode 100644 index 00000000000..ece9e6419dd --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(), + }); diff --git a/app/assets/javascripts/admin/statistics_panel/store/mutation_types.js b/app/assets/javascripts/admin/statistics_panel/store/mutation_types.js new file mode 100644 index 00000000000..4e0ca4ed3cd --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/store/mutation_types.js @@ -0,0 +1,3 @@ +export const REQUEST_STATISTICS = 'REQUEST_STATISTICS'; +export const RECEIVE_STATISTICS_SUCCESS = 'RECEIVE_STATISTICS_SUCCESS'; +export const RECEIVE_STATISTICS_ERROR = 'RECEIVE_STATISTICS_ERROR'; diff --git a/app/assets/javascripts/admin/statistics_panel/store/mutations.js b/app/assets/javascripts/admin/statistics_panel/store/mutations.js new file mode 100644 index 00000000000..d0fac5cfbab --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/store/mutations.js @@ -0,0 +1,16 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_STATISTICS](state) { + state.isLoading = true; + }, + [types.RECEIVE_STATISTICS_SUCCESS](state, data) { + state.isLoading = false; + state.error = null; + state.statistics = data; + }, + [types.RECEIVE_STATISTICS_ERROR](state, error) { + state.isLoading = false; + state.error = error; + }, +}; diff --git a/app/assets/javascripts/admin/statistics_panel/store/state.js b/app/assets/javascripts/admin/statistics_panel/store/state.js new file mode 100644 index 00000000000..f2f2dc0a4d2 --- /dev/null +++ b/app/assets/javascripts/admin/statistics_panel/store/state.js @@ -0,0 +1,5 @@ +export default () => ({ + error: null, + isLoading: false, + statistics: null, +}); diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 136ffdf8b9d..1d97ad5ec11 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -36,6 +36,7 @@ const Api = { branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', createBranchPath: '/api/:version/projects/:id/repository/branches', releasesPath: '/api/:version/projects/:id/releases', + adminStatisticsPath: 'api/:version/application/statistics', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -376,6 +377,11 @@ const Api = { return axios.get(url); }, + adminStatistics() { + const url = Api.buildUrl(this.adminStatisticsPath); + return axios.get(url); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js index 8a32556f06c..74f2eead755 100644 --- a/app/assets/javascripts/pages/admin/index.js +++ b/app/assets/javascripts/pages/admin/index.js @@ -1,3 +1,8 @@ import initAdmin from './admin'; +import initAdminStatisticsPanel from '../../admin/statistics_panel/index'; -document.addEventListener('DOMContentLoaded', initAdmin()); +document.addEventListener('DOMContentLoaded', () => { + const statisticsPanelContainer = document.getElementById('js-admin-statistics-container'); + initAdmin(); + initAdminStatisticsPanel(statisticsPanelContainer); +}); diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 23cc9ee247a..64b959e2431 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -3,8 +3,7 @@ class Admin::DashboardController < Admin::ApplicationController include CountHelper - COUNTED_ITEMS = [Project, User, Group, ForkNetworkMember, ForkNetwork, Issue, - MergeRequest, Note, Snippet, Key, Milestone].freeze + COUNTED_ITEMS = [Project, User, Group].freeze # rubocop: disable CodeReuse/ActiveRecord def index diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 8aca61efe7b..8fad42436ca 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -35,41 +35,7 @@ = link_to 'New group', new_admin_group_path, class: "btn btn-success" .row .col-md-4 - .info-well - .well-segment.admin-well.admin-well-statistics - %h4 Statistics - %p - Forks - %span.light.float-right - = approximate_fork_count_with_delimiters(@counts) - %p - Issues - %span.light.float-right - = approximate_count_with_delimiters(@counts, Issue) - %p - Merge Requests - %span.light.float-right - = approximate_count_with_delimiters(@counts, MergeRequest) - %p - Notes - %span.light.float-right - = approximate_count_with_delimiters(@counts, Note) - %p - Snippets - %span.light.float-right - = approximate_count_with_delimiters(@counts, Snippet) - %p - SSH Keys - %span.light.float-right - = approximate_count_with_delimiters(@counts, Key) - %p - Milestones - %span.light.float-right - = approximate_count_with_delimiters(@counts, Milestone) - %p - Active Users - %span.light.float-right - = number_with_delimiter(User.active.count) + #js-admin-statistics-container .col-md-4 .info-well .well-segment.admin-well.admin-well-features diff --git a/changelogs/unreleased/51123-error-500-viewing-admin-page-due-to-statement-timeout-on-counting-t.yml b/changelogs/unreleased/51123-error-500-viewing-admin-page-due-to-statement-timeout-on-counting-t.yml new file mode 100644 index 00000000000..9862137c80c --- /dev/null +++ b/changelogs/unreleased/51123-error-500-viewing-admin-page-due-to-statement-timeout-on-counting-t.yml @@ -0,0 +1,5 @@ +--- +title: 'Admin dashboard: Fetch and render statistics async' +merge_request: 32449 +author: +type: other diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index 9af5430f1c8..e2ddc2cbc18 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -126,6 +126,7 @@ The following API resources are available outside of project and group contexts | [Runners](runners.md) | `/runners` (also available for projects) | | [Search](search.md) | `/search` (also available for groups and projects) | | [Settings](settings.md) | `/application/settings` | +| [Statistics](statistics.md) | `/application/statistics` | | [Sidekiq metrics](sidekiq_metrics.md) | `/sidekiq` | | [Suggestions](suggestions.md) | `/suggestions` | | [System hooks](system_hooks.md) | `/hooks` | diff --git a/doc/api/statistics.md b/doc/api/statistics.md new file mode 100644 index 00000000000..5078b2f26d4 --- /dev/null +++ b/doc/api/statistics.md @@ -0,0 +1,35 @@ +# Application statistics API + +## Get current application statistics + +List the current statistics of the GitLab instance. You have to be an +administrator in order to perform this action. + +NOTE: **Note:** +These statistics are approximate. + +``` +GET /application/statistics +``` + +```bash +curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/application/statistics +``` + +Example response: + +```json +{ + "forks": "10", + "issues": "76", + "merge_requests": "27", + "notes": "954", + "snippets": "50", + "ssh_keys": "10", + "milestones": "40", + "users": "50", + "groups": "10", + "projects": "20", + "active_users": "50" +} +``` diff --git a/lib/api/api.rb b/lib/api/api.rb index aa6a67d817a..88d411e22a9 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -161,6 +161,7 @@ module API mount ::API::Settings mount ::API::SidekiqMetrics mount ::API::Snippets + mount ::API::Statistics mount ::API::Submodules mount ::API::Subscriptions mount ::API::Suggestions diff --git a/lib/api/entities.rb b/lib/api/entities.rb index f7cd6d35854..c9b3483acaf 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1169,6 +1169,55 @@ module API expose :message, :starts_at, :ends_at, :color, :font end + class ApplicationStatistics < Grape::Entity + include ActionView::Helpers::NumberHelper + include CountHelper + + expose :forks do |counts| + approximate_fork_count_with_delimiters(counts) + end + + expose :issues do |counts| + approximate_count_with_delimiters(counts, ::Issue) + end + + expose :merge_requests do |counts| + approximate_count_with_delimiters(counts, ::MergeRequest) + end + + expose :notes do |counts| + approximate_count_with_delimiters(counts, ::Note) + end + + expose :snippets do |counts| + approximate_count_with_delimiters(counts, ::Snippet) + end + + expose :ssh_keys do |counts| + approximate_count_with_delimiters(counts, ::Key) + end + + expose :milestones do |counts| + approximate_count_with_delimiters(counts, ::Milestone) + end + + expose :users do |counts| + approximate_count_with_delimiters(counts, ::User) + end + + expose :projects do |counts| + approximate_count_with_delimiters(counts, ::Project) + end + + expose :groups do |counts| + approximate_count_with_delimiters(counts, ::Group) + end + + expose :active_users do |_| + number_with_delimiter(::User.active.count) + end + end + class ApplicationSetting < Grape::Entity def self.exposed_attributes attributes = ::ApplicationSettingsHelper.visible_attributes diff --git a/lib/api/statistics.rb b/lib/api/statistics.rb new file mode 100644 index 00000000000..d2dce34dfa5 --- /dev/null +++ b/lib/api/statistics.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module API + class Statistics < Grape::API + before { authenticated_as_admin! } + + COUNTED_ITEMS = [Project, User, Group, ForkNetworkMember, ForkNetwork, Issue, + MergeRequest, Note, Snippet, Key, Milestone].freeze + + desc 'Get the current application statistics' do + success Entities::ApplicationStatistics + end + get "application/statistics" do + counts = Gitlab::Database::Count.approximate_counts(COUNTED_ITEMS) + present counts, with: Entities::ApplicationStatistics + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f2277dc3446..a77d70a5700 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -810,6 +810,9 @@ msgstr "" msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running." msgstr "" +msgid "AdminDashboard|Error loading the statistics. Please try again" +msgstr "" + msgid "AdminProjects| You’re about to permanently delete the project %{projectName}, its repository, and all related resources including issues, merge requests, etc.. Once you confirm and press %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered." msgstr "" @@ -837,6 +840,30 @@ msgstr "" msgid "AdminSettings|When creating a new environment variable it will be protected by default." msgstr "" +msgid "AdminStatistics|Active Users" +msgstr "" + +msgid "AdminStatistics|Forks" +msgstr "" + +msgid "AdminStatistics|Issues" +msgstr "" + +msgid "AdminStatistics|Merge Requests" +msgstr "" + +msgid "AdminStatistics|Milestones" +msgstr "" + +msgid "AdminStatistics|Notes" +msgstr "" + +msgid "AdminStatistics|SSH Keys" +msgstr "" + +msgid "AdminStatistics|Snippets" +msgstr "" + msgid "AdminUsers|2FA Disabled" msgstr "" @@ -10982,6 +11009,9 @@ msgstr "" msgid "State your message to activate" msgstr "" +msgid "Statistics" +msgstr "" + msgid "Status" msgstr "" diff --git a/spec/features/admin/dashboard_spec.rb b/spec/features/admin/dashboard_spec.rb index e204e0a515d..6cb345c5066 100644 --- a/spec/features/admin/dashboard_spec.rb +++ b/spec/features/admin/dashboard_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'admin visits dashboard' do +describe 'admin visits dashboard', :js do include ProjectForksHelper before do diff --git a/spec/fixtures/api/schemas/statistics.json b/spec/fixtures/api/schemas/statistics.json new file mode 100644 index 00000000000..ef2f39aad9d --- /dev/null +++ b/spec/fixtures/api/schemas/statistics.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "required" : [ + "forks", + "issues", + "merge_requests", + "notes", + "snippets", + "ssh_keys", + "milestones", + "users", + "projects", + "groups", + "active_users" + ], + "properties" : { + "forks": { "type": "string" }, + "issues'": { "type": "string" }, + "merge_requests'": { "type": "string" }, + "notes'": { "type": "string" }, + "snippets'": { "type": "string" }, + "ssh_keys'": { "type": "string" }, + "milestones'": { "type": "string" }, + "users'": { "type": "string" }, + "projects'": { "type": "string" }, + "groups'": { "type": "string" }, + "active_users'": { "type": "string" } + } +} diff --git a/spec/frontend/admin/statistics_panel/components/app_spec.js b/spec/frontend/admin/statistics_panel/components/app_spec.js new file mode 100644 index 00000000000..25b1d432e2d --- /dev/null +++ b/spec/frontend/admin/statistics_panel/components/app_spec.js @@ -0,0 +1,73 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import StatisticsPanelApp from '~/admin/statistics_panel/components/app.vue'; +import statisticsLabels from '~/admin/statistics_panel/constants'; +import createStore from '~/admin/statistics_panel/store'; +import { GlLoadingIcon } from '@gitlab/ui'; +import mockStatistics from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Admin statistics app', () => { + let wrapper; + let store; + let axiosMock; + + const createComponent = () => { + wrapper = shallowMount(StatisticsPanelApp, { + localVue, + store, + sync: false, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet(/api\/(.*)\/application\/statistics/).reply(200); + store = createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findStats = idx => wrapper.findAll('.js-stats').at(idx); + + describe('template', () => { + describe('when app is loading', () => { + it('renders a loading indicator', () => { + store.dispatch('requestStatistics'); + createComponent(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('when app has finished loading', () => { + const statistics = convertObjectPropsToCamelCase(mockStatistics, { deep: true }); + + it.each` + statistic | count | index + ${'forks'} | ${12} | ${0} + ${'issues'} | ${180} | ${1} + ${'mergeRequests'} | ${31} | ${2} + ${'notes'} | ${986} | ${3} + ${'snippets'} | ${50} | ${4} + ${'sshKeys'} | ${10} | ${5} + ${'milestones'} | ${40} | ${6} + ${'activeUsers'} | ${50} | ${7} + `('renders the count for the $statistic statistic', ({ statistic, count, index }) => { + const label = statisticsLabels[statistic]; + store.dispatch('receiveStatisticsSuccess', statistics); + createComponent(); + + expect(findStats(index).text()).toContain(label); + expect(findStats(index).text()).toContain(count); + }); + }); + }); +}); diff --git a/spec/frontend/admin/statistics_panel/mock_data.js b/spec/frontend/admin/statistics_panel/mock_data.js new file mode 100644 index 00000000000..6d861059dfd --- /dev/null +++ b/spec/frontend/admin/statistics_panel/mock_data.js @@ -0,0 +1,15 @@ +const mockStatistics = { + forks: 12, + issues: 180, + merge_requests: 31, + notes: 986, + snippets: 50, + ssh_keys: 10, + milestones: 40, + users: 50, + projects: 29, + groups: 9, + active_users: 50, +}; + +export default mockStatistics; diff --git a/spec/frontend/admin/statistics_panel/store/actions_spec.js b/spec/frontend/admin/statistics_panel/store/actions_spec.js new file mode 100644 index 00000000000..9b18b1aebda --- /dev/null +++ b/spec/frontend/admin/statistics_panel/store/actions_spec.js @@ -0,0 +1,115 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import * as actions from '~/admin/statistics_panel/store/actions'; +import * as types from '~/admin/statistics_panel/store/mutation_types'; +import getInitialState from '~/admin/statistics_panel/store/state'; +import mockStatistics from '../mock_data'; + +describe('Admin statistics panel actions', () => { + let mock; + let state; + + beforeEach(() => { + state = getInitialState(); + mock = new MockAdapter(axios); + }); + + describe('fetchStatistics', () => { + describe('success', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(200, mockStatistics); + }); + + it('dispatches success with received data', done => + testAction( + actions.fetchStatistics, + null, + state, + [], + [ + { type: 'requestStatistics' }, + { + type: 'receiveStatisticsSuccess', + payload: expect.objectContaining( + convertObjectPropsToCamelCase(mockStatistics, { deep: true }), + ), + }, + ], + done, + )); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(500); + }); + + it('dispatches error', done => + testAction( + actions.fetchStatistics, + null, + state, + [], + [ + { + type: 'requestStatistics', + }, + { + type: 'receiveStatisticsError', + payload: new Error('Request failed with status code 500'), + }, + ], + done, + )); + }); + }); + + describe('requestStatistic', () => { + it('should commit the request mutation', done => + testAction( + actions.requestStatistics, + null, + state, + [{ type: types.REQUEST_STATISTICS }], + [], + done, + )); + }); + + describe('receiveStatisticsSuccess', () => { + it('should commit received data', done => + testAction( + actions.receiveStatisticsSuccess, + mockStatistics, + state, + [ + { + type: types.RECEIVE_STATISTICS_SUCCESS, + payload: mockStatistics, + }, + ], + [], + done, + )); + }); + + describe('receiveStatisticsError', () => { + it('should commit error', done => { + testAction( + actions.receiveStatisticsError, + 500, + state, + [ + { + type: types.RECEIVE_STATISTICS_ERROR, + payload: 500, + }, + ], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/admin/statistics_panel/store/getters_spec.js b/spec/frontend/admin/statistics_panel/store/getters_spec.js new file mode 100644 index 00000000000..152d82531ed --- /dev/null +++ b/spec/frontend/admin/statistics_panel/store/getters_spec.js @@ -0,0 +1,48 @@ +import createState from '~/admin/statistics_panel/store/state'; +import * as getters from '~/admin/statistics_panel/store/getters'; + +describe('Admin statistics panel getters', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe('getStatistics', () => { + describe('when statistics data exists', () => { + it('returns an array of statistics objects with key, label and value', () => { + state.statistics = { forks: 10, issues: 20 }; + + const statisticsLabels = { + forks: 'Forks', + issues: 'Issues', + }; + + const statisticsData = [ + { key: 'forks', label: 'Forks', value: 10 }, + { key: 'issues', label: 'Issues', value: 20 }, + ]; + + expect(getters.getStatistics(state)(statisticsLabels)).toEqual(statisticsData); + }); + }); + + describe('when no statistics data exists', () => { + it('returns an array of statistics objects with key, label and sets value to null', () => { + state.statistics = null; + + const statisticsLabels = { + forks: 'Forks', + issues: 'Issues', + }; + + const statisticsData = [ + { key: 'forks', label: 'Forks', value: null }, + { key: 'issues', label: 'Issues', value: null }, + ]; + + expect(getters.getStatistics(state)(statisticsLabels)).toEqual(statisticsData); + }); + }); + }); +}); diff --git a/spec/frontend/admin/statistics_panel/store/mutations_spec.js b/spec/frontend/admin/statistics_panel/store/mutations_spec.js new file mode 100644 index 00000000000..179f38d2bc5 --- /dev/null +++ b/spec/frontend/admin/statistics_panel/store/mutations_spec.js @@ -0,0 +1,41 @@ +import mutations from '~/admin/statistics_panel/store/mutations'; +import * as types from '~/admin/statistics_panel/store/mutation_types'; +import getInitialState from '~/admin/statistics_panel/store/state'; +import mockStatistics from '../mock_data'; + +describe('Admin statistics panel mutations', () => { + let state; + + beforeEach(() => { + state = getInitialState(); + }); + + describe(`${types.REQUEST_STATISTICS}`, () => { + it('sets isLoading to true', () => { + mutations[types.REQUEST_STATISTICS](state); + + expect(state.isLoading).toBe(true); + }); + }); + + describe(`${types.RECEIVE_STATISTICS_SUCCESS}`, () => { + it('updates the store with the with statistics', () => { + mutations[types.RECEIVE_STATISTICS_SUCCESS](state, mockStatistics); + + expect(state.isLoading).toBe(false); + expect(state.error).toBe(null); + expect(state.statistics).toEqual(mockStatistics); + }); + }); + + describe(`${types.RECEIVE_STATISTICS_ERROR}`, () => { + it('sets error and clears data', () => { + const error = 500; + mutations[types.RECEIVE_STATISTICS_ERROR](state, error); + + expect(state.isLoading).toBe(false); + expect(state.error).toBe(error); + expect(state.statistics).toEqual(null); + }); + }); +}); diff --git a/spec/requests/api/statistics_spec.rb b/spec/requests/api/statistics_spec.rb new file mode 100644 index 00000000000..91fc4d4c123 --- /dev/null +++ b/spec/requests/api/statistics_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Statistics, 'Statistics' do + include ProjectForksHelper + TABLES_TO_ANALYZE = %w[ + projects + users + namespaces + issues + merge_requests + notes + snippets + fork_networks + fork_network_members + keys + milestones + ].freeze + + let(:path) { "/application/statistics" } + + describe "GET /application/statistics" do + context 'when no user' do + it "returns authentication error" do + get api(path, nil) + + expect(response).to have_gitlab_http_status(401) + end + end + + context "when not an admin" do + let(:user) { create(:user) } + + it "returns forbidden error" do + get api(path, user) + + expect(response).to have_gitlab_http_status(403) + end + end + + context 'when authenticated as admin' do + let(:admin) { create(:admin) } + + it 'matches the response schema' do + get api(path, admin) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('statistics') + end + + it 'gives the right statistics' do + projects = create_list(:project, 4, namespace: create(:namespace, owner: admin)) + issues = create_list(:issue, 2, project: projects.first, updated_by: admin) + + create_list(:snippet, 2, :public, author: admin) + create_list(:note, 2, author: admin, project: projects.first, noteable: issues.first) + create_list(:milestone, 3, project: projects.first) + create(:key, user: admin) + create(:merge_request, source_project: projects.first) + fork_project(projects.first, admin) + + # Make sure the reltuples have been updated + # to get a correct count on postgresql + TABLES_TO_ANALYZE.each do |table| + ActiveRecord::Base.connection.execute("ANALYZE #{table}") + end + + get api(path, admin) + + expected_statistics = { + issues: 2, + merge_requests: 1, + notes: 2, + snippets: 2, + forks: 1, + ssh_keys: 1, + milestones: 3, + users: 1, + projects: 5, + groups: 1, + active_users: 1 + } + + expected_statistics.each do |entity, count| + expect(json_response[entity.to_s]).to eq(count.to_s) + end + end + end + end +end -- cgit v1.2.1