diff options
Diffstat (limited to 'app/assets/javascripts/runner')
31 files changed, 663 insertions, 180 deletions
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 8aba91eedf7..accc9926a57 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -1,12 +1,14 @@ <script> import { GlBadge, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import { formatNumber } from '~/locale'; +import { fetchPolicies } from '~/lib/graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; +import RunnerBulkDelete from '../components/runner_bulk_delete.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; import RunnerStats from '../components/stat/runner_stats.vue'; @@ -14,6 +16,7 @@ import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; import { @@ -37,7 +40,7 @@ import { captureException } from '../sentry_utils'; const runnersCountSmartQuery = { query: runnersAdminCountQuery, - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, update(data) { return data?.runners?.count; }, @@ -53,6 +56,7 @@ export default { GlLink, RegistrationDropdown, RunnerFilteredSearchBar, + RunnerBulkDelete, RunnerList, RunnerName, RunnerStats, @@ -60,6 +64,8 @@ export default { RunnerTypeTabs, RunnerActionsCell, }, + mixins: [glFeatureFlagMixin()], + inject: ['localMutations'], props: { registrationToken: { type: String, @@ -78,10 +84,7 @@ export default { apollo: { runners: { query: runnersAdminQuery, - // Runners can be updated by users directly in this list. - // A "cache and network" policy prevents outdated filtered - // results. - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; }, @@ -176,6 +179,7 @@ export default { }, searchTokens() { return [ + pausedTokenConfig, statusTokenConfig, { ...tagTokenConfig, @@ -183,6 +187,11 @@ export default { }, ]; }, + isBulkDeleteEnabled() { + // Feature flag: admin_runners_bulk_delete + // Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981 + return this.glFeatures.adminRunnersBulkDelete; + }, }, watch: { search: { @@ -224,13 +233,29 @@ export default { } return ''; }, + refetchFilteredCounts() { + this.$apollo.queries.allRunnersCount.refetch(); + this.$apollo.queries.instanceRunnersCount.refetch(); + this.$apollo.queries.groupRunnersCount.refetch(); + this.$apollo.queries.projectRunnersCount.refetch(); + }, + onToggledPaused() { + // When a runner is Paused, the tab count can + // become stale, refetch outdated counts. + this.refetchFilteredCounts(); + }, onDeleted({ message }) { this.$root.$toast?.show(message); - this.$apollo.queries.runners.refetch(); }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, + onChecked({ runner, isChecked }) { + this.localMutations.setRunnerChecked({ + runner, + isChecked, + }); + }, }, filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, @@ -279,7 +304,13 @@ export default { {{ __('No runners found') }} </div> <template v-else> - <runner-list :runners="runners.items" :loading="runnersLoading"> + <runner-bulk-delete v-if="isBulkDeleteEnabled" /> + <runner-list + :runners="runners.items" + :loading="runnersLoading" + :checkable="isBulkDeleteEnabled" + @checked="onChecked" + > <template #runner-name="{ runner }"> <gl-link :href="runner.adminUrl"> <runner-name :runner="runner" /> @@ -289,6 +320,7 @@ export default { <runner-actions-cell :runner="runner" :edit-url="runner.editAdminUrl" + @toggledPaused="onToggledPaused" @deleted="onDeleted" /> </template> diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js index 3b8a8fe9cd1..12e2cb2ee9f 100644 --- a/app/assets/javascripts/runner/admin_runners/index.js +++ b/app/assets/javascripts/runner/admin_runners/index.js @@ -1,9 +1,10 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { visitUrl } from '~/lib/utils/url_utility'; import { updateOutdatedUrl } from '~/runner/runner_search_utils'; +import createDefaultClient from '~/lib/graphql'; +import { createLocalState } from '../graphql/list/local_state'; import AdminRunnersApp from './admin_runners_app.vue'; Vue.use(GlToast); @@ -25,10 +26,17 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { return null; } - const { runnerInstallHelpPage, registrationToken } = el.dataset; + const { + runnerInstallHelpPage, + registrationToken, + onlineContactTimeoutSecs, + staleTimeoutSecs, + } = el.dataset; + + const { cacheConfig, typeDefs, localMutations } = createLocalState(); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }), }); return new Vue({ @@ -36,6 +44,9 @@ export const initAdminRunners = (selector = '#js-admin-runners') => { apolloProvider, provide: { runnerInstallHelpPage, + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, }, render(h) { return h(AdminRunnersApp, { diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index c69321de001..7a4760f81ee 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -23,7 +23,7 @@ export default { required: false, }, }, - emits: ['deleted'], + emits: ['toggledPaused', 'deleted'], computed: { canUpdate() { return this.runner.userPermissions?.updateRunner; @@ -33,6 +33,9 @@ export default { }, }, methods: { + onToggledPaused() { + this.$emit('toggledPaused'); + }, onDeleted(value) { this.$emit('deleted', value); }, @@ -43,7 +46,17 @@ export default { <template> <gl-button-group> <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" /> - <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" /> - <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" /> + <runner-pause-button + v-if="canUpdate" + :runner="runner" + :compact="true" + @toggledPaused="onToggledPaused" + /> + <runner-delete-button + :disabled="!canDelete" + :runner="runner" + :compact="true" + @deleted="onDeleted" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue index 937ec631633..1eb383a1904 100644 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -33,6 +33,9 @@ export default { description() { return this.runner.description; }, + ipAddress() { + return this.runner.ipAddress; + }, }, i18n: { I18N_LOCKED_RUNNER_DESCRIPTION, @@ -53,10 +56,12 @@ export default { :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" name="lock" /> - <tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child"> - <div class="gl-text-truncate"> - {{ description }} - </div> + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description"> + {{ description }} + </tooltip-on-truncate> + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress"> + <span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span> + <strong>{{ ipAddress }}</strong> </tooltip-on-truncate> </div> </template> diff --git a/app/assets/javascripts/runner/components/registration/registration_token.vue b/app/assets/javascripts/runner/components/registration/registration_token.vue index d54a66ff0e4..68c6429a056 100644 --- a/app/assets/javascripts/runner/components/registration/registration_token.vue +++ b/app/assets/javascripts/runner/components/registration/registration_token.vue @@ -1,16 +1,10 @@ <script> -import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { s__ } from '~/locale'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; export default { components: { - GlButtonGroup, - GlButton, - ModalCopyButton, - }, - directives: { - GlTooltip: GlTooltipDirective, + InputCopyToggleVisibility, }, props: { value: { @@ -19,65 +13,21 @@ export default { default: '', }, }, - data() { - return { - isMasked: true, - }; - }, - computed: { - maskLabel() { - if (this.isMasked) { - return __('Click to reveal'); - } - return __('Click to hide'); - }, - maskIcon() { - if (this.isMasked) { - return 'eye'; - } - return 'eye-slash'; - }, - displayedValue() { - if (this.isMasked && this.value?.length) { - return '*'.repeat(this.value.length); - } - return this.value; - }, - }, methods: { - onToggleMasked() { - this.isMasked = !this.isMasked; - }, - onCopied() { + onCopy() { // value already in the clipboard, simply notify the user this.$toast?.show(s__('Runners|Registration token copied!')); }, }, - i18n: { - copyLabel: s__('Runners|Copy registration token'), - }, + I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'), }; </script> <template> - <gl-button-group> - <gl-button class="gl-font-monospace" data-testid="token-value" label> - {{ displayedValue }} - </gl-button> - <gl-button - v-gl-tooltip - :aria-label="maskLabel" - :title="maskLabel" - :icon="maskIcon" - class="gl-w-auto! gl-flex-shrink-0!" - data-testid="toggle-masked" - @click.stop="onToggleMasked" - /> - <modal-copy-button - class="gl-w-auto! gl-flex-shrink-0!" - :aria-label="$options.i18n.copyLabel" - :title="$options.i18n.copyLabel" - :text="value" - @success="onCopied" - /> - </gl-button-group> + <input-copy-toggle-visibility + class="gl-m-0" + :value="value" + data-testid="token-value" + :copy-button-title="$options.I18N_COPY_BUTTON_TITLE" + @copy="onCopy" + /> </template> diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue index ea8074199a6..38bdfecb7df 100644 --- a/app/assets/javascripts/runner/components/runner_assigned_item.vue +++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue @@ -1,5 +1,6 @@ <script> import { GlAvatar, GlLink } from '@gitlab/ui'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { components: { @@ -25,13 +26,20 @@ export default { default: null, }, }, + 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="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" /> + <gl-avatar + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-name="name" + :alt="name" + :src="avatarUrl" + :size="48" + /> </gl-link> <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue new file mode 100644 index 00000000000..50791de0bda --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue @@ -0,0 +1,111 @@ +<script> +import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { n__, sprintf } from '~/locale'; +import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; + +export default { + components: { + GlButton, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: ['localMutations'], + data() { + return { + checkedRunnerIds: [], + }; + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + }, + }, + computed: { + checkedCount() { + return this.checkedRunnerIds.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); + }, + modalHtmlMessage() { + 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, + ), + { + strongStart: '<strong>', + strongEnd: '</strong>', + count: this.checkedCount, + }, + false, + ); + }, + primaryBtnText() { + return n__( + 'Runners|Permanently delete %d runner', + 'Runners|Permanently delete %d runners', + this.checkedCount, + ); + }, + }, + methods: { + onClearChecked() { + this.localMutations.clearChecked(); + }, + onClickDelete: ignoreWhilePending(async function onClickDelete() { + const confirmed = await confirmAction(null, { + title: this.modalTitle, + modalHtmlMessage: this.modalHtmlMessage, + primaryBtnVariant: 'danger', + primaryBtnText: this.primaryBtnText, + }); + + if (confirmed) { + // TODO Call $apollo.mutate with list of runner + // ids in `this.checkedRunnerIds`. + // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ + } + }), + }, +}; +</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 data-testid="clear-btn" variant="default" @click="onClearChecked">{{ + s__('Runners|Clear selection') + }}</gl-button> + <gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{ + s__('Runners|Delete selected') + }}</gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue index 854c983f4da..b58665ecbc9 100644 --- a/app/assets/javascripts/runner/components/runner_delete_button.vue +++ b/app/assets/javascripts/runner/components/runner_delete_button.vue @@ -5,7 +5,12 @@ import { createAlert } from '~/flash'; import { sprintf } from '~/locale'; import { captureException } from '~/runner/sentry_utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; +import { + I18N_DELETE_DISABLED_MANY_PROJECTS, + I18N_DELETE_DISABLED_UNKNOWN_REASON, + I18N_DELETE_RUNNER, + I18N_DELETED_TOAST, +} from '../constants'; import RunnerDeleteModal from './runner_delete_modal.vue'; export default { @@ -26,6 +31,11 @@ export default { return runner?.id && runner?.shortSha; }, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, compact: { type: Boolean, required: false, @@ -75,7 +85,14 @@ export default { return null; }, tooltip() { - // Only show tooltip when compact. + if (this.disabled && this.runner.projectCount > 1) { + return I18N_DELETE_DISABLED_MANY_PROJECTS; + } + if (this.disabled) { + return I18N_DELETE_DISABLED_UNKNOWN_REASON; + } + + // Only show basic "delete" tooltip when compact. // Also prevent a "sticky" tooltip: If this button is // disabled, mouseout listeners don't run leaving the tooltip stuck if (this.compact && !this.deleting) { @@ -83,6 +100,14 @@ export default { } return ''; }, + wrapperTabindex() { + if (this.disabled) { + // Trigger tooltip on keyboard-focusable wrapper + // See https://bootstrap-vue.org/docs/directives/tooltip + return '0'; + } + return null; + }, }, methods: { async onDelete() { @@ -90,31 +115,37 @@ export default { // should only change back if the operation fails. this.deleting = true; try { - const { - data: { - runnerDelete: { errors }, - }, - } = await this.$apollo.mutate({ + 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(); + }, }); - if (errors && errors.length) { - throw new Error(errors.join(' ')); - } else { - this.$emit('deleted', { - message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), - }); - } } catch (e) { - this.deleting = false; this.onError(e); } }, onError(error) { + this.deleting = false; const { message } = error; createAlert({ message }); @@ -125,20 +156,22 @@ export default { </script> <template> - <gl-button - v-gl-tooltip.hover.viewport="tooltip" - v-gl-modal="runnerDeleteModalId" - :aria-label="ariaLabel" - :icon="icon" - :class="buttonClass" - :loading="deleting" - variant="danger" - > - {{ buttonContent }} + <div v-gl-tooltip="tooltip" class="btn-group" :tabindex="wrapperTabindex"> + <gl-button + v-gl-modal="runnerDeleteModalId" + :aria-label="ariaLabel" + :icon="icon" + :class="buttonClass" + :loading="deleting" + :disabled="disabled" + variant="danger" + > + {{ buttonContent }} + </gl-button> <runner-delete-modal :modal-id="runnerDeleteModalId" :runner-name="runnerName" @primary="onDelete" /> - </gl-button> + </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue index eb77babcc57..b25d92d049e 100644 --- a/app/assets/javascripts/runner/components/runner_jobs.vue +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -1,5 +1,5 @@ <script> -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { createAlert } from '~/flash'; import runnerJobsQuery from '../graphql/details/runner_jobs.query.graphql'; import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 51749b0255f..dcfd4b84dd2 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -4,17 +4,30 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/toolt import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; import { formatJobCount, tableField } from '../utils'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; +import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; +const defaultFields = [ + tableField({ key: 'status', label: s__('Runners|Status') }), + tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), + tableField({ key: 'version', label: __('Version') }), + tableField({ key: 'jobCount', label: __('Jobs') }), + tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), + tableField({ key: 'contactedAt', label: __('Last contact') }), + tableField({ key: 'actions', label: '' }), +]; + export default { components: { GlTableLite, GlSkeletonLoader, TooltipOnTruncate, TimeAgo, + RunnerStatusPopover, RunnerSummaryCell, RunnerTags, RunnerStatusCell, @@ -22,7 +35,20 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + skip() { + return !this.checkable; + }, + }, + }, props: { + checkable: { + type: Boolean, + required: false, + default: false, + }, loading: { type: Boolean, required: false, @@ -33,6 +59,10 @@ export default { required: true, }, }, + emits: ['checked'], + data() { + return { checkedRunnerIds: [] }; + }, computed: { tableClass() { // <gl-table-lite> does not provide a busy state, add @@ -42,6 +72,18 @@ export default { 'gl-opacity-6': this.loading, }; }, + fields() { + if (this.checkable) { + const checkboxField = tableField({ + key: 'checkbox', + label: s__('Runners|Checkbox'), + thClasses: ['gl-w-9'], + tdClass: ['gl-text-center'], + }); + return [checkboxField, ...defaultFields]; + } + return defaultFields; + }, }, methods: { formatJobCount(jobCount) { @@ -55,17 +97,16 @@ export default { } return {}; }, + onCheckboxChange(runner, isChecked) { + this.$emit('checked', { + runner, + isChecked, + }); + }, + isChecked(runner) { + return this.checkedRunnerIds.includes(runner.id); + }, }, - fields: [ - tableField({ key: 'status', label: s__('Runners|Status') }), - tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }), - tableField({ key: 'version', label: __('Version') }), - tableField({ key: 'ipAddress', label: __('IP') }), - tableField({ key: 'jobCount', label: __('Jobs') }), - tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), - tableField({ key: 'contactedAt', label: __('Last contact') }), - tableField({ key: 'actions', label: '' }), - ], }; </script> <template> @@ -74,13 +115,34 @@ export default { :aria-busy="loading" :class="tableClass" :items="runners" - :fields="$options.fields" + :fields="fields" :tbody-tr-attr="runnerTrAttr" data-testid="runner-list" stacked="md" primary-key="id" fixed > + <template #head(checkbox)> + <!-- + Checkbox to select all to be added here + See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ + --> + <span></span> + </template> + + <template #cell(checkbox)="{ item }"> + <input + type="checkbox" + :checked="isChecked(item)" + @change="onCheckboxChange(item, $event.target.checked)" + /> + </template> + + <template #head(status)="{ label }"> + {{ label }} + <runner-status-popover /> + </template> + <template #cell(status)="{ item }"> <runner-status-cell :runner="item" /> </template> @@ -99,12 +161,6 @@ export default { </tooltip-on-truncate> </template> - <template #cell(ipAddress)="{ item: { ipAddress } }"> - <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress"> - {{ ipAddress }} - </tooltip-on-truncate> - </template> - <template #cell(jobCount)="{ item: { jobCount } }"> {{ formatJobCount(jobCount) }} </template> diff --git a/app/assets/javascripts/runner/components/runner_pause_button.vue b/app/assets/javascripts/runner/components/runner_pause_button.vue index c88634bfbd9..334e5f6023a 100644 --- a/app/assets/javascripts/runner/components/runner_pause_button.vue +++ b/app/assets/javascripts/runner/components/runner_pause_button.vue @@ -24,6 +24,7 @@ export default { default: false, }, }, + emits: ['toggledPaused'], data() { return { updating: false, @@ -83,6 +84,7 @@ export default { if (errors && errors.length) { throw new Error(errors.join(' ')); } + this.$emit('toggledPaused'); } catch (e) { this.onError(e); } finally { diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue index f8ec29b8a24..d080d34fdd3 100644 --- a/app/assets/javascripts/runner/components/runner_projects.vue +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -1,5 +1,5 @@ <script> -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, formatNumber } from '~/locale'; import { createAlert } from '~/flash'; import runnerProjectsQuery from '../graphql/details/runner_projects.query.graphql'; diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue index 6d0445ecb7a..073d0a49f59 100644 --- a/app/assets/javascripts/runner/components/runner_status_badge.vue +++ b/app/assets/javascripts/runner/components/runner_status_badge.vue @@ -3,10 +3,11 @@ import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { - I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, - I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION, - I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, - I18N_STALE_RUNNER_DESCRIPTION, + 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, @@ -32,7 +33,7 @@ export default { return getTimeago().format(this.runner.contactedAt); } // Prevent "just now" from being rendered, in case data is missing. - return __('n/a'); + return __('never'); }, badge() { switch (this.runner?.status) { @@ -40,35 +41,39 @@ export default { return { variant: 'success', label: s__('Runners|online'), - tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, { - timeAgo: this.contactedAtTimeAgo, - }), + tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP), }; case STATUS_NEVER_CONTACTED: return { variant: 'muted', label: s__('Runners|never contacted'), - tooltip: I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION, + tooltip: I18N_NEVER_CONTACTED_TOOLTIP, }; case STATUS_OFFLINE: return { variant: 'muted', label: s__('Runners|offline'), - tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, { - timeAgo: this.contactedAtTimeAgo, - }), + tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP), }; case STATUS_STALE: return { variant: 'warning', label: s__('Runners|stale'), - tooltip: I18N_STALE_RUNNER_DESCRIPTION, + // 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> diff --git a/app/assets/javascripts/runner/components/runner_status_popover.vue b/app/assets/javascripts/runner/components/runner_status_popover.vue new file mode 100644 index 00000000000..5b22f7828a1 --- /dev/null +++ b/app/assets/javascripts/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 '~/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/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue index e44450a2a8d..119e5236f85 100644 --- a/app/assets/javascripts/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/runner/components/runner_update_form.vue @@ -138,7 +138,11 @@ export default { > {{ __('Lock to current projects') }} <template #help> - {{ s__('Runners|Use the runner for the currently assigned projects only.') }} + {{ + s__( + 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', + ) + }} </template> </gl-form-checkbox> diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js new file mode 100644 index 00000000000..1bab875a8a1 --- /dev/null +++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js @@ -0,0 +1,28 @@ +import { __, s__ } 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 } from '../../constants'; + +const options = [ + { value: 'true', title: __('Yes') }, + { value: 'false', title: __('No') }, +]; + +export const pausedTokenConfig = { + icon: 'pause', + title: s__('Runners|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(' ', '\u00a0'), + })), + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index 79038eb8228..f28bd491ea5 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -2,8 +2,6 @@ import { __, s__ } 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 { - STATUS_ACTIVE, - STATUS_PAUSED, STATUS_ONLINE, STATUS_OFFLINE, STATUS_NEVER_CONTACTED, @@ -12,8 +10,6 @@ import { } from '../../constants'; const options = [ - { value: STATUS_ACTIVE, title: s__('Runners|Active') }, - { value: STATUS_PAUSED, title: s__('Runners|Paused') }, { value: STATUS_ONLINE, title: s__('Runners|Online') }, { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, { value: STATUS_NEVER_CONTACTED, title: s__('Runners|Never contacted') }, diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index bd5be2175ad..b9621c26b59 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -21,18 +21,39 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__( ); export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); -// Status -export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( +// Status help popover +export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses'); + +export const I18N_STATUS_POPOVER_NEVER_CONTACTED = s__('Runners|Never contacted:'); +export const I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION = s__( + 'Runners|Runner has never contacted GitLab (when you register a runner, use %{codeStart}gitlab-runner run%{codeEnd} to bring it online)', +); +export const I18N_STATUS_POPOVER_ONLINE = s__('Runners|Online:'); +export const I18N_STATUS_POPOVER_ONLINE_DESCRIPTION = s__( + 'Runners|Runner has contacted GitLab within the last %{elapsedTime}', +); +export const I18N_STATUS_POPOVER_OFFLINE = s__('Runners|Offline:'); +export const I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION = s__( + 'Runners|Runner has not contacted GitLab in more than %{elapsedTime}', +); +export const I18N_STATUS_POPOVER_STALE = s__('Runners|Stale:'); +export const I18N_STATUS_POPOVER_STALE_DESCRIPTION = s__( + 'Runners|Runner has not contacted GitLab in more than %{elapsedTime}', +); + +// Status tooltips +export const I18N_ONLINE_TIMEAGO_TOOLTIP = s__( 'Runners|Runner is online; last contact was %{timeAgo}', ); -export const I18N_NEVER_CONTACTED_RUNNER_DESCRIPTION = s__( - 'Runners|This runner has never contacted this instance', +export const I18N_NEVER_CONTACTED_TOOLTIP = s__('Runners|Runner has never contacted this instance'); +export const I18N_OFFLINE_TIMEAGO_TOOLTIP = s__( + 'Runners|Runner is offline; last contact was %{timeAgo}', ); -export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( - 'Runners|No recent contact from this runner; last contact was %{timeAgo}', +export const I18N_STALE_TIMEAGO_TOOLTIP = s__( + 'Runners|Runner is stale; last contact was %{timeAgo}', ); -export const I18N_STALE_RUNNER_DESCRIPTION = s__( - 'Runners|No contact from this runner in over 3 months', +export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__( + 'Runners|Runner is stale; it has never contacted this instance', ); // Actions @@ -46,15 +67,23 @@ export const I18N_RESUME = __('Resume'); export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs'); export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); +export const I18N_DELETE_DISABLED_MANY_PROJECTS = s__( + 'Runners|Multi-project runners cannot be deleted', +); +export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__( + 'Runners|Runner cannot be deleted, please contact your administrator', +); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); -export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); +export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( + 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', +); // Runner details export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); export const I18N_NONE = __('None'); -export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.'); +export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.'); // Styles @@ -66,6 +95,7 @@ export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; // - GlFilteredSearch tokens type export const PARAM_KEY_STATUS = 'status'; +export const PARAM_KEY_PAUSED = 'paused'; export const PARAM_KEY_RUNNER_TYPE = 'runner_type'; export const PARAM_KEY_TAG = 'tag'; export const PARAM_KEY_SEARCH = 'search'; @@ -83,9 +113,6 @@ export const PROJECT_TYPE = 'PROJECT_TYPE'; // CiRunnerStatus -export const STATUS_ACTIVE = 'ACTIVE'; -export const STATUS_PAUSED = 'PAUSED'; - export const STATUS_ONLINE = 'ONLINE'; export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; export const STATUS_OFFLINE = 'OFFLINE'; diff --git a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql index 2b1decd3ddd..14585e62bf2 100644 --- a/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_jobs.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) { runner(id: $id) { diff --git a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql index f97237b8267..cb27de7c200 100644 --- a/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql +++ b/app/assets/javascripts/runner/graphql/details/runner_projects.query.graphql @@ -1,4 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getRunnerProjects( $id: CiRunnerID! diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql index 8df4c2fc65c..5d0450e7418 100644 --- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql @@ -1,11 +1,12 @@ #import "~/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getRunners( $before: String $after: String $first: Int $last: Int + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $tagList: [String!] @@ -17,6 +18,7 @@ query getRunners( after: $after first: $first last: $last + paused: $paused status: $status type: $type tagList: $tagList diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql index 181a4495cae..1dd258a3524 100644 --- a/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/admin_runners_count.query.graphql @@ -1,10 +1,11 @@ query getRunnersCount( + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $tagList: [String!] $search: String ) { - runners(status: $status, type: $type, tagList: $tagList, search: $search) { + runners(paused: $paused, status: $status, type: $type, tagList: $tagList, search: $search) { count } } diff --git a/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql b/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql new file mode 100644 index 00000000000..c01f1edb451 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/checked_runner_ids.query.graphql @@ -0,0 +1,3 @@ +query getCheckedRunnerIds { + checkedRunnerIds @client +} diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql index b517f5e89a8..b4f2b5cd8c8 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql @@ -1,5 +1,5 @@ #import "~/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" query getGroupRunners( $groupFullPath: ID! @@ -7,6 +7,7 @@ query getGroupRunners( $after: String $first: Int $last: Int + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $search: String @@ -20,6 +21,7 @@ query getGroupRunners( after: $after first: $first last: $last + paused: $paused status: $status type: $type search: $search @@ -30,6 +32,7 @@ query getGroupRunners( editUrl node { ...ListItem + projectCount # Used to determine why some project runners can't be deleted } } pageInfo { diff --git a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql index 554eb09e372..958b4ea0dd3 100644 --- a/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/group_runners_count.query.graphql @@ -1,5 +1,6 @@ query getGroupRunnersCount( $groupFullPath: ID! + $paused: Boolean $status: CiRunnerStatus $type: CiRunnerType $tagList: [String!] @@ -9,6 +10,7 @@ query getGroupRunnersCount( id # Apollo required runners( membership: DESCENDANTS + paused: $paused status: $status type: $type tagList: $tagList diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js new file mode 100644 index 00000000000..e87bc72c86a --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/local_state.js @@ -0,0 +1,63 @@ +import { makeVar } from '@apollo/client/core'; +import typeDefs from './typedefs.graphql'; + +/** + * Local state for checkable runner items. + * + * Usage: + * + * ``` + * import { createLocalState } from '~/runner/graphql/list/local_state'; + * + * // initialize local state + * const { cacheConfig, typeDefs, localMutations } = createLocalState(); + * + * // configure the client + * apolloClient = createApolloClient({}, { cacheConfig, typeDefs }); + * + * // modify local state + * localMutations.setRunnerChecked( ... ) + * ``` + * + * Note: Currently only in use behind a feature flag: + * admin_runners_bulk_delete for the admin list, rollout issue: + * https://gitlab.com/gitlab-org/gitlab/-/issues/353981 + * + * @returns {Object} An object to configure an Apollo client: + * contains cacheConfig, typeDefs, localMutations. + */ +export const createLocalState = () => { + const checkedRunnerIdsVar = makeVar({}); + + const cacheConfig = { + typePolicies: { + Query: { + fields: { + checkedRunnerIds() { + return Object.entries(checkedRunnerIdsVar()) + .filter(([, isChecked]) => isChecked) + .map(([key]) => key); + }, + }, + }, + }, + }; + + const localMutations = { + setRunnerChecked({ runner, isChecked }) { + checkedRunnerIdsVar({ + ...checkedRunnerIdsVar(), + [runner.id]: isChecked, + }); + }, + clearChecked() { + checkedRunnerIdsVar({}); + }, + }; + + return { + cacheConfig, + typeDefs, + localMutations, + }; +}; diff --git a/app/assets/javascripts/runner/graphql/list/typedefs.graphql b/app/assets/javascripts/runner/graphql/list/typedefs.graphql new file mode 100644 index 00000000000..24e9e20cc8c --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/typedefs.graphql @@ -0,0 +1,3 @@ +extend type Query { + checkedRunnerIds: [ID!]! +} diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index 35fd7fff6d3..b299d7c40fe 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -1,9 +1,9 @@ <script> import { GlBadge, GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; import { formatNumber } from '~/locale'; +import { fetchPolicies } from '~/lib/graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -14,6 +14,7 @@ import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { GROUP_FILTERED_SEARCH_NAMESPACE, @@ -35,7 +36,7 @@ import { captureException } from '../sentry_utils'; const runnersCountSmartQuery = { query: groupRunnersCountQuery, - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, update(data) { return data?.group?.runners?.count; }, @@ -85,10 +86,7 @@ export default { apollo: { runners: { query: groupRunnersQuery, - // Runners can be updated by users directly in this list. - // A "cache and network" policy prevents outdated filtered - // results. - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; }, @@ -192,7 +190,7 @@ export default { return !this.runnersLoading && !this.runners.items.length; }, searchTokens() { - return [statusTokenConfig]; + return [pausedTokenConfig, statusTokenConfig]; }, filteredSearchNamespace() { return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; @@ -241,9 +239,18 @@ export default { editUrl(runner) { return this.runners.urlsById[runner.id]?.edit; }, + refetchFilteredCounts() { + this.$apollo.queries.allRunnersCount.refetch(); + this.$apollo.queries.groupRunnersCount.refetch(); + this.$apollo.queries.projectRunnersCount.refetch(); + }, + onToggledPaused() { + // When a runner is Paused, the tab count can + // become stale, refetch outdated counts. + this.refetchFilteredCounts(); + }, onDeleted({ message }) { this.$root.$toast?.show(message); - this.$apollo.queries.runners.refetch(); }, reportToSentry(error) { captureException({ error, component: this.$options.name }); @@ -302,7 +309,12 @@ export default { </gl-link> </template> <template #runner-actions-cell="{ runner }"> - <runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" @deleted="onDeleted" /> + <runner-actions-cell + :runner="runner" + :edit-url="editUrl(runner)" + @toggledPaused="onToggledPaused" + @deleted="onDeleted" + /> </template> </runner-list> <runner-pagination diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js index 60b7a7ab541..0dade30f820 100644 --- a/app/assets/javascripts/runner/group_runners/index.js +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -20,6 +20,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => { groupId, groupFullPath, groupRunnersLimitedCount, + onlineContactTimeoutSecs, + staleTimeoutSecs, } = el.dataset; const apolloProvider = new VueApollo({ @@ -32,6 +34,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => { provide: { runnerInstallHelpPage, groupId, + onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10), + staleTimeoutSecs: parseInt(staleTimeoutSecs, 10), }, render(h) { return h(GroupRunnersApp, { diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index fe141332be3..5e3c412ddb6 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -5,7 +5,9 @@ import { urlQueryToFilter, prepareTokens, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { + PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG, @@ -83,6 +85,19 @@ const getPaginationFromParams = (params) => { // Outdated URL parameters const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; +const STATUS_ACTIVE = 'ACTIVE'; +const STATUS_PAUSED = 'PAUSED'; + +/** + * Replaces params into a URL + * + * @param {String} url - Original URL + * @param {Object} params - Query parameters to update + * @returns Updated URL + */ +const updateUrlParams = (url, params = {}) => { + return setUrlParams(params, url, false, true, true); +}; /** * Returns an updated URL for old (or deprecated) admin runner URLs. @@ -98,14 +113,26 @@ export const updateOutdatedUrl = (url = window.location.href) => { const params = queryToObject(query, { gatherArrays: true }); - const runnerType = params[PARAM_KEY_STATUS]?.[0] || null; - if (runnerType === STATUS_NOT_CONNECTED) { - const updatedParams = { - [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED], - }; - return setUrlParams(updatedParams, url, false, true, true); + const status = params[PARAM_KEY_STATUS]?.[0] || null; + + switch (status) { + case STATUS_NOT_CONNECTED: + return updateUrlParams(url, { + [PARAM_KEY_STATUS]: [STATUS_NEVER_CONTACTED], + }); + case STATUS_ACTIVE: + return updateUrlParams(url, { + [PARAM_KEY_PAUSED]: ['false'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }); + case STATUS_PAUSED: + return updateUrlParams(url, { + [PARAM_KEY_PAUSED]: ['true'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }); + default: + return null; } - return null; }; /** @@ -121,7 +148,7 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { runnerType, filters: prepareTokens( urlQueryToFilter(query, { - filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_TAG], + filterNamesAllowList: [PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG], filteredSearchTermKey: PARAM_KEY_SEARCH, }), ), @@ -195,6 +222,12 @@ export const fromSearchToVariables = ({ filterVariables.search = queryObj[PARAM_KEY_SEARCH]; filterVariables.tagList = queryObj[PARAM_KEY_TAG]; + if (queryObj[PARAM_KEY_PAUSED]) { + filterVariables.paused = parseBoolean(queryObj[PARAM_KEY_PAUSED]); + } else { + filterVariables.paused = undefined; + } + if (runnerType) { filterVariables.type = runnerType; } diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js index 6e4c8c45e7b..1f7794720de 100644 --- a/app/assets/javascripts/runner/utils.js +++ b/app/assets/javascripts/runner/utils.js @@ -24,7 +24,7 @@ export const formatJobCount = (jobCount) => { * @param {Object} options * @returns Field object to add to GlTable fields */ -export const tableField = ({ key, label = '', thClasses = [] }) => { +export const tableField = ({ key, label = '', thClasses = [], ...options }) => { return { key, label, @@ -32,6 +32,7 @@ export const tableField = ({ key, label = '', thClasses = [] }) => { tdAttr: { 'data-testid': `td-${key}`, }, + ...options, }; }; |