diff options
Diffstat (limited to 'app/assets/javascripts/runner')
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; +}; |