Commit 1eeee0dd authored by Ash McKenzie's avatar Ash McKenzie

Merge branch...

Merge branch '51123-error-500-viewing-admin-page-due-to-statement-timeout-on-counting-total-notes-again' into 'master'

Resolve "Add Widget for statistics in Admin/Dashboard page"

See merge request gitlab-org/gitlab-ce!32449
parents e3763f9c b041321a
<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 = { ...@@ -36,6 +36,7 @@ const Api = {
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches', createBranchPath: '/api/:version/projects/:id/repository/branches',
releasesPath: '/api/:version/projects/:id/releases', releasesPath: '/api/:version/projects/:id/releases',
adminStatisticsPath: 'api/:version/application/statistics',
group(groupId, callback) { group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
...@@ -376,6 +377,11 @@ const Api = { ...@@ -376,6 +377,11 @@ const Api = {
return axios.get(url); return axios.get(url);
}, },
adminStatistics() {
const url = Api.buildUrl(this.adminStatisticsPath);
return axios.get(url);
},
buildUrl(url) { buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
}, },
......
import initAdmin from './admin'; 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 @@ ...@@ -3,8 +3,7 @@
class Admin::DashboardController < Admin::ApplicationController class Admin::DashboardController < Admin::ApplicationController
include CountHelper include CountHelper
COUNTED_ITEMS = [Project, User, Group, ForkNetworkMember, ForkNetwork, Issue, COUNTED_ITEMS = [Project, User, Group].freeze
MergeRequest, Note, Snippet, Key, Milestone].freeze
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def index def index
......
...@@ -35,41 +35,7 @@ ...@@ -35,41 +35,7 @@
= link_to 'New group', new_admin_group_path, class: "btn btn-success" = link_to 'New group', new_admin_group_path, class: "btn btn-success"
.row .row
.col-md-4 .col-md-4
.info-well #js-admin-statistics-container
.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)
.col-md-4 .col-md-4
.info-well .info-well
.well-segment.admin-well.admin-well-features .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 ...@@ -126,6 +126,7 @@ The following API resources are available outside of project and group contexts
| [Runners](runners.md) | `/runners` (also available for projects) | | [Runners](runners.md) | `/runners` (also available for projects) |
| [Search](search.md) | `/search` (also available for groups and projects) | | [Search](search.md) | `/search` (also available for groups and projects) |
| [Settings](settings.md) | `/application/settings` | | [Settings](settings.md) | `/application/settings` |
| [Statistics](statistics.md) | `/application/statistics` |
| [Sidekiq metrics](sidekiq_metrics.md) | `/sidekiq` | | [Sidekiq metrics](sidekiq_metrics.md) | `/sidekiq` |
| [Suggestions](suggestions.md) | `/suggestions` | | [Suggestions](suggestions.md) | `/suggestions` |
| [System hooks](system_hooks.md) | `/hooks` | | [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 ...@@ -161,6 +161,7 @@ module API
mount ::API::Settings mount ::API::Settings
mount ::API::SidekiqMetrics mount ::API::SidekiqMetrics
mount ::API::Snippets mount ::API::Snippets
mount ::API::Statistics
mount ::API::Submodules mount ::API::Submodules
mount ::API::Subscriptions mount ::API::Subscriptions
mount ::API::Suggestions mount ::API::Suggestions
......
...@@ -1169,6 +1169,55 @@ module API ...@@ -1169,6 +1169,55 @@ module API
expose :message, :starts_at, :ends_at, :color, :font expose :message, :starts_at, :ends_at, :color, :font
end 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 class ApplicationSetting < Grape::Entity
def self.exposed_attributes def self.exposed_attributes
attributes = ::ApplicationSettingsHelper.visible_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 "" ...@@ -810,6 +810,9 @@ msgstr ""
msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running." msgid "AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running."
msgstr "" 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." 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 "" msgstr ""
...@@ -837,6 +840,30 @@ msgstr "" ...@@ -837,6 +840,30 @@ msgstr ""
msgid "AdminSettings|When creating a new environment variable it will be protected by default." msgid "AdminSettings|When creating a new environment variable it will be protected by default."
msgstr "" 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" msgid "AdminUsers|2FA Disabled"
msgstr "" msgstr ""
...@@ -10982,6 +11009,9 @@ msgstr "" ...@@ -10982,6 +11009,9 @@ msgstr ""
msgid "State your message to activate" msgid "State your message to activate"
msgstr "" msgstr ""
msgid "Statistics"
msgstr ""
msgid "Status" msgid "Status"
msgstr "" msgstr ""
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe 'admin visits dashboard' do describe 'admin visits dashboard', :js do
include ProjectForksHelper include ProjectForksHelper
before do 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