diff options
Diffstat (limited to 'app/assets/javascripts/runner')
29 files changed, 518 insertions, 292 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 f6b7a8b46d7..777a332333d 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -12,10 +12,12 @@ import { isSearchFiltered, } from 'ee_else_ce/runner/runner_search_utils'; import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; +import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql'; 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 RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; import RunnerName from '../components/runner_name.vue'; @@ -37,6 +39,7 @@ export default { RegistrationDropdown, RunnerFilteredSearchBar, RunnerBulkDelete, + RunnerBulkDeleteCheckbox, RunnerList, RunnerListEmptyState, RunnerName, @@ -138,11 +141,15 @@ export default { onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. - this.$refs['runner-type-tabs'].refetch(); + this.refetchCounts(); }, onDeleted({ message }) { + this.refetchCounts(); this.$root.$toast?.show(message); }, + refetchCounts() { + this.$apollo.getClient().refetchQueries({ include: [allRunnersCountQuery] }); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -152,6 +159,9 @@ export default { isChecked, }); }, + onPaginationInput(value) { + this.search.pagination = value; + }, }, filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, @@ -163,7 +173,6 @@ export default { class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > <runner-type-tabs - ref="runner-type-tabs" v-model="search" :count-scope="$options.INSTANCE_TYPE" :count-variables="countVariables" @@ -196,13 +205,20 @@ export default { :filtered-svg-path="emptyStateFilteredSvgPath" /> <template v-else> - <runner-bulk-delete v-if="isBulkDeleteEnabled" /> + <runner-bulk-delete + v-if="isBulkDeleteEnabled" + :runners="runners.items" + @deleted="onDeleted" + /> <runner-list :runners="runners.items" :loading="runnersLoading" :checkable="isBulkDeleteEnabled" @checked="onChecked" > + <template v-if="isBulkDeleteEnabled" #head-checkbox> + <runner-bulk-delete-checkbox :runners="runners.items" /> + </template> <template #runner-name="{ runner }"> <gl-link :href="runner.adminUrl"> <runner-name :runner="runner" /> @@ -217,11 +233,13 @@ export default { /> </template> </runner-list> - <runner-pagination - v-model="search.pagination" - class="gl-mt-3" - :page-info="runners.pageInfo" - /> </template> + + <runner-pagination + class="gl-mt-3" + :disabled="runnersLoading" + :page-info="runners.pageInfo" + @input="onPaginationInput" + /> </div> </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 1eb383a1904..1cd098d6713 100644 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -59,7 +59,11 @@ export default { <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"> + <tooltip-on-truncate + v-if="ipAddress" + 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> diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue index 38bdfecb7df..2fa87bdd776 100644 --- a/app/assets/javascripts/runner/components/runner_assigned_item.vue +++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue @@ -1,10 +1,11 @@ <script> -import { GlAvatar, GlLink } from '@gitlab/ui'; +import { GlAvatar, GlBadge, GlLink } from '@gitlab/ui'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; export default { components: { GlAvatar, + GlBadge, GlLink, }, props: { @@ -25,6 +26,16 @@ export default { required: false, default: null, }, + description: { + type: String, + required: false, + default: null, + }, + isOwner: { + type: Boolean, + required: false, + default: false, + }, }, AVATAR_SHAPE_OPTION_RECT, }; @@ -41,7 +52,12 @@ export default { :size="48" /> </gl-link> - - <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</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/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue index 50791de0bda..703da01d9c8 100644 --- a/app/assets/javascripts/runner/components/runner_bulk_delete.vue +++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue @@ -1,21 +1,31 @@ <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 { 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: [], }; }, @@ -25,8 +35,13 @@ export default { }, }, computed: { + currentCheckedRunnerIds() { + return this.runners + .map(({ id }) => id) + .filter((id) => this.checkedRunnerIds.indexOf(id) >= 0); + }, checkedCount() { - return this.checkedRunnerIds.length || 0; + return this.currentCheckedRunnerIds.length || 0; }, bannerMessage() { return sprintf( @@ -43,48 +58,103 @@ export default { modalTitle() { return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount); }, - modalHtmlMessage() { + 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, ), - { - strongStart: '<strong>', - strongEnd: '</strong>', - count: this.checkedCount, - }, - false, + { count: this.checkedCount }, ); }, - primaryBtnText() { + }, + methods: { + toastConfirmationMessage(deletedCount) { return n__( - 'Runners|Permanently delete %d runner', - 'Runners|Permanently delete %d runners', - this.checkedCount, + 'Runners|%d selected runner deleted', + 'Runners|%d selected runners deleted', + deletedCount, ); }, - }, - 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, - }); + async onConfirmDelete(e) { + this.isDeleting = true; + e.preventDefault(); // don't close modal until deletion is complete + + try { + await this.$apollo.mutate({ + mutation: BulkRunnerDelete, + variables: { + input: { + ids: this.currentCheckedRunnerIds, + }, + }, + update: (cache, { data }) => { + const { errors, deletedIds } = data.bulkRunnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + this.$refs.modal.hide(); + return; + } + + this.$emit('deleted', { + message: this.toastConfirmationMessage(deletedIds.length), + }); - if (confirmed) { - // TODO Call $apollo.mutate with list of runner - // ids in `this.checkedRunnerIds`. - // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ + // Clean up + + // Remove deleted runners from the cache + deletedIds.forEach((id) => { + const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id }); + cache.evict({ id: cacheId }); + }); + cache.gc(); + + this.$refs.modal.hide(); + }, + }); + } catch (error) { + this.onError(error); + } finally { + this.isDeleting = false; } - }), + }, + onError(error) { + createAlert({ + message: s__( + 'Runners|Something went wrong while deleting. Please refresh the page to try again.', + ), + captureError: true, + error, + }); + }, }, + BULK_DELETE_MODAL_ID: 'bulk-delete-modal', }; </script> @@ -99,13 +169,28 @@ export default { </gl-sprintf> </div> <div class="gl-ml-auto"> - <gl-button data-testid="clear-btn" variant="default" @click="onClearChecked">{{ + <gl-button variant="default" @click="onClearChecked">{{ s__('Runners|Clear selection') }}</gl-button> - <gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{ + <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/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue new file mode 100644 index 00000000000..dde5a5a4a05 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue @@ -0,0 +1,59 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; +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: { + disabled() { + return !this.runners.length; + }, + checked() { + return Boolean(this.runners.length) && this.runners.every(this.isChecked); + }, + indeterminate() { + return !this.checked && this.runners.some(this.isChecked); + }, + }, + methods: { + isChecked({ id }) { + return this.checkedRunnerIds.indexOf(id) >= 0; + }, + onChange($event) { + this.localMutations.setRunnersChecked({ + runners: this.runners, + isChecked: $event, + }); + }, + }, +}; +</script> + +<template> + <gl-form-checkbox + :indeterminate="indeterminate" + :checked="checked" + :disabled="disabled" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue index db67acef3db..584f77b7648 100644 --- a/app/assets/javascripts/runner/components/runner_detail.vue +++ b/app/assets/javascripts/runner/components/runner_detail.vue @@ -38,11 +38,10 @@ export default { </script> <template> - <div class="gl-display-flex gl-pb-4"> - <dt class="gl-mr-2">{{ label }}</dt> - <dd class="gl-mb-0"> - <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> - <template v-if="value || $slots.value"> + <div class="gl-display-contents"> + <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">{{ label }}</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> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index 60469d26dd5..d5222f39b81 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -51,6 +51,9 @@ export default { } return null; }, + tagList() { + return this.runner.tagList || []; + }, isGroupRunner() { return this.runner?.runnerType === GROUP_TYPE; }, @@ -66,14 +69,17 @@ export default { <div> <runner-upgrade-status-alert class="gl-my-4" :runner="runner" /> <div class="gl-pt-4"> - <dl class="gl-mb-0" data-testid="runner-details-list"> + <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 #value> - <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + <template v-if="runner.contactedAt" #value> + <time-ago :time="runner.contactedAt" /> </template> </runner-detail> <runner-detail :label="s__('Runners|Version')"> @@ -87,8 +93,8 @@ export default { <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 #value> - <gl-intersperse v-if="configTextProtected || configTextUntagged"> + <template v-if="configTextProtected || configTextUntagged" #value> + <gl-intersperse> <span v-if="configTextProtected">{{ configTextProtected }}</span> <span v-if="configTextUntagged">{{ configTextUntagged }}</span> </gl-intersperse> @@ -96,13 +102,8 @@ export default { </runner-detail> <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> <runner-detail :label="s__('Runners|Tags')"> - <template #value> - <runner-tags - v-if="runner.tagList && runner.tagList.length" - class="gl-vertical-align-middle" - :tag-list="runner.tagList" - size="sm" - /> + <template v-if="tagList.length" #value> + <runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" /> </template> </runner-detail> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index bff5ec9b238..5a9ab21a457 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -64,19 +64,19 @@ export default { }, methods: { onFilter(filters) { - // Apply new filters, from page 1 + // Apply new filters, resetting pagination this.$emit('input', { ...this.value, filters, - pagination: { page: 1 }, + pagination: {}, }); }, onSort(sort) { - // Apply new sort, from page 1 + // Apply new sort, resetting pagination this.$emit('input', { ...this.value, sort, - pagination: { page: 1 }, + pagination: {}, }); }, }, diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue index 57afdc4b9be..9003eba3636 100644 --- a/app/assets/javascripts/runner/components/runner_jobs.vue +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -27,9 +27,7 @@ export default { items: [], pageInfo: {}, }, - pagination: { - page: 1, - }, + pagination: {}, }; }, apollo: { @@ -62,6 +60,11 @@ export default { return this.$apollo.queries.jobs.loading; }, }, + methods: { + onPaginationInput(value) { + this.pagination = value; + }, + }, I18N_NO_JOBS_FOUND, }; </script> @@ -74,6 +77,6 @@ export default { <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" /> <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p> - <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" /> + <runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" /> </div> </template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index f1f99c728c5..2e406f71792 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,5 +1,5 @@ <script> -import { GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; @@ -23,6 +23,7 @@ const defaultFields = [ export default { components: { + GlFormCheckbox, GlTableLite, GlSkeletonLoader, TooltipOnTruncate, @@ -123,19 +124,11 @@ export default { fixed > <template #head(checkbox)> - <!-- - Checkbox to select all to be added here - See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ - --> - <span></span> + <slot name="head-checkbox"></slot> </template> <template #cell(checkbox)="{ item }"> - <input - type="checkbox" - :checked="isChecked(item)" - @change="onCheckboxChange(item, $event.target.checked)" - /> + <gl-form-checkbox :checked="isChecked(item)" @change="onCheckboxChange(item, $event)" /> </template> <template #head(status)="{ label }"> diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue index cfc21d1407b..a5bf3074dd1 100644 --- a/app/assets/javascripts/runner/components/runner_pagination.vue +++ b/app/assets/javascripts/runner/components/runner_pagination.vue @@ -1,18 +1,12 @@ <script> -import { GlPagination } from '@gitlab/ui'; +import { GlKeysetPagination } from '@gitlab/ui'; export default { components: { - GlPagination, + GlKeysetPagination, }, + inheritAttrs: false, props: { - value: { - required: false, - type: Object, - default: () => ({ - page: 1, - }), - }, pageInfo: { required: false, type: Object, @@ -20,46 +14,37 @@ export default { }, }, computed: { - prevPage() { - return this.pageInfo?.hasPreviousPage ? this.value.page - 1 : null; + paginationProps() { + return { ...this.pageInfo, ...this.$attrs }; }, - nextPage() { - return this.pageInfo?.hasNextPage ? this.value.page + 1 : null; + isShown() { + const { hasPreviousPage, hasNextPage } = this.pageInfo; + return hasPreviousPage || hasNextPage; }, }, methods: { - handlePageChange(page) { - if (page === 1) { - // Small optimization for first page - // If we have loaded using "first", - // page is already cached. - this.$emit('input', { - page, - }); - } else if (page > this.value.page) { - this.$emit('input', { - page, - after: this.pageInfo.endCursor, - }); - } else { - this.$emit('input', { - page, - before: this.pageInfo.startCursor, - }); - } + prevPage() { + this.$emit('input', { + before: this.pageInfo.startCursor, + }); + }, + nextPage() { + this.$emit('input', { + after: this.pageInfo.endCursor, + }); }, }, }; </script> <template> - <gl-pagination - v-bind="$attrs" - :value="value.page" - :prev-page="prevPage" - :next-page="nextPage" - align="center" - class="gl-pagination" - @input="handlePageChange" - /> + <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/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue index c0c0c14e91e..2c1d2fc2b10 100644 --- a/app/assets/javascripts/runner/components/runner_projects.vue +++ b/app/assets/javascripts/runner/components/runner_projects.vue @@ -30,13 +30,12 @@ export default { data() { return { projects: { + ownerProjectId: null, items: [], pageInfo: {}, count: 0, }, - pagination: { - page: 1, - }, + pagination: {}, }; }, apollo: { @@ -48,6 +47,7 @@ export default { update(data) { const { runner } = data; return { + ownerProjectId: runner?.ownerProject?.id, count: runner?.projectCount || 0, items: runner?.projects?.nodes || [], pageInfo: runner?.projects?.pageInfo || {}, @@ -76,6 +76,14 @@ export default { }); }, }, + methods: { + isOwner(projectId) { + return projectId === this.projects.ownerProjectId; + }, + onPaginationInput(value) { + this.pagination = value; + }, + }, I18N_NONE, }; </script> @@ -98,10 +106,16 @@ export default { :name="project.name" :full-name="project.nameWithNamespace" :avatar-url="project.avatarUrl" + :description="project.description" + :is-owner="isOwner(project.id)" /> </template> <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span> - <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" /> + <runner-pagination + :disabled="loading" + :page-info="projects.pageInfo" + @input="onPaginationInput" + /> </div> </template> diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue index af18b203f90..37c6f922f9a 100644 --- a/app/assets/javascripts/runner/components/stat/runner_count.vue +++ b/app/assets/javascripts/runner/components/stat/runner_count.vue @@ -1,8 +1,9 @@ <script> import { fetchPolicies } from '~/lib/graphql'; +import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql'; + import { captureException } from '../../sentry_utils'; -import allRunnersCountQuery from '../../graphql/list/all_runners_count.query.graphql'; -import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql'; import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; /** @@ -38,7 +39,7 @@ export default { variables: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, skip: { type: Boolean, diff --git a/app/assets/javascripts/runner/components/stat/runner_single_stat.vue b/app/assets/javascripts/runner/components/stat/runner_single_stat.vue new file mode 100644 index 00000000000..ae732b052ac --- /dev/null +++ b/app/assets/javascripts/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/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue index 9e1ca9ba4ee..93e54ebe7f4 100644 --- a/app/assets/javascripts/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue @@ -1,12 +1,13 @@ <script> +import { s__ } from '~/locale'; +import RunnerSingleStat from '~/runner/components/stat/runner_single_stat.vue'; import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; -import RunnerCount from './runner_count.vue'; -import RunnerStatusStat from './runner_status_stat.vue'; export default { components: { - RunnerCount, - RunnerStatusStat, + RunnerSingleStat, + RunnerUpgradeStatusStats: () => + import('ee_component/runner/components/stat/runner_upgrade_status_stats.vue'), }, props: { scope: { @@ -16,32 +17,67 @@ export default { variables: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, }, - methods: { - countVariables(vars) { - return { ...this.variables, ...vars }; + computed: { + stats() { + return [ + { + key: STATUS_ONLINE, + props: { + skip: this.statusCountSkip(STATUS_ONLINE), + variables: { ...this.variables, status: STATUS_ONLINE }, + variant: 'success', + title: s__('Runners|Online runners'), + metaText: s__('Runners|online'), + }, + }, + { + key: STATUS_OFFLINE, + props: { + skip: this.statusCountSkip(STATUS_OFFLINE), + variables: { ...this.variables, status: STATUS_OFFLINE }, + variant: 'muted', + title: s__('Runners|Offline runners'), + metaText: s__('Runners|offline'), + }, + }, + { + key: STATUS_STALE, + props: { + skip: this.statusCountSkip(STATUS_STALE), + variables: { ...this.variables, status: STATUS_STALE }, + variant: 'warning', + title: s__('Runners|Stale runners'), + metaText: s__('Runners|stale'), + }, + }, + ]; }, + }, + methods: { statusCountSkip(status) { // Show an empty result when we already filter by another status return this.variables.status && this.variables.status !== status; }, }, - STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE], }; </script> <template> - <div class="gl-display-flex gl-py-6"> - <runner-count - v-for="status in $options.STATUS_LIST" - #default="{ count }" - :key="status" + <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="countVariables({ status })" - :skip="statusCountSkip(status)" - > - <runner-status-stat class="gl-px-5" :status="status" :value="count" /> - </runner-count> + :variables="variables" + /> </div> </template> diff --git a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue b/app/assets/javascripts/runner/components/stat/runner_status_stat.vue deleted file mode 100644 index b77bbe15541..00000000000 --- a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { s__, formatNumber } from '~/locale'; -import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants'; - -export default { - components: { - GlSingleStat, - }, - props: { - value: { - type: Number, - required: false, - default: null, - }, - status: { - type: String, - required: true, - }, - }, - computed: { - formattedValue() { - if (typeof this.value === 'number') { - return formatNumber(this.value); - } - return '-'; - }, - stat() { - switch (this.status) { - case STATUS_ONLINE: - return { - variant: 'success', - title: s__('Runners|Online runners'), - metaText: s__('Runners|online'), - }; - case STATUS_OFFLINE: - return { - variant: 'muted', - title: s__('Runners|Offline runners'), - metaText: s__('Runners|offline'), - }; - case STATUS_STALE: - return { - variant: 'warning', - title: s__('Runners|Stale runners'), - metaText: s__('Runners|stale'), - }; - default: - return { - title: s__('Runners|Runners'), - }; - } - }, - }, -}; -</script> -<template> - <gl-single-stat - v-if="stat" - :value="formattedValue" - :variant="stat.variant" - :title="stat.title" - :meta-text="stat.metaText" - /> -</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 64541729701..ed1afcbf691 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,5 +1,7 @@ import { __, s__ } from '~/locale'; +export const RUNNER_TYPENAME = 'CiRunner'; // __typename + export const RUNNER_PAGE_SIZE = 20; export const RUNNER_JOB_COUNT_LIMIT = 1000; @@ -102,7 +104,6 @@ export const PARAM_KEY_TAG = 'tag'; export const PARAM_KEY_SEARCH = 'search'; export const PARAM_KEY_SORT = 'sort'; -export const PARAM_KEY_PAGE = 'page'; export const PARAM_KEY_AFTER = 'after'; export const PARAM_KEY_BEFORE = 'before'; diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql index f900a0450e5..29abddf84f5 100644 --- a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql @@ -1,5 +1,4 @@ fragment RunnerFieldsShared on CiRunner { - __typename id shortSha runnerType diff --git a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql index 6bb896dda16..1160596aff3 100644 --- a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql @@ -1,5 +1,4 @@ -#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/runner/graphql/list/all_runners_connection.fragment.graphql" query getAllRunners( $before: String @@ -25,14 +24,6 @@ query getAllRunners( search: $search sort: $sort ) { - nodes { - ...ListItem - adminUrl - editAdminUrl - } - pageInfo { - __typename - ...PageInfo - } + ...AllRunnersConnection } } diff --git a/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql new file mode 100644 index 00000000000..4440b8e98da --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +fragment AllRunnersConnection on CiRunnerConnection { + nodes { + ...ListItem + adminUrl + editAdminUrl + } + pageInfo { + ...PageInfo + } +} diff --git a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql new file mode 100644 index 00000000000..b73c016b1de --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql @@ -0,0 +1,6 @@ +mutation bulkRunnerDelete($input: BulkRunnerDeleteInput!) { + bulkRunnerDelete(input: $input) { + deletedIds + errors + } +} diff --git a/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql new file mode 100644 index 00000000000..baef16a4b41 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql @@ -0,0 +1,16 @@ +#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +fragment GroupRunnerConnection on CiRunnerConnection { + edges { + webUrl + editUrl + node { + ...ListItem + projectCount # Used to determine why some project runners can't be deleted + } + } + pageInfo { + ...PageInfo + } +} 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 8755636a7ad..4c519b9b867 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,4 @@ -#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql" -#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/runner/graphql/list/group_runner_connection.fragment.graphql" query getGroupRunners( $groupFullPath: ID! @@ -27,18 +26,7 @@ query getGroupRunners( search: $search sort: $sort ) { - edges { - webUrl - editUrl - node { - ...ListItem - projectCount # Used to determine why some project runners can't be deleted - } - } - pageInfo { - __typename - ...PageInfo - } + ...GroupRunnerConnection } } } diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql index cf925359ffb..ce23bddb898 100644 --- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql @@ -1,11 +1,9 @@ fragment ListItemShared on CiRunner { - __typename id description runnerType shortSha version - revision ipAddress active locked diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js index e87bc72c86a..154af261bba 100644 --- a/app/assets/javascripts/runner/graphql/list/local_state.js +++ b/app/assets/javascripts/runner/graphql/list/local_state.js @@ -1,4 +1,5 @@ import { makeVar } from '@apollo/client/core'; +import { RUNNER_TYPENAME } from '../../constants'; import typeDefs from './typedefs.graphql'; /** @@ -33,10 +34,16 @@ export const createLocalState = () => { typePolicies: { Query: { fields: { - checkedRunnerIds() { + checkedRunnerIds(_, { canRead, toReference }) { return Object.entries(checkedRunnerIdsVar()) + .filter(([id]) => { + // Some runners may be deleted by the user separately. + // Skip dangling references, those not in the cache. + // See: https://www.apollographql.com/docs/react/caching/garbage-collection/#dangling-references + return canRead(toReference({ __typename: RUNNER_TYPENAME, id })); + }) .filter(([, isChecked]) => isChecked) - .map(([key]) => key); + .map(([id]) => id); }, }, }, @@ -50,6 +57,13 @@ export const createLocalState = () => { [runner.id]: isChecked, }); }, + setRunnersChecked({ runners, isChecked }) { + const newVal = runners.reduce( + (acc, { id }) => ({ ...acc, [id]: isChecked }), + checkedRunnerIdsVar(), + ); + checkedRunnerIdsVar(newVal); + }, clearChecked() { checkedRunnerIdsVar({}); }, diff --git a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql index b79ad4d9280..499c0156770 100644 --- a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql @@ -1,5 +1,4 @@ fragment RunnerDetailsShared on CiRunner { - __typename id shortSha runnerType diff --git a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql index cb27de7c200..acc4a641565 100644 --- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql +++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql @@ -9,11 +9,15 @@ query getRunnerProjects( ) { runner(id: $id) { id + ownerProject { + id + } projectCount projects(first: $first, last: $last, before: $before, after: $after) { nodes { id avatarUrl + description name nameWithNamespace webUrl 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 e8446dbe345..a82411a2120 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -3,6 +3,14 @@ import { GlLink } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; +import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, + isSearchFiltered, +} from 'ee_else_ce/runner/runner_search_utils'; +import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -22,13 +30,6 @@ import { PROJECT_TYPE, I18N_FETCH_ERROR, } from '../constants'; -import groupRunnersQuery from '../graphql/list/group_runners.query.graphql'; -import { - fromUrlQueryToSearch, - fromSearchToUrl, - fromSearchToVariables, - isSearchFiltered, -} from '../runner_search_utils'; import { captureException } from '../sentry_utils'; export default { @@ -123,7 +124,7 @@ export default { return !this.runnersLoading && !this.runners.items.length; }, searchTokens() { - return [pausedTokenConfig, statusTokenConfig]; + return [pausedTokenConfig, statusTokenConfig, upgradeStatusTokenConfig]; }, filteredSearchNamespace() { return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; @@ -166,6 +167,9 @@ export default { reportToSentry(error) { captureException({ error, component: this.$options.name }); }, + onPaginationInput(value) { + this.search.pagination = value; + }, }, TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE], GROUP_TYPE, @@ -225,11 +229,13 @@ export default { /> </template> </runner-list> - <runner-pagination - v-model="search.pagination" - class="gl-mt-3" - :page-info="runners.pageInfo" - /> </template> + + <runner-pagination + class="gl-mt-3" + :disabled="runnersLoading" + :page-info="runners.pageInfo" + @input="onPaginationInput" + /> </div> </template> diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js index e01878f355a..dc582ccbac1 100644 --- a/app/assets/javascripts/runner/runner_search_utils.js +++ b/app/assets/javascripts/runner/runner_search_utils.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import { queryToObject, setUrlParams } from '~/lib/utils/url_utility'; import { filterToQueryObject, @@ -13,7 +14,6 @@ import { PARAM_KEY_TAG, PARAM_KEY_SEARCH, PARAM_KEY_SORT, - PARAM_KEY_PAGE, PARAM_KEY_AFTER, PARAM_KEY_BEFORE, DEFAULT_SORT, @@ -41,7 +41,7 @@ import { getPaginationVariables } from './utils'; * sort: 'CREATED_DESC', * * // Pagination information - * pagination: { page: 1 }, + * pagination: { "after": "..." }, * }; * ``` * @@ -66,25 +66,16 @@ export const searchValidator = ({ runnerType, filters, sort }) => { }; const getPaginationFromParams = (params) => { - const page = parseInt(params[PARAM_KEY_PAGE], 10); - const after = params[PARAM_KEY_AFTER]; - const before = params[PARAM_KEY_BEFORE]; - - if (page && (before || after)) { - return { - page, - before, - after, - }; - } return { - page: 1, + after: params[PARAM_KEY_AFTER], + before: params[PARAM_KEY_BEFORE], }; }; // Outdated URL parameters const STATUS_ACTIVE = 'ACTIVE'; const STATUS_PAUSED = 'PAUSED'; +const PARAM_KEY_PAGE = 'page'; /** * Replaces params into a URL @@ -97,6 +88,21 @@ const updateUrlParams = (url, params = {}) => { return setUrlParams(params, url, false, true, true); }; +const outdatedStatusParams = (status) => { + if (status === STATUS_ACTIVE) { + return { + [PARAM_KEY_PAUSED]: ['false'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }; + } else if (status === STATUS_PAUSED) { + return { + [PARAM_KEY_PAUSED]: ['true'], + [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! + }; + } + return {}; +}; + /** * Returns an updated URL for old (or deprecated) admin runner URLs. * @@ -108,25 +114,22 @@ const updateUrlParams = (url, params = {}) => { export const updateOutdatedUrl = (url = window.location.href) => { const urlObj = new URL(url); const query = urlObj.search; - const params = queryToObject(query, { gatherArrays: true }); - const status = params[PARAM_KEY_STATUS]?.[0] || null; - - switch (status) { - 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; + // Remove `page` completely, not needed for keyset pagination + const pageParams = PARAM_KEY_PAGE in params ? { [PARAM_KEY_PAGE]: null } : {}; + + const status = params[PARAM_KEY_STATUS]?.[0]; + const redirectParams = { + // Replace paused status (active, paused) with a paused flag + ...outdatedStatusParams(status), + ...pageParams, + }; + + if (!isEmpty(redirectParams)) { + return updateUrlParams(url, redirectParams); } + return null; }; /** @@ -182,13 +185,11 @@ export const fromSearchToUrl = ( } const isDefaultSort = sort !== DEFAULT_SORT; - const isFirstPage = pagination?.page === 1; const otherParams = { // Sorting & Pagination [PARAM_KEY_SORT]: isDefaultSort ? sort : null, - [PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page, - [PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before, - [PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after, + [PARAM_KEY_BEFORE]: pagination?.before || null, + [PARAM_KEY_AFTER]: pagination?.after || null, }; return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true); @@ -247,6 +248,6 @@ export const fromSearchToVariables = ({ */ export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => { return Boolean( - runnerType !== null || filters?.length !== 0 || (pagination && pagination?.page !== 1), + runnerType !== null || filters?.length !== 0 || pagination?.before || pagination?.after, ); }; |