Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/ci/runner/components')
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/link_cell.vue27
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue57
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue63
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue112
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue46
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue33
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue92
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token.vue49
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue141
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_assigned_item.vue63
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue199
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue67
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue156
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_modal.vue51
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_detail.vue55
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_details.vue159
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_edit_button.vue24
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue100
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_groups.vue37
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_header.vue70
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs.vue82
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_table.vue114
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list.vue188
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue82
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue42
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_name.vue20
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pagination.vue50
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_button.vue120
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_paused_badge.vue25
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_projects.vue144
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_status_badge.vue97
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_status_popover.vue75
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_tag.vue56
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_tags.vue32
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_type_badge.vue67
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_type_tabs.vue123
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_update_form.vue225
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js28
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js42
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue91
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js12
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js2
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_count.vue104
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue41
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_stats.vue89
45 files changed, 3552 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..1ec3f8da7c3
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
@@ -0,0 +1,199 @@
+<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) {
+ createAlert({
+ message: s__(
+ 'Runners|An error occurred while deleting. Some runners may not have been deleted.',
+ ),
+ captureError: true,
+ error: new Error(errors.join(' ')),
+ });
+ }
+
+ if (deletedIds?.length) {
+ this.$emit('deleted', {
+ message: this.toastConfirmationMessage(deletedIds.length),
+ });
+
+ // Remove deleted runners from the cache
+ deletedIds.forEach((id) => {
+ const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
+ cache.evict({ id: cacheId });
+ });
+ cache.gc();
+ }
+ },
+ });
+ } catch (error) {
+ this.onError(error);
+ } finally {
+ this.isDeleting = false;
+ this.$refs.modal.hide();
+ }
+ },
+ 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..32d4076b00f
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -0,0 +1,156 @@
+<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, s__ } 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;
+ const title = sprintf(s__('Runner|Runner %{runnerName} failed to delete'), {
+ runnerName: this.runnerName,
+ });
+
+ createAlert({ title, 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..117a630719e
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
@@ -0,0 +1,42 @@
+import {
+ OPERATOR_IS_ONLY,
+ TOKEN_TITLE_STATUS,
+} 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: TOKEN_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>