diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-21 15:11:29 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-21 15:11:29 +0300 |
commit | 559b1da28e46a9969315beb11ee2d2056f75b06d (patch) | |
tree | fad20c706047f4aca44c1f030cb81d5b1e302cab /app/assets/javascripts/ci/runner/components | |
parent | a065770457b66dc856897fc5282bf897b9e4f65b (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/ci/runner/components')
45 files changed, 3544 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/runner/components/cells/link_cell.vue b/app/assets/javascripts/ci/runner/components/cells/link_cell.vue new file mode 100644 index 00000000000..2843ddbacaf --- /dev/null +++ b/app/assets/javascripts/ci/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/ci/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue new file mode 100644 index 00000000000..13f520c4edb --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue @@ -0,0 +1,57 @@ +<script> +import { GlButtonGroup } from '@gitlab/ui'; +import RunnerEditButton from '../runner_edit_button.vue'; +import RunnerPauseButton from '../runner_pause_button.vue'; +import RunnerDeleteButton from '../runner_delete_button.vue'; + +export default { + name: 'RunnerActionsCell', + components: { + GlButtonGroup, + RunnerEditButton, + RunnerPauseButton, + RunnerDeleteButton, + }, + props: { + runner: { + type: Object, + required: true, + }, + editUrl: { + type: String, + default: null, + required: false, + }, + }, + emits: ['toggledPaused', 'deleted'], + computed: { + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, + }, + methods: { + onToggledPaused() { + this.$emit('toggledPaused'); + }, + onDeleted(value) { + this.$emit('deleted', value); + }, + }, +}; +</script> + +<template> + <gl-button-group> + <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" /> + <runner-pause-button + v-if="canUpdate" + :runner="runner" + :compact="true" + @toggledPaused="onToggledPaused" + /> + <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue new file mode 100644 index 00000000000..cb43760b2d6 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue @@ -0,0 +1,63 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, I18N_ADMIN } from '../../constants'; + +export default { + components: { + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + cell() { + switch (this.runner?.runnerType) { + case INSTANCE_TYPE: + return { + text: I18N_ADMIN, + }; + case GROUP_TYPE: { + const { name, fullName, webUrl } = this.runner?.groups?.nodes[0] || {}; + + return { + text: name, + href: webUrl, + tooltip: fullName !== name ? fullName : '', + }; + } + case PROJECT_TYPE: { + const { name, nameWithNamespace, webUrl } = this.runner?.ownerProject || {}; + + return { + text: name, + href: webUrl, + tooltip: nameWithNamespace !== name ? nameWithNamespace : '', + }; + } + default: + return {}; + } + }, + }, +}; +</script> + +<template> + <div> + <gl-link + v-if="cell.href" + v-gl-tooltip="cell.tooltip" + :href="cell.href" + class="gl-text-body gl-text-decoration-underline" + > + {{ cell.text }} + </gl-link> + <span v-else>{{ cell.text }}</span> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue new file mode 100644 index 00000000000..1e44d5fccc2 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue @@ -0,0 +1,112 @@ +<script> +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; + +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerName from '../runner_name.vue'; +import RunnerTags from '../runner_tags.vue'; +import RunnerTypeBadge from '../runner_type_badge.vue'; + +import { formatJobCount } from '../../utils'; +import { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_VERSION_LABEL, + I18N_LAST_CONTACT_LABEL, + I18N_CREATED_AT_LABEL, +} from '../../constants'; +import RunnerSummaryField from './runner_summary_field.vue'; + +export default { + components: { + GlIcon, + GlSprintf, + TimeAgo, + RunnerSummaryField, + RunnerName, + RunnerTags, + RunnerTypeBadge, + RunnerUpgradeStatusIcon: () => + import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), + TooltipOnTruncate, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + jobCount() { + return formatJobCount(this.runner.jobCount); + }, + }, + i18n: { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_VERSION_LABEL, + I18N_LAST_CONTACT_LABEL, + I18N_CREATED_AT_LABEL, + }, +}; +</script> + +<template> + <div> + <div> + <slot :runner="runner" name="runner-name"> + <runner-name :runner="runner" /> + </slot> + <gl-icon + v-if="runner.locked" + v-gl-tooltip + :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + /> + <runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" /> + </div> + + <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2"> + <div class="gl-flex-shrink-0"> + <runner-upgrade-status-icon :runner="runner" /> + <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL"> + <template #version>{{ runner.version }}</template> + </gl-sprintf> + </div> + <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> + <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description"> + {{ runner.description }} + </tooltip-on-truncate> + </div> + + <div> + <runner-summary-field icon="clock"> + <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + <template v-else>{{ __('Never') }}</template> + </template> + </gl-sprintf> + </runner-summary-field> + + <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')"> + {{ runner.ipAddress }} + </runner-summary-field> + + <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')"> + {{ jobCount }} + </runner-summary-field> + + <runner-summary-field icon="calendar"> + <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> + </template> + </gl-sprintf> + </runner-summary-field> + </div> + + <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue new file mode 100644 index 00000000000..67b9b0a266f --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -0,0 +1,46 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; + +import RunnerStatusBadge from '../runner_status_badge.vue'; +import RunnerPausedBadge from '../runner_paused_badge.vue'; + +export default { + components: { + RunnerStatusBadge, + RunnerUpgradeStatusBadge: () => + import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), + RunnerPausedBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + paused() { + return !this.runner.active; + }, + }, +}; +</script> + +<template> + <div> + <runner-status-badge + :runner="runner" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> + <runner-upgrade-status-badge + :runner="runner" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> + <runner-paused-badge + v-if="paused" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue new file mode 100644 index 00000000000..1bbbd55089a --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue @@ -0,0 +1,33 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + icon: { + type: String, + required: false, + default: '', + }, + tooltip: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2"> + <gl-icon v-if="icon" :name="icon" /> + <!-- display tooltip as a label for screen readers --> + <span class="gl-sr-only">{{ tooltip }}</span> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue new file mode 100644 index 00000000000..212ad5fa5a0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue @@ -0,0 +1,92 @@ +<script> +import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; +import RegistrationToken from './registration_token.vue'; +import RegistrationTokenResetDropdownItem from './registration_token_reset_dropdown_item.vue'; + +export default { + i18n: { + showInstallationInstructions: s__( + 'Runners|Show runner installation and registration instructions', + ), + }, + components: { + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlDropdownDivider, + RegistrationToken, + RunnerInstructionsModal, + RegistrationTokenResetDropdownItem, + }, + props: { + registrationToken: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + validator(type) { + return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); + }, + }, + }, + data() { + return { + currentRegistrationToken: this.registrationToken, + }; + }, + computed: { + dropdownText() { + switch (this.type) { + case INSTANCE_TYPE: + return s__('Runners|Register an instance runner'); + case GROUP_TYPE: + return s__('Runners|Register a group runner'); + case PROJECT_TYPE: + return s__('Runners|Register a project runner'); + default: + return s__('Runners|Register a runner'); + } + }, + }, + methods: { + onShowInstructionsClick() { + this.$refs.runnerInstructionsModal.show(); + }, + onTokenReset(token) { + this.currentRegistrationToken = token; + + this.$refs.runnerRegistrationDropdown.hide(true); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="runnerRegistrationDropdown" + menu-class="gl-w-auto!" + :text="dropdownText" + variant="confirm" + v-bind="$attrs" + > + <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick"> + {{ $options.i18n.showInstallationInstructions }} + <runner-instructions-modal + ref="runnerInstructionsModal" + :registration-token="currentRegistrationToken" + data-testid="runner-instructions-modal" + /> + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-form class="gl-p-4!"> + <registration-token input-id="token-value" :value="currentRegistrationToken" /> + </gl-dropdown-form> + <gl-dropdown-divider /> + <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue new file mode 100644 index 00000000000..6b4e6a929b7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue @@ -0,0 +1,49 @@ +<script> +import { s__ } from '~/locale'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +export default { + components: { + InputCopyToggleVisibility, + }, + i18n: { + registrationToken: s__('Runners|Registration token'), + }, + props: { + inputId: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: '', + }, + }, + computed: { + formInputGroupProps() { + return { + id: this.inputId, + }; + }, + }, + methods: { + onCopy() { + // value already in the clipboard, simply notify the user + this.$toast?.show(s__('Runners|Registration token copied!')); + }, + }, + I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'), +}; +</script> +<template> + <input-copy-toggle-visibility + class="gl-m-0" + :value="value" + :label="$options.i18n.registrationToken" + :label-for="inputId" + :copy-button-title="$options.I18N_COPY_BUTTON_TITLE" + :form-input-group-props="formInputGroupProps" + @copy="onCopy" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue new file mode 100644 index 00000000000..6740065e860 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -0,0 +1,141 @@ +<script> +import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; + +const i18n = { + modalAction: s__('Runners|Reset token'), + modalCancel: __('Cancel'), + modalCopy: __('Are you sure you want to reset the registration token?'), + modalTitle: __('Reset registration token'), +}; + +export default { + name: 'RunnerRegistrationTokenReset', + i18n, + components: { + GlDropdownItem, + GlLoadingIcon, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + groupId: { + default: null, + }, + projectId: { + default: null, + }, + }, + modalId: 'token-reset-modal', + props: { + type: { + type: String, + required: true, + validator(type) { + return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); + }, + }, + }, + data() { + return { + loading: false, + }; + }, + computed: { + resetTokenInput() { + switch (this.type) { + case INSTANCE_TYPE: + return { + type: this.type, + }; + case GROUP_TYPE: + return { + id: convertToGraphQLId(TYPE_GROUP, this.groupId), + type: this.type, + }; + case PROJECT_TYPE: + return { + id: convertToGraphQLId(TYPE_PROJECT, this.projectId), + type: this.type, + }; + default: + return null; + } + }, + actionPrimary() { + return { + text: i18n.modalAction, + attributes: [{ variant: 'danger' }], + }; + }, + actionSecondary() { + return { + text: i18n.modalCancel, + attributes: [{ variant: 'default' }], + }; + }, + }, + methods: { + handleModalPrimary() { + this.resetToken(); + }, + async resetToken() { + this.loading = true; + try { + const { + data: { + runnersRegistrationTokenReset: { token, errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnersRegistrationTokenResetMutation, + variables: { + input: this.resetTokenInput, + }, + }); + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + this.onSuccess(token); + } catch (e) { + this.onError(e); + } finally { + this.loading = false; + } + }, + onError(error) { + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + onSuccess(token) { + this.$toast?.show(s__('Runners|New registration token generated!')); + this.$emit('tokenReset', token); + }, + }, +}; +</script> +<template> + <gl-dropdown-item v-gl-modal="$options.modalId"> + {{ __('Reset registration token') }} + <gl-modal + size="sm" + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-secondary="actionSecondary" + :title="$options.i18n.modalTitle" + @primary="handleModalPrimary" + > + <p>{{ $options.i18n.modalCopy }}</p> + </gl-modal> + <gl-loading-icon v-if="loading" inline /> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue new file mode 100644 index 00000000000..2fa87bdd776 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue @@ -0,0 +1,63 @@ +<script> +import { GlAvatar, GlBadge, GlLink } from '@gitlab/ui'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; + +export default { + components: { + GlAvatar, + GlBadge, + GlLink, + }, + props: { + href: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + fullName: { + type: String, + required: true, + }, + avatarUrl: { + type: String, + required: false, + default: null, + }, + description: { + type: String, + required: false, + default: null, + }, + isOwner: { + type: Boolean, + required: false, + default: false, + }, + }, + AVATAR_SHAPE_OPTION_RECT, +}; +</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="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-name="name" + :alt="name" + :src="avatarUrl" + :size="48" + /> + </gl-link> + <div> + <div class="gl-mb-1"> + <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> + <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge> + </div> + <div v-if="description">{{ description }}</div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue new file mode 100644 index 00000000000..703da01d9c8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue @@ -0,0 +1,196 @@ +<script> +import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { __, s__, n__, sprintf } from '~/locale'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; +import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql'; +import { RUNNER_TYPENAME } from '../constants'; + +export default { + components: { + GlButton, + GlModal, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: ['localMutations'], + props: { + runners: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + isDeleting: false, + checkedRunnerIds: [], + }; + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + }, + }, + computed: { + currentCheckedRunnerIds() { + return this.runners + .map(({ id }) => id) + .filter((id) => this.checkedRunnerIds.indexOf(id) >= 0); + }, + checkedCount() { + return this.currentCheckedRunnerIds.length || 0; + }, + bannerMessage() { + return sprintf( + n__( + 'Runners|%{strongStart}%{count}%{strongEnd} runner selected', + 'Runners|%{strongStart}%{count}%{strongEnd} runners selected', + this.checkedCount, + ), + { + count: this.checkedCount, + }, + ); + }, + modalTitle() { + return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount); + }, + modalActionPrimary() { + return { + text: n__( + 'Runners|Permanently delete %d runner', + 'Runners|Permanently delete %d runners', + this.checkedCount, + ), + attributes: { + loading: this.isDeleting, + variant: 'danger', + }, + }; + }, + modalActionCancel() { + return { + text: __('Cancel'), + attributes: { + loading: this.isDeleting, + }, + }; + }, + modalMessage() { + return sprintf( + n__( + 'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + 'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + this.checkedCount, + ), + { count: this.checkedCount }, + ); + }, + }, + methods: { + toastConfirmationMessage(deletedCount) { + return n__( + 'Runners|%d selected runner deleted', + 'Runners|%d selected runners deleted', + deletedCount, + ); + }, + onClearChecked() { + this.localMutations.clearChecked(); + }, + async onConfirmDelete(e) { + this.isDeleting = true; + e.preventDefault(); // don't close modal until deletion is complete + + try { + await this.$apollo.mutate({ + mutation: BulkRunnerDelete, + variables: { + input: { + ids: this.currentCheckedRunnerIds, + }, + }, + update: (cache, { data }) => { + const { errors, deletedIds } = data.bulkRunnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + this.$refs.modal.hide(); + return; + } + + this.$emit('deleted', { + message: this.toastConfirmationMessage(deletedIds.length), + }); + + // Clean up + + // Remove deleted runners from the cache + deletedIds.forEach((id) => { + const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id }); + cache.evict({ id: cacheId }); + }); + cache.gc(); + + this.$refs.modal.hide(); + }, + }); + } catch (error) { + this.onError(error); + } finally { + this.isDeleting = false; + } + }, + onError(error) { + createAlert({ + message: s__( + 'Runners|Something went wrong while deleting. Please refresh the page to try again.', + ), + captureError: true, + error, + }); + }, + }, + BULK_DELETE_MODAL_ID: 'bulk-delete-modal', +}; +</script> + +<template> + <div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"> + <div class="gl-display-flex gl-align-items-center"> + <div> + <gl-sprintf :message="bannerMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <div class="gl-ml-auto"> + <gl-button variant="default" @click="onClearChecked">{{ + s__('Runners|Clear selection') + }}</gl-button> + <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ + s__('Runners|Delete selected') + }}</gl-button> + </div> + </div> + <gl-modal + ref="modal" + size="sm" + :modal-id="$options.BULK_DELETE_MODAL_ID" + :title="modalTitle" + :action-primary="modalActionPrimary" + :action-cancel="modalActionCancel" + @primary="onConfirmDelete" + > + <gl-sprintf :message="modalMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue new file mode 100644 index 00000000000..75afb7a00bc --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue @@ -0,0 +1,67 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; + +export default { + components: { + GlFormCheckbox, + }, + inject: ['localMutations'], + props: { + runners: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + checkedRunnerIds: [], + }; + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + }, + }, + computed: { + deletableRunners() { + return this.runners.filter((runner) => runner.userPermissions?.deleteRunner); + }, + disabled() { + return !this.deletableRunners.length; + }, + checked() { + return Boolean(this.deletableRunners.length) && this.deletableRunners.every(this.isChecked); + }, + indeterminate() { + return !this.checked && this.deletableRunners.some(this.isChecked); + }, + label() { + return this.checked ? s__('Runners|Unselect all') : s__('Runners|Select all'); + }, + }, + methods: { + isChecked({ id }) { + return this.checkedRunnerIds.indexOf(id) >= 0; + }, + onChange($event) { + this.localMutations.setRunnersChecked({ + runners: this.deletableRunners, + isChecked: $event, + }); + }, + }, +}; +</script> + +<template> + <gl-form-checkbox + :aria-label="label" + :indeterminate="indeterminate" + :checked="checked" + :disabled="disabled" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue new file mode 100644 index 00000000000..13404baad89 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue @@ -0,0 +1,153 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; +import { createAlert } from '~/flash'; +import { sprintf } from '~/locale'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; +import RunnerDeleteModal from './runner_delete_modal.vue'; + +export default { + name: 'RunnerDeleteButton', + components: { + GlButton, + RunnerDeleteModal, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + props: { + runner: { + type: Object, + required: true, + validator: (runner) => { + return runner?.id && runner?.shortSha; + }, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['deleted'], + data() { + return { + deleting: false, + }; + }, + computed: { + runnerId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerName() { + return `#${this.runnerId} (${this.runner.shortSha})`; + }, + runnerDeleteModalId() { + return `delete-runner-modal-${this.runnerId}`; + }, + icon() { + if (this.compact) { + return 'close'; + } + return ''; + }, + buttonContent() { + if (this.compact) { + return null; + } + return I18N_DELETE_RUNNER; + }, + buttonClass() { + // Ensure a square button is shown when compact: true. + // Without this class we will have distorted/rectangular button. + if (this.compact) { + return 'btn-icon'; + } + return null; + }, + ariaLabel() { + if (this.compact) { + return I18N_DELETE_RUNNER; + } + return null; + }, + tooltip() { + // Only show basic "delete" tooltip when compact. + // Also prevent a "sticky" tooltip: If this button is + // loading, mouseout listeners don't run leaving the tooltip stuck + if (this.compact && !this.deleting) { + return I18N_DELETE_RUNNER; + } + return ''; + }, + }, + methods: { + async onDelete() { + // Deleting stays "true" until this row is removed, + // should only change back if the operation fails. + this.deleting = true; + try { + await this.$apollo.mutate({ + mutation: runnerDeleteMutation, + variables: { + input: { + id: this.runner.id, + }, + }, + update: (cache, { data }) => { + const { errors } = data.runnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + return; + } + + this.$emit('deleted', { + message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), + }); + + // Remove deleted runner from the cache + const cacheId = cache.identify(this.runner); + cache.evict({ id: cacheId }); + cache.gc(); + }, + }); + } catch (e) { + this.onError(e); + } + }, + onError(error) { + this.deleting = false; + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltip" class="btn-group"> + <gl-button + v-gl-modal="runnerDeleteModalId" + :aria-label="ariaLabel" + :icon="icon" + :class="buttonClass" + :loading="deleting" + variant="danger" + category="secondary" + v-bind="$attrs" + > + {{ buttonContent }} + </gl-button> + <runner-delete-modal + :modal-id="runnerDeleteModalId" + :runner-name="runnerName" + @primary="onDelete" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue new file mode 100644 index 00000000000..8be216a7eb5 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue @@ -0,0 +1,51 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; + +const I18N_TITLE = s__('Runners|Delete runner %{name}?'); +const I18N_BODY = s__( + 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', +); +const I18N_PRIMARY = s__('Runners|Delete runner'); +const I18N_CANCEL = __('Cancel'); + +export default { + components: { + GlModal, + }, + props: { + runnerName: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(I18N_TITLE, { name: this.runnerName }); + }, + }, + methods: { + onPrimary() { + this.$refs.modal.hide(); + }, + }, + actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } }, + actionCancel: { text: I18N_CANCEL }, + I18N_BODY, +}; +</script> + +<template> + <gl-modal + ref="modal" + size="sm" + :title="title" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + v-bind="$attrs" + v-on="$listeners" + @primary="onPrimary" + > + {{ $options.I18N_BODY }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue new file mode 100644 index 00000000000..c260670b517 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue @@ -0,0 +1,55 @@ +<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, + default: null, + required: false, + }, + value: { + type: String, + default: null, + required: false, + }, + emptyValue: { + type: String, + default: __('None'), + required: false, + }, + }, +}; +</script> + +<template> + <div class="gl-display-contents"> + <dt class="gl-mb-5 gl-mr-6 gl-max-w-26"> + <template v-if="label || $scopedSlots.label"> + <slot name="label">{{ label }}</slot> + </template> + </dt> + <dd class="gl-mb-5"> + <template v-if="value || $scopedSlots.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/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue new file mode 100644 index 00000000000..6eba8f2e49f --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_details.vue @@ -0,0 +1,159 @@ +<script> +import { GlIntersperse, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +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 RunnerDetail from './runner_detail.vue'; +import RunnerGroups from './runner_groups.vue'; +import RunnerProjects from './runner_projects.vue'; +import RunnerTags from './runner_tags.vue'; + +export default { + components: { + GlIntersperse, + GlLink, + HelpPopover, + RunnerDetail, + RunnerMaintenanceNoteDetail: () => + import('ee_component/ci/runner/components/runner_maintenance_note_detail.vue'), + RunnerGroups, + RunnerProjects, + RunnerUpgradeStatusBadge: () => + import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), + RunnerUpgradeStatusAlert: () => + import('ee_component/ci/runner/components/runner_upgrade_status_alert.vue'), + 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; + }, + tagList() { + return this.runner.tagList || []; + }, + isGroupRunner() { + return this.runner?.runnerType === GROUP_TYPE; + }, + isProjectRunner() { + return this.runner?.runnerType === PROJECT_TYPE; + }, + tokenExpirationHelpPopoverOptions() { + return { + title: s__('Runners|Runner authentication token expiration'), + }; + }, + tokenExpirationHelpUrl() { + return helpPagePath('ci/runners/configure_runners', { + anchor: 'authentication-token-security', + }); + }, + }, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> + +<template> + <div> + <runner-upgrade-status-alert class="gl-my-4" :runner="runner" /> + <div class="gl-pt-4"> + <dl + class="gl-mb-0 gl-display-grid runner-details-grid-template" + 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 v-if="runner.contactedAt" #value> + <time-ago :time="runner.contactedAt" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Version')"> + <template v-if="runner.version" #value> + {{ runner.version }} + <runner-upgrade-status-badge size="sm" :runner="runner" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> + <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" /> + <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" /> + <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" /> + <runner-detail :label="s__('Runners|Configuration')"> + <template v-if="configTextProtected || configTextUntagged" #value> + <gl-intersperse> + <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 :empty-value="s__('Runners|Never expires')"> + <template #label> + {{ s__('Runners|Token expiry') }} + <help-popover :options="tokenExpirationHelpPopoverOptions"> + <p> + {{ + s__( + 'Runners|Runner authentication tokens will expire based on a set interval. They will automatically rotate once expired.', + ) + }} + </p> + <p class="gl-mb-0"> + <gl-link + :href="tokenExpirationHelpUrl" + target="_blank" + class="gl-reset-font-size" + >{{ __('Learn more') }}</gl-link + > + </p> + </help-popover> + </template> + <template v-if="runner.tokenExpiresAt" #value> + <time-ago :time="runner.tokenExpiresAt" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Tags')"> + <template v-if="tagList.length" #value> + <runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" /> + </template> + </runner-detail> + + <runner-maintenance-note-detail + class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid" + :value="runner.maintenanceNoteHtml" + /> + </dl> + </div> + + <runner-groups v-if="isGroupRunner" :runner="runner" /> + <runner-projects v-if="isProjectRunner" :runner="runner" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue new file mode 100644 index 00000000000..33e0acaf5c0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue @@ -0,0 +1,24 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_EDIT } from '../constants'; + +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/ci/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue new file mode 100644 index 00000000000..ee56fea8282 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue @@ -0,0 +1,100 @@ +<script> +import { cloneDeep } from 'lodash'; +import { __ } from '~/locale'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { searchValidator } from '~/ci/runner/runner_search_utils'; +import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants'; + +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: searchValidator, + }, + tokens: { + type: Array, + required: false, + default: () => [], + }, + namespace: { + type: String, + required: true, + }, + }, + data() { + // filtered_search_bar_root.vue may mutate the initial + // filters. Use `cloneDeep` to prevent those mutations + // from affecting this component + const { filters, sort } = cloneDeep(this.value); + return { + initialFilterValue: filters, + initialSortBy: sort, + }; + }, + computed: { + validTokens() { + // Some filters are only available in EE + // EE-only tokens are represented by `null` or `undefined` + // values when in CE + return this.tokens.filter(Boolean); + }, + }, + methods: { + onFilter(filters) { + // Apply new filters, resetting pagination + this.$emit('input', { + ...this.value, + filters, + pagination: {}, + }); + }, + onSort(sort) { + // Apply new sort, resetting pagination + this.$emit('input', { + ...this.value, + sort, + pagination: {}, + }); + }, + }, + sortOptions, +}; +</script> +<template> + <filtered-search + v-bind="$attrs" + :namespace="namespace" + recent-searches-storage-key="runners-search" + :sort-options="$options.sortOptions" + :initial-filter-value="initialFilterValue" + :tokens="validTokens" + :initial-sort-by="initialSortBy" + :search-input-placeholder="__('Search or filter results...')" + data-testid="runners-filtered-search" + @onFilter="onFilter" + @onSort="onSort" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_groups.vue b/app/assets/javascripts/ci/runner/components/runner_groups.vue new file mode 100644 index 00000000000..c3b35bd52a9 --- /dev/null +++ b/app/assets/javascripts/ci/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/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue new file mode 100644 index 00000000000..874c234ca4c --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_header.vue @@ -0,0 +1,70 @@ +<script> +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, 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, + required: true, + }, + }, + computed: { + paused() { + return !this.runner.active; + }, + heading() { + const id = getIdFromGraphQLId(this.runner.id); + return sprintf(I18N_DETAILS_TITLE, { runner_id: id }); + }, + }, + I18N_LOCKED_RUNNER_DESCRIPTION, +}; +</script> +<template> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-gap-3 gl-flex-wrap gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap"> + <runner-status-badge :runner="runner" /> + <runner-type-badge v-if="runner" :type="runner.runnerType" /> + <span> + <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> + </span> + </div> + <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs.vue b/app/assets/javascripts/ci/runner/components/runner_jobs.vue new file mode 100644 index 00000000000..9003eba3636 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_jobs.vue @@ -0,0 +1,82 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import runnerJobsQuery from '../graphql/show/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: { + GlSkeletonLoader, + RunnerJobsTable, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + jobs: { + items: [], + pageInfo: {}, + }, + pagination: {}, + }; + }, + apollo: { + jobs: { + query: runnerJobsQuery, + variables() { + return this.variables; + }, + update({ runner }) { + return { + items: runner?.jobs?.nodes || [], + pageInfo: runner?.jobs?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + captureException({ error, component: this.$options.name }); + }, + }, + }, + computed: { + variables() { + const { id } = this.runner; + return { + id, + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.jobs.loading; + }, + }, + methods: { + onPaginationInput(value) { + this.pagination = value; + }, + }, + I18N_NO_JOBS_FOUND, +}; +</script> + +<template> + <div class="gl-pt-3"> + <div v-if="loading" class="gl-py-5"> + <gl-skeleton-loader /> + </div> + <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" /> + <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p> + + <runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue new file mode 100644 index 00000000000..efa7909c913 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -0,0 +1,114 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { durationTimeFormatted } from '~/lib/utils/datetime_utility'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import RunnerTags from '~/ci/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; + }, + duration(job) { + const { duration } = job; + return duration ? durationTimeFormatted(duration) : ''; + }, + queued(job) { + const { queuedDuration } = job; + return queuedDuration ? durationTimeFormatted(queuedDuration) : ''; + }, + }, + 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: 'duration', label: s__('Job|Duration') }), + tableField({ key: 'queued', label: s__('Job|Queued') }), + 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(finished_at)="{ item = {} }"> + <time-ago v-if="item.finishedAt" :time="item.finishedAt" /> + </template> + + <template #cell(duration)="{ item = {} }"> + {{ duration(item) }} + </template> + + <template #cell(queued)="{ item = {} }"> + {{ queued(item) }} + </template> + + <template #cell(tags)="{ item = {} }"> + <runner-tags :tag-list="item.tags" /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue new file mode 100644 index 00000000000..e895537dcdc --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -0,0 +1,188 @@ +<script> +import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { s__ } from '~/locale'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; +import { formatJobCount, tableField } from '../utils'; +import RunnerBulkDelete from './runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; +import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; +import RunnerStatusPopover from './runner_status_popover.vue'; +import RunnerStatusCell from './cells/runner_status_cell.vue'; +import RunnerOwnerCell from './cells/runner_owner_cell.vue'; + +const defaultFields = [ + tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }), + tableField({ key: 'summary', label: s__('Runners|Runner') }), + tableField({ key: 'owner', label: s__('Runners|Owner'), thClasses: ['gl-w-20p'] }), + tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }), +]; + +export default { + components: { + GlFormCheckbox, + GlTableLite, + GlSkeletonLoader, + HelpPopover, + RunnerBulkDelete, + RunnerBulkDeleteCheckbox, + RunnerStatusPopover, + RunnerStackedSummaryCell, + RunnerStatusCell, + RunnerOwnerCell, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + skip() { + return !this.checkable; + }, + }, + }, + inject: ['localMutations'], + props: { + checkable: { + type: Boolean, + required: false, + default: false, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + runners: { + type: Array, + required: true, + }, + }, + emits: ['deleted'], + data() { + return { checkedRunnerIds: [] }; + }, + computed: { + tableClass() { + // <gl-table-lite> does not provide a busy state, add + // simple support for it. + // See http://bootstrap-vue.org/docs/components/table#table-busy-state + return { + 'gl-opacity-6': this.loading, + }; + }, + fields() { + const fields = defaultFields; + + if (this.checkable) { + const checkboxField = tableField({ + key: 'checkbox', + label: s__('Runners|Checkbox'), + thClasses: ['gl-w-9'], + tdClass: ['gl-text-center'], + }); + return [checkboxField, ...fields]; + } + return fields; + }, + }, + methods: { + canDelete(runner) { + return runner.userPermissions?.deleteRunner; + }, + onDeleted(event) { + this.$emit('deleted', event); + }, + formatJobCount(jobCount) { + return formatJobCount(jobCount); + }, + runnerTrAttr(runner) { + if (runner) { + return { + 'data-testid': `runner-row-${getIdFromGraphQLId(runner.id)}`, + }; + } + return {}; + }, + onCheckboxChange(runner, isChecked) { + this.localMutations.setRunnerChecked({ + runner, + isChecked, + }); + }, + isChecked(runner) { + return this.checkedRunnerIds.includes(runner.id); + }, + }, +}; +</script> +<template> + <div> + <runner-bulk-delete v-if="checkable" :runners="runners" @deleted="onDeleted" /> + <gl-table-lite + :aria-busy="loading" + :class="tableClass" + :items="runners" + :fields="fields" + :tbody-tr-attr="runnerTrAttr" + data-testid="runner-list" + stacked="md" + primary-key="id" + fixed + > + <template #head(checkbox)> + <runner-bulk-delete-checkbox :runners="runners" /> + </template> + + <template #cell(checkbox)="{ item }"> + <gl-form-checkbox + v-if="canDelete(item)" + :checked="isChecked(item)" + @change="onCheckboxChange(item, $event)" + /> + </template> + + <template #head(status)="{ label }"> + {{ label }} + <runner-status-popover /> + </template> + + <template #cell(status)="{ item }"> + <runner-status-cell :runner="item" /> + </template> + + <template #cell(summary)="{ item, index }"> + <runner-stacked-summary-cell :runner="item"> + <template #runner-name="{ runner }"> + <slot name="runner-name" :runner="runner" :index="index"></slot> + </template> + </runner-stacked-summary-cell> + </template> + + <template #head(owner)="{ label }"> + {{ label }} + <help-popover> + {{ + s__( + 'Runners|The project, group or instance where the runner was registered. Instance runners are always owned by Administrator.', + ) + }} + </help-popover> + </template> + + <template #cell(owner)="{ item }"> + <runner-owner-cell :runner="item" /> + </template> + + <template #cell(actions)="{ item }"> + <slot name="runner-actions-cell" :runner="item"></slot> + </template> + </gl-table-lite> + + <template v-if="!runners.length && loading"> + <gl-skeleton-loader v-for="i in 4" :key="i" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue new file mode 100644 index 00000000000..e6576c83e69 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue @@ -0,0 +1,82 @@ +<script> +import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; + +export default { + components: { + GlEmptyState, + GlLink, + GlSprintf, + RunnerInstructionsModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + isSearchFiltered: { + type: Boolean, + required: false, + default: false, + }, + svgPath: { + type: String, + required: false, + default: '', + }, + filteredSvgPath: { + type: String, + required: false, + default: '', + }, + registrationToken: { + type: String, + required: false, + default: null, + }, + }, + modalId: 'runners-empty-state-instructions-modal', + svgHeight: 145, +}; +</script> + +<template> + <gl-empty-state + v-if="isSearchFiltered" + :title="s__('Runners|No results found')" + :svg-path="filteredSvgPath" + :svg-height="$options.svgHeight" + :description="s__('Runners|Edit your search and try again')" + /> + <gl-empty-state + v-else + :title="s__('Runners|Get started with runners')" + :svg-path="svgPath" + :svg-height="$options.svgHeight" + > + <template v-if="registrationToken" #description> + <gl-sprintf + :message=" + s__( + 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', + ) + " + > + <template #link="{ content }"> + <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> + </template> + </gl-sprintf> + + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="registrationToken" + /> + </template> + <template v-else #description> + {{ + s__( + 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', + ) + }} + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue b/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue new file mode 100644 index 00000000000..2b37b1cc797 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue @@ -0,0 +1,42 @@ +<script> +import { GlToggle } from '@gitlab/ui'; +import { + I18N_SHOW_ONLY_INHERITED, + MEMBERSHIP_DESCENDANTS, + MEMBERSHIP_ALL_AVAILABLE, +} from '../constants'; + +export default { + components: { + GlToggle, + }, + props: { + value: { + type: String, + default: MEMBERSHIP_DESCENDANTS, + required: false, + }, + }, + computed: { + toggle() { + return this.value === MEMBERSHIP_DESCENDANTS; + }, + }, + methods: { + onChange(value) { + this.$emit('input', value ? MEMBERSHIP_DESCENDANTS : MEMBERSHIP_ALL_AVAILABLE); + }, + }, + I18N_SHOW_ONLY_INHERITED, +}; +</script> + +<template> + <gl-toggle + data-testid="runner-membership-toggle" + :value="toggle" + :label="$options.I18N_SHOW_ONLY_INHERITED" + label-position="left" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_name.vue b/app/assets/javascripts/ci/runner/components/runner_name.vue new file mode 100644 index 00000000000..d4ecfd2d776 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_name.vue @@ -0,0 +1,20 @@ +<script> +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + props: { + runner: { + type: Object, + required: true, + }, + }, + methods: { + getIdFromGraphQLId, + }, +}; +</script> +<template> + <span class="gl-font-weight-bold gl-vertical-align-middle" + >#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span + > +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pagination.vue b/app/assets/javascripts/ci/runner/components/runner_pagination.vue new file mode 100644 index 00000000000..a5bf3074dd1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pagination.vue @@ -0,0 +1,50 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; + +export default { + components: { + GlKeysetPagination, + }, + inheritAttrs: false, + props: { + pageInfo: { + required: false, + type: Object, + default: () => ({}), + }, + }, + computed: { + paginationProps() { + return { ...this.pageInfo, ...this.$attrs }; + }, + isShown() { + const { hasPreviousPage, hasNextPage } = this.pageInfo; + return hasPreviousPage || hasNextPage; + }, + }, + methods: { + prevPage() { + this.$emit('input', { + before: this.pageInfo.startCursor, + }); + }, + nextPage() { + this.$emit('input', { + after: this.pageInfo.endCursor, + }); + }, + }, +}; +</script> + +<template> + <div v-if="isShown" class="gl-text-center"> + <gl-keyset-pagination + v-bind="paginationProps" + :prev-text="s__('Pagination|Prev')" + :next-text="s__('Pagination|Next')" + @prev="prevPage" + @next="nextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue new file mode 100644 index 00000000000..2c80518e772 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue @@ -0,0 +1,120 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql'; +import { createAlert } from '~/flash'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants'; + +export default { + name: 'RunnerPauseButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['toggledPaused'], + 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() { + // Prevent a "sticky" tooltip: If this button is disabled, + // mouseout listeners don't run leaving the tooltip stuck + if (!this.updating) { + return this.isActive ? I18N_PAUSE_TOOLTIP : I18N_RESUME_TOOLTIP; + } + 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(' ')); + } + this.$emit('toggledPaused'); + } catch (e) { + this.onError(e); + } finally { + this.updating = false; + } + }, + onError(error) { + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip="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/ci/runner/components/runner_paused_badge.vue b/app/assets/javascripts/ci/runner/components/runner_paused_badge.vue new file mode 100644 index 00000000000..00fd84a48d8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_paused_badge.vue @@ -0,0 +1,25 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_PAUSED, I18N_PAUSED_DESCRIPTION } from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + I18N_PAUSED, + I18N_PAUSED_DESCRIPTION, +}; +</script> +<template> + <gl-badge + v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" + variant="warning" + icon="status-paused" + v-bind="$attrs" + > + {{ $options.I18N_PAUSED }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue new file mode 100644 index 00000000000..84008e8eee8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue @@ -0,0 +1,144 @@ +<script> +import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { sprintf, formatNumber } from '~/locale'; +import { createAlert } from '~/flash'; +import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql'; +import { + I18N_ASSIGNED_PROJECTS, + I18N_CLEAR_FILTER_PROJECTS, + I18N_FILTER_PROJECTS, + I18N_NO_PROJECTS_FOUND, + 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'; + +const SHORT_SEARCH_LENGTH = 3; + +export default { + name: 'RunnerProjects', + components: { + GlSearchBoxByType, + GlSkeletonLoader, + RunnerAssignedItem, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + projects: { + ownerProjectId: null, + items: [], + pageInfo: {}, + count: 0, + }, + search: '', + pagination: {}, + }; + }, + apollo: { + projects: { + query: runnerProjectsQuery, + variables() { + return this.variables; + }, + update(data) { + const { runner } = data; + return { + ownerProjectId: runner?.ownerProject?.id, + count: runner?.projectCount || 0, + items: runner?.projects?.nodes || [], + pageInfo: runner?.projects?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + captureException({ error, component: this.$options.name }); + }, + }, + }, + computed: { + variables() { + const { search, runner } = this; + return { + id: runner.id, + search: search.length >= SHORT_SEARCH_LENGTH ? search : '', + ...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: { + isOwner(projectId) { + return projectId === this.projects.ownerProjectId; + }, + onSearchInput(search) { + this.search = search; + this.pagination = {}; + }, + onPaginationInput(value) { + this.pagination = value; + }, + }, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + I18N_CLEAR_FILTER_PROJECTS, + I18N_FILTER_PROJECTS, + I18N_NO_PROJECTS_FOUND, +}; +</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"> + {{ heading }} + </h3> + <gl-search-box-by-type + :is-loading="loading" + :clear-button-title="$options.I18N_CLEAR_FILTER_PROJECTS" + :placeholder="$options.I18N_FILTER_PROJECTS" + debounce="500" + class="gl-w-28" + :value="search" + @input="onSearchInput" + /> + + <div v-if="!projects.items.length && loading" class="gl-py-5"> + <gl-skeleton-loader v-for="i in $options.RUNNER_DETAILS_PROJECTS_PAGE_SIZE" :key="i" /> + </div> + <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" + :description="project.description" + :is-owner="isOwner(project.id)" + /> + </template> + <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> + + <runner-pagination + :disabled="loading" + :page-info="projects.pageInfo" + @input="onPaginationInput" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue new file mode 100644 index 00000000000..d084408781e --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue @@ -0,0 +1,97 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_NEVER_CONTACTED, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + I18N_ONLINE_TIMEAGO_TOOLTIP, + I18N_NEVER_CONTACTED_TOOLTIP, + I18N_OFFLINE_TIMEAGO_TOOLTIP, + I18N_STALE_TIMEAGO_TOOLTIP, + I18N_STALE_NEVER_CONTACTED_TOOLTIP, + STATUS_ONLINE, + STATUS_NEVER_CONTACTED, + STATUS_OFFLINE, + STATUS_STALE, +} from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + required: true, + type: Object, + }, + }, + computed: { + contactedAtTimeAgo() { + if (this.runner.contactedAt) { + return getTimeago().format(this.runner.contactedAt); + } + // Prevent "just now" from being rendered, in case data is missing. + return __('never'); + }, + badge() { + switch (this.runner?.status) { + case STATUS_ONLINE: + return { + icon: 'status-active', + variant: 'success', + label: I18N_STATUS_ONLINE, + tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP), + }; + case STATUS_NEVER_CONTACTED: + return { + icon: 'time-out', + variant: 'muted', + label: I18N_STATUS_NEVER_CONTACTED, + tooltip: I18N_NEVER_CONTACTED_TOOLTIP, + }; + case STATUS_OFFLINE: + return { + icon: 'time-out', + variant: 'muted', + label: I18N_STATUS_OFFLINE, + tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP), + }; + case STATUS_STALE: + return { + icon: 'time-out', + variant: 'warning', + label: I18N_STATUS_STALE, + // runner may have contacted (or not) and be stale: consider both cases. + tooltip: this.runner.contactedAt + ? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP) + : I18N_STALE_NEVER_CONTACTED_TOOLTIP, + }; + default: + return null; + } + }, + }, + methods: { + timeAgoTooltip(text) { + return sprintf(text, { timeAgo: this.contactedAtTimeAgo }); + }, + }, +}; +</script> +<template> + <gl-badge + v-if="badge" + v-gl-tooltip="badge.tooltip" + :variant="badge.variant" + :icon="badge.icon" + v-bind="$attrs" + > + {{ badge.label }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_status_popover.vue b/app/assets/javascripts/ci/runner/components/runner_status_popover.vue new file mode 100644 index 00000000000..06174d39a59 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_status_popover.vue @@ -0,0 +1,75 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { duration } from '~/lib/utils/datetime/timeago_utility'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { + I18N_STATUS_POPOVER_TITLE, + I18N_STATUS_POPOVER_NEVER_CONTACTED, + I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION, + I18N_STATUS_POPOVER_ONLINE, + I18N_STATUS_POPOVER_ONLINE_DESCRIPTION, + I18N_STATUS_POPOVER_OFFLINE, + I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION, + I18N_STATUS_POPOVER_STALE, + I18N_STATUS_POPOVER_STALE_DESCRIPTION, +} from '~/ci/runner/constants'; + +export default { + name: 'RunnerStatusPopover', + components: { + GlSprintf, + HelpPopover, + }, + inject: ['onlineContactTimeoutSecs', 'staleTimeoutSecs'], + computed: { + onlineContactTimeoutDuration() { + return duration(this.onlineContactTimeoutSecs * 1000); + }, + staleTimeoutDuration() { + return duration(this.staleTimeoutSecs * 1000); + }, + }, + I18N_STATUS_POPOVER_TITLE, + I18N_STATUS_POPOVER_NEVER_CONTACTED, + I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION, + I18N_STATUS_POPOVER_ONLINE, + I18N_STATUS_POPOVER_ONLINE_DESCRIPTION, + I18N_STATUS_POPOVER_OFFLINE, + I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION, + I18N_STATUS_POPOVER_STALE, + I18N_STATUS_POPOVER_STALE_DESCRIPTION, +}; +</script> + +<template> + <help-popover> + <template #title>{{ $options.I18N_STATUS_POPOVER_TITLE }}</template> + + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_NEVER_CONTACTED }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_ONLINE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_ONLINE_DESCRIPTION"> + <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_OFFLINE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION"> + <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_STALE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_STALE_DESCRIPTION"> + <template #elapsedTime>{{ staleTimeoutDuration }}</template> + </gl-sprintf> + </p> + </help-popover> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_tag.vue b/app/assets/javascripts/ci/runner/components/runner_tag.vue new file mode 100644 index 00000000000..6ad2023a866 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_tag.vue @@ -0,0 +1,56 @@ +<script> +import { GlBadge, GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui'; +import { RUNNER_TAG_BADGE_VARIANT } from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, + props: { + tag: { + type: String, + required: true, + }, + size: { + type: String, + required: false, + default: 'sm', + }, + }, + data() { + return { + overflowing: false, + }; + }, + computed: { + tooltip() { + if (this.overflowing) { + return this.tag; + } + return ''; + }, + }, + methods: { + onResize() { + const { scrollWidth, offsetWidth } = this.$el; + this.overflowing = scrollWidth > offsetWidth; + }, + }, + RUNNER_TAG_BADGE_VARIANT, +}; +</script> +<template> + <gl-badge + v-gl-tooltip="tooltip" + v-gl-resize-observer="onResize" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + :size="size" + :variant="$options.RUNNER_TAG_BADGE_VARIANT" + > + {{ tag }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_tags.vue b/app/assets/javascripts/ci/runner/components/runner_tags.vue new file mode 100644 index 00000000000..38e566f9f53 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_tags.vue @@ -0,0 +1,32 @@ +<script> +import RunnerTag from './runner_tag.vue'; + +export default { + components: { + RunnerTag, + }, + props: { + tagList: { + type: Array, + required: false, + default: () => [], + }, + size: { + type: String, + required: false, + default: 'sm', + }, + }, +}; +</script> +<template> + <span v-if="tagList && tagList.length"> + <runner-tag + v-for="tag in tagList" + :key="tag" + class="gl-display-inline gl-mr-1" + :tag="tag" + :size="size" + /> + </span> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_type_badge.vue b/app/assets/javascripts/ci/runner/components/runner_type_badge.vue new file mode 100644 index 00000000000..f568f914004 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_type_badge.vue @@ -0,0 +1,67 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_INSTANCE_TYPE, + I18N_INSTANCE_RUNNER_DESCRIPTION, + I18N_GROUP_TYPE, + I18N_GROUP_RUNNER_DESCRIPTION, + I18N_PROJECT_TYPE, + I18N_PROJECT_RUNNER_DESCRIPTION, +} from '../constants'; + +const BADGE_DATA = { + [INSTANCE_TYPE]: { + icon: 'users', + text: I18N_INSTANCE_TYPE, + tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION, + }, + [GROUP_TYPE]: { + icon: 'group', + text: I18N_GROUP_TYPE, + tooltip: I18N_GROUP_RUNNER_DESCRIPTION, + }, + [PROJECT_TYPE]: { + icon: 'project', + text: I18N_PROJECT_TYPE, + tooltip: I18N_PROJECT_RUNNER_DESCRIPTION, + }, +}; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + type: { + type: String, + required: false, + default: null, + validator(type) { + return Boolean(BADGE_DATA[type]); + }, + }, + }, + computed: { + badge() { + return BADGE_DATA[this.type]; + }, + }, +}; +</script> +<template> + <gl-badge + v-if="badge" + v-gl-tooltip="badge.tooltip" + variant="muted" + :icon="badge.icon" + v-bind="$attrs" + > + {{ badge.text }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue new file mode 100644 index 00000000000..584236168ac --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue @@ -0,0 +1,123 @@ +<script> +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import { searchValidator } from '~/ci/runner/runner_search_utils'; +import { formatNumber } from '~/locale'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_ALL_TYPES, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, +} from '../constants'; +import RunnerCount from './stat/runner_count.vue'; + +const I18N_TAB_TITLES = { + [INSTANCE_TYPE]: I18N_INSTANCE_TYPE, + [GROUP_TYPE]: I18N_GROUP_TYPE, + [PROJECT_TYPE]: I18N_PROJECT_TYPE, +}; + +const TAB_COUNT_REF = 'tab-count'; + +export default { + components: { + GlBadge, + GlTabs, + GlTab, + RunnerCount, + }, + props: { + runnerTypes: { + type: Array, + required: false, + default: () => [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE], + }, + value: { + type: Object, + required: true, + validator: searchValidator, + }, + countScope: { + type: String, + required: true, + }, + countVariables: { + type: Object, + required: true, + }, + }, + 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', { + ...this.value, + runnerType, + pagination: { page: 1 }, + }); + }, + isTabActive({ runnerType }) { + return runnerType === this.value.runnerType; + }, + tabBadgeCountVariables(runnerType) { + return { ...this.countVariables, type: runnerType }; + }, + tabCount(count) { + if (typeof count === 'number') { + return formatNumber(count); + } + return ''; + }, + + // Component API + refetch() { + // Refresh all of the counts here, can be called by parent component + this.$refs[TAB_COUNT_REF].forEach((countComponent) => { + countComponent.refetch(); + }); + }, + }, + TAB_COUNT_REF, +}; +</script> +<template> + <gl-tabs v-bind="$attrs" data-testid="runner-type-tabs"> + <gl-tab + v-for="tab in tabs" + :key="`${tab.runnerType}`" + :active="isTabActive(tab)" + @click="onTabSelected(tab)" + > + <template #title> + {{ tab.title }} + <runner-count + #default="{ count }" + :ref="$options.TAB_COUNT_REF" + :scope="countScope" + :variables="tabBadgeCountVariables(tab.runnerType)" + > + <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm"> + {{ tabCount(count) }} + </gl-badge> + </runner-count> + </template> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue new file mode 100644 index 00000000000..a9790d06ca7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue @@ -0,0 +1,225 @@ +<script> +import { + GlButton, + GlIcon, + GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInputGroup, + GlSkeletonLoader, + GlTooltipDirective, +} from '@gitlab/ui'; +import { + modelToUpdateMutationVariables, + runnerToModel, +} from 'ee_else_ce/ci/runner/runner_update_form_utils'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; +import runnerUpdateMutation from '../graphql/edit/runner_update.mutation.graphql'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; + +export default { + name: 'RunnerUpdateForm', + components: { + GlButton, + GlIcon, + GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInputGroup, + GlSkeletonLoader, + RunnerMaintenanceNoteField: () => + import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), + RunnerUpdateCostFactorFields: () => + import('ee_component/ci/runner/components/runner_update_cost_factor_fields.vue'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: false, + default: null, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + runnerPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + saving: false, + model: runnerToModel(this.runner), + }; + }, + computed: { + canBeLockedToProject() { + return this.runner?.runnerType === PROJECT_TYPE; + }, + }, + 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: modelToUpdateMutationVariables(this.model), + }); + + if (errors?.length) { + this.onError(errors[0]); + } else { + this.onSuccess(); + } + } catch (error) { + const { message } = error; + this.onError(message); + captureException({ error, component: this.$options.name }); + } + }, + onSuccess() { + saveAlertToLocalStorage({ message: __('Changes saved.'), variant: VARIANT_SUCCESS }); + redirectTo(this.runnerPath); + }, + onError(message) { + this.saving = false; + createAlert({ message }); + }, + }, + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> +<template> + <gl-form @submit.prevent="onSubmit"> + <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Details') }}</h4> + + <gl-skeleton-loader v-if="loading" /> + + <template v-else> + <gl-form-group :label="__('Description')" data-testid="runner-field-description"> + <gl-form-input-group v-model="model.description" /> + </gl-form-group> + <runner-maintenance-note-field v-model="model.maintenanceNote" /> + </template> + + <hr /> + + <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Configuration') }}</h4> + + <template v-if="loading"> + <gl-skeleton-loader v-for="i in 3" :key="i" /> + </template> + <template v-else> + <div class="gl-mb-5"> + <gl-form-checkbox + v-model="model.active" + data-testid="runner-field-paused" + :value="false" + :unchecked-value="true" + > + {{ __('Paused') }} + <template #help> + {{ s__('Runners|Stop the runner from accepting 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> + {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged"> + {{ __('Run untagged jobs') }} + <template #help> + {{ s__('Runners|Use the runner for jobs without tags, in addition to tagged jobs.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox + v-if="canBeLockedToProject" + v-model="model.locked" + data-testid="runner-field-locked" + > + {{ __('Lock to current projects') }} <gl-icon name="lock" /> + <template #help> + {{ + s__( + 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', + ) + }} + </template> + </gl-form-checkbox> + </div> + + <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> + + <runner-update-cost-factor-fields v-model="model" /> + </template> + + <div class="gl-mt-6"> + <gl-button + type="submit" + variant="confirm" + class="js-no-auto-disable" + :loading="loading || saving" + > + {{ __('Save changes') }} + </gl-button> + <gl-button :href="runnerPath"> + {{ __('Cancel') }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js new file mode 100644 index 00000000000..97ee8ec3eef --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js @@ -0,0 +1,28 @@ +import { __ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants'; + +const options = [ + { value: 'true', title: __('Yes') }, + { value: 'false', title: __('No') }, +]; + +export const pausedTokenConfig = { + icon: 'pause', + title: I18N_PAUSED, + type: PARAM_KEY_PAUSED, + token: BaseToken, + unique: true, + options: options.map(({ value, title }) => ({ + value, + // Replace whitespace with a special character to avoid + // splitting this value. + // Replacing in each option, as translations may also + // contain spaces! + // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + title: title.replace(/\s/g, '\u00a0'), + })), + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js new file mode 100644 index 00000000000..f5c42d120fb --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js @@ -0,0 +1,40 @@ +import { __ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_NEVER_CONTACTED, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_NEVER_CONTACTED, + STATUS_STALE, + PARAM_KEY_STATUS, +} from '../../constants'; + +const options = [ + { value: STATUS_ONLINE, title: I18N_STATUS_ONLINE }, + { value: STATUS_OFFLINE, title: I18N_STATUS_OFFLINE }, + { value: STATUS_NEVER_CONTACTED, title: I18N_STATUS_NEVER_CONTACTED }, + { value: STATUS_STALE, title: I18N_STATUS_STALE }, +]; + +export const statusTokenConfig = { + icon: 'status', + title: __('Status'), + type: PARAM_KEY_STATUS, + token: BaseToken, + unique: true, + options: options.map(({ value, title }) => ({ + value, + // Replace whitespace with a special character to avoid + // splitting this value. + // Replacing in each option, as translations may also + // contain spaces! + // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + title: title.replace(/\s/g, '\u00a0'), + })), + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue new file mode 100644 index 00000000000..6e7c41885f8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue @@ -0,0 +1,91 @@ +<script> +import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; + +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { RUNNER_TAG_BG_CLASS } from '../../constants'; + +// TODO This should be implemented via a GraphQL API +// The API should +// 1) scope to the rights of the user +// 2) stay up to date to the removal of old tags +// 3) consider the scope of search, like searching within the tags of a group +// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 +export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + GlToken, + }, + props: { + config: { + type: Object, + required: true, + }, + }, + data() { + return { + tags: [], + loading: false, + }; + }, + methods: { + getTagsOptions(search) { + return axios + .get(TAG_SUGGESTIONS_PATH, { + params: { + search, + }, + }) + .then(({ data }) => { + return data.map(({ id, name }) => ({ id, value: name, text: name })); + }); + }, + async fetchTags(searchTerm) { + // Note: Suggestions should only be enabled for admin users + if (this.config.suggestionsDisabled) { + this.tags = []; + return; + } + + this.loading = true; + try { + this.tags = await this.getTagsOptions(searchTerm); + } catch { + createAlert({ + message: s__('Runners|Something went wrong while fetching the tags suggestions'), + }); + } finally { + this.loading = false; + } + }, + }, + RUNNER_TAG_BG_CLASS, +}; +</script> + +<template> + <base-token + v-bind="$attrs" + :config="config" + :suggestions-loading="loading" + :suggestions="tags" + @fetch-suggestions="fetchTags" + v-on="$listeners" + > + <template #view-token="{ viewTokenProps: { listeners, inputValue, activeTokenValue } }"> + <gl-token variant="search-value" :class="$options.RUNNER_TAG_BG_CLASS" v-on="listeners"> + {{ activeTokenValue ? activeTokenValue.text : inputValue }} + </gl-token> + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion v-for="tag in suggestions" :key="tag.id" :value="tag.value"> + {{ tag.text }} + </gl-filtered-search-suggestion> + </template> + </base-token> +</template> diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js new file mode 100644 index 00000000000..fdeba714385 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js @@ -0,0 +1,12 @@ +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { PARAM_KEY_TAG } from '../../constants'; +import TagToken from './tag_token.vue'; + +export const tagTokenConfig = { + icon: 'tag', + title: s__('Runners|Tags'), + type: PARAM_KEY_TAG, + token: TagToken, + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js new file mode 100644 index 00000000000..17ee7073360 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js @@ -0,0 +1,2 @@ +// Overridden in EE +export const upgradeStatusTokenConfig = null; diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue new file mode 100644 index 00000000000..4ad9259f59d --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue @@ -0,0 +1,104 @@ +<script> +import { fetchPolicies } from '~/lib/graphql'; +import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; + +import { captureException } from '../../sentry_utils'; +import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; + +/** + * Renderless component that wraps a "count" query for the + * number of runners that follow a filter criteria. + * + * Example usage: + * + * Render the count of "online" runners in the instance in a + * <strong/> tag. + * + * ```vue + * <runner-count-stat + * #default="{ count }" + * :scope="INSTANCE_TYPE" + * :variables="{ status: 'ONLINE' }" + * > + * <strong>{{ count }}</strong> + * </runner-count-stat> + * ``` + * + * Use `:skip="true"` to prevent data from being fetched and + * even rendered. + */ +export default { + name: 'RunnerCount', + props: { + scope: { + type: String, + required: true, + validator: (val) => [INSTANCE_TYPE, GROUP_TYPE].includes(val), + }, + variables: { + type: Object, + required: false, + default: () => ({}), + }, + skip: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { count: null }; + }, + apollo: { + count: { + query() { + if (this.scope === INSTANCE_TYPE) { + return allRunnersCountQuery; + } else if (this.scope === GROUP_TYPE) { + return groupRunnersCountQuery; + } + return null; + }, + fetchPolicy: fetchPolicies.NETWORK_ONLY, + variables() { + return this.variables; + }, + skip() { + if (this.skip) { + // Don't show data for skipped stats + this.count = null; + } + return this.skip; + }, + update(data) { + if (this.scope === INSTANCE_TYPE) { + return data?.runners?.count; + } else if (this.scope === GROUP_TYPE) { + return data?.group?.runners?.count; + } + return null; + }, + error(error) { + this.reportToSentry(error); + }, + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + + // Component API + refetch() { + // Parent components can use this method to refresh the count + this.$apollo.queries.count.refetch(); + }, + }, + render() { + return this.$scopedSlots.default({ + count: this.count, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue b/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue new file mode 100644 index 00000000000..ae732b052ac --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue @@ -0,0 +1,41 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { formatNumber } from '~/locale'; +import RunnerCount from './runner_count.vue'; + +export default { + components: { + GlSingleStat, + RunnerCount, + }, + props: { + scope: { + type: String, + required: true, + }, + variables: { + type: Object, + required: false, + default: () => ({}), + }, + skip: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + formattedValue(value) { + if (typeof value === 'number') { + return formatNumber(value); + } + return '-'; + }, + }, +}; +</script> +<template> + <runner-count #default="{ count }" :scope="scope" :variables="variables" :skip="skip"> + <gl-single-stat v-bind="$attrs" :value="formattedValue(count)" /> + </runner-count> +</template> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue new file mode 100644 index 00000000000..3965e5551f1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -0,0 +1,89 @@ +<script> +import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, +} from '../../constants'; + +export default { + components: { + RunnerSingleStat, + RunnerUpgradeStatusStats: () => + import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'), + }, + props: { + scope: { + type: String, + required: true, + }, + variables: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + stats() { + return [ + { + key: STATUS_ONLINE, + props: { + skip: this.statusCountSkip(STATUS_ONLINE), + variables: { ...this.variables, status: STATUS_ONLINE }, + variant: 'success', + title: I18N_STATUS_ONLINE, + metaIcon: 'status-active', + }, + }, + { + key: STATUS_OFFLINE, + props: { + skip: this.statusCountSkip(STATUS_OFFLINE), + variables: { ...this.variables, status: STATUS_OFFLINE }, + variant: 'muted', + title: I18N_STATUS_OFFLINE, + metaIcon: 'status-waiting', + }, + }, + { + key: STATUS_STALE, + props: { + skip: this.statusCountSkip(STATUS_STALE), + variables: { ...this.variables, status: STATUS_STALE }, + variant: 'warning', + title: I18N_STATUS_STALE, + metaIcon: 'time-out', + }, + }, + ]; + }, + }, + methods: { + statusCountSkip(status) { + // Show an empty result when we already filter by another status + return this.variables.status && this.variables.status !== status; + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-flex-wrap gl-py-6"> + <runner-single-stat + v-for="stat in stats" + :key="stat.key" + :scope="scope" + v-bind="stat.props" + class="gl-px-5" + /> + + <runner-upgrade-status-stats + class="gl-display-contents" + :scope="scope" + :variables="variables" + /> + </div> +</template> |