Commit b547c315 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '323058-connectivity-status-of-the-agent' into 'master'

Add connectivity status to Kubernetes Agents listing

See merge request gitlab-org/gitlab!69345
parents e999d1bd 4230010a
...@@ -205,6 +205,8 @@ export default { ...@@ -205,6 +205,8 @@ export default {
:items="clusters" :items="clusters"
:fields="fields" :fields="fields"
stacked="md" stacked="md"
head-variant="white"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
class="qa-clusters-table" class="qa-clusters-table"
data-testid="cluster_list_table" data-testid="cluster_list_table"
> >
......
<script> <script>
import { GlButton, GlLink, GlModalDirective, GlTable } from '@gitlab/ui'; import {
GlButton,
GlLink,
GlModalDirective,
GlTable,
GlIcon,
GlSprintf,
GlTooltip,
GlPopover,
} from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { INSTALL_AGENT_MODAL_ID } from '../constants'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, TROUBLESHOOTING_LINK } from '../constants';
export default { export default {
modalId: INSTALL_AGENT_MODAL_ID,
components: { components: {
GlButton, GlButton,
GlLink, GlLink,
GlTable, GlTable,
GlIcon,
GlSprintf,
GlTooltip,
GlPopover,
TimeAgoTooltip,
}, },
directives: { directives: {
GlModalDirective, GlModalDirective,
}, },
mixins: [timeagoMixin],
inject: ['integrationDocsUrl'], inject: ['integrationDocsUrl'],
INSTALL_AGENT_MODAL_ID,
AGENT_STATUSES,
TROUBLESHOOTING_LINK,
props: { props: {
agents: { agents: {
required: true, required: true,
...@@ -27,6 +46,14 @@ export default { ...@@ -27,6 +46,14 @@ export default {
key: 'name', key: 'name',
label: s__('ClusterAgents|Name'), label: s__('ClusterAgents|Name'),
}, },
{
key: 'status',
label: s__('ClusterAgents|Connection status'),
},
{
key: 'lastContact',
label: s__('ClusterAgents|Last contact'),
},
{ {
key: 'configuration', key: 'configuration',
label: s__('ClusterAgents|Configuration'), label: s__('ClusterAgents|Configuration'),
...@@ -40,25 +67,85 @@ export default { ...@@ -40,25 +67,85 @@ export default {
<template> <template>
<div> <div>
<div class="gl-display-block gl-text-right gl-my-3"> <div class="gl-display-block gl-text-right gl-my-3">
<gl-button v-gl-modal-directive="$options.modalId" variant="success" category="primary" <gl-button
v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
variant="confirm"
category="primary"
>{{ s__('ClusterAgents|Install a new GitLab Agent') }} >{{ s__('ClusterAgents|Install a new GitLab Agent') }}
</gl-button> </gl-button>
</div> </div>
<gl-table :items="agents" :fields="fields" stacked="md" data-testid="cluster-agent-list-table"> <gl-table
:items="agents"
:fields="fields"
stacked="md"
head-variant="white"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
data-testid="cluster-agent-list-table"
>
<template #cell(name)="{ item }"> <template #cell(name)="{ item }">
<gl-link :href="item.webPath"> <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">
{{ item.name }} {{ item.name }}
</gl-link> </gl-link>
</template> </template>
<template #cell(status)="{ item }">
<span
:id="`connection-status-${item.name}`"
class="gl-pr-5"
data-testid="cluster-agent-connection-status"
>
<span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
<gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
>{{ $options.AGENT_STATUSES[item.status].name }}
</span>
<gl-tooltip
v-if="item.status === 'active'"
:target="`connection-status-${item.name}`"
placement="right"
>
<gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
</gl-sprintf>
</gl-tooltip>
<gl-popover
v-else
:target="`connection-status-${item.name}`"
:title="$options.AGENT_STATUSES[item.status].tooltip.title"
placement="right"
container="viewport"
>
<p>
<gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
>
</p>
<p class="gl-mb-0">
{{ s__('ClusterAgents|For more troubleshooting information go to') }}
<gl-link :href="$options.TROUBLESHOOTING_LINK" target="_blank" class="gl-font-sm">
{{ $options.TROUBLESHOOTING_LINK }}</gl-link
>
</p>
</gl-popover>
</template>
<template #cell(lastContact)="{ item }">
<span data-testid="cluster-agent-last-contact">
<time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
<span v-else>{{ s__('ClusterAgents|Never') }}</span>
</span>
</template>
<template #cell(configuration)="{ item }"> <template #cell(configuration)="{ item }">
<!-- eslint-disable @gitlab/vue-require-i18n-strings --> <span data-testid="cluster-agent-configuration-link">
<gl-link v-if="item.configFolder" :href="item.configFolder.webPath"> <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
.gitlab/agents/{{ item.name }} <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
</gl-link> .gitlab/agents/{{ item.name }}
</gl-link>
<p v-else>.gitlab/agents/{{ item.name }}</p> <span v-else>.gitlab/agents/{{ item.name }}</span>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</span>
</template> </template>
</gl-table> </gl-table>
</div> </div>
......
<script> <script>
import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui';
import { MAX_LIST_COUNT } from '../constants'; import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import AgentEmptyState from './agent_empty_state.vue'; import AgentEmptyState from './agent_empty_state.vue';
import AgentTable from './agent_table.vue'; import AgentTable from './agent_table.vue';
...@@ -55,7 +55,9 @@ export default { ...@@ -55,7 +55,9 @@ export default {
if (list) { if (list) {
list = list.map((agent) => { list = list.map((agent) => {
const configFolder = this.folderList[agent.name]; const configFolder = this.folderList[agent.name];
return { ...agent, configFolder }; const lastContact = this.getLastContact(agent);
const status = this.getStatus(lastContact);
return { ...agent, configFolder, lastContact, status };
}); });
} }
...@@ -106,6 +108,28 @@ export default { ...@@ -106,6 +108,28 @@ export default {
}); });
} }
}, },
getLastContact(agent) {
const tokens = agent?.tokens?.nodes;
let lastContact = null;
if (tokens?.length) {
tokens.forEach((token) => {
const lastContactToDate = new Date(token.lastUsedAt).getTime();
if (lastContactToDate > lastContact) {
lastContact = lastContactToDate;
}
});
}
return lastContact;
},
getStatus(lastContact) {
if (lastContact) {
const now = new Date().getTime();
const diff = now - lastContact;
return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
}
return 'unused';
},
}, },
}; };
</script> </script>
......
import { __, s__ } from '~/locale'; import { __, s__, sprintf } from '~/locale';
export const MAX_LIST_COUNT = 25; export const MAX_LIST_COUNT = 25;
export const INSTALL_AGENT_MODAL_ID = 'install-agent'; export const INSTALL_AGENT_MODAL_ID = 'install-agent';
export const ACTIVE_CONNECTION_TIME = 480000;
export const TROUBLESHOOTING_LINK =
'https://docs.gitlab.com/ee/user/clusters/agent/#troubleshooting';
export const I18N_INSTALL_AGENT_MODAL = { export const I18N_INSTALL_AGENT_MODAL = {
next: __('Next'), next: __('Next'),
...@@ -46,3 +49,36 @@ export const I18N_AVAILABLE_AGENTS_DROPDOWN = { ...@@ -46,3 +49,36 @@ export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
selectAgent: s__('ClusterAgents|Select an Agent'), selectAgent: s__('ClusterAgents|Select an Agent'),
registeringAgent: s__('ClusterAgents|Registering Agent'), registeringAgent: s__('ClusterAgents|Registering Agent'),
}; };
export const AGENT_STATUSES = {
active: {
name: s__('ClusterAgents|Connected'),
icon: 'status-success',
class: 'text-success-500',
tooltip: {
title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')),
},
},
inactive: {
name: s__('ClusterAgents|Not connected'),
icon: 'severity-critical',
class: 'text-danger-800',
tooltip: {
title: s__('ClusterAgents|Agent might not be connected to GitLab'),
body: sprintf(
s__(
'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.',
),
),
},
},
unused: {
name: s__('ClusterAgents|Never connected'),
icon: 'status-neutral',
class: 'text-secondary-400',
tooltip: {
title: s__('ClusterAgents|Agent never connected to GitLab'),
body: s__('ClusterAgents|Make sure you are using a valid token.'),
},
},
};
...@@ -16,6 +16,11 @@ query getAgents( ...@@ -16,6 +16,11 @@ query getAgents(
id id
name name
webPath webPath
tokens {
nodes {
lastUsedAt
}
}
} }
pageInfo { pageInfo {
......
import { GlButton, GlLink } from '@gitlab/ui'; import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import AgentTable from 'ee/clusters_list/components/agent_table.vue'; import AgentTable from 'ee/clusters_list/components/agent_table.vue';
import { ACTIVE_CONNECTION_TIME } from 'ee/clusters_list/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import timeagoMixin from '~/vue_shared/mixins/timeago';
const connectedTimeNow = new Date();
const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME);
const propsData = { const propsData = {
agents: [ agents: [
...@@ -10,10 +15,35 @@ const propsData = { ...@@ -10,10 +15,35 @@ const propsData = {
webPath: '/agent/full/path', webPath: '/agent/full/path',
}, },
webPath: '/agent-1', webPath: '/agent-1',
status: 'unused',
lastContact: null,
tokens: null,
}, },
{ {
name: 'agent-2', name: 'agent-2',
webPath: '/agent-2', webPath: '/agent-2',
status: 'active',
lastContact: connectedTimeNow.getTime(),
tokens: {
nodes: [
{
lastUsedAt: connectedTimeNow,
},
],
},
},
{
name: 'agent-3',
webPath: '/agent-3',
status: 'inactive',
lastContact: connectedTimeInactive.getTime(),
tokens: {
nodes: [
{
lastUsedAt: connectedTimeInactive,
},
],
},
}, },
], ],
}; };
...@@ -22,8 +52,15 @@ const provideData = { integrationDocsUrl: 'path/to/integrationDocs' }; ...@@ -22,8 +52,15 @@ const provideData = { integrationDocsUrl: 'path/to/integrationDocs' };
describe('AgentTable', () => { describe('AgentTable', () => {
let wrapper; let wrapper;
const findAgentLink = (at) => wrapper.findAllByTestId('cluster-agent-name-link').at(at);
const findStatusIcon = (at) => wrapper.findAllComponents(GlIcon).at(at);
const findStatusText = (at) => wrapper.findAllByTestId('cluster-agent-connection-status').at(at);
const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at);
const findConfiguration = (at) =>
wrapper.findAllByTestId('cluster-agent-configuration-link').at(at);
beforeEach(() => { beforeEach(() => {
wrapper = mount(AgentTable, { propsData, provide: provideData }); wrapper = mountExtended(AgentTable, { propsData, provide: provideData });
}); });
afterEach(() => { afterEach(() => {
...@@ -43,13 +80,27 @@ describe('AgentTable', () => { ...@@ -43,13 +80,27 @@ describe('AgentTable', () => {
${'agent-1'} | ${'/agent-1'} | ${0} ${'agent-1'} | ${'/agent-1'} | ${0}
${'agent-2'} | ${'/agent-2'} | ${1} ${'agent-2'} | ${'/agent-2'} | ${1}
`('displays agent link', ({ agentName, link, lineNumber }) => { `('displays agent link', ({ agentName, link, lineNumber }) => {
const agents = wrapper.findAll( expect(findAgentLink(lineNumber).text()).toBe(agentName);
'[data-testid="cluster-agent-list-table"] tbody tr > td:first-child', expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
); });
const agent = agents.at(lineNumber).find(GlLink);
expect(agent.text()).toBe(agentName); it.each`
expect(agent.attributes('href')).toBe(link); status | iconName | lineNumber
${'Never connected'} | ${'status-neutral'} | ${0}
${'Connected'} | ${'status-success'} | ${1}
${'Not connected'} | ${'severity-critical'} | ${2}
`('displays agent connection status', ({ status, iconName, lineNumber }) => {
expect(findStatusText(lineNumber).text()).toBe(status);
expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
});
it.each`
lastContact | lineNumber
${'Never'} | ${0}
${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
`('displays agent last contact time', ({ lastContact, lineNumber }) => {
expect(findLastContactText(lineNumber).text()).toBe(lastContact);
}); });
it.each` it.each`
...@@ -57,13 +108,10 @@ describe('AgentTable', () => { ...@@ -57,13 +108,10 @@ describe('AgentTable', () => {
${'.gitlab/agents/agent-1'} | ${true} | ${0} ${'.gitlab/agents/agent-1'} | ${true} | ${0}
${'.gitlab/agents/agent-2'} | ${false} | ${1} ${'.gitlab/agents/agent-2'} | ${false} | ${1}
`('displays config file path', ({ agentPath, hasLink, lineNumber }) => { `('displays config file path', ({ agentPath, hasLink, lineNumber }) => {
const agents = wrapper.findAll( const findLink = findConfiguration(lineNumber).find(GlLink);
'[data-testid="cluster-agent-list-table"] tbody tr > td:nth-child(2)',
);
const agent = agents.at(lineNumber);
expect(agent.find(GlLink).exists()).toBe(hasLink); expect(findLink.exists()).toBe(hasLink);
expect(agent.text()).toBe(agentPath); expect(findConfiguration(lineNumber).text()).toBe(agentPath);
}); });
}); });
}); });
...@@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo'; ...@@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo';
import AgentEmptyState from 'ee/clusters_list/components/agent_empty_state.vue'; import AgentEmptyState from 'ee/clusters_list/components/agent_empty_state.vue';
import AgentTable from 'ee/clusters_list/components/agent_table.vue'; import AgentTable from 'ee/clusters_list/components/agent_table.vue';
import Agents from 'ee/clusters_list/components/agents.vue'; import Agents from 'ee/clusters_list/components/agents.vue';
import { ACTIVE_CONNECTION_TIME } from 'ee/clusters_list/constants';
import getAgentsQuery from 'ee/clusters_list/graphql/queries/get_agents.query.graphql'; import getAgentsQuery from 'ee/clusters_list/graphql/queries/get_agents.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
...@@ -26,7 +27,7 @@ describe('Agents', () => { ...@@ -26,7 +27,7 @@ describe('Agents', () => {
const apolloQueryResponse = { const apolloQueryResponse = {
data: { data: {
project: { project: {
clusterAgents: { nodes: agents, pageInfo }, clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] } },
repository: { tree: { trees: { nodes: trees, pageInfo } } }, repository: { tree: { trees: { nodes: trees, pageInfo } } },
}, },
}, },
...@@ -58,16 +59,25 @@ describe('Agents', () => { ...@@ -58,16 +59,25 @@ describe('Agents', () => {
}); });
describe('when there is a list of agents', () => { describe('when there is a list of agents', () => {
let testDate = new Date();
const agents = [ const agents = [
{ {
id: '1', id: '1',
name: 'agent-1', name: 'agent-1',
webPath: '/agent-1', webPath: '/agent-1',
tokens: null,
}, },
{ {
id: '2', id: '2',
name: 'agent-2', name: 'agent-2',
webPath: '/agent-2', webPath: '/agent-2',
tokens: {
nodes: [
{
lastUsedAt: testDate,
},
],
},
}, },
]; ];
...@@ -79,6 +89,37 @@ describe('Agents', () => { ...@@ -79,6 +89,37 @@ describe('Agents', () => {
}, },
]; ];
const expectedAgentsList = [
{
id: '1',
name: 'agent-1',
webPath: '/agent-1',
configFolder: undefined,
status: 'unused',
lastContact: null,
tokens: null,
},
{
id: '2',
name: 'agent-2',
configFolder: {
name: 'agent-2',
path: '.gitlab/agents/agent-2',
webPath: '/project/path/.gitlab/agents/agent-2',
},
webPath: '/agent-2',
status: 'active',
lastContact: new Date(testDate).getTime(),
tokens: {
nodes: [
{
lastUsedAt: testDate,
},
],
},
},
];
beforeEach(() => { beforeEach(() => {
return createWrapper({ agents, trees }); return createWrapper({ agents, trees });
}); });
...@@ -89,19 +130,28 @@ describe('Agents', () => { ...@@ -89,19 +130,28 @@ describe('Agents', () => {
}); });
it('should pass agent and folder info to table component', () => { it('should pass agent and folder info to table component', () => {
expect(findAgentTable().props('agents')).toEqual([ expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
{ id: '1', name: 'agent-1', webPath: '/agent-1', configFolder: undefined }, });
{
id: '2', describe('when the agent has recently connected tokens', () => {
name: 'agent-2', it('should set agent status to active', () => {
configFolder: { expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
name: 'agent-2', });
path: '.gitlab/agents/agent-2', });
webPath: '/project/path/.gitlab/agents/agent-2',
}, describe('when the agent has tokens connected more then 8 minutes ago', () => {
webPath: '/agent-2', const now = new Date();
}, testDate = new Date(now.getTime() - ACTIVE_CONNECTION_TIME);
]); it('should set agent status to inactive', () => {
expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
});
});
describe('when the agent has no connected tokens', () => {
testDate = null;
it('should set agent status to unused', () => {
expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
});
}); });
it('should not render pagination buttons when there are no additional pages', () => { it('should not render pagination buttons when there are no additional pages', () => {
......
...@@ -7174,6 +7174,12 @@ msgstr "" ...@@ -7174,6 +7174,12 @@ msgstr ""
msgid "ClusterAgents|Access tokens" msgid "ClusterAgents|Access tokens"
msgstr "" msgstr ""
msgid "ClusterAgents|Agent might not be connected to GitLab"
msgstr ""
msgid "ClusterAgents|Agent never connected to GitLab"
msgstr ""
msgid "ClusterAgents|Alternative installation methods" msgid "ClusterAgents|Alternative installation methods"
msgstr "" msgstr ""
...@@ -7189,6 +7195,12 @@ msgstr "" ...@@ -7189,6 +7195,12 @@ msgstr ""
msgid "ClusterAgents|Configuration" msgid "ClusterAgents|Configuration"
msgstr "" msgstr ""
msgid "ClusterAgents|Connected"
msgstr ""
msgid "ClusterAgents|Connection status"
msgstr ""
msgid "ClusterAgents|Copy token" msgid "ClusterAgents|Copy token"
msgstr "" msgstr ""
...@@ -7207,6 +7219,9 @@ msgstr "" ...@@ -7207,6 +7219,9 @@ msgstr ""
msgid "ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}." msgid "ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}."
msgstr "" msgstr ""
msgid "ClusterAgents|For more troubleshooting information go to"
msgstr ""
msgid "ClusterAgents|Go to the repository" msgid "ClusterAgents|Go to the repository"
msgstr "" msgstr ""
...@@ -7222,18 +7237,33 @@ msgstr "" ...@@ -7222,18 +7237,33 @@ msgstr ""
msgid "ClusterAgents|Integrate with the GitLab Agent" msgid "ClusterAgents|Integrate with the GitLab Agent"
msgstr "" msgstr ""
msgid "ClusterAgents|Last connected %{timeAgo}."
msgstr ""
msgid "ClusterAgents|Last contact"
msgstr ""
msgid "ClusterAgents|Last used" msgid "ClusterAgents|Last used"
msgstr "" msgstr ""
msgid "ClusterAgents|Learn how to create an agent access token" msgid "ClusterAgents|Learn how to create an agent access token"
msgstr "" msgstr ""
msgid "ClusterAgents|Make sure you are using a valid token."
msgstr ""
msgid "ClusterAgents|Name" msgid "ClusterAgents|Name"
msgstr "" msgstr ""
msgid "ClusterAgents|Never" msgid "ClusterAgents|Never"
msgstr "" msgstr ""
msgid "ClusterAgents|Never connected"
msgstr ""
msgid "ClusterAgents|Not connected"
msgstr ""
msgid "ClusterAgents|Read more about getting started" msgid "ClusterAgents|Read more about getting started"
msgstr "" msgstr ""
...@@ -7255,6 +7285,9 @@ msgstr "" ...@@ -7255,6 +7285,9 @@ msgstr ""
msgid "ClusterAgents|Select which Agent you want to install" msgid "ClusterAgents|Select which Agent you want to install"
msgstr "" msgstr ""
msgid "ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}."
msgstr ""
msgid "ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}" msgid "ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}"
msgstr "" msgstr ""
......
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