summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/runner
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/runner')
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue171
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_name_cell.vue44
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_type_cell.vue42
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue145
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue142
-rw-r--r--app/assets/javascripts/runner/components/runner_manual_setup_help.vue76
-rw-r--r--app/assets/javascripts/runner/components/runner_pagination.vue57
-rw-r--r--app/assets/javascripts/runner/components/runner_tags.vue33
-rw-r--r--app/assets/javascripts/runner/components/runner_type_alert.vue66
-rw-r--r--app/assets/javascripts/runner/components/runner_type_badge.vue19
-rw-r--r--app/assets/javascripts/runner/components/runner_type_help.vue60
-rw-r--r--app/assets/javascripts/runner/components/runner_update_form.vue227
-rw-r--r--app/assets/javascripts/runner/constants.js36
-rw-r--r--app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql5
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner.query.graphql5
-rw-r--r--app/assets/javascripts/runner/graphql/get_runners.query.graphql31
-rw-r--r--app/assets/javascripts/runner/graphql/runner_details.fragment.graphql12
-rw-r--r--app/assets/javascripts/runner/graphql/runner_node.fragment.graphql13
-rw-r--r--app/assets/javascripts/runner/graphql/runner_update.mutation.graphql10
-rw-r--r--app/assets/javascripts/runner/runner_details/runner_details_app.vue20
-rw-r--r--app/assets/javascripts/runner/runner_list/index.js42
-rw-r--r--app/assets/javascripts/runner/runner_list/runner_list_app.vue127
-rw-r--r--app/assets/javascripts/runner/runner_list/runner_search_utils.js109
23 files changed, 1476 insertions, 16 deletions
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
new file mode 100644
index 00000000000..7f9f796bdee
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -0,0 +1,171 @@
+<script>
+import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { __, s__ } from '~/locale';
+import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql';
+import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
+
+const i18n = {
+ I18N_EDIT: __('Edit'),
+ I18N_PAUSE: __('Pause'),
+ I18N_RESUME: __('Resume'),
+ I18N_REMOVE: __('Remove'),
+ I18N_REMOVE_CONFIRMATION: s__('Runners|Are you sure you want to delete this runner?'),
+};
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ updating: false,
+ deleting: false,
+ };
+ },
+ computed: {
+ runnerNumericalId() {
+ return getIdFromGraphQLId(this.runner.id);
+ },
+ runnerUrl() {
+ // TODO implement using webUrl from the API
+ return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
+ },
+ isActive() {
+ return this.runner.active;
+ },
+ toggleActiveIcon() {
+ return this.isActive ? 'pause' : 'play';
+ },
+ toggleActiveTitle() {
+ if (this.updating) {
+ // Prevent a "sticky" tooltip: If this button is disabled,
+ // mouseout listeners don't run leaving the tooltip stuck
+ return '';
+ }
+ return this.isActive ? i18n.I18N_PAUSE : i18n.I18N_RESUME;
+ },
+ deleteTitle() {
+ // Prevent a "sticky" tooltip: If element gets removed,
+ // mouseout listeners don't run and leaving the tooltip stuck
+ return this.deleting ? '' : i18n.I18N_REMOVE;
+ },
+ },
+ methods: {
+ async onToggleActive() {
+ this.updating = true;
+ // TODO In HAML iteration we had a confirmation modal via:
+ // data-confirm="_('Are you sure?')"
+ // this may not have to ported, this is an easily reversible operation
+
+ try {
+ const toggledActive = !this.runner.active;
+
+ const {
+ data: {
+ runnerUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerUpdateMutation,
+ variables: {
+ input: {
+ id: this.runner.id,
+ active: toggledActive,
+ },
+ },
+ });
+
+ if (errors && errors.length) {
+ this.onError(new Error(errors[0]));
+ }
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ this.updating = false;
+ }
+ },
+
+ async onDelete() {
+ // TODO Replace confirmation with gl-modal
+ // eslint-disable-next-line no-alert
+ if (!window.confirm(i18n.I18N_REMOVE_CONFIRMATION)) {
+ return;
+ }
+
+ this.deleting = true;
+ try {
+ const {
+ data: {
+ runnerDelete: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: deleteRunnerMutation,
+ variables: {
+ input: {
+ id: this.runner.id,
+ },
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: ['getRunners'],
+ });
+ if (errors && errors.length) {
+ this.onError(new Error(errors[0]));
+ }
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ this.deleting = false;
+ }
+ },
+
+ onError(error) {
+ // TODO Render errors when "delete" action is done
+ // `active` toggle would not fail due to user input.
+ throw error;
+ },
+ },
+ i18n,
+};
+</script>
+
+<template>
+ <gl-button-group>
+ <gl-button
+ v-gl-tooltip.hover.viewport
+ :title="$options.i18n.I18N_EDIT"
+ :aria-label="$options.i18n.I18N_EDIT"
+ icon="pencil"
+ :href="runnerUrl"
+ data-testid="edit-runner"
+ />
+ <gl-button
+ v-gl-tooltip.hover.viewport
+ :title="toggleActiveTitle"
+ :aria-label="toggleActiveTitle"
+ :icon="toggleActiveIcon"
+ :loading="updating"
+ data-testid="toggle-active-runner"
+ @click="onToggleActive"
+ />
+ <gl-button
+ v-gl-tooltip.hover.viewport
+ :title="deleteTitle"
+ :aria-label="deleteTitle"
+ icon="close"
+ :loading="deleting"
+ variant="danger"
+ data-testid="delete-runner"
+ @click="onDelete"
+ />
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue b/app/assets/javascripts/runner/components/cells/runner_name_cell.vue
new file mode 100644
index 00000000000..797a3359147
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_name_cell.vue
@@ -0,0 +1,44 @@
+<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>
diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
new file mode 100644
index 00000000000..b3ebdfd82e3
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
@@ -0,0 +1,42 @@
+<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>
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
new file mode 100644
index 00000000000..bec33ce2f44
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -0,0 +1,145 @@
+<script>
+import { GlFilteredSearchToken } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import { __, s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import {
+ STATUS_ACTIVE,
+ STATUS_PAUSED,
+ STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_NOT_CONNECTED,
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ CREATED_DESC,
+ CREATED_ASC,
+ CONTACTED_DESC,
+ CONTACTED_ASC,
+ PARAM_KEY_STATUS,
+ PARAM_KEY_RUNNER_TYPE,
+} from '../constants';
+
+const searchTokens = [
+ {
+ icon: 'status',
+ title: __('Status'),
+ type: PARAM_KEY_STATUS,
+ token: GlFilteredSearchToken,
+ // TODO Get more than one value when GraphQL API supports OR for "status"
+ unique: true,
+ options: [
+ { value: STATUS_ACTIVE, title: s__('Runners|Active') },
+ { value: STATUS_PAUSED, title: s__('Runners|Paused') },
+ { value: STATUS_ONLINE, title: s__('Runners|Online') },
+ { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
+
+ // Added extra quotes in this title to avoid splitting this value:
+ // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
+ { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
+ ],
+ // TODO In principle we could support more complex search rules,
+ // this can be added to a separate issue.
+ operators: OPERATOR_IS_ONLY,
+ },
+
+ {
+ icon: 'file-tree',
+ title: __('Type'),
+ type: PARAM_KEY_RUNNER_TYPE,
+ token: GlFilteredSearchToken,
+ // TODO Get more than one value when GraphQL API supports OR for "status"
+ unique: true,
+ options: [
+ { value: INSTANCE_TYPE, title: s__('Runners|shared') },
+ { value: GROUP_TYPE, title: s__('Runners|group') },
+ { value: PROJECT_TYPE, title: s__('Runners|specific') },
+ ],
+ // TODO We should support more complex search rules,
+ // search for multiple states (OR) or have NOT operators
+ operators: OPERATOR_IS_ONLY,
+ },
+
+ // TODO Support tags
+];
+
+const sortOptions = [
+ {
+ id: 1,
+ title: __('Created date'),
+ sortDirection: {
+ descending: CREATED_DESC,
+ ascending: CREATED_ASC,
+ },
+ },
+ {
+ id: 2,
+ title: __('Last contact'),
+ sortDirection: {
+ descending: CONTACTED_DESC,
+ ascending: CONTACTED_ASC,
+ },
+ },
+];
+
+export default {
+ components: {
+ FilteredSearch,
+ },
+ props: {
+ value: {
+ type: Object,
+ required: true,
+ validator(val) {
+ return Array.isArray(val?.filters) && typeof val?.sort === 'string';
+ },
+ },
+ },
+ data() {
+ // filtered_search_bar_root.vue may mutate the inital
+ // filters. Use `cloneDeep` to prevent those mutations
+ // from affecting this component
+ const { filters, sort } = cloneDeep(this.value);
+ return {
+ initialFilterValue: filters,
+ initialSortBy: sort,
+ };
+ },
+ methods: {
+ onFilter(filters) {
+ const { sort } = this.value;
+
+ this.$emit('input', {
+ filters,
+ sort,
+ pagination: { page: 1 },
+ });
+ },
+ onSort(sort) {
+ const { filters } = this.value;
+
+ this.$emit('input', {
+ filters,
+ sort,
+ pagination: { page: 1 },
+ });
+ },
+ },
+ sortOptions,
+ searchTokens,
+};
+</script>
+<template>
+ <filtered-search
+ v-bind="$attrs"
+ recent-searches-storage-key="runners-search"
+ :sort-options="$options.sortOptions"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ :tokens="$options.searchTokens"
+ :search-input-placeholder="__('Search or filter results...')"
+ @onFilter="onFilter"
+ @onSort="onSort"
+ />
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
new file mode 100644
index 00000000000..41adbbb55f6
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -0,0 +1,142 @@
+<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 RunnerActionsCell from './cells/runner_actions_cell.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,
+ RunnerActionsCell,
+ 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 v-if="!runners.length" #table-busy>
+ <gl-skeleton-loader v-for="i in 4" :key="i" />
+ </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)="{ item }">
+ <runner-actions-cell :runner="item" />
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
new file mode 100644
index 00000000000..4755977b051
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
@@ -0,0 +1,76 @@
+<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>
diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue
new file mode 100644
index 00000000000..8645b90f5cd
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_pagination.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlPagination } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlPagination,
+ },
+ props: {
+ value: {
+ required: false,
+ type: Object,
+ default: () => ({
+ page: 1,
+ }),
+ },
+ pageInfo: {
+ required: false,
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ prevPage() {
+ return this.pageInfo?.hasPreviousPage ? this.value?.page - 1 : null;
+ },
+ nextPage() {
+ return this.pageInfo?.hasNextPage ? this.value?.page + 1 : null;
+ },
+ },
+ methods: {
+ handlePageChange(page) {
+ if (page > this.value.page) {
+ this.$emit('input', {
+ page,
+ after: this.pageInfo.endCursor,
+ });
+ } else {
+ this.$emit('input', {
+ page,
+ before: this.pageInfo.startCursor,
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-pagination
+ :value="value.page"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="handlePageChange"
+ />
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue
new file mode 100644
index 00000000000..4ba07e00c96
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_tags.vue
@@ -0,0 +1,33 @@
+<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>
diff --git a/app/assets/javascripts/runner/components/runner_type_alert.vue b/app/assets/javascripts/runner/components/runner_type_alert.vue
new file mode 100644
index 00000000000..72ce582e02c
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_type_alert.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlAlert, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+
+const ALERT_DATA = {
+ [INSTANCE_TYPE]: {
+ title: s__(
+ 'Runners|This runner is available to all groups and projects in your GitLab instance.',
+ ),
+ message: s__(
+ 'Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.',
+ ),
+ variant: 'success',
+ anchor: 'shared-runners',
+ },
+ [GROUP_TYPE]: {
+ title: s__('Runners|This runner is available to all projects and subgroups in a group.'),
+ message: s__(
+ 'Runners|Use Group runners when you want all projects in a group to have access to a set of runners.',
+ ),
+ variant: 'success',
+ anchor: 'group-runners',
+ },
+ [PROJECT_TYPE]: {
+ title: s__('Runners|This runner is associated with specific projects.'),
+ message: s__(
+ 'Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.',
+ ),
+ variant: 'info',
+ anchor: 'specific-runners',
+ },
+};
+
+export default {
+ components: {
+ GlAlert,
+ GlLink,
+ },
+ props: {
+ type: {
+ type: String,
+ required: false,
+ default: null,
+ validator(type) {
+ return Boolean(ALERT_DATA[type]);
+ },
+ },
+ },
+ computed: {
+ alert() {
+ return ALERT_DATA[this.type];
+ },
+ helpHref() {
+ return helpPagePath('ci/runners/runners_scope', { anchor: this.alert.anchor });
+ },
+ },
+};
+</script>
+<template>
+ <gl-alert v-if="alert" :variant="alert.variant" :title="alert.title" :dismissible="false">
+ {{ alert.message }}
+ <gl-link :href="helpHref">{{ __('Learn more.') }}</gl-link>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue
index dd4fff3a77a..c2f43daa899 100644
--- a/app/assets/javascripts/runner/components/runner_type_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_type_badge.vue
@@ -3,7 +3,7 @@ import { GlBadge } from '@gitlab/ui';
import { s__ } from '~/locale';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
-const badge = {
+const BADGE_DATA = {
[INSTANCE_TYPE]: {
variant: 'success',
text: s__('Runners|shared'),
@@ -25,21 +25,22 @@ export default {
props: {
type: {
type: String,
- required: true,
+ required: false,
+ default: null,
+ validator(type) {
+ return Boolean(BADGE_DATA[type]);
+ },
},
},
computed: {
- variant() {
- return badge[this.type]?.variant;
- },
- text() {
- return badge[this.type]?.text;
+ badge() {
+ return BADGE_DATA[this.type];
},
},
};
</script>
<template>
- <gl-badge v-if="text" :variant="variant" v-bind="$attrs">
- {{ text }}
+ <gl-badge v-if="badge" :variant="badge.variant" v-bind="$attrs">
+ {{ badge.text }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_help.vue b/app/assets/javascripts/runner/components/runner_type_help.vue
new file mode 100644
index 00000000000..927deb290a4
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_type_help.vue
@@ -0,0 +1,60 @@
+<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>
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue
new file mode 100644
index 00000000000..0c1b83b6830
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_update_form.vue
@@ -0,0 +1,227 @@
+<script>
+import {
+ GlButton,
+ GlForm,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import { __ } from '~/locale';
+import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
+import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql';
+
+const runnerToModel = (runner) => {
+ const {
+ id,
+ description,
+ maximumTimeout,
+ accessLevel,
+ active,
+ locked,
+ runUntagged,
+ tagList = [],
+ } = runner || {};
+
+ return {
+ id,
+ description,
+ maximumTimeout,
+ accessLevel,
+ active,
+ locked,
+ runUntagged,
+ tagList: tagList.join(', '),
+ };
+};
+
+export default {
+ components: {
+ GlButton,
+ GlForm,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInputGroup,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ saving: false,
+ model: runnerToModel(this.runner),
+ };
+ },
+ computed: {
+ canBeLockedToProject() {
+ return this.runner?.runnerType === PROJECT_TYPE;
+ },
+ readonlyIpAddress() {
+ return this.runner?.ipAddress;
+ },
+ updateMutationInput() {
+ const { maximumTimeout, tagList } = this.model;
+
+ return {
+ ...this.model,
+ maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null,
+ tagList: tagList
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter((tag) => Boolean(tag)),
+ };
+ },
+ },
+ watch: {
+ runner(newVal, oldVal) {
+ if (oldVal === null) {
+ this.model = runnerToModel(newVal);
+ }
+ },
+ },
+ methods: {
+ async onSubmit() {
+ this.saving = true;
+
+ try {
+ const {
+ data: {
+ runnerUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerUpdateMutation,
+ variables: {
+ input: this.updateMutationInput,
+ },
+ });
+
+ if (errors?.length) {
+ this.onError(new Error(errors[0]));
+ return;
+ }
+
+ this.onSuccess();
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ this.saving = false;
+ }
+ },
+ onError(error) {
+ const { message } = error;
+ createFlash({ message });
+ },
+ onSuccess() {
+ createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS });
+ this.model = runnerToModel(this.runner);
+ },
+ },
+ ACCESS_LEVEL_NOT_PROTECTED,
+ ACCESS_LEVEL_REF_PROTECTED,
+};
+</script>
+<template>
+ <gl-form @submit.prevent="onSubmit">
+ <gl-form-checkbox
+ v-model="model.active"
+ data-testid="runner-field-paused"
+ :value="false"
+ :unchecked-value="true"
+ >
+ {{ __('Paused') }}
+ <template #help>
+ {{ __("Paused runners don't accept new jobs") }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-checkbox
+ v-model="model.accessLevel"
+ data-testid="runner-field-protected"
+ :value="$options.ACCESS_LEVEL_REF_PROTECTED"
+ :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
+ >
+ {{ __('Protected') }}
+ <template #help>
+ {{ __('This runner will only run on pipelines triggered on protected branches') }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged">
+ {{ __('Run untagged jobs') }}
+ <template #help>
+ {{ __('Indicates whether this runner can pick jobs without tags') }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-checkbox
+ v-model="model.locked"
+ data-testid="runner-field-locked"
+ :disabled="!canBeLockedToProject"
+ >
+ {{ __('Lock to current projects') }}
+ <template #help>
+ {{ __('When a runner is locked, it cannot be assigned to other projects') }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-group :label="__('IP Address')" data-testid="runner-field-ip-address">
+ <gl-form-input-group :value="readonlyIpAddress" readonly select-on-click>
+ <template #append>
+ <gl-button
+ v-gl-tooltip.hover
+ :title="__('Copy IP Address')"
+ :aria-label="__('Copy IP Address')"
+ :data-clipboard-text="readonlyIpAddress"
+ icon="copy-to-clipboard"
+ class="d-inline-flex"
+ />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+
+ <gl-form-group :label="__('Description')" data-testid="runner-field-description">
+ <gl-form-input-group v-model="model.description" />
+ </gl-form-group>
+
+ <gl-form-group
+ data-testid="runner-field-max-timeout"
+ :label="__('Maximum job timeout')"
+ :description="
+ s__(
+ 'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.',
+ )
+ "
+ >
+ <gl-form-input-group v-model.number="model.maximumTimeout" type="number" />
+ </gl-form-group>
+
+ <gl-form-group
+ data-testid="runner-field-tags"
+ :label="__('Tags')"
+ :description="
+ __('You can set up jobs to only use runners with specific tags. Separate tags with commas.')
+ "
+ >
+ <gl-form-input-group v-model="model.tagList" />
+ </gl-form-group>
+
+ <div class="form-actions">
+ <gl-button
+ type="submit"
+ variant="confirm"
+ class="js-no-auto-disable"
+ :loading="saving || !runner"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index de3a3fda47e..a57d18ba745 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,11 +1,47 @@
import { s__ } from '~/locale';
+export const RUNNER_PAGE_SIZE = 20;
+
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
+// Filtered search parameter names
+// - Used for URL params names
+// - GlFilteredSearch tokens type
+
+export const PARAM_KEY_SEARCH = 'search';
+export const PARAM_KEY_STATUS = 'status';
+export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
+export const PARAM_KEY_SORT = 'sort';
+export const PARAM_KEY_PAGE = 'page';
+export const PARAM_KEY_AFTER = 'after';
+export const PARAM_KEY_BEFORE = 'before';
+
// CiRunnerType
export const INSTANCE_TYPE = 'INSTANCE_TYPE';
export const GROUP_TYPE = 'GROUP_TYPE';
export const PROJECT_TYPE = 'PROJECT_TYPE';
+
+// CiRunnerStatus
+
+export const STATUS_ACTIVE = 'ACTIVE';
+export const STATUS_PAUSED = 'PAUSED';
+export const STATUS_ONLINE = 'ONLINE';
+export const STATUS_OFFLINE = 'OFFLINE';
+export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
+
+// CiRunnerAccessLevel
+
+export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED';
+export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED';
+
+// CiRunnerSort
+
+export const CREATED_DESC = 'CREATED_DESC';
+export const CREATED_ASC = 'CREATED_ASC'; // TODO Add this to the API
+export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
+export const CONTACTED_ASC = 'CONTACTED_ASC';
+
+export const DEFAULT_SORT = CREATED_DESC;
diff --git a/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql b/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql
new file mode 100644
index 00000000000..d580ea2785e
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql
@@ -0,0 +1,5 @@
+mutation runnerDelete($input: RunnerDeleteInput!) {
+ runnerDelete(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
index d209313d4df..84e0d6cc95c 100644
--- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
@@ -1,6 +1,7 @@
+#import "~/runner/graphql/runner_details.fragment.graphql"
+
query getRunner($id: CiRunnerID!) {
runner(id: $id) {
- id
- runnerType
+ ...RunnerDetails
}
}
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
new file mode 100644
index 00000000000..45df9c625a6
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
@@ -0,0 +1,31 @@
+#import "~/runner/graphql/runner_node.fragment.graphql"
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getRunners(
+ $before: String
+ $after: String
+ $first: Int
+ $last: Int
+ $search: String
+ $status: CiRunnerStatus
+ $type: CiRunnerType
+ $sort: CiRunnerSort
+) {
+ runners(
+ before: $before
+ after: $after
+ first: $first
+ last: $last
+ search: $search
+ status: $status
+ type: $type
+ sort: $sort
+ ) {
+ nodes {
+ ...RunnerNode
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql
new file mode 100644
index 00000000000..6d7dc1e2798
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql
@@ -0,0 +1,12 @@
+fragment RunnerDetails on CiRunner {
+ id
+ runnerType
+ active
+ accessLevel
+ runUntagged
+ locked
+ ipAddress
+ description
+ maximumTimeout
+ tagList
+}
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
new file mode 100644
index 00000000000..0835e3c7c09
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
@@ -0,0 +1,13 @@
+fragment RunnerNode on CiRunner {
+ id
+ description
+ runnerType
+ shortSha
+ version
+ revision
+ ipAddress
+ active
+ locked
+ tagList
+ contactedAt
+}
diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
new file mode 100644
index 00000000000..d50c1880d77
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
@@ -0,0 +1,10 @@
+#import "~/runner/graphql/runner_details.fragment.graphql"
+
+mutation runnerUpdate($input: RunnerUpdateInput!) {
+ runnerUpdate(input: $input) {
+ runner {
+ ...RunnerDetails
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
index 4736e547cb9..5d5fa81b851 100644
--- a/app/assets/javascripts/runner/runner_details/runner_details_app.vue
+++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
@@ -1,12 +1,16 @@
<script>
import { convertToGraphQLId } from '~/graphql_shared/utils';
+import RunnerTypeAlert from '../components/runner_type_alert.vue';
import RunnerTypeBadge from '../components/runner_type_badge.vue';
+import RunnerUpdateForm from '../components/runner_update_form.vue';
import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants';
import getRunnerQuery from '../graphql/get_runner.query.graphql';
export default {
components: {
+ RunnerTypeAlert,
RunnerTypeBadge,
+ RunnerUpdateForm,
},
i18n: {
I18N_DETAILS_TITLE,
@@ -19,7 +23,7 @@ export default {
},
data() {
return {
- runner: {},
+ runner: null,
};
},
apollo: {
@@ -35,9 +39,15 @@ export default {
};
</script>
<template>
- <h2 class="page-title">
- {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }}
+ <div>
+ <h2 class="page-title">
+ {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }}
- <runner-type-badge v-if="runner.runnerType" :type="runner.runnerType" />
- </h2>
+ <runner-type-badge v-if="runner" :type="runner.runnerType" />
+ </h2>
+
+ <runner-type-alert v-if="runner" :type="runner.runnerType" />
+
+ <runner-update-form :runner="runner" class="gl-my-5" />
+ </div>
</template>
diff --git a/app/assets/javascripts/runner/runner_list/index.js b/app/assets/javascripts/runner/runner_list/index.js
new file mode 100644
index 00000000000..5eba14a7948
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_list/index.js
@@ -0,0 +1,42 @@
+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,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/runner/runner_list/runner_list_app.vue b/app/assets/javascripts/runner/runner_list/runner_list_app.vue
new file mode 100644
index 00000000000..b4eacb911a2
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_list/runner_list_app.vue
@@ -0,0 +1,127 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { fetchPolicies } from '~/lib/graphql';
+import { updateHistory } from '~/lib/utils/url_utility';
+import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
+import RunnerList from '../components/runner_list.vue';
+import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
+import RunnerPagination from '../components/runner_pagination.vue';
+import RunnerTypeHelp from '../components/runner_type_help.vue';
+import getRunnersQuery from '../graphql/get_runners.query.graphql';
+import {
+ fromUrlQueryToSearch,
+ fromSearchToUrl,
+ fromSearchToVariables,
+} from './runner_search_utils';
+
+export default {
+ components: {
+ RunnerFilteredSearchBar,
+ RunnerList,
+ RunnerManualSetupHelp,
+ RunnerTypeHelp,
+ RunnerPagination,
+ },
+ props: {
+ activeRunnersCount: {
+ type: Number,
+ required: true,
+ },
+ registrationToken: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ search: fromUrlQueryToSearch(),
+ runners: {
+ items: [],
+ pageInfo: {},
+ },
+ };
+ },
+ apollo: {
+ runners: {
+ query: getRunnersQuery,
+ // Runners can be updated by users directly in this list.
+ // A "cache and network" policy prevents outdated filtered
+ // results.
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { runners } = data;
+ return {
+ items: runners?.nodes || [],
+ pageInfo: runners?.pageInfo || {},
+ };
+ },
+ error(err) {
+ this.captureException(err);
+ },
+ },
+ },
+ computed: {
+ variables() {
+ return fromSearchToVariables(this.search);
+ },
+ runnersLoading() {
+ return this.$apollo.queries.runners.loading;
+ },
+ noRunnersFound() {
+ return !this.runnersLoading && !this.runners.items.length;
+ },
+ },
+ watch: {
+ search: {
+ deep: true,
+ handler() {
+ // TODO Implement back button reponse using onpopstate
+ updateHistory({
+ url: fromSearchToUrl(this.search),
+ title: document.title,
+ });
+ },
+ },
+ },
+ 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>
+
+ <runner-filtered-search-bar v-model="search" namespace="admin_runners" />
+
+ <div v-if="noRunnersFound" class="gl-text-center gl-p-5">
+ {{ __('No runners found') }}
+ </div>
+ <template v-else>
+ <runner-list
+ :runners="runners.items"
+ :loading="runnersLoading"
+ :active-runners-count="activeRunnersCount"
+ />
+ <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/runner_list/runner_search_utils.js b/app/assets/javascripts/runner/runner_list/runner_search_utils.js
new file mode 100644
index 00000000000..e45972b81db
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_list/runner_search_utils.js
@@ -0,0 +1,109 @@
+import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
+import {
+ filterToQueryObject,
+ processFilters,
+ urlQueryToFilter,
+ prepareTokens,
+} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import {
+ PARAM_KEY_SEARCH,
+ PARAM_KEY_STATUS,
+ PARAM_KEY_RUNNER_TYPE,
+ PARAM_KEY_SORT,
+ PARAM_KEY_PAGE,
+ PARAM_KEY_AFTER,
+ PARAM_KEY_BEFORE,
+ DEFAULT_SORT,
+ RUNNER_PAGE_SIZE,
+} from '../constants';
+
+const getPaginationFromParams = (params) => {
+ const page = parseInt(params[PARAM_KEY_PAGE], 10);
+ const after = params[PARAM_KEY_AFTER];
+ const before = params[PARAM_KEY_BEFORE];
+
+ if (page && (before || after)) {
+ return {
+ page,
+ before,
+ after,
+ };
+ }
+ return {
+ page: 1,
+ };
+};
+
+export const fromUrlQueryToSearch = (query = window.location.search) => {
+ const params = queryToObject(query, { gatherArrays: true });
+
+ return {
+ filters: prepareTokens(
+ urlQueryToFilter(query, {
+ filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE],
+ filteredSearchTermKey: PARAM_KEY_SEARCH,
+ legacySpacesDecode: false,
+ }),
+ ),
+ sort: params[PARAM_KEY_SORT] || DEFAULT_SORT,
+ pagination: getPaginationFromParams(params),
+ };
+};
+
+export const fromSearchToUrl = (
+ { filters = [], sort = null, pagination = {} },
+ url = window.location.href,
+) => {
+ const filterParams = {
+ // Defaults
+ [PARAM_KEY_SEARCH]: null,
+ [PARAM_KEY_STATUS]: [],
+ [PARAM_KEY_RUNNER_TYPE]: [],
+ // Current filters
+ ...filterToQueryObject(processFilters(filters), {
+ filteredSearchTermKey: PARAM_KEY_SEARCH,
+ }),
+ };
+
+ const isDefaultSort = sort !== DEFAULT_SORT;
+ const isFirstPage = pagination?.page === 1;
+ const otherParams = {
+ // Sorting & Pagination
+ [PARAM_KEY_SORT]: isDefaultSort ? sort : null,
+ [PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page,
+ [PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before,
+ [PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after,
+ };
+
+ return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true);
+};
+
+export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => {
+ const variables = {};
+
+ const queryObj = filterToQueryObject(processFilters(filters), {
+ filteredSearchTermKey: PARAM_KEY_SEARCH,
+ });
+
+ variables.search = queryObj[PARAM_KEY_SEARCH];
+
+ // TODO Get more than one value when GraphQL API supports OR for "status"
+ [variables.status] = queryObj[PARAM_KEY_STATUS] || [];
+
+ // TODO Get more than one value when GraphQL API supports OR for "runner type"
+ [variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || [];
+
+ if (sort) {
+ variables.sort = sort;
+ }
+
+ if (pagination.before) {
+ variables.before = pagination.before;
+ variables.last = RUNNER_PAGE_SIZE;
+ } else {
+ variables.after = pagination.after;
+ variables.first = RUNNER_PAGE_SIZE;
+ }
+
+ return variables;
+};