Commit 932901ed authored by Thong Kuah's avatar Thong Kuah

Merge branch '196544-nodemetrics-size' into 'master'

Added node size to cluster index

Closes #196544

See merge request gitlab-org/gitlab!32435
parents 0b28ed52 d27b03c7
......@@ -43,17 +43,17 @@ export default {
key: 'environment_scope',
label: __('Environment scope'),
},
// Wait for backend to send these fields
// {
// key: 'size',
// label: __('Size'),
// },
{
key: 'node_size',
label: __('Nodes'),
},
// Fields are missing calculation methods and not ready to display
// {
// key: 'cpu',
// key: 'node_cpu',
// label: __('Total cores (vCPUs)'),
// },
// {
// key: 'memory',
// key: 'node_memory',
// label: __('Total memory (GB)'),
// },
{
......@@ -111,6 +111,14 @@ export default {
></div>
</div>
</template>
<template #cell(node_size)="{ item }">
<span v-if="item.nodes">{{ item.nodes.length }}</span>
<small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{
__('Unknown')
}}</small>
</template>
<template #cell(cluster_type)="{value}">
<gl-badge variant="light">
{{ value }}
......
......@@ -6,6 +6,8 @@ export const CLUSTER_TYPES = {
instance_type: __('Instance'),
};
export const MAX_REQUESTS = 3;
export const STATUSES = {
default: { className: 'bg-white', title: __('Unknown') },
disabled: { className: 'disabled', title: __('Disabled') },
......
......@@ -2,10 +2,23 @@ import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { __ } from '~/locale';
import { MAX_REQUESTS } from '../constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import * as Sentry from '@sentry/browser';
import * as types from './mutation_types';
const allNodesPresent = (clusters, retryCount) => {
/*
Nodes are coming from external Kubernetes clusters.
They may fail for reasons GitLab cannot control.
MAX_REQUESTS will ensure this poll stops at some point.
*/
return retryCount > MAX_REQUESTS || clusters.every(cluster => cluster.nodes != null);
};
export const fetchClusters = ({ state, commit }) => {
let retryCount = 0;
const poll = new Poll({
resource: {
fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint),
......@@ -13,16 +26,40 @@ export const fetchClusters = ({ state, commit }) => {
data: `${state.endpoint}?page=${state.page}`,
method: 'fetchClusters',
successCallback: ({ data, headers }) => {
retryCount += 1;
try {
if (data.clusters) {
const normalizedHeaders = normalizeHeaders(headers);
const paginationInformation = parseIntPagination(normalizedHeaders);
commit(types.SET_CLUSTERS_DATA, { data, paginationInformation });
commit(types.SET_LOADING_STATE, false);
if (allNodesPresent(data.clusters, retryCount)) {
poll.stop();
}
}
} catch (error) {
poll.stop();
Sentry.withScope(scope => {
scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback');
Sentry.captureException(error);
});
}
},
errorCallback: () => flash(__('An error occurred while loading clusters')),
errorCallback: response => {
poll.stop();
commit(types.SET_LOADING_STATE, false);
flash(__('Clusters|An error occurred while loading clusters'));
Sentry.withScope(scope => {
scope.setTag('javascript_clusters_list', 'fetchClustersErrorCallback');
Sentry.captureException(response);
});
},
});
poll.makeRequest();
......
......@@ -23,6 +23,7 @@ class Clusters::ClustersController < Clusters::BaseController
respond_to do |format|
format.html
format.json do
Gitlab::PollingInterval.set_header(response, interval: STATUS_POLLING_INTERVAL)
serializer = ClusterSerializer.new(current_user: current_user)
render json: {
......
---
title: Added node size to cluster index
merge_request: 32435
author:
type: changed
......@@ -2231,9 +2231,6 @@ msgstr ""
msgid "An error occurred while loading chart data"
msgstr ""
msgid "An error occurred while loading clusters"
msgstr ""
msgid "An error occurred while loading commit signatures"
msgstr ""
......@@ -5429,6 +5426,9 @@ msgstr ""
msgid "ClusterIntergation|Select service role"
msgstr ""
msgid "Clusters|An error occurred while loading clusters"
msgstr ""
msgid "Code"
msgstr ""
......
......@@ -42,6 +42,13 @@ describe Admin::ClustersController do
expect(response).to match_response_schema('cluster_list')
end
it 'sets the polling interval header for json requests' do
get_index(format: :json)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Poll-Interval']).to eq("10000")
end
context 'when page is specified' do
let(:last_page) { Clusters::Cluster.instance_type.page.total_pages }
let(:total_count) { Clusters::Cluster.instance_type.page.total_count }
......
......@@ -47,6 +47,13 @@ describe Groups::ClustersController do
expect(response).to match_response_schema('cluster_list')
end
it 'sets the polling interval header for json requests' do
go(format: :json)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Poll-Interval']).to eq("10000")
end
context 'when page is specified' do
let(:last_page) { group.clusters.page.total_pages }
let(:total_count) { group.clusters.page.total_count }
......
......@@ -41,6 +41,13 @@ describe Projects::ClustersController do
expect(response).to match_response_schema('cluster_list')
end
it 'sets the polling interval header for json requests' do
go(format: :json)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Poll-Interval']).to eq("10000")
end
context 'when page is specified' do
let(:last_page) { project.clusters.page.total_pages }
let(:total_count) { project.clusters.page.total_count }
......
......@@ -28,13 +28,17 @@ describe('Clusters', () => {
return axios.waitForAll();
};
const paginationHeader = (total = apiData.clusters.length, perPage = 20, currentPage = 1) => {
return {
'x-total': total,
'x-per-page': perPage,
'x-page': currentPage,
};
};
beforeEach(() => {
mock = new MockAdapter(axios);
mockPollingApi(200, apiData, {
'x-total': apiData.clusters.length,
'x-per-page': 20,
'x-page': 1,
});
mockPollingApi(200, apiData, paginationHeader());
return mountWrapper();
});
......@@ -99,17 +103,30 @@ describe('Clusters', () => {
});
});
describe('nodes present', () => {
it.each`
nodeSize | lineNumber
${'Unknown'} | ${0}
${'1'} | ${1}
${'2'} | ${2}
${'Unknown'} | ${3}
${'Unknown'} | ${4}
${'Unknown'} | ${5}
`('renders node size for each cluster', ({ nodeSize, lineNumber }) => {
const sizes = findTable().findAll('td:nth-child(3)');
const size = sizes.at(lineNumber);
expect(size.text()).toBe(nodeSize);
});
});
describe('pagination', () => {
const perPage = apiData.clusters.length;
const totalFirstPage = 100;
const totalSecondPage = 500;
beforeEach(() => {
mockPollingApi(200, apiData, {
'x-total': totalFirstPage,
'x-per-page': perPage,
'x-page': 1,
});
mockPollingApi(200, apiData, paginationHeader(totalFirstPage, perPage, 1));
return mountWrapper();
});
......@@ -123,11 +140,7 @@ describe('Clusters', () => {
describe('when updating currentPage', () => {
beforeEach(() => {
mockPollingApi(200, apiData, {
'x-total': totalSecondPage,
'x-per-page': perPage,
'x-page': 2,
});
mockPollingApi(200, apiData, paginationHeader(totalSecondPage, perPage, 2));
wrapper.setData({ currentPage: 2 });
return axios.waitForAll();
});
......
export const clusterList = [
{
name: 'My Cluster 1',
environmentScope: '*',
size: '3',
clusterType: 'group_type',
environment_scope: '*',
cluster_type: 'group_type',
status: 'disabled',
cpu: '6 (100% free)',
memory: '22.50 (30% free)',
nodes: null,
},
{
name: 'My Cluster 2',
environmentScope: 'development',
size: '12',
clusterType: 'project_type',
environment_scope: 'development',
cluster_type: 'project_type',
status: 'unreachable',
cpu: '3 (50% free)',
memory: '11 (60% free)',
nodes: [{ usage: { cpu: '246155922n', memory: '1255212Ki' } }],
},
{
name: 'My Cluster 3',
environmentScope: 'development',
size: '12',
clusterType: 'project_type',
environment_scope: 'development',
cluster_type: 'project_type',
status: 'authentication_failure',
cpu: '1 (0% free)',
memory: '22 (33% free)',
nodes: [
{ usage: { cpu: '246155922n', memory: '1255212Ki' } },
{ usage: { cpu: '307051934n', memory: '1379136Ki' } },
],
},
{
name: 'My Cluster 4',
environmentScope: 'production',
size: '12',
clusterType: 'project_type',
environment_scope: 'production',
cluster_type: 'project_type',
status: 'deleting',
cpu: '6 (100% free)',
memory: '45 (15% free)',
},
{
name: 'My Cluster 5',
environmentScope: 'development',
size: '12',
clusterType: 'project_type',
environment_scope: 'development',
cluster_type: 'project_type',
status: 'created',
cpu: '6 (100% free)',
memory: '20.12 (35% free)',
},
{
name: 'My Cluster 6',
environmentScope: '*',
size: '1',
clusterType: 'project_type',
environment_scope: '*',
cluster_type: 'project_type',
status: 'cleanup_ongoing',
cpu: '6 (100% free)',
memory: '20.12 (35% free)',
},
];
......
import MockAdapter from 'axios-mock-adapter';
import Poll from '~/lib/utils/poll';
import flashError from '~/flash';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import { apiData } from '../mock_data';
import { MAX_REQUESTS } from '~/clusters_list/constants';
import * as types from '~/clusters_list/store/mutation_types';
import * as actions from '~/clusters_list/store/actions';
import * as Sentry from '@sentry/browser';
jest.mock('~/flash.js');
......@@ -12,28 +16,31 @@ describe('Clusters store actions', () => {
describe('fetchClusters', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => mock.restore());
it('should commit SET_CLUSTERS_DATA with received response', done => {
const headers = {
'x-next-page': 1,
'x-total': apiData.clusters.length,
'x-total-pages': 1,
'x-per-page': 20,
'x-page': 1,
'x-prev-page': 1,
};
const paginationInformation = {
nextPage: NaN,
nextPage: 1,
page: 1,
perPage: 20,
previousPage: NaN,
previousPage: 1,
total: apiData.clusters.length,
totalPages: NaN,
totalPages: 1,
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => mock.restore());
it('should commit SET_CLUSTERS_DATA with received response', done => {
mock.onGet().reply(200, apiData, headers);
testAction(
......@@ -52,9 +59,110 @@ describe('Clusters store actions', () => {
it('should show flash on API error', done => {
mock.onGet().reply(400, 'Not Found');
testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => {
testAction(
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
[{ type: types.SET_LOADING_STATE, payload: false }],
[],
() => {
expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error'));
done();
},
);
});
describe('multiple api requests', () => {
let captureException;
let pollRequest;
let pollStop;
const pollInterval = 10;
const pollHeaders = { 'poll-interval': pollInterval, ...headers };
beforeEach(() => {
captureException = jest.spyOn(Sentry, 'captureException');
pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
pollStop = jest.spyOn(Poll.prototype, 'stop');
mock.onGet().reply(200, apiData, pollHeaders);
});
afterEach(() => {
captureException.mockRestore();
pollRequest.mockRestore();
pollStop.mockRestore();
});
it('should stop polling after MAX Requests', done => {
testAction(
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
[
{ type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
() => {
expect(pollRequest).toHaveBeenCalledTimes(1);
expect(pollStop).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(pollInterval);
waitForPromises()
.then(() => {
expect(pollRequest).toHaveBeenCalledTimes(2);
expect(pollStop).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(pollInterval);
})
.then(() => waitForPromises())
.then(() => {
expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS);
expect(pollStop).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(pollInterval);
})
.then(() => waitForPromises())
.then(() => {
expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1);
// Stops poll once it exceeds the MAX_REQUESTS limit
expect(pollStop).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(pollInterval);
})
.then(() => waitForPromises())
.then(() => {
// Additional poll requests are not made once pollStop is called
expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1);
expect(pollStop).toHaveBeenCalledTimes(1);
})
.then(done)
.catch(done.fail);
},
);
});
it('should stop polling and report to Sentry when data is invalid', done => {
const badApiResponse = { clusters: {} };
mock.onGet().reply(200, badApiResponse, pollHeaders);
testAction(
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
[
{
type: types.SET_CLUSTERS_DATA,
payload: { data: badApiResponse, paginationInformation },
},
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
() => {
expect(pollRequest).toHaveBeenCalledTimes(1);
expect(pollStop).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledTimes(1);
done();
},
);
});
});
});
......
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