diff options
Diffstat (limited to 'app/assets/javascripts/runner/components')
17 files changed, 823 insertions, 139 deletions
diff --git a/app/assets/javascripts/runner/components/cells/link_cell.vue b/app/assets/javascripts/runner/components/cells/link_cell.vue new file mode 100644 index 00000000000..2843ddbacaf --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/link_cell.vue @@ -0,0 +1,27 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + props: { + href: { + type: String, + required: false, + default: null, + }, + }, + computed: { + component() { + if (this.href) { + return GlLink; + } + return 'span'; + }, + }, +}; +</script> + +<template> + <component :is="component" :href="href" v-bind="$attrs" v-on="$listeners"> + <slot></slot> + </component> +</template> diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index 0934508c87f..ae9c774f2a2 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,16 +1,14 @@ <script> import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { __, s__, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; -import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerEditButton from '../runner_edit_button.vue'; +import RunnerPauseButton from '../runner_pause_button.vue'; import RunnerDeleteModal from '../runner_delete_modal.vue'; -const I18N_EDIT = __('Edit'); -const I18N_PAUSE = __('Pause'); -const I18N_RESUME = __('Resume'); const I18N_DELETE = s__('Runners|Delete runner'); const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); @@ -19,6 +17,8 @@ export default { components: { GlButton, GlButtonGroup, + RunnerEditButton, + RunnerPauseButton, RunnerDeleteModal, }, directives: { @@ -38,20 +38,6 @@ export default { }; }, computed: { - 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_PAUSE : I18N_RESUME; - }, deleteTitle() { if (this.deleting) { // Prevent a "sticky" tooltip: If this button is disabled, @@ -77,35 +63,6 @@ export default { }, }, methods: { - async onToggleActive() { - this.updating = true; - try { - const toggledActive = !this.runner.active; - - const { - data: { - runnerUpdate: { errors }, - }, - } = await this.$apollo.mutate({ - mutation: runnerActionsUpdateMutation, - variables: { - input: { - id: this.runner.id, - active: toggledActive, - }, - }, - }); - - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } - } catch (e) { - this.onError(e); - } finally { - this.updating = false; - } - }, - async onDelete() { // Deleting stays "true" until this row is removed, // should only change back if the operation fails. @@ -147,7 +104,6 @@ export default { captureException({ error, component: this.$options.name }); }, }, - I18N_EDIT, I18N_DELETE, }; </script> @@ -161,23 +117,8 @@ export default { See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 --> - <gl-button - v-if="canUpdate && runner.editAdminUrl" - v-gl-tooltip.hover.viewport="$options.I18N_EDIT" - :href="runner.editAdminUrl" - :aria-label="$options.I18N_EDIT" - icon="pencil" - data-testid="edit-runner" - /> - <gl-button - v-if="canUpdate" - v-gl-tooltip.hover.viewport="toggleActiveTitle" - :aria-label="toggleActiveTitle" - :icon="toggleActiveIcon" - :loading="updating" - data-testid="toggle-active-runner" - @click="onToggleActive" - /> + <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" /> <gl-button v-if="canDelete" v-gl-tooltip.hover.viewport="deleteTitle" diff --git a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue index 0e259807f98..54c35e483dc 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -11,8 +11,10 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; export default { name: 'RunnerRegistrationTokenReset', i18n: { - modalTitle: __('Reset registration token'), + modalAction: s__('Runners|Reset token'), + modalCancel: __('Cancel'), modalCopy: __('Are you sure you want to reset the registration token?'), + modalTitle: __('Reset registration token'), }, components: { GlDropdownItem, @@ -30,7 +32,7 @@ export default { default: null, }, }, - modalID: 'token-reset-modal', + modalId: 'token-reset-modal', props: { type: { type: String, @@ -111,10 +113,19 @@ export default { }; </script> <template> - <gl-dropdown-item v-gl-modal="$options.modalID"> + <gl-dropdown-item v-gl-modal="$options.modalId"> {{ __('Reset registration token') }} <gl-modal - :modal-id="$options.modalID" + size="sm" + :modal-id="$options.modalId" + :action-primary="{ + text: $options.i18n.modalAction, + attributes: [{ variant: 'danger' }], + }" + :action-secondary="{ + text: $options.i18n.modalCancel, + attributes: [{ variant: 'default' }], + }" :title="$options.i18n.modalTitle" @primary="handleModalPrimary" > diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue new file mode 100644 index 00000000000..ea8074199a6 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue @@ -0,0 +1,39 @@ +<script> +import { GlAvatar, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlLink, + }, + props: { + href: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + fullName: { + type: String, + required: true, + }, + avatarUrl: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-py-5"> + <gl-link :href="href" data-testid="item-avatar" class="gl-text-decoration-none! gl-mr-3"> + <gl-avatar shape="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" /> + </gl-link> + + <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue new file mode 100644 index 00000000000..b1234818b7e --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_detail.vue @@ -0,0 +1,50 @@ +<script> +import { __ } from '~/locale'; + +/** + * Usage: + * + * With a `value` prop: + * + * <runner-detail label="Field Name" :value="value" /> + * + * Or a `value` slot: + * + * <runner-detail label="Field Name"> + * <template #value> + * <strong>{{ value }}</strong> + * </template> + * </runner-detail> + * + */ +export default { + props: { + label: { + type: String, + required: true, + }, + value: { + type: String, + default: null, + required: false, + }, + emptyValue: { + type: String, + default: __('None'), + required: false, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-pb-4"> + <dt class="gl-mr-2">{{ label }}</dt> + <dd class="gl-mb-0"> + <template v-if="value || $slots.value"> + <slot name="value">{{ value }}</slot> + </template> + <span v-else class="gl-text-gray-500">{{ emptyValue }}</span> + </dd> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue new file mode 100644 index 00000000000..b6a5ffc7a64 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -0,0 +1,124 @@ +<script> +import { GlBadge, GlTabs, GlTab, GlIntersperse } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import { formatJobCount } from '../utils'; +import RunnerDetail from './runner_detail.vue'; +import RunnerGroups from './runner_groups.vue'; +import RunnerProjects from './runner_projects.vue'; +import RunnerJobs from './runner_jobs.vue'; +import RunnerTags from './runner_tags.vue'; + +export default { + components: { + GlBadge, + GlTabs, + GlTab, + GlIntersperse, + RunnerDetail, + RunnerGroups, + RunnerProjects, + RunnerJobs, + RunnerTags, + TimeAgo, + }, + props: { + runner: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + maximumTimeout() { + const { maximumTimeout } = this.runner; + if (typeof maximumTimeout !== 'number') { + return null; + } + return timeIntervalInWords(maximumTimeout); + }, + configTextProtected() { + if (this.runner.accessLevel === ACCESS_LEVEL_REF_PROTECTED) { + return s__('Runners|Protected'); + } + return null; + }, + configTextUntagged() { + if (this.runner.runUntagged) { + return s__('Runners|Runs untagged jobs'); + } + return null; + }, + isGroupRunner() { + return this.runner?.runnerType === GROUP_TYPE; + }, + isProjectRunner() { + return this.runner?.runnerType === PROJECT_TYPE; + }, + jobCount() { + return formatJobCount(this.runner?.jobCount); + }, + }, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> + +<template> + <gl-tabs> + <gl-tab> + <template #title>{{ s__('Runners|Details') }}</template> + + <template v-if="runner"> + <div class="gl-pt-4"> + <dl class="gl-mb-0" data-testid="runner-details-list"> + <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> + <runner-detail + :label="s__('Runners|Last contact')" + :empty-value="s__('Runners|Never contacted')" + > + <template #value> + <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Version')" :value="runner.version" /> + <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> + <runner-detail :label="s__('Runners|Configuration')"> + <template #value> + <gl-intersperse v-if="configTextProtected || configTextUntagged"> + <span v-if="configTextProtected">{{ configTextProtected }}</span> + <span v-if="configTextUntagged">{{ configTextUntagged }}</span> + </gl-intersperse> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> + <runner-detail :label="s__('Runners|Tags')"> + <template #value> + <runner-tags + v-if="runner.tagList && runner.tagList.length" + class="gl-vertical-align-middle" + :tag-list="runner.tagList" + size="sm" + /> + </template> + </runner-detail> + </dl> + </div> + + <runner-groups v-if="isGroupRunner" :runner="runner" /> + <runner-projects v-if="isProjectRunner" :runner="runner" /> + </template> + </gl-tab> + <gl-tab> + <template #title> + {{ s__('Runners|Jobs') }} + <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm"> + {{ jobCount }} + </gl-badge> + </template> + + <runner-jobs v-if="runner" :runner="runner" /> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/runner/components/runner_edit_button.vue b/app/assets/javascripts/runner/components/runner_edit_button.vue new file mode 100644 index 00000000000..b115be09e69 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_edit_button.vue @@ -0,0 +1,26 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const I18N_EDIT = __('Edit'); + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + I18N_EDIT, +}; +</script> + +<template> + <gl-button + v-gl-tooltip="$options.I18N_EDIT" + v-bind="$attrs" + :aria-label="$options.I18N_EDIT" + icon="pencil" + v-on="$listeners" + /> +</template> diff --git a/app/assets/javascripts/runner/components/runner_groups.vue b/app/assets/javascripts/runner/components/runner_groups.vue new file mode 100644 index 00000000000..c3b35bd52a9 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_groups.vue @@ -0,0 +1,37 @@ +<script> +import RunnerAssignedItem from './runner_assigned_item.vue'; + +export default { + components: { + RunnerAssignedItem, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + groups() { + return this.runner.groups?.nodes || []; + }, + }, +}; +</script> + +<template> + <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"> + <h3 class="gl-font-lg gl-mt-5 gl-mb-0">{{ s__('Runners|Assigned Group') }}</h3> + <template v-if="groups.length"> + <runner-assigned-item + v-for="group in groups" + :key="group.id" + :href="group.webUrl" + :name="group.name" + :full-name="group.fullName" + :avatar-url="group.avatarUrl" + /> + </template> + <span v-else class="gl-text-gray-500">{{ __('None') }}</span> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue index 09f58df7bd0..abc07cec1ad 100644 --- a/app/assets/javascripts/runner/components/runner_header.vue +++ b/app/assets/javascripts/runner/components/runner_header.vue @@ -1,19 +1,23 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { I18N_DETAILS_TITLE } from '../constants'; +import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; import RunnerTypeBadge from './runner_type_badge.vue'; import RunnerStatusBadge from './runner_status_badge.vue'; export default { components: { + GlIcon, GlSprintf, TimeAgo, RunnerTypeBadge, RunnerStatusBadge, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { runner: { type: Object, @@ -29,24 +33,36 @@ export default { return sprintf(I18N_DETAILS_TITLE, { runner_id: id }); }, }, + I18N_LOCKED_RUNNER_DESCRIPTION, }; </script> <template> - <div class="gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> - <runner-status-badge :runner="runner" /> - <runner-type-badge v-if="runner" :type="runner.runnerType" /> - <template v-if="runner.createdAt"> - <gl-sprintf :message="__('%{runner} created %{timeago}')"> - <template #runner> - <strong>{{ heading }}</strong> - </template> - <template #timeago> - <time-ago :time="runner.createdAt" /> - </template> - </gl-sprintf> - </template> - <template v-else> - <strong>{{ heading }}</strong> - </template> + <div + class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <div> + <runner-status-badge :runner="runner" /> + <runner-type-badge v-if="runner" :type="runner.runnerType" /> + <template v-if="runner.createdAt"> + <gl-sprintf :message="__('%{runner} created %{timeago}')"> + <template #runner> + <strong>{{ heading }}</strong> + <gl-icon + v-if="runner.locked" + v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + /> + </template> + <template #timeago> + <time-ago :time="runner.createdAt" /> + </template> + </gl-sprintf> + </template> + <template v-else> + <strong>{{ heading }}</strong> + </template> + </div> + <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue new file mode 100644 index 00000000000..c13e7e90168 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -0,0 +1,82 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql'; +import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; +import { captureException } from '../sentry_utils'; +import { getPaginationVariables } from '../utils'; +import RunnerJobsTable from './runner_jobs_table.vue'; +import RunnerPagination from './runner_pagination.vue'; + +export default { + name: 'RunnerJobs', + components: { + GlSkeletonLoading, + RunnerJobsTable, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + jobs: { + items: [], + pageInfo: {}, + }, + pagination: { + page: 1, + }, + }; + }, + apollo: { + jobs: { + query: getRunnerJobsQuery, + variables() { + return this.variables; + }, + update({ runner }) { + return { + items: runner?.jobs?.nodes || [], + pageInfo: runner?.jobs?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + const { id } = this.runner; + return { + id, + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.jobs.loading; + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, + I18N_NO_JOBS_FOUND, +}; +</script> + +<template> + <div class="gl-pt-3"> + <gl-skeleton-loading v-if="loading" class="gl-py-5" /> + <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" /> + <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p> + + <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_jobs_table.vue b/app/assets/javascripts/runner/components/runner_jobs_table.vue new file mode 100644 index 00000000000..7817577bab0 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_jobs_table.vue @@ -0,0 +1,95 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import RunnerTags from '~/runner/components/runner_tags.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { tableField } from '../utils'; +import LinkCell from './cells/link_cell.vue'; + +export default { + components: { + CiBadge, + GlTableLite, + LinkCell, + RunnerTags, + TimeAgo, + }, + props: { + jobs: { + type: Array, + required: true, + }, + }, + methods: { + trAttr(job) { + if (job?.id) { + return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` }; + } + return {}; + }, + jobId(job) { + return getIdFromGraphQLId(job.id); + }, + jobPath(job) { + return job.detailedStatus?.detailsPath; + }, + projectName(job) { + return job.pipeline?.project?.name; + }, + projectWebUrl(job) { + return job.pipeline?.project?.webUrl; + }, + commitShortSha(job) { + return job.shortSha; + }, + commitPath(job) { + return job.commitPath; + }, + }, + fields: [ + tableField({ key: 'status', label: s__('Job|Status') }), + tableField({ key: 'job', label: __('Job') }), + tableField({ key: 'project', label: __('Project') }), + tableField({ key: 'commit', label: __('Commit') }), + tableField({ key: 'finished_at', label: s__('Job|Finished at') }), + tableField({ key: 'tags', label: s__('Runners|Tags') }), + ], +}; +</script> + +<template> + <gl-table-lite + :items="jobs" + :fields="$options.fields" + :tbody-tr-attr="trAttr" + primary-key="id" + stacked="md" + fixed + > + <template #cell(status)="{ item = {} }"> + <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" /> + </template> + + <template #cell(job)="{ item = {} }"> + <link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell> + </template> + + <template #cell(project)="{ item = {} }"> + <link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell> + </template> + + <template #cell(commit)="{ item = {} }"> + <link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell> + </template> + + <template #cell(tags)="{ item = {} }"> + <runner-tags :tag-list="item.tags" /> + </template> + + <template #cell(finished_at)="{ item = {} }"> + <time-ago v-if="item.finishedAt" :time="item.finishedAt" /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 023308dbac2..bb36882d3ae 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -2,31 +2,14 @@ import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { formatNumber, __, s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; -import { RUNNER_JOB_COUNT_LIMIT } from '../constants'; +import { formatJobCount, tableField } from '../utils'; import RunnerActionsCell from './cells/runner_actions_cell.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; -const tableField = ({ key, label = '', thClasses = [] }) => { - return { - key, - label, - thClass: [ - 'gl-bg-transparent!', - 'gl-border-b-solid!', - 'gl-border-b-gray-100!', - 'gl-border-b-1!', - ...thClasses, - ], - tdAttr: { - 'data-testid': `td-${key}`, - }, - }; -}; - export default { components: { GlTable, @@ -54,10 +37,7 @@ export default { }, methods: { formatJobCount(jobCount) { - if (jobCount > RUNNER_JOB_COUNT_LIMIT) { - return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; - } - return formatNumber(jobCount); + return formatJobCount(jobCount); }, runnerTrAttr(runner) { if (runner) { @@ -70,9 +50,9 @@ export default { }, fields: [ tableField({ key: 'status', label: s__('Runners|Status') }), - tableField({ key: 'summary', label: s__('Runners|Runner ID'), thClasses: ['gl-lg-w-25p'] }), + tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'version', label: __('Version') }), - tableField({ key: 'ipAddress', label: __('IP Address') }), + tableField({ key: 'ipAddress', label: __('IP') }), tableField({ key: 'jobCount', label: __('Jobs') }), tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'contactedAt', label: __('Last contact') }), diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue index 8645b90f5cd..b683a7f2330 100644 --- a/app/assets/javascripts/runner/components/runner_pagination.vue +++ b/app/assets/javascripts/runner/components/runner_pagination.vue @@ -29,7 +29,14 @@ export default { }, methods: { handlePageChange(page) { - if (page > this.value.page) { + if (page === 1) { + // Small optimization for first page + // If we have loaded using "first", + // page is already cached. + this.$emit('input', { + page, + }); + } else if (page > this.value.page) { this.$emit('input', { page, after: this.pageInfo.endCursor, @@ -47,11 +54,12 @@ export default { <template> <gl-pagination + v-bind="$attrs" :value="value.page" :prev-page="prevPage" :next-page="nextPage" align="center" - class="gl-pagination gl-mt-3" + class="gl-pagination" @input="handlePageChange" /> </template> diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue new file mode 100644 index 00000000000..a8b259f5b90 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_pause_button.vue @@ -0,0 +1,122 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql'; +import { createAlert } from '~/flash'; +import { captureException } from '~/runner/sentry_utils'; +import { I18N_PAUSE, I18N_RESUME } from '../constants'; + +export default { + name: 'RunnerPauseButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + updating: false, + }; + }, + computed: { + isActive() { + return this.runner.active; + }, + icon() { + return this.isActive ? 'pause' : 'play'; + }, + label() { + return this.isActive ? I18N_PAUSE : I18N_RESUME; + }, + buttonContent() { + if (this.compact) { + return null; + } + return this.label; + }, + ariaLabel() { + if (this.compact) { + return this.label; + } + return null; + }, + tooltip() { + // Only show tooltip when compact. + // Also prevent a "sticky" tooltip: If this button is + // disabled, mouseout listeners don't run leaving the tooltip stuck + if (this.compact && !this.updating) { + return this.label; + } + return ''; + }, + }, + methods: { + async onToggle() { + this.updating = true; + try { + const input = { + id: this.runner.id, + active: !this.isActive, + }; + + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerToggleActiveMutation, + variables: { + input, + }, + }); + + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + } catch (e) { + this.onError(e); + } finally { + this.updating = false; + } + }, + onError(error) { + const { message } = error; + createAlert({ message }); + + this.reportToSentry(error); + }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover.viewport="tooltip" + v-bind="$attrs" + :aria-label="ariaLabel" + :icon="icon" + :loading="updating" + @click="onToggle" + v-on="$listeners" + > + <!-- + Use <template v-if> to ensure a square button is shown when compact: true. + Sending empty content will still show a distorted/rectangular button. + --> + <template v-if="buttonContent">{{ buttonContent }}</template> + </gl-button> +</template> diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue new file mode 100644 index 00000000000..c4065a24ff2 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -0,0 +1,111 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import { sprintf, formatNumber } from '~/locale'; +import { createAlert } from '~/flash'; +import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql'; +import { + I18N_ASSIGNED_PROJECTS, + I18N_NONE, + I18N_FETCH_ERROR, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, +} from '../constants'; +import { getPaginationVariables } from '../utils'; +import { captureException } from '../sentry_utils'; +import RunnerAssignedItem from './runner_assigned_item.vue'; +import RunnerPagination from './runner_pagination.vue'; + +export default { + name: 'RunnerProjects', + components: { + GlSkeletonLoading, + RunnerAssignedItem, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + projects: { + items: [], + pageInfo: {}, + count: 0, + }, + pagination: { + page: 1, + }, + }; + }, + apollo: { + projects: { + query: getRunnerProjectsQuery, + variables() { + return this.variables; + }, + update(data) { + const { runner } = data; + return { + count: runner?.projectCount || 0, + items: runner?.projects?.nodes || [], + pageInfo: runner?.projects?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + const { id } = this.runner; + return { + id, + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.projects.loading; + }, + heading() { + return sprintf(I18N_ASSIGNED_PROJECTS, { + projectCount: formatNumber(this.projects.count), + }); + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, + I18N_NONE, +}; +</script> + +<template> + <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"> + <h3 class="gl-font-lg gl-mt-5 gl-mb-0"> + {{ heading }} + </h3> + + <gl-skeleton-loading v-if="loading" class="gl-py-5" /> + <template v-else-if="projects.items.length"> + <runner-assigned-item + v-for="(project, i) in projects.items" + :key="project.id" + :class="{ 'gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid': i !== 0 }" + :href="project.webUrl" + :name="project.name" + :full-name="project.nameWithNamespace" + :avatar-url="project.avatarUrl" + /> + </template> + <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span> + + <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue index 8da5e33076f..797d2a35b2c 100644 --- a/app/assets/javascripts/runner/components/runner_tags.vue +++ b/app/assets/javascripts/runner/components/runner_tags.vue @@ -20,7 +20,7 @@ export default { }; </script> <template> - <div> + <span> <runner-tag v-for="tag in tagList" :key="tag" @@ -28,5 +28,5 @@ export default { :tag="tag" :size="size" /> - </div> + </span> </template> diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue index b767dafaccf..25ed6600dc9 100644 --- a/app/assets/javascripts/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue @@ -1,27 +1,21 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; -import { s__ } from '~/locale'; import { searchValidator } from '~/runner/runner_search_utils'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_ALL_TYPES, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, +} from '../constants'; -const tabs = [ - { - title: s__('Runners|All'), - runnerType: null, - }, - { - title: s__('Runners|Instance'), - runnerType: INSTANCE_TYPE, - }, - { - title: s__('Runners|Group'), - runnerType: GROUP_TYPE, - }, - { - title: s__('Runners|Project'), - runnerType: PROJECT_TYPE, - }, -]; +const I18N_TAB_TITLES = { + [INSTANCE_TYPE]: I18N_INSTANCE_TYPE, + [GROUP_TYPE]: I18N_GROUP_TYPE, + [PROJECT_TYPE]: I18N_PROJECT_TYPE, +}; export default { components: { @@ -29,12 +23,34 @@ export default { GlTab, }, props: { + runnerTypes: { + type: Array, + required: false, + default: () => [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE], + }, value: { type: Object, required: true, validator: searchValidator, }, }, + computed: { + tabs() { + const tabs = this.runnerTypes.map((runnerType) => ({ + title: I18N_TAB_TITLES[runnerType], + runnerType, + })); + + // Always add a "All" tab that resets filters + return [ + { + title: I18N_ALL_TYPES, + runnerType: null, + }, + ...tabs, + ]; + }, + }, methods: { onTabSelected({ runnerType }) { this.$emit('input', { @@ -47,13 +63,12 @@ export default { return runnerType === this.value.runnerType; }, }, - tabs, }; </script> <template> <gl-tabs v-bind="$attrs" data-testid="runner-type-tabs"> <gl-tab - v-for="tab in $options.tabs" + v-for="tab in tabs" :key="`${tab.runnerType}`" :active="isTabActive(tab)" @click="onTabSelected(tab)" |