Commit 8965f0e7 authored by Emily Ring's avatar Emily Ring Committed by Joao Cunha

Show cluster nodes CPU and Memory

Also:
- Adds Sentry tracking and flash error message
- Adds specs
- Updates localization
parent dde42885
<script>
import * as Sentry from '@sentry/browser';
import { mapState, mapActions } from 'vuex';
import {
GlDeprecatedBadge as GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlSprintf,
GlTable,
} from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
......@@ -12,11 +14,14 @@ import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
export default {
nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'),
nodeCpuText: __('%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)'),
components: {
GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlSprintf,
GlTable,
},
directives: {
......@@ -47,15 +52,14 @@ export default {
key: 'node_size',
label: __('Nodes'),
},
// Fields are missing calculation methods and not ready to display
// {
// key: 'node_cpu',
// label: __('Total cores (vCPUs)'),
// },
// {
// key: 'node_memory',
// label: __('Total memory (GB)'),
// },
{
key: 'total_cpu',
label: __('Total cores (CPUs)'),
},
{
key: 'total_memory',
label: __('Total memory (GB)'),
},
{
key: 'cluster_type',
label: __('Cluster level'),
......@@ -72,11 +76,102 @@ export default {
},
methods: {
...mapActions(['fetchClusters', 'setPage']),
k8sQuantityToGb(quantity) {
if (!quantity) {
return 0;
} else if (quantity.endsWith(__('Ki'))) {
return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.000001024;
} else if (quantity.endsWith(__('Mi'))) {
return parseInt(quantity.substr(0, quantity.length - 2), 10) * 0.001048576;
}
// We are trying to track quantity types coming from Kubernetes.
// Sentry will notify us if we are missing types.
throw new Error(`UnknownK8sMemoryQuantity:${quantity}`);
},
k8sQuantityToCpu(quantity) {
if (!quantity) {
return 0;
} else if (quantity.endsWith('m')) {
return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000.0;
} else if (quantity.endsWith('n')) {
return parseInt(quantity.substr(0, quantity.length - 1), 10) / 1000000000.0;
}
// We are trying to track quantity types coming from Kubernetes.
// Sentry will notify us if we are missing types.
throw new Error(`UnknownK8sCpuQuantity:${quantity}`);
},
statusTitle(status) {
const iconTitle = STATUSES[status] || STATUSES.default;
return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false);
},
totalMemoryAndUsage(nodes) {
try {
// For EKS node.usage will not be present unless the user manually
// install the metrics server
if (nodes && nodes[0].usage) {
let totalAllocatableMemory = 0;
let totalUsedMemory = 0;
nodes.reduce((total, node) => {
const allocatableMemoryQuantity = node.status.allocatable.memory;
const allocatableMemoryGb = this.k8sQuantityToGb(allocatableMemoryQuantity);
totalAllocatableMemory += allocatableMemoryGb;
const usedMemoryQuantity = node.usage.memory;
const usedMemoryGb = this.k8sQuantityToGb(usedMemoryQuantity);
totalUsedMemory += usedMemoryGb;
return null;
}, 0);
const freeSpacePercentage = (1 - totalUsedMemory / totalAllocatableMemory) * 100;
return {
totalMemory: totalAllocatableMemory.toFixed(2),
freeSpacePercentage: Math.round(freeSpacePercentage),
};
}
} catch (error) {
Sentry.captureException(error);
}
return { totalMemory: null, freeSpacePercentage: null };
},
totalCpuAndUsage(nodes) {
try {
// For EKS node.usage will not be present unless the user manually
// install the metrics server
if (nodes && nodes[0].usage) {
let totalAllocatableCpu = 0;
let totalUsedCpu = 0;
nodes.reduce((total, node) => {
const allocatableCpuQuantity = node.status.allocatable.cpu;
const allocatableCpu = this.k8sQuantityToCpu(allocatableCpuQuantity);
totalAllocatableCpu += allocatableCpu;
const usedCpuQuantity = node.usage.cpu;
const usedCpuGb = this.k8sQuantityToCpu(usedCpuQuantity);
totalUsedCpu += usedCpuGb;
return null;
}, 0);
const freeSpacePercentage = (1 - totalUsedCpu / totalAllocatableCpu) * 100;
return {
totalCpu: totalAllocatableCpu.toFixed(2),
freeSpacePercentage: Math.round(freeSpacePercentage),
};
}
} catch (error) {
Sentry.captureException(error);
}
return { totalCpu: null, freeSpacePercentage: null };
},
},
};
</script>
......@@ -109,6 +204,34 @@ export default {
}}</small>
</template>
<template #cell(total_cpu)="{ item }">
<span v-if="item.nodes">
<gl-sprintf :message="$options.nodeCpuText">
<template #totalCpu>{{ totalCpuAndUsage(item.nodes).totalCpu }}</template>
<template #freeSpacePercentage>{{
totalCpuAndUsage(item.nodes).freeSpacePercentage
}}</template>
<template #percentSymbol
>%</template
>
</gl-sprintf>
</span>
</template>
<template #cell(total_memory)="{ item }">
<span v-if="item.nodes">
<gl-sprintf :message="$options.nodeMemoryText">
<template #totalMemory>{{ totalMemoryAndUsage(item.nodes).totalMemory }}</template>
<template #freeSpacePercentage>{{
totalMemoryAndUsage(item.nodes).freeSpacePercentage
}}</template>
<template #percentSymbol
>%</template
>
</gl-sprintf>
</span>
</template>
<template #cell(cluster_type)="{value}">
<gl-badge variant="light">
{{ value }}
......
......@@ -373,7 +373,10 @@ module Clusters
def retrieve_nodes
result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.get_nodes }
cluster_nodes = result[:response].to_a
return unless result[:response]
cluster_nodes = result[:response]
result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.metrics_client.get_nodes }
nodes_metrics = result[:response].to_a
......
---
title: Adds cluster CPU and Memory to cluster index
merge_request: 32601
author:
type: changed
......@@ -622,6 +622,12 @@ msgstr ""
msgid "%{token}..."
msgstr ""
msgid "%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)"
msgstr ""
msgid "%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)"
msgstr ""
msgid "%{totalWeight} total weight"
msgstr ""
......@@ -12638,6 +12644,9 @@ msgstr ""
msgid "Keyboard shortcuts"
msgstr ""
msgid "Ki"
msgstr ""
msgid "Kubernetes"
msgstr ""
......@@ -14095,6 +14104,9 @@ msgstr ""
msgid "Metrics|e.g. req/sec"
msgstr ""
msgid "Mi"
msgstr ""
msgid "Microsoft Azure"
msgstr ""
......@@ -23489,9 +23501,15 @@ msgstr ""
msgid "Total artifacts size: %{total_size}"
msgstr ""
msgid "Total cores (CPUs)"
msgstr ""
msgid "Total issues"
msgstr ""
msgid "Total memory (GB)"
msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
......
......@@ -5,6 +5,7 @@ import MockAdapter from 'axios-mock-adapter';
import { apiData } from '../mock_data';
import { mount } from '@vue/test-utils';
import { GlLoadingIcon, GlTable, GlPagination } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
describe('Clusters', () => {
let mock;
......@@ -36,7 +37,11 @@ describe('Clusters', () => {
};
};
let captureException;
beforeEach(() => {
captureException = jest.spyOn(Sentry, 'captureException');
mock = new MockAdapter(axios);
mockPollingApi(200, apiData, paginationHeader());
......@@ -46,6 +51,7 @@ describe('Clusters', () => {
afterEach(() => {
wrapper.destroy();
mock.restore();
captureException.mockRestore();
});
describe('clusters table', () => {
......@@ -106,8 +112,8 @@ describe('Clusters', () => {
${'Unknown'} | ${0}
${'1'} | ${1}
${'2'} | ${2}
${'Unknown'} | ${3}
${'Unknown'} | ${4}
${'1'} | ${3}
${'1'} | ${4}
${'Unknown'} | ${5}
`('renders node size for each cluster', ({ nodeSize, lineNumber }) => {
const sizes = findTable().findAll('td:nth-child(3)');
......@@ -115,6 +121,58 @@ describe('Clusters', () => {
expect(size.text()).toBe(nodeSize);
});
describe('nodes with unknown quantity', () => {
it('notifies Sentry about all missing quantity types', () => {
expect(captureException).toHaveBeenCalledTimes(8);
});
it('notifies Sentry about CPU missing quantity types', () => {
const missingCpuTypeError = new Error('UnknownK8sCpuQuantity:1missingCpuUnit');
expect(captureException).toHaveBeenCalledWith(missingCpuTypeError);
});
it('notifies Sentry about Memory missing quantity types', () => {
const missingMemoryTypeError = new Error('UnknownK8sMemoryQuantity:1missingMemoryUnit');
expect(captureException).toHaveBeenCalledWith(missingMemoryTypeError);
});
});
});
describe('cluster CPU', () => {
it.each`
clusterCpu | lineNumber
${''} | ${0}
${'1.93 (87% free)'} | ${1}
${'3.87 (86% free)'} | ${2}
${'(% free)'} | ${3}
${'(% free)'} | ${4}
${''} | ${5}
`('renders total cpu for each cluster', ({ clusterCpu, lineNumber }) => {
const clusterCpus = findTable().findAll('td:nth-child(4)');
const cpuData = clusterCpus.at(lineNumber);
expect(cpuData.text()).toBe(clusterCpu);
});
});
describe('cluster Memory', () => {
it.each`
clusterMemory | lineNumber
${''} | ${0}
${'5.92 (78% free)'} | ${1}
${'12.86 (79% free)'} | ${2}
${'(% free)'} | ${3}
${'(% free)'} | ${4}
${''} | ${5}
`('renders total memory for each cluster', ({ clusterMemory, lineNumber }) => {
const clusterMemories = findTable().findAll('td:nth-child(5)');
const memoryData = clusterMemories.at(lineNumber);
expect(memoryData.text()).toBe(clusterMemory);
});
});
describe('pagination', () => {
......
......@@ -11,7 +11,12 @@ export const clusterList = [
environment_scope: 'development',
cluster_type: 'project_type',
status: 'unreachable',
nodes: [{ usage: { cpu: '246155922n', memory: '1255212Ki' } }],
nodes: [
{
status: { allocatable: { cpu: '1930m', memory: '5777156Ki' } },
usage: { cpu: '246155922n', memory: '1255212Ki' },
},
],
},
{
name: 'My Cluster 3',
......@@ -19,8 +24,14 @@ export const clusterList = [
cluster_type: 'project_type',
status: 'authentication_failure',
nodes: [
{ usage: { cpu: '246155922n', memory: '1255212Ki' } },
{ usage: { cpu: '307051934n', memory: '1379136Ki' } },
{
status: { allocatable: { cpu: '1930m', memory: '5777156Ki' } },
usage: { cpu: '246155922n', memory: '1255212Ki' },
},
{
status: { allocatable: { cpu: '1940m', memory: '6777156Ki' } },
usage: { cpu: '307051934n', memory: '1379136Ki' },
},
],
},
{
......@@ -28,12 +39,23 @@ export const clusterList = [
environment_scope: 'production',
cluster_type: 'project_type',
status: 'deleting',
nodes: [
{
status: { allocatable: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' } },
usage: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' },
},
],
},
{
name: 'My Cluster 5',
environment_scope: 'development',
cluster_type: 'project_type',
status: 'created',
nodes: [
{
status: { allocatable: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' } },
},
],
},
{
name: 'My Cluster 6',
......
......@@ -1057,7 +1057,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(SocketError)
end
it { is_expected.to eq(connection_status: :unreachable, nodes: []) }
it { is_expected.to eq(connection_status: :unreachable, nodes: nil) }
end
context 'cluster cannot be authenticated to' do
......@@ -1066,7 +1066,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(OpenSSL::X509::CertificateError.new("Certificate error"))
end
it { is_expected.to eq(connection_status: :authentication_failure, nodes: []) }
it { is_expected.to eq(connection_status: :authentication_failure, nodes: nil) }
end
describe 'Kubeclient::HttpError' do
......@@ -1078,18 +1078,18 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(Kubeclient::HttpError.new(error_code, error_message, nil))
end
it { is_expected.to eq(connection_status: :authentication_failure, nodes: []) }
it { is_expected.to eq(connection_status: :authentication_failure, nodes: nil) }
context 'generic timeout' do
let(:error_message) { 'Timed out connecting to server'}
it { is_expected.to eq(connection_status: :unreachable, nodes: []) }
it { is_expected.to eq(connection_status: :unreachable, nodes: nil) }
end
context 'gateway timeout' do
let(:error_message) { '504 Gateway Timeout for GET https://kubernetes.example.com/api/v1'}
it { is_expected.to eq(connection_status: :unreachable, nodes: []) }
it { is_expected.to eq(connection_status: :unreachable, nodes: nil) }
end
end
......@@ -1099,7 +1099,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
.and_raise(StandardError)
end
it { is_expected.to eq(connection_status: :unknown_failure, nodes: []) }
it { is_expected.to eq(connection_status: :unknown_failure, nodes: nil) }
it 'notifies Sentry' do
expect(Gitlab::ErrorTracking).to receive(:track_exception)
......
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