Commit 92436acd authored by Michael Lunøe's avatar Michael Lunøe Committed by Nicolò Maria Mezzopera

Feat(Instance Analytics): add pipelines chart

Add pipelines chart, so the user can see the
development in numbers of failed, succeeded,
cancelled, skipped, and total pipelines accross
the instance.
parent a525107c
<script> <script>
import InstanceCounts from './instance_counts.vue'; import InstanceCounts from './instance_counts.vue';
import PipelinesChart from './pipelines_chart.vue';
export default { export default {
name: 'InstanceStatisticsApp', name: 'InstanceStatisticsApp',
components: { components: {
InstanceCounts, InstanceCounts,
PipelinesChart,
}, },
}; };
</script> </script>
<template> <template>
<instance-counts /> <div>
<instance-counts />
<pipelines-chart />
</div>
</template> </template>
<script>
import { GlLineChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui';
import { mapKeys, mapValues, pick, some, sum } from 'lodash';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { s__ } from '~/locale';
import { formatDateAsMonth, getDayDifference } from '~/lib/utils/datetime_utility';
import { getAverageByMonth, sortByDate, extractValues } from '../utils';
import pipelineStatsQuery from '../graphql/queries/pipeline_stats.query.graphql';
import { TODAY, START_DATE } from '../constants';
const DATA_KEYS = [
'pipelinesTotal',
'pipelinesSucceeded',
'pipelinesFailed',
'pipelinesCanceled',
'pipelinesSkipped',
];
const PREFIX = 'pipelines';
export default {
name: 'PipelinesChart',
components: {
GlLineChart,
GlAlert,
ChartSkeletonLoader,
},
startDate: START_DATE,
endDate: TODAY,
i18n: {
loadPipelineChartError: s__(
'InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again.',
),
noDataMessage: s__('InstanceAnalytics|There is no data available.'),
total: s__('InstanceAnalytics|Total'),
succeeded: s__('InstanceAnalytics|Succeeded'),
failed: s__('InstanceAnalytics|Failed'),
canceled: s__('InstanceAnalytics|Canceled'),
skipped: s__('InstanceAnalytics|Skipped'),
chartTitle: s__('InstanceAnalytics|Pipelines'),
yAxisTitle: s__('InstanceAnalytics|Items'),
xAxisTitle: s__('InstanceAnalytics|Month'),
},
data() {
return {
loading: true,
loadingError: null,
};
},
apollo: {
pipelineStats: {
query: pipelineStatsQuery,
variables() {
return {
firstTotal: this.totalDaysToShow,
firstSucceeded: this.totalDaysToShow,
firstFailed: this.totalDaysToShow,
firstCanceled: this.totalDaysToShow,
firstSkipped: this.totalDaysToShow,
};
},
update(data) {
const allData = extractValues(data, DATA_KEYS, PREFIX, 'nodes');
const allPageInfo = extractValues(data, DATA_KEYS, PREFIX, 'pageInfo');
return {
...mapValues(allData, sortByDate),
...allPageInfo,
};
},
result() {
if (this.hasNextPage) {
this.fetchNextPage();
}
},
error() {
this.handleError();
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.pipelineStats.loading;
},
totalDaysToShow() {
return getDayDifference(this.$options.startDate, this.$options.endDate);
},
firstVariables() {
const allData = pick(this.pipelineStats, [
'nodesTotal',
'nodesSucceeded',
'nodesFailed',
'nodesCanceled',
'nodesSkipped',
]);
const allDayDiffs = mapValues(allData, data => {
const firstdataPoint = data[0];
if (!firstdataPoint) {
return 0;
}
return Math.max(
0,
getDayDifference(this.$options.startDate, new Date(firstdataPoint.recordedAt)),
);
});
return mapKeys(allDayDiffs, (value, key) => key.replace('nodes', 'first'));
},
cursorVariables() {
const pageInfoKeys = [
'pageInfoTotal',
'pageInfoSucceeded',
'pageInfoFailed',
'pageInfoCanceled',
'pageInfoSkipped',
];
return extractValues(this.pipelineStats, pageInfoKeys, 'pageInfo', 'endCursor');
},
hasNextPage() {
return (
sum(Object.values(this.firstVariables)) > 0 &&
some(this.pipelineStats, ({ hasNextPage }) => hasNextPage)
);
},
hasEmptyDataSet() {
return this.chartData.every(({ data }) => data.length === 0);
},
chartData() {
const allData = pick(this.pipelineStats, [
'nodesTotal',
'nodesSucceeded',
'nodesFailed',
'nodesCanceled',
'nodesSkipped',
]);
const options = { shouldRound: true };
return Object.keys(allData).map(key => {
const i18nName = key.slice('nodes'.length).toLowerCase();
return {
name: this.$options.i18n[i18nName],
data: getAverageByMonth(allData[key], options),
};
});
},
range() {
return {
min: this.$options.startDate,
max: this.$options.endDate,
};
},
differenceInMonths() {
const yearDiff = this.$options.endDate.getYear() - this.$options.startDate.getYear();
const monthDiff = this.$options.endDate.getMonth() - this.$options.startDate.getMonth();
return monthDiff + 12 * yearDiff;
},
chartOptions() {
return {
xAxis: {
...this.range,
name: this.$options.i18n.xAxisTitle,
type: 'time',
splitNumber: this.differenceInMonths + 1,
axisLabel: {
interval: 0,
showMinLabel: false,
showMaxLabel: false,
align: 'right',
formatter: formatDateAsMonth,
},
},
yAxis: {
name: this.$options.i18n.yAxisTitle,
},
};
},
},
methods: {
handleError() {
this.loadingError = true;
},
fetchNextPage() {
this.$apollo.queries.pipelineStats
.fetchMore({
variables: {
...this.firstVariables,
...this.cursorVariables,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
return Object.keys(fetchMoreResult).reduce((memo, key) => {
const { nodes, ...rest } = fetchMoreResult[key];
const previousNodes = previousResult[key].nodes;
return { ...memo, [key]: { ...rest, nodes: [...previousNodes, ...nodes] } };
}, {});
},
})
.catch(this.handleError);
},
},
};
</script>
<template>
<div>
<h3>{{ $options.i18n.chartTitle }}</h3>
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
{{ this.$options.i18n.loadPipelineChartError }}
</gl-alert>
<chart-skeleton-loader v-else-if="isLoading" />
<gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.noDataMessage }}
</gl-alert>
<gl-line-chart v-else :option="chartOptions" :include-legend-avg-max="true" :data="chartData" />
</div>
</template>
import { getDateInPast } from '~/lib/utils/datetime_utility';
const TOTAL_DAYS_TO_SHOW = 365;
export const TODAY = new Date();
export const START_DATE = getDateInPast(TODAY, TOTAL_DAYS_TO_SHOW);
fragment Count on InstanceStatisticsMeasurement {
count
recordedAt
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./count.fragment.graphql"
query pipelineStats(
$firstTotal: Int
$firstSucceeded: Int
$firstFailed: Int
$firstCanceled: Int
$firstSkipped: Int
$endCursorTotal: String
$endCursorSucceeded: String
$endCursorFailed: String
$endCursorCanceled: String
$endCursorSkipped: String
) {
pipelinesTotal: instanceStatisticsMeasurements(
identifier: PIPELINES
first: $firstTotal
after: $endCursorTotal
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesSucceeded: instanceStatisticsMeasurements(
identifier: PIPELINES_SUCCEEDED
first: $firstSucceeded
after: $endCursorSucceeded
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesFailed: instanceStatisticsMeasurements(
identifier: PIPELINES_FAILED
first: $firstFailed
after: $endCursorFailed
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesCanceled: instanceStatisticsMeasurements(
identifier: PIPELINES_CANCELED
first: $firstCanceled
after: $endCursorCanceled
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesSkipped: instanceStatisticsMeasurements(
identifier: PIPELINES_SKIPPED
first: $firstSkipped
after: $endCursorSkipped
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
import { masks } from 'dateformat'; import { masks } from 'dateformat';
import { mapKeys, mapValues, pick, sortBy } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
const { isoDate } = masks; const { isoDate } = masks;
...@@ -38,3 +39,31 @@ export function getAverageByMonth(items = [], options = {}) { ...@@ -38,3 +39,31 @@ export function getAverageByMonth(items = [], options = {}) {
return [month, avg]; return [month, avg];
}); });
} }
/**
* Extracts values given a data set and a set of keys
* @example
* const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
* extractValues(data, ['fooBar'], 'foo', 'baz') => { bazBar: 'quis' }
* @param {Object} data set to extract values from
* @param {Array} dataKeys keys describing where to look for values in the data set
* @param {String} replaceKey name key to be replaced in the data set
* @param {String} nestedKey key nested in the data set to be extracted,
* this is also used to rename the newly created data set
* @return {Object} the newly created data set with the extracted values
*/
export function extractValues(data, dataKeys = [], replaceKey, nestedKey) {
return mapKeys(pick(mapValues(data, nestedKey), dataKeys), (value, key) =>
key.replace(replaceKey, nestedKey),
);
}
/**
* Creates a new array of items sorted by the date string of each item
* @param {Array} items [description]
* @param {String} items[0] date string
* @return {Array} the new sorted array.
*/
export function sortByDate(items = []) {
return sortBy(items, ({ recordedAt }) => new Date(recordedAt).getTime());
}
...@@ -14095,6 +14095,36 @@ msgstr "" ...@@ -14095,6 +14095,36 @@ msgstr ""
msgid "Instance administrators group already exists" msgid "Instance administrators group already exists"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Canceled"
msgstr ""
msgid "InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again."
msgstr ""
msgid "InstanceAnalytics|Failed"
msgstr ""
msgid "InstanceAnalytics|Items"
msgstr ""
msgid "InstanceAnalytics|Month"
msgstr ""
msgid "InstanceAnalytics|Pipelines"
msgstr ""
msgid "InstanceAnalytics|Skipped"
msgstr ""
msgid "InstanceAnalytics|Succeeded"
msgstr ""
msgid "InstanceAnalytics|There is no data available."
msgstr ""
msgid "InstanceAnalytics|Total"
msgstr ""
msgid "InstanceStatistics|Groups" msgid "InstanceStatistics|Groups"
msgstr "" msgstr ""
......
const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null };
export function getApolloResponse(options = {}) {
const {
pipelinesTotal = [],
pipelinesSucceeded = [],
pipelinesFailed = [],
pipelinesCanceled = [],
pipelinesSkipped = [],
hasNextPage = false,
} = options;
return {
data: {
pipelinesTotal: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesTotal },
pipelinesSucceeded: {
pageInfo: { ...defaultPageInfo, hasNextPage },
nodes: pipelinesSucceeded,
},
pipelinesFailed: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesFailed },
pipelinesCanceled: {
pageInfo: { ...defaultPageInfo, hasNextPage },
nodes: pipelinesCanceled,
},
pipelinesSkipped: {
pageInfo: { ...defaultPageInfo, hasNextPage },
nodes: pipelinesSkipped,
},
},
};
}
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PipelinesChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
Array [
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
Array [
"2020-08-01",
5,
],
],
"name": "Total",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
Array [
"2020-08-01",
5,
],
],
"name": "Succeeded",
},
Object {
"data": Array [
Array [
"2020-06-01",
22,
],
Array [
"2020-07-01",
41,
],
Array [
"2020-08-01",
5,
],
],
"name": "Failed",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
Array [
"2020-08-01",
5,
],
],
"name": "Canceled",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
Array [
"2020-08-01",
5,
],
],
"name": "Skipped",
},
]
`;
exports[`PipelinesChart with data passes the data to the line chart 1`] = `
Array [
Object {
"data": Array [
Array [
"2020-06-01",
22,
],
Array [
"2020-07-01",
41,
],
],
"name": "Total",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
],
"name": "Succeeded",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
],
"name": "Failed",
},
Object {
"data": Array [
Array [
"2020-06-01",
22,
],
Array [
"2020-07-01",
41,
],
],
"name": "Canceled",
},
Object {
"data": Array [
Array [
"2020-06-01",
22,
],
Array [
"2020-07-01",
41,
],
],
"name": "Skipped",
},
]
`;
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue'; import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
describe('InstanceStatisticsApp', () => { describe('InstanceStatisticsApp', () => {
let wrapper; let wrapper;
...@@ -21,4 +22,8 @@ describe('InstanceStatisticsApp', () => { ...@@ -21,4 +22,8 @@ describe('InstanceStatisticsApp', () => {
it('displays the instance counts component', () => { it('displays the instance counts component', () => {
expect(wrapper.find(InstanceCounts).exists()).toBe(true); expect(wrapper.find(InstanceCounts).exists()).toBe(true);
}); });
it('displays the pipelines chart component', () => {
expect(wrapper.find(PipelinesChart).exists()).toBe(true);
});
}); });
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
import pipelinesStatsQuery from '~/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { mockCountsData1, mockCountsData2 } from '../mock_data';
import { getApolloResponse } from '../apollo_mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('PipelinesChart', () => {
let wrapper;
let queryHandler;
const createApolloProvider = pipelineStatsHandler => {
return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]);
};
const createComponent = apolloProvider => {
return shallowMount(PipelinesChart, {
localVue,
apolloProvider,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findLoader = () => wrapper.find(ChartSkeletonLoader);
const findChart = () => wrapper.find(GlLineChart);
const findAlert = () => wrapper.find(GlAlert);
describe('while loading', () => {
beforeEach(() => {
queryHandler = jest.fn().mockReturnValue(new Promise(() => {}));
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
});
it('requests data', () => {
expect(queryHandler).toBeCalledTimes(1);
});
it('displays the skeleton loader', () => {
expect(findLoader().exists()).toBe(true);
});
it('hides the chart', () => {
expect(findChart().exists()).toBe(false);
});
it('does not show an error', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('without data', () => {
beforeEach(() => {
const emptyResponse = getApolloResponse();
queryHandler = jest.fn().mockResolvedValue(emptyResponse);
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
});
it('renders an no data message', () => {
expect(findAlert().text()).toBe('There is no data available.');
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('renders the chart', () => {
expect(findChart().exists()).toBe(false);
});
});
describe('with data', () => {
beforeEach(() => {
const response = getApolloResponse({
pipelinesTotal: mockCountsData1,
pipelinesSucceeded: mockCountsData2,
pipelinesFailed: mockCountsData2,
pipelinesCanceled: mockCountsData1,
pipelinesSkipped: mockCountsData1,
});
queryHandler = jest.fn().mockResolvedValue(response);
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
});
it('requests data', () => {
expect(queryHandler).toBeCalledTimes(1);
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('renders the chart', () => {
expect(findChart().exists()).toBe(true);
});
it('passes the data to the line chart', () => {
expect(findChart().props('data')).toMatchSnapshot();
});
it('does not show an error', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('when fetching more data', () => {
const recordedAt = '2020-08-01';
describe('when the fetchMore query returns data', () => {
beforeEach(async () => {
const newData = { recordedAt, count: 5 };
const firstResponse = getApolloResponse({
pipelinesTotal: mockCountsData2,
pipelinesSucceeded: mockCountsData2,
pipelinesFailed: mockCountsData1,
pipelinesCanceled: mockCountsData2,
pipelinesSkipped: mockCountsData2,
hasNextPage: true,
});
const secondResponse = getApolloResponse({
pipelinesTotal: [newData],
pipelinesSucceeded: [newData],
pipelinesFailed: [newData],
pipelinesCanceled: [newData],
pipelinesSkipped: [newData],
hasNextPage: false,
});
queryHandler = jest
.fn()
.mockResolvedValueOnce(firstResponse)
.mockResolvedValueOnce(secondResponse);
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
await wrapper.vm.$nextTick();
});
it('requests data twice', () => {
expect(queryHandler).toBeCalledTimes(2);
});
it('passes the data to the line chart', () => {
expect(findChart().props('data')).toMatchSnapshot();
});
});
describe('when the fetchMore query throws an error', () => {
beforeEach(async () => {
const response = getApolloResponse({
pipelinesTotal: mockCountsData2,
pipelinesSucceeded: mockCountsData2,
pipelinesFailed: mockCountsData1,
pipelinesCanceled: mockCountsData2,
pipelinesSkipped: mockCountsData2,
hasNextPage: true,
});
queryHandler = jest.fn().mockResolvedValue(response);
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
jest
.spyOn(wrapper.vm.$apollo.queries.pipelineStats, 'fetchMore')
.mockImplementation(jest.fn().mockRejectedValue());
await wrapper.vm.$nextTick();
});
it('calls fetchMore', () => {
expect(wrapper.vm.$apollo.queries.pipelineStats.fetchMore).toHaveBeenCalledTimes(1);
});
it('show an error message', () => {
expect(findAlert().text()).toBe(
'Could not load the pipelines chart. Please refresh the page to try again.',
);
});
});
});
});
import { getAverageByMonth } from '~/analytics/instance_statistics/utils'; import {
getAverageByMonth,
extractValues,
sortByDate,
} from '~/analytics/instance_statistics/utils';
import { import {
mockCountsData1, mockCountsData1,
mockCountsData2, mockCountsData2,
...@@ -39,3 +43,42 @@ describe('getAverageByMonth', () => { ...@@ -39,3 +43,42 @@ describe('getAverageByMonth', () => {
}); });
}); });
}); });
describe('extractValues', () => {
it('extracts only requested values', () => {
const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' });
});
it('is able to extract multiple values', () => {
const data = {
fooBar: { baz: 'quis' },
fooBaz: { baz: 'quis' },
fooQuis: { baz: 'quis' },
};
expect(extractValues(data, ['fooBar', 'fooBaz', 'fooQuis'], 'foo', 'baz')).toEqual({
bazBar: 'quis',
bazBaz: 'quis',
bazQuis: 'quis',
});
});
it('returns empty data set when keys are not found', () => {
const data = { foo: { baz: 'quis' }, ignored: 'ignored' };
expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({});
});
it('returns empty data when params are missing', () => {
expect(extractValues()).toEqual({});
});
});
describe('sortByDate', () => {
it('sorts the array by date', () => {
expect(sortByDate(mockCountsData1)).toStrictEqual([...mockCountsData1].reverse());
});
it('does not modify the original array', () => {
expect(sortByDate(countsMonthlyChartData1)).not.toBe(countsMonthlyChartData1);
});
});
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