Skip to content
Snippets Groups Projects
Commit e8c0d65c authored by Miguel Rincon's avatar Miguel Rincon
Browse files

Migrate admin runner list from HAML to Vue

This change introduces feature flag: `runner_list_view_vue_ui`. To
migrate the implementation of the runner admin section to Vue.

This is an incomplete implementation, as some more features will be
added gradually behind this feature flag.
parent 8fcc2607
No related branches found
No related tags found
No related merge requests found
Showing
with 918 additions and 149 deletions
Loading
Loading
@@ -2,6 +2,7 @@ import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import { initRunnerList } from '~/runner/runner_list';
 
initFilteredSearch({
page: FILTERED_SEARCH.ADMIN_RUNNERS,
Loading
Loading
@@ -10,3 +11,7 @@ initFilteredSearch({
});
 
initInstallRunner();
if (gon.features?.runnerListViewVueUi) {
initRunnerList();
}
<script>
import { GlLink } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
components: {
GlLink,
TooltipOnTruncate,
},
props: {
runner: {
type: Object,
required: true,
},
},
computed: {
runnerNumericalId() {
return getIdFromGraphQLId(this.runner.id);
},
runnerUrl() {
// TODO implement using webUrl from the API
return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
},
description() {
return this.runner.description;
},
shortSha() {
return this.runner.shortSha;
},
},
};
</script>
<template>
<div>
<gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link>
<tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child">
<div class="gl-text-truncate">
{{ description }}
</div>
</tooltip-on-truncate>
</div>
</template>
<script>
import { GlBadge } from '@gitlab/ui';
import RunnerTypeBadge from '../runner_type_badge.vue';
export default {
components: {
GlBadge,
RunnerTypeBadge,
},
props: {
runner: {
type: Object,
required: true,
},
},
computed: {
runnerType() {
return this.runner.runnerType;
},
locked() {
return this.runner.locked;
},
paused() {
return !this.runner.active;
},
},
};
</script>
<template>
<div>
<runner-type-badge :type="runnerType" size="sm" />
<gl-badge v-if="locked" variant="warning" size="sm">
{{ __('locked') }}
</gl-badge>
<gl-badge v-if="paused" variant="danger" size="sm">
{{ __('paused') }}
</gl-badge>
</div>
</template>
<script>
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { formatNumber, sprintf, __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerNameCell from './cells/runner_name_cell.vue';
import RunnerTypeCell from './cells/runner_type_cell.vue';
import RunnerTags from './runner_tags.vue';
const tableField = ({ key, label = '', width = 10 }) => {
return {
key,
label,
thClass: [
`gl-w-${width}p`,
'gl-bg-transparent!',
'gl-border-b-solid!',
'gl-border-b-gray-100!',
'gl-py-5!',
'gl-px-0!',
'gl-border-b-1!',
],
tdClass: ['gl-py-5!', 'gl-px-1!'],
tdAttr: {
'data-testid': `td-${key}`,
},
};
};
export default {
components: {
GlTable,
GlSkeletonLoader,
TimeAgo,
RunnerNameCell,
RunnerTags,
RunnerTypeCell,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
runners: {
type: Array,
required: true,
},
activeRunnersCount: {
type: Number,
required: true,
},
},
computed: {
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
});
},
},
methods: {
runnerTrAttr(runner) {
if (runner) {
return {
'data-testid': `runner-row-${getIdFromGraphQLId(runner.id)}`,
};
}
return {};
},
},
fields: [
tableField({ key: 'type', label: __('Type/State') }),
tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'ipAddress', label: __('IP Address') }),
tableField({ key: 'projectCount', label: __('Projects'), width: 5 }),
tableField({ key: 'jobCount', label: __('Jobs'), width: 5 }),
tableField({ key: 'tagList', label: __('Tags') }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
tableField({ key: 'actions', label: '' }),
],
};
</script>
<template>
<div>
<div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
<gl-table
:busy="loading"
:items="runners"
:fields="$options.fields"
:tbody-tr-attr="runnerTrAttr"
stacked="md"
fixed
>
<template #table-busy>
<gl-skeleton-loader />
</template>
<template #cell(type)="{ item }">
<runner-type-cell :runner="item" />
</template>
<template #cell(name)="{ item }">
<runner-name-cell :runner="item" />
</template>
<template #cell(version)="{ item: { version } }">
{{ version }}
</template>
<template #cell(ipAddress)="{ item: { ipAddress } }">
{{ ipAddress }}
</template>
<template #cell(projectCount)>
<!-- TODO add projects count -->
</template>
<template #cell(jobCount)>
<!-- TODO add jobs count -->
</template>
<template #cell(tagList)="{ item: { tagList } }">
<runner-tags :tag-list="tagList" size="sm" />
</template>
<template #cell(contactedAt)="{ item: { contactedAt } }">
<time-ago v-if="contactedAt" :time="contactedAt" />
<template v-else>{{ __('Never') }}</template>
</template>
<template #cell(actions)>
<!-- TODO add actions to update runners -->
</template>
</gl-table>
<!-- TODO implement pagination -->
</div>
</template>
<script>
import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
export default {
components: {
GlLink,
GlSprintf,
ClipboardButton,
RunnerInstructions,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
runnerInstallHelpPage: {
default: null,
},
},
props: {
registrationToken: {
type: String,
required: true,
},
typeName: {
type: String,
required: false,
default: __('shared'),
},
},
computed: {
rootUrl() {
return gon.gitlab_url || '';
},
},
};
</script>
<template>
<div class="bs-callout">
<h5 data-testid="runner-help-title">
<gl-sprintf :message="__('Set up a %{type} runner manually')">
<template #type>
{{ typeName }}
</template>
</gl-sprintf>
</h5>
<ol>
<li>
<gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank">
{{ __("Install GitLab Runner and ensure it's running.") }}
</gl-link>
</li>
<li>
{{ __('Register the runner with this URL:') }}
<br />
<code data-testid="coordinator-url">{{ rootUrl }}</code>
<clipboard-button :title="__('Copy URL')" :text="rootUrl" />
</li>
<li>
{{ __('And this registration token:') }}
<br />
<code data-testid="registration-token">{{ registrationToken }}</code>
<clipboard-button :title="__('Copy token')" :text="registrationToken" />
</li>
</ol>
<!-- TODO Implement reset token functionality -->
<runner-instructions />
</div>
</template>
<script>
import { GlBadge } from '@gitlab/ui';
export default {
components: {
GlBadge,
},
props: {
tagList: {
type: Array,
required: false,
default: () => [],
},
size: {
type: String,
required: false,
default: 'md',
},
variant: {
type: String,
required: false,
default: 'info',
},
},
};
</script>
<template>
<div>
<gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant">
{{ tag }}
</gl-badge>
</div>
</template>
<script>
import { GlBadge } from '@gitlab/ui';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import RunnerTypeBadge from './runner_type_badge.vue';
export default {
components: {
GlBadge,
RunnerTypeBadge,
},
runnerTypes: {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
},
};
</script>
<template>
<div class="bs-callout">
<p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p>
<p>
{{
__(
'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.',
)
}}
</p>
<div>
<span> {{ __('Runners can be:') }}</span>
<ul>
<li>
<runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" />
- {{ __('Runs jobs from all unassigned projects.') }}
</li>
<li>
<runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" />
- {{ __('Runs jobs from all unassigned projects in its group.') }}
</li>
<li>
<runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" />
- {{ __('Runs jobs from assigned projects.') }}
</li>
<li>
<gl-badge variant="warning" size="sm">
{{ __('locked') }}
</gl-badge>
- {{ __('Cannot be assigned to other projects.') }}
</li>
<li>
<gl-badge variant="danger" size="sm">
{{ __('paused') }}
</gl-badge>
- {{ __('Not available to run jobs.') }}
</li>
</ul>
</div>
</div>
</template>
query getRunners {
runners {
nodes {
id
description
runnerType
shortSha
version
revision
ipAddress
active
locked
tagList
contactedAt
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import RunnerDetailsApp from './runner_list_app.vue';
Vue.use(VueApollo);
export const initRunnerList = (selector = '#js-runner-list') => {
const el = document.querySelector(selector);
if (!el) {
return null;
}
// TODO `activeRunnersCount` should be implemented using a GraphQL API.
const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
assumeImmutableResults: true,
},
),
});
return new Vue({
el,
apolloProvider,
provide: {
runnerInstallHelpPage,
},
render(h) {
return h(RunnerDetailsApp, {
props: {
activeRunnersCount: parseInt(activeRunnersCount, 10),
registrationToken,
},
});
},
});
};
<script>
import * as Sentry from '@sentry/browser';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
export default {
components: {
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
},
props: {
activeRunnersCount: {
type: Number,
required: true,
},
registrationToken: {
type: String,
required: true,
},
},
data() {
return {
runners: [],
};
},
apollo: {
runners: {
query: getRunnersQuery,
update({ runners }) {
return runners?.nodes || [];
},
error(err) {
this.captureException(err);
},
},
},
computed: {
runnersLoading() {
return this.$apollo.queries.runners.loading;
},
noRunnersFound() {
return !this.runnersLoading && !this.runners.length;
},
},
errorCaptured(err) {
this.captureException(err);
},
methods: {
captureException(err) {
Sentry.withScope((scope) => {
scope.setTag('component', 'runner_list_app');
Sentry.captureException(err);
});
},
},
};
</script>
<template>
<div>
<div class="row">
<div class="col-sm-6">
<runner-type-help />
</div>
<div class="col-sm-6">
<runner-manual-setup-help :registration-token="registrationToken" />
</div>
</div>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<runner-list
v-else
:runners="runners"
:loading="runnersLoading"
:active-runners-count="activeRunnersCount"
/>
</div>
</template>
Loading
Loading
@@ -4,6 +4,9 @@ class Admin::RunnersController < Admin::ApplicationController
include RunnerSetupScripts
 
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
before_action only: [:index] do
push_frontend_feature_flag(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
end
 
feature_category :continuous_integration
 
Loading
Loading
-# Note: This file should stay aligned with:
-# `app/views/groups/runners/_runner.html.haml`
 
.gl-responsive-table-row{ id: dom_id(runner) }
.gl-responsive-table-row{ data: { testid: "runner-row-#{runner.id}" } }
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Type')
.table-mobile-content
Loading
Loading
- breadcrumb_title _('Runners')
- page_title _('Runners')
 
.row
.col-sm-6
.bs-callout
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
%br
= _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.')
%br
- if Feature.enabled?(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
#js-runner-list{ data: { registration_token: Gitlab::CurrentSettings.runners_registration_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', active_runners_count: @active_runners_count } }
- else
.row
.col-sm-6
.bs-callout
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
%br
= _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.')
%br
 
%div
%span= _('Runners can be:')
%ul
%li
%span.badge.badge-pill.gl-badge.sm.badge-success shared
\-
= _('Runs jobs from all unassigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-success group
\-
= _('Runs jobs from all unassigned projects in its group.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-info specific
\-
= _('Runs jobs from assigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-warning locked
\-
= _('Cannot be assigned to other projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-danger paused
\-
= _('Not available to run jobs.')
%div
%span= _('Runners can be:')
%ul
%li
%span.badge.badge-pill.gl-badge.sm.badge-success shared
\-
= _('Runs jobs from all unassigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-success group
\-
= _('Runs jobs from all unassigned projects in its group.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-info specific
\-
= _('Runs jobs from assigned projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-warning locked
\-
= _('Cannot be assigned to other projects.')
%li
%span.badge.badge-pill.gl-badge.sm.badge-danger paused
\-
= _('Not available to run jobs.')
 
.col-sm-6
.bs-callout
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
type: 'shared',
reset_token_url: reset_registration_token_admin_application_settings_path,
project_path: '',
group_path: '' }
.col-sm-6
.bs-callout
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
type: 'shared',
reset_token_url: reset_registration_token_admin_application_settings_path,
project_path: '',
group_path: '' }
 
.row
.col-sm-9
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
.filtered-search-wrapper.d-flex
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[gl-button btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.gl-button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
.row
.col-sm-9
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
.filtered-search-wrapper.d-flex
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[gl-button btn btn-link] do
= status.titleize
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.gl-button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
= button_tag class: %w[gl-button btn btn-link] do
= status.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
 
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
Loading
Loading
@@ -90,49 +100,41 @@
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
 
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.gl-button.btn.btn-link
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.gl-button.btn.btn-link.js-data-value
%span.dropdown-light-content
{{name}}
= button_tag class: %w[clear-search hidden] do
= sprite_icon('close', size: 16, css_class: 'clear-search-icon')
.filter-dropdown-container
= render 'sort_dropdown'
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.gl-button.btn.btn-link
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.gl-button.btn.btn-link.js-data-value
%span.dropdown-light-content
{{name}}
 
.col-sm-3.text-right-lg
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
= button_tag class: %w[clear-search hidden] do
= sprite_icon('close', size: 16, css_class: 'clear-search-icon')
.filter-dropdown-container
= render 'sort_dropdown'
 
- if @runners.any?
.content-list{ data: { testid: 'runners-table' } }
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
.table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
.table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
.table-section.section-10{ role: 'rowheader' }= _('Tags')
.table-section.section-10{ role: 'rowheader' }= _('Last contact')
.table-section.section-10{ role: 'rowheader' }
.col-sm-3.text-right-lg
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
- if @runners.any?
.content-list{ data: { testid: 'runners-table' } }
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
.table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
.table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
.table-section.section-10{ role: 'rowheader' }= _('Tags')
.table-section.section-10{ role: 'rowheader' }= _('Last contact')
.table-section.section-10{ role: 'rowheader' }
 
- @runners.each do |runner|
= render 'admin/runners/runner', runner: runner
= paginate @runners, theme: 'gitlab'
- else
.nothing-here-block= _('No runners found')
- @runners.each do |runner|
= render 'admin/runners/runner', runner: runner
= paginate @runners, theme: 'gitlab'
- else
.nothing-here-block= _('No runners found')
Loading
Loading
@@ -14,7 +14,7 @@
%br
= _("And this registration token:")
%br
%code#registration_token= registration_token
%code#registration_token{ data: {testid: 'registration_token' } }= registration_token
= clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard")
 
.gl-mt-3.gl-mb-3
Loading
Loading
---
name: runner_list_view_vue_ui
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61241
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330969
milestone: '13.12'
type: development
group: group::runner
default_enabled: false
Loading
Loading
@@ -12,6 +12,10 @@
describe '#index' do
render_views
 
before do
stub_feature_flags(runner_list_view_vue_ui: false)
end
it 'lists all runners' do
get :index
 
Loading
Loading
Loading
Loading
@@ -17,6 +17,10 @@
describe "Runners page" do
let(:pipeline) { create(:ci_pipeline) }
 
before do
stub_feature_flags(runner_list_view_vue_ui: false)
end
context "when there are runners" do
it 'has all necessary texts' do
runner = create(:ci_runner, contacted_at: Time.now)
Loading
Loading
@@ -240,7 +244,7 @@
it 'shows the label and does not show the project count' do
visit admin_runners_path
 
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'group'
expect(page).to have_text 'n/a'
end
Loading
Loading
@@ -253,7 +257,7 @@
 
visit admin_runners_path
 
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'shared'
expect(page).to have_text 'n/a'
end
Loading
Loading
@@ -267,12 +271,36 @@
 
visit admin_runners_path
 
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'specific'
expect(page).to have_text '1'
end
end
end
describe 'runners registration token' do
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
end
it 'has a registration token' do
expect(page.find('[data-testid="registration_token"]')).to have_content(token)
end
describe 'reset registration token' do
let(:page_token) { find('[data-testid="registration_token"]').text }
before do
click_button 'Reset registration token'
end
it 'changes registration token' do
expect(page_token).not_to eq token
end
end
end
end
 
describe "Runner show page" do
Loading
Loading
@@ -381,28 +409,4 @@
end
end
end
describe 'runners registration token' do
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
end
it 'has a registration token' do
expect(page.find('#registration_token')).to have_content(token)
end
describe 'reload registration token' do
let(:page_token) { find('#registration_token').text }
before do
click_button 'Reset registration token'
end
it 'changes registration token' do
expect(page_token).not_to eq token
end
end
end
end
import { GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue';
const mockId = '1';
const mockShortSha = '2P6oDVDm';
const mockDescription = 'runner-1';
describe('RunnerTypeCell', () => {
let wrapper;
const findLink = () => wrapper.findComponent(GlLink);
const createComponent = () => {
wrapper = mount(RunnerNameCell, {
propsData: {
runner: {
id: `gid://gitlab/Ci::Runner/${mockId}`,
shortSha: mockShortSha,
description: mockDescription,
},
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays the runner link with id and short token', () => {
expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`);
expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`);
});
it('Displays the runner description', () => {
expect(wrapper.text()).toContain(mockDescription);
});
});
import { GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue';
import { INSTANCE_TYPE } from '~/runner/constants';
describe('RunnerTypeCell', () => {
let wrapper;
const findBadges = () => wrapper.findAllComponents(GlBadge);
const createComponent = ({ runner = {} } = {}) => {
wrapper = mount(RunnerTypeCell, {
propsData: {
runner: {
runnerType: INSTANCE_TYPE,
active: true,
locked: false,
...runner,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('Displays the runner type', () => {
createComponent();
expect(findBadges()).toHaveLength(1);
expect(findBadges().at(0).text()).toBe('shared');
});
it('Displays locked and paused states', () => {
createComponent({
runner: {
active: false,
locked: true,
},
});
expect(findBadges()).toHaveLength(3);
expect(findBadges().at(0).text()).toBe('shared');
expect(findBadges().at(1).text()).toBe('locked');
expect(findBadges().at(2).text()).toBe('paused');
});
});
import { GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
const mockActiveRunnersCount = mockRunners.length;
describe('RunnerList', () => {
let wrapper;
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findHeaders = () => wrapper.findAll('th');
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
const findCell = ({ row = 0, fieldKey }) =>
findRows().at(row).find(`[data-testid="td-${fieldKey}"]`);
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
mount(RunnerList, {
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
...props,
},
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays active runner count', () => {
expect(findActiveRunnersMessage().text()).toBe(
`Runners currently online: ${mockActiveRunnersCount}`,
);
});
it('Displays a large active runner count', () => {
createComponent({ props: { activeRunnersCount: 2000 } });
expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
});
it('Displays headers', () => {
const headerLabels = findHeaders().wrappers.map((w) => w.text());
expect(headerLabels).toEqual([
'Type/State',
'Runner',
'Version',
'IP Address',
'Projects',
'Jobs',
'Tags',
'Last contact',
'', // actions has no label
]);
});
it('Displays a list of runners', () => {
expect(findRows()).toHaveLength(2);
expect(findSkeletonLoader().exists()).toBe(false);
});
it('Displays details of a runner', () => {
const { id, description, version, ipAddress, shortSha } = mockRunners[0];
// Badges
expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('shared locked');
// Runner identifier
expect(findCell({ fieldKey: 'name' }).text()).toContain(
`#${getIdFromGraphQLId(id)} (${shortSha})`,
);
expect(findCell({ fieldKey: 'name' }).text()).toContain(description);
// Other fields: some cells are empty in the first iteration
// See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features
expect(findCell({ fieldKey: 'version' }).text()).toBe(version);
expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress);
expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('');
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('');
expect(findCell({ fieldKey: 'tagList' }).text()).toBe('');
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
expect(findCell({ fieldKey: 'actions' }).text()).toBe('');
});
it('Links to the runner page', () => {
const { id } = mockRunners[0];
expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe(
`/admin/runners/${getIdFromGraphQLId(id)}`,
);
});
describe('When data is loading', () => {
beforeEach(() => {
createComponent({ props: { loading: true } });
});
it('shows an skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment