Commit b041321a authored by Manoj MJ's avatar Manoj MJ Committed by Ash McKenzie

Application Statistics API

This change implements Application
Statistics API
parent e3763f9c
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import statisticsLabels from '../constants';
export default {
components: {
GlLoadingIcon,
},
data() {
return {
statisticsLabels,
};
},
computed: {
...mapState(['isLoading', 'statistics']),
...mapGetters(['getStatistics']),
},
mounted() {
this.fetchStatistics();
},
methods: {
...mapActions(['fetchStatistics']),
},
};
</script>
<template>
<div class="info-well">
<div class="well-segment admin-well admin-well-statistics">
<h4>{{ __('Statistics') }}</h4>
<gl-loading-icon v-if="isLoading" size="md" class="my-3" />
<template v-else>
<p
v-for="statistic in getStatistics(statisticsLabels)"
:key="statistic.key"
class="js-stats"
>
{{ statistic.label }}
<span class="light float-right">{{ statistic.value }}</span>
</p>
</template>
</div>
</div>
</template>
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;
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);
},
});
}
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 () => {};
/**
* 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 () => {};
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(),
});
export const REQUEST_STATISTICS = 'REQUEST_STATISTICS';
export const RECEIVE_STATISTICS_SUCCESS = 'RECEIVE_STATISTICS_SUCCESS';
export const RECEIVE_STATISTICS_ERROR = 'RECEIVE_STATISTICS_ERROR';
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;
},
};
export default () => ({
error: null,
isLoading: false,
statistics: null,
});
......@@ -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));
},
......
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);
});
......@@ -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
......
......@@ -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
......
---
title: 'Admin dashboard: Fetch and render statistics async'
merge_request: 32449
author:
type: other
......@@ -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` |
......
# 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: <your_access_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"
}
```
......@@ -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
......
......@@ -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
......
# 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
......@@ -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 ""
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe 'admin visits dashboard' do
describe 'admin visits dashboard', :js do
include ProjectForksHelper
before do
......
{
"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" }
}
}
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);
});
});
});
});
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;
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,
);
});
});
});
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);
});
});
});
});
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);
});
});
});
# 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
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