diff options
Diffstat (limited to 'app/assets/javascripts/ci')
103 files changed, 6459 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue b/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue new file mode 100644 index 00000000000..16bfc7f3abe --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue @@ -0,0 +1,45 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; + +export default { + modal: { + id: 'delete-pipeline-schedule-modal', + deleteConfirmation: s__( + 'PipelineSchedules|Are you sure you want to delete this pipeline schedule?', + ), + actionPrimary: { + text: s__('PipelineSchedules|Delete pipeline schedule'), + attributes: [{ variant: 'danger' }], + }, + actionCancel: { + text: __('Cancel'), + attributes: [], + }, + }, + components: { + GlModal, + }, + props: { + visible: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-modal + :visible="visible" + :title="$options.modal.actionPrimary.text" + :modal-id="$options.modal.id" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + size="sm" + @primary="$emit('deleteSchedule')" + @hide="$emit('hideModal')" + > + {{ $options.modal.deleteConfirmation }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue new file mode 100644 index 00000000000..fe16cb7a92e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -0,0 +1,256 @@ +<script> +import { GlAlert, GlBadge, GlButton, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; +import { queryToObject } from '~/lib/utils/url_utility'; +import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; +import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; +import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; +import TakeOwnershipModal from './take_ownership_modal.vue'; +import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; + +export default { + i18n: { + schedulesFetchError: s__('PipelineSchedules|There was a problem fetching pipeline schedules.'), + scheduleDeleteError: s__( + 'PipelineSchedules|There was a problem deleting the pipeline schedule.', + ), + takeOwnershipError: s__( + 'PipelineSchedules|There was a problem taking ownership of the pipeline schedule.', + ), + newSchedule: s__('PipelineSchedules|New schedule'), + deleteSuccess: s__('PipelineSchedules|Pipeline schedule successfully deleted.'), + }, + components: { + DeletePipelineScheduleModal, + GlAlert, + GlBadge, + GlButton, + GlLoadingIcon, + GlTabs, + GlTab, + PipelineSchedulesTable, + TakeOwnershipModal, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + schedules: { + query: getPipelineSchedulesQuery, + variables() { + return { + projectPath: this.fullPath, + status: this.scope, + }; + }, + update(data) { + const { pipelineSchedules: { nodes: list = [], count } = {} } = data.project || {}; + + return { + list, + count, + }; + }, + error() { + this.reportError(this.$options.i18n.schedulesFetchError); + }, + }, + }, + data() { + const { scope } = queryToObject(window.location.search); + return { + schedules: { + list: [], + }, + scope, + hasError: false, + errorMessage: '', + scheduleId: null, + showDeleteModal: false, + showTakeOwnershipModal: false, + count: 0, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.schedules.loading; + }, + schedulesCount() { + return this.schedules.count; + }, + tabs() { + return [ + { + text: s__('PipelineSchedules|All'), + count: limitedCounterWithDelimiter(this.count), + scope: null, + showBadge: true, + attrs: { 'data-testid': 'pipeline-schedules-all-tab' }, + }, + { + text: s__('PipelineSchedules|Active'), + scope: 'ACTIVE', + showBadge: false, + attrs: { 'data-testid': 'pipeline-schedules-active-tab' }, + }, + { + text: s__('PipelineSchedules|Inactive'), + scope: 'INACTIVE', + showBadge: false, + attrs: { 'data-testid': 'pipeline-schedules-inactive-tab' }, + }, + ]; + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to other tabs + schedulesCount(newCount) { + if (!this.scope) { + this.count = newCount; + } + }, + }, + methods: { + reportError(error) { + this.hasError = true; + this.errorMessage = error; + }, + setDeleteModal(id) { + this.showDeleteModal = true; + this.scheduleId = id; + }, + setTakeOwnershipModal(id) { + this.showTakeOwnershipModal = true; + this.scheduleId = id; + }, + hideModal() { + this.showDeleteModal = false; + this.showTakeOwnershipModal = false; + this.scheduleId = null; + }, + async deleteSchedule() { + try { + const { + data: { + pipelineScheduleDelete: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: deletePipelineScheduleMutation, + variables: { id: this.scheduleId }, + }); + + if (errors.length > 0) { + throw new Error(); + } else { + this.$apollo.queries.schedules.refetch(); + this.$toast.show(this.$options.i18n.deleteSuccess); + } + } catch { + this.reportError(this.$options.i18n.scheduleDeleteError); + } + }, + async takeOwnership() { + try { + const { + data: { + pipelineScheduleTakeOwnership: { pipelineSchedule, errors }, + }, + } = await this.$apollo.mutate({ + mutation: takeOwnershipMutation, + variables: { id: this.scheduleId }, + }); + + if (errors.length > 0) { + throw new Error(); + } else { + this.$apollo.queries.schedules.refetch(); + + if (pipelineSchedule?.owner?.name) { + const toastMsg = sprintf( + s__('PipelineSchedules|Successfully taken ownership from %{owner}.'), + { + owner: pipelineSchedule.owner.name, + }, + ); + + this.$toast.show(toastMsg); + } + } + } catch { + this.reportError(this.$options.i18n.takeOwnershipError); + } + }, + fetchPipelineSchedulesByStatus(scope) { + this.scope = scope; + this.$apollo.queries.schedules.refetch(); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false"> + {{ errorMessage }} + </gl-alert> + + <template v-else> + <gl-tabs + sync-active-tab-with-query-params + query-param-name="scope" + nav-class="gl-flex-grow-1 gl-align-items-center" + > + <gl-tab + v-for="tab in tabs" + :key="tab.text" + :title-link-attributes="tab.attrs" + :query-param-value="tab.scope" + @click="fetchPipelineSchedulesByStatus(tab.scope)" + > + <template #title> + <span>{{ tab.text }}</span> + + <template v-if="tab.showBadge"> + <gl-loading-icon v-if="tab.scope === scope && isLoading" class="gl-ml-2" /> + + <gl-badge v-else-if="tab.count" size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> + </template> + </template> + + <gl-loading-icon v-if="isLoading" size="lg" /> + <pipeline-schedules-table + v-else + :schedules="schedules.list" + @showTakeOwnershipModal="setTakeOwnershipModal" + @showDeleteModal="setDeleteModal" + /> + </gl-tab> + + <template #tabs-end> + <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button"> + {{ $options.i18n.newSchedule }} + </gl-button> + </template> + </gl-tabs> + + <take-ownership-modal + :visible="showTakeOwnershipModal" + @takeOwnership="takeOwnership" + @hideModal="hideModal" + /> + + <delete-pipeline-schedule-modal + :visible="showDeleteModal" + @deleteSchedule="deleteSchedule" + @hideModal="hideModal" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue new file mode 100644 index 00000000000..6e24ac6b8d4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -0,0 +1,18 @@ +<script> +import { GlForm } from '@gitlab/ui'; + +export default { + components: { + GlForm, + }, + inject: { + fullPath: { + default: '', + }, + }, +}; +</script> + +<template> + <gl-form /> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue new file mode 100644 index 00000000000..8656e5d3536 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -0,0 +1,68 @@ +<script> +import { GlButton, GlButtonGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export const i18n = { + playTooltip: s__('PipelineSchedules|Run pipeline schedule'), + editTooltip: s__('PipelineSchedules|Edit pipeline schedule'), + deleteTooltip: s__('PipelineSchedules|Delete pipeline schedule'), + takeOwnershipTooltip: s__('PipelineSchedules|Take ownership of pipeline schedule'), +}; + +export default { + i18n, + components: { + GlButton, + GlButtonGroup, + }, + directives: { + GlTooltip, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + canPlay() { + return this.schedule.userPermissions.playPipelineSchedule; + }, + canTakeOwnership() { + return this.schedule.userPermissions.takeOwnershipPipelineSchedule; + }, + canUpdate() { + return this.schedule.userPermissions.updatePipelineSchedule; + }, + canRemove() { + return this.schedule.userPermissions.adminPipelineSchedule; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button-group> + <gl-button v-if="canPlay" v-gl-tooltip :title="$options.i18n.playTooltip" icon="play" /> + <gl-button + v-if="canTakeOwnership" + v-gl-tooltip + :title="$options.i18n.takeOwnershipTooltip" + icon="user" + data-testid="take-ownership-pipeline-schedule-btn" + @click="$emit('showTakeOwnershipModal', schedule.id)" + /> + <gl-button v-if="canUpdate" v-gl-tooltip :title="$options.i18n.editTooltip" icon="pencil" /> + <gl-button + v-if="canRemove" + v-gl-tooltip + :title="$options.i18n.deleteTooltip" + icon="remove" + variant="danger" + data-testid="delete-pipeline-schedule-btn" + @click="$emit('showDeleteModal', schedule.id)" + /> + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue new file mode 100644 index 00000000000..216796b357c --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -0,0 +1,32 @@ +<script> +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; + +export default { + components: { + CiBadge, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + hasPipeline() { + return this.schedule.lastPipeline; + }, + lastPipelineStatus() { + return this.schedule?.lastPipeline?.detailedStatus; + }, + }, +}; +</script> + +<template> + <div> + <ci-badge v-if="hasPipeline" :status="lastPipelineStatus" class="gl-vertical-align-middle" /> + <span v-else data-testid="pipeline-schedule-status-text"> + {{ s__('PipelineSchedules|None') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue new file mode 100644 index 00000000000..48d59bf6e7c --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue @@ -0,0 +1,32 @@ +<script> +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + TimeAgoTooltip, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + showTimeAgo() { + return this.schedule.active && this.schedule.nextRunAt; + }, + realNextRunTime() { + return this.schedule.realNextRun; + }, + }, +}; +</script> + +<template> + <div> + <time-ago-tooltip v-if="showTimeAgo" :time="realNextRunTime" /> + <span v-else data-testid="pipeline-schedule-inactive"> + {{ s__('PipelineSchedules|Inactive') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue new file mode 100644 index 00000000000..e7fa94eb7fc --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_owner.vue @@ -0,0 +1,29 @@ +<script> +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlAvatarLink, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + owner() { + return this.schedule.owner; + }, + }, +}; +</script> + +<template> + <div> + <gl-avatar-link :href="owner.webPath" :title="owner.name" class="gl-ml-3"> + <gl-avatar :size="32" :src="owner.avatarUrl" /> + </gl-avatar-link> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue new file mode 100644 index 00000000000..08efa794bcc --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue @@ -0,0 +1,36 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + schedule: { + type: Object, + required: true, + }, + }, + computed: { + iconName() { + return this.schedule.forTag ? 'tag' : 'fork'; + }, + refPath() { + return this.schedule.refPath; + }, + refDisplay() { + return this.schedule.refForDisplay; + }, + }, +}; +</script> + +<template> + <div> + <gl-icon :name="iconName" /> + <span v-if="refPath"> + <gl-link :href="refPath" class="gl-text-gray-900">{{ refDisplay }}</gl-link> + </span> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue new file mode 100644 index 00000000000..1b97a35a51e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue @@ -0,0 +1,102 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import PipelineScheduleActions from './cells/pipeline_schedule_actions.vue'; +import PipelineScheduleLastPipeline from './cells/pipeline_schedule_last_pipeline.vue'; +import PipelineScheduleNextRun from './cells/pipeline_schedule_next_run.vue'; +import PipelineScheduleOwner from './cells/pipeline_schedule_owner.vue'; +import PipelineScheduleTarget from './cells/pipeline_schedule_target.vue'; + +export default { + fields: [ + { + key: 'description', + label: s__('PipelineSchedules|Description'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-40p', + }, + { + key: 'target', + label: s__('PipelineSchedules|Target'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-10p', + }, + { + key: 'pipeline', + label: s__('PipelineSchedules|Last Pipeline'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-10p', + }, + { + key: 'next', + label: s__('PipelineSchedules|Next Run'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-15p', + }, + { + key: 'owner', + label: s__('PipelineSchedules|Owner'), + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-10p', + }, + { + key: 'actions', + label: '', + thClass: 'gl-border-t-none!', + columnClass: 'gl-w-15p', + }, + ], + components: { + GlTableLite, + PipelineScheduleActions, + PipelineScheduleLastPipeline, + PipelineScheduleNextRun, + PipelineScheduleOwner, + PipelineScheduleTarget, + }, + props: { + schedules: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <gl-table-lite :fields="$options.fields" :items="schedules" stacked="md"> + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(description)="{ item }"> + <span data-testid="pipeline-schedule-description"> + {{ item.description }} + </span> + </template> + + <template #cell(target)="{ item }"> + <pipeline-schedule-target :schedule="item" /> + </template> + + <template #cell(pipeline)="{ item }"> + <pipeline-schedule-last-pipeline :schedule="item" /> + </template> + + <template #cell(next)="{ item }"> + <pipeline-schedule-next-run :schedule="item" /> + </template> + + <template #cell(owner)="{ item }"> + <pipeline-schedule-owner :schedule="item" /> + </template> + + <template #cell(actions)="{ item }"> + <pipeline-schedule-actions + :schedule="item" + @showTakeOwnershipModal="$emit('showTakeOwnershipModal', $event)" + @showDeleteModal="$emit('showDeleteModal', $event)" + /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue new file mode 100644 index 00000000000..3ac52d4735d --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue @@ -0,0 +1,54 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + modalId: 'pipeline-take-ownership-modal', + i18n: { + takeOwnership: s__('PipelineSchedules|Take ownership'), + ownershipMessage: s__( + 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', + ), + cancelLabel: __('Cancel'), + }, + components: { + GlModal, + }, + props: { + visible: { + type: Boolean, + required: true, + }, + }, + computed: { + actionCancel() { + return { text: this.$options.i18n.cancelLabel }; + }, + actionPrimary() { + return { + text: this.$options.i18n.takeOwnership, + attributes: [ + { + variant: 'confirm', + category: 'primary', + }, + ], + }; + }, + }, +}; +</script> +<template> + <gl-modal + :visible="visible" + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-cancel="actionCancel" + :title="$options.i18n.takeOwnership" + size="sm" + @primary="$emit('takeOwnership')" + @hide="$emit('hideModal')" + > + <p>{{ $options.i18n.ownershipMessage }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue new file mode 100644 index 00000000000..7ded3945a32 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue @@ -0,0 +1,52 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + components: { + GlModal, + }, + props: { + ownershipUrl: { + type: String, + required: true, + }, + }, + modalId: 'pipeline-take-ownership-modal', + i18n: { + takeOwnership: s__('PipelineSchedules|Take ownership'), + ownershipMessage: s__( + 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', + ), + cancelLabel: __('Cancel'), + }, + computed: { + actionCancel() { + return { text: this.$options.i18n.cancelLabel }; + }, + actionPrimary() { + return { + text: this.$options.i18n.takeOwnership, + attributes: [ + { + variant: 'confirm', + category: 'primary', + href: this.ownershipUrl, + 'data-method': 'post', + }, + ], + }; + }, + }, +}; +</script> +<template> + <gl-modal + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-cancel="actionCancel" + :title="$options.i18n.takeOwnership" + > + <p>{{ $options.i18n.ownershipMessage }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..8aab0b3fbde --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation deletePipelineSchedule($id: CiPipelineScheduleID!) { + pipelineScheduleDelete(input: { id: $id }) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql new file mode 100644 index 00000000000..e410ef91d8b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql @@ -0,0 +1,12 @@ +mutation takeOwnership($id: CiPipelineScheduleID!) { + pipelineScheduleTakeOwnership(input: { id: $id }) { + pipelineSchedule { + id + owner { + id + name + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql new file mode 100644 index 00000000000..9f6cb429cca --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql @@ -0,0 +1,41 @@ +query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) { + project(fullPath: $projectPath) { + id + pipelineSchedules(status: $status) { + count + nodes { + id + description + forTag + refPath + refForDisplay + lastPipeline { + id + detailedStatus { + id + group + icon + label + text + detailsPath + } + } + active + nextRunAt + realNextRun + owner { + id + avatarUrl + name + webPath + } + userPermissions { + playPipelineSchedule + takeOwnershipPipelineSchedule + updatePipelineSchedule + adminPipelineSchedule + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js new file mode 100644 index 00000000000..4c06fa321e5 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js @@ -0,0 +1,34 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import PipelineSchedules from './components/pipeline_schedules.vue'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const containerEl = document.querySelector('#pipeline-schedules-app'); + + if (!containerEl) { + return false; + } + + const { fullPath } = containerEl.dataset; + + return new Vue({ + el: containerEl, + name: 'PipelineSchedulesRoot', + apolloProvider, + provide: { + fullPath, + }, + render(createElement) { + return createElement(PipelineSchedules); + }, + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js new file mode 100644 index 00000000000..d83417ab84a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import PipelineSchedulesForm from './components/pipeline_schedules_form.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default (selector) => { + const containerEl = document.querySelector(selector); + + if (!containerEl) { + return false; + } + + const { fullPath } = containerEl.dataset; + + return new Vue({ + el: containerEl, + name: 'PipelineSchedulesFormRoot', + apolloProvider, + provide: { + fullPath, + }, + render(createElement) { + return createElement(PipelineSchedulesForm); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue new file mode 100644 index 00000000000..9fa4b521ebc --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -0,0 +1,124 @@ +<script> +import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { formatJobCount } from '../utils'; +import RunnerDeleteButton from '../components/runner_delete_button.vue'; +import RunnerEditButton from '../components/runner_edit_button.vue'; +import RunnerPauseButton from '../components/runner_pause_button.vue'; +import RunnerHeader from '../components/runner_header.vue'; +import RunnerDetails from '../components/runner_details.vue'; +import RunnerJobs from '../components/runner_jobs.vue'; +import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants'; +import runnerQuery from '../graphql/show/runner.query.graphql'; +import { captureException } from '../sentry_utils'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; + +export default { + name: 'AdminRunnerShowApp', + components: { + GlBadge, + GlTabs, + GlTab, + RunnerDeleteButton, + RunnerEditButton, + RunnerPauseButton, + RunnerHeader, + RunnerDetails, + RunnerJobs, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runnerId: { + type: String, + required: true, + }, + runnersPath: { + type: String, + required: true, + }, + }, + data() { + return { + runner: null, + }; + }, + apollo: { + runner: { + query: runnerQuery, + variables() { + return { + id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, + jobCount() { + return formatJobCount(this.runner?.jobCount); + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + onDeleted({ message }) { + saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS }); + redirectTo(this.runnersPath); + }, + }, + I18N_DETAILS, +}; +</script> +<template> + <div> + <runner-header v-if="runner" :runner="runner"> + <template #actions> + <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-pause-button v-if="canUpdate" :runner="runner" /> + <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + </template> + </runner-header> + + <gl-tabs> + <gl-tab> + <template #title>{{ $options.I18N_DETAILS }}</template> + + <runner-details v-if="runner" :runner="runner" /> + </gl-tab> + <gl-tab> + <template #title> + {{ s__('Runners|Jobs') }} + <gl-badge + v-if="jobCount" + data-testid="job-count-badge" + class="gl-tab-counter-badge" + size="sm" + > + {{ jobCount }} + </gl-badge> + </template> + + <runner-jobs v-if="runner" :runner="runner" /> + </gl-tab> + </gl-tabs> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/index.js b/app/assets/javascripts/ci/runner/admin_runner_show/index.js new file mode 100644 index 00000000000..ea455416648 --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_runner_show/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; +import AdminRunnerShowApp from './admin_runner_show_app.vue'; + +Vue.use(VueApollo); + +export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => { + showAlertFromLocalStorage(); + + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { runnerId, runnersPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(AdminRunnerShowApp, { + props: { + runnerId, + runnersPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue new file mode 100644 index 00000000000..2915e460085 --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -0,0 +1,229 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { updateHistory } from '~/lib/utils/url_utility'; +import { fetchPolicies } from '~/lib/graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, + isSearchFiltered, +} from 'ee_else_ce/ci/runner/runner_search_utils'; +import allRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners.query.graphql'; +import allRunnersCountQuery from 'ee_else_ce/ci/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 RunnerList from '../components/runner_list.vue'; +import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; +import RunnerName from '../components/runner_name.vue'; +import RunnerStats from '../components/stat/runner_stats.vue'; +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 { + ADMIN_FILTERED_SEARCH_NAMESPACE, + INSTANCE_TYPE, + I18N_FETCH_ERROR, + FILTER_CSS_CLASSES, +} from '../constants'; +import { captureException } from '../sentry_utils'; + +export default { + name: 'AdminRunnersApp', + components: { + GlLink, + RegistrationDropdown, + RunnerFilteredSearchBar, + RunnerList, + RunnerListEmptyState, + RunnerName, + RunnerStats, + RunnerPagination, + RunnerTypeTabs, + RunnerActionsCell, + }, + mixins: [glFeatureFlagMixin()], + inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], + props: { + registrationToken: { + type: String, + required: true, + }, + }, + data() { + return { + search: fromUrlQueryToSearch(), + runners: { + items: [], + pageInfo: {}, + }, + }; + }, + apollo: { + runners: { + query: allRunnersQuery, + fetchPolicy: fetchPolicies.NETWORK_ONLY, + variables() { + return this.variables; + }, + update(data) { + const { runners } = data; + return { + items: runners?.nodes || [], + pageInfo: runners?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + return fromSearchToVariables(this.search); + }, + countVariables() { + // Exclude pagination variables, leave only filters variables + const { sort, before, last, after, first, ...countVariables } = this.variables; + return countVariables; + }, + runnersLoading() { + return this.$apollo.queries.runners.loading; + }, + noRunnersFound() { + return !this.runnersLoading && !this.runners.items.length; + }, + searchTokens() { + return [ + pausedTokenConfig, + statusTokenConfig, + { + ...tagTokenConfig, + recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, + }, + upgradeStatusTokenConfig, + ]; + }, + isSearchFiltered() { + return isSearchFiltered(this.search); + }, + }, + watch: { + search: { + deep: true, + handler() { + // TODO Implement back button response using onpopstate + // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333804 + updateHistory({ + url: fromSearchToUrl(this.search), + title: document.title, + }); + }, + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + onToggledPaused() { + // When a runner becomes Paused, the tab count can + // become stale, refetch outdated counts. + 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 }); + }, + onPaginationInput(value) { + this.search.pagination = value; + }, + }, + filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE, + INSTANCE_TYPE, + FILTER_CSS_CLASSES, +}; +</script> +<template> + <div> + <div + 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 + v-model="search" + :count-scope="$options.INSTANCE_TYPE" + :count-variables="countVariables" + class="gl-w-full" + content-class="gl-display-none" + nav-class="gl-border-none!" + /> + + <registration-dropdown + class="gl-w-full gl-sm-w-auto gl-mr-auto" + :registration-token="registrationToken" + :type="$options.INSTANCE_TYPE" + right + /> + </div> + + <runner-filtered-search-bar + v-model="search" + :class="$options.FILTER_CSS_CLASSES" + :tokens="searchTokens" + :namespace="$options.filteredSearchNamespace" + /> + + <runner-stats :scope="$options.INSTANCE_TYPE" :variables="countVariables" /> + + <runner-list-empty-state + v-if="noRunnersFound" + :registration-token="registrationToken" + :is-search-filtered="isSearchFiltered" + :svg-path="emptyStateSvgPath" + :filtered-svg-path="emptyStateFilteredSvgPath" + /> + <template v-else> + <runner-list + :runners="runners.items" + :loading="runnersLoading" + :checkable="true" + @deleted="onDeleted" + > + <template #runner-name="{ runner }"> + <gl-link :href="runner.adminUrl"> + <runner-name :runner="runner" /> + </gl-link> + </template> + <template #runner-actions-cell="{ runner }"> + <runner-actions-cell + :runner="runner" + :edit-url="runner.editAdminUrl" + @toggledPaused="onToggledPaused" + @deleted="onDeleted" + /> + </template> + </runner-list> + </template> + + <runner-pagination + class="gl-mt-3" + :disabled="runnersLoading" + :page-info="runners.pageInfo" + @input="onPaginationInput" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js new file mode 100644 index 00000000000..c6db7148eb1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/admin_runners/index.js @@ -0,0 +1,66 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { updateOutdatedUrl } from '~/ci/runner/runner_search_utils'; +import createDefaultClient from '~/lib/graphql'; +import { createLocalState } from '../graphql/list/local_state'; +import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; +import AdminRunnersApp from './admin_runners_app.vue'; + +Vue.use(GlToast); +Vue.use(VueApollo); + +export const initAdminRunners = (selector = '#js-admin-runners') => { + showAlertFromLocalStorage(); + + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + // Redirect outdated URLs + const updatedUrlQuery = updateOutdatedUrl(); + if (updatedUrlQuery) { + visitUrl(updatedUrlQuery); + + // Prevent mounting the rest of the app, redirecting now. + return null; + } + + const { + runnerInstallHelpPage, + registrationToken, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, + } = el.dataset; + + const { cacheConfig, typeDefs, localMutations } = createLocalState(); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + runnerInstallHelpPage, + localMutations, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, + }, + render(h) { + return h(AdminRunnersApp, { + props: { + registrationToken, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/components/cells/link_cell.vue b/app/assets/javascripts/ci/runner/components/cells/link_cell.vue new file mode 100644 index 00000000000..2843ddbacaf --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/link_cell.vue @@ -0,0 +1,27 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + props: { + href: { + type: String, + required: false, + default: null, + }, + }, + computed: { + component() { + if (this.href) { + return GlLink; + } + return 'span'; + }, + }, +}; +</script> + +<template> + <component :is="component" :href="href" v-bind="$attrs" v-on="$listeners"> + <slot></slot> + </component> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue new file mode 100644 index 00000000000..13f520c4edb --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_actions_cell.vue @@ -0,0 +1,57 @@ +<script> +import { GlButtonGroup } from '@gitlab/ui'; +import RunnerEditButton from '../runner_edit_button.vue'; +import RunnerPauseButton from '../runner_pause_button.vue'; +import RunnerDeleteButton from '../runner_delete_button.vue'; + +export default { + name: 'RunnerActionsCell', + components: { + GlButtonGroup, + RunnerEditButton, + RunnerPauseButton, + RunnerDeleteButton, + }, + props: { + runner: { + type: Object, + required: true, + }, + editUrl: { + type: String, + default: null, + required: false, + }, + }, + emits: ['toggledPaused', 'deleted'], + computed: { + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, + }, + methods: { + onToggledPaused() { + this.$emit('toggledPaused'); + }, + onDeleted(value) { + this.$emit('deleted', value); + }, + }, +}; +</script> + +<template> + <gl-button-group> + <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" /> + <runner-pause-button + v-if="canUpdate" + :runner="runner" + :compact="true" + @toggledPaused="onToggledPaused" + /> + <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue new file mode 100644 index 00000000000..cb43760b2d6 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue @@ -0,0 +1,63 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, I18N_ADMIN } from '../../constants'; + +export default { + components: { + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + cell() { + switch (this.runner?.runnerType) { + case INSTANCE_TYPE: + return { + text: I18N_ADMIN, + }; + case GROUP_TYPE: { + const { name, fullName, webUrl } = this.runner?.groups?.nodes[0] || {}; + + return { + text: name, + href: webUrl, + tooltip: fullName !== name ? fullName : '', + }; + } + case PROJECT_TYPE: { + const { name, nameWithNamespace, webUrl } = this.runner?.ownerProject || {}; + + return { + text: name, + href: webUrl, + tooltip: nameWithNamespace !== name ? nameWithNamespace : '', + }; + } + default: + return {}; + } + }, + }, +}; +</script> + +<template> + <div> + <gl-link + v-if="cell.href" + v-gl-tooltip="cell.tooltip" + :href="cell.href" + class="gl-text-body gl-text-decoration-underline" + > + {{ cell.text }} + </gl-link> + <span v-else>{{ cell.text }}</span> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue new file mode 100644 index 00000000000..1e44d5fccc2 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue @@ -0,0 +1,112 @@ +<script> +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; + +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerName from '../runner_name.vue'; +import RunnerTags from '../runner_tags.vue'; +import RunnerTypeBadge from '../runner_type_badge.vue'; + +import { formatJobCount } from '../../utils'; +import { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_VERSION_LABEL, + I18N_LAST_CONTACT_LABEL, + I18N_CREATED_AT_LABEL, +} from '../../constants'; +import RunnerSummaryField from './runner_summary_field.vue'; + +export default { + components: { + GlIcon, + GlSprintf, + TimeAgo, + RunnerSummaryField, + RunnerName, + RunnerTags, + RunnerTypeBadge, + RunnerUpgradeStatusIcon: () => + import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), + TooltipOnTruncate, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + jobCount() { + return formatJobCount(this.runner.jobCount); + }, + }, + i18n: { + I18N_LOCKED_RUNNER_DESCRIPTION, + I18N_VERSION_LABEL, + I18N_LAST_CONTACT_LABEL, + I18N_CREATED_AT_LABEL, + }, +}; +</script> + +<template> + <div> + <div> + <slot :runner="runner" name="runner-name"> + <runner-name :runner="runner" /> + </slot> + <gl-icon + v-if="runner.locked" + v-gl-tooltip + :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + /> + <runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" /> + </div> + + <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2"> + <div class="gl-flex-shrink-0"> + <runner-upgrade-status-icon :runner="runner" /> + <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL"> + <template #version>{{ runner.version }}</template> + </gl-sprintf> + </div> + <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> + <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description"> + {{ runner.description }} + </tooltip-on-truncate> + </div> + + <div> + <runner-summary-field icon="clock"> + <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> + <template v-else>{{ __('Never') }}</template> + </template> + </gl-sprintf> + </runner-summary-field> + + <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')"> + {{ runner.ipAddress }} + </runner-summary-field> + + <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')"> + {{ jobCount }} + </runner-summary-field> + + <runner-summary-field icon="calendar"> + <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> + <template #timeAgo> + <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> + </template> + </gl-sprintf> + </runner-summary-field> + </div> + + <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue new file mode 100644 index 00000000000..67b9b0a266f --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -0,0 +1,46 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; + +import RunnerStatusBadge from '../runner_status_badge.vue'; +import RunnerPausedBadge from '../runner_paused_badge.vue'; + +export default { + components: { + RunnerStatusBadge, + RunnerUpgradeStatusBadge: () => + import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), + RunnerPausedBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + paused() { + return !this.runner.active; + }, + }, +}; +</script> + +<template> + <div> + <runner-status-badge + :runner="runner" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> + <runner-upgrade-status-badge + :runner="runner" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> + <runner-paused-badge + v-if="paused" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue new file mode 100644 index 00000000000..1bbbd55089a --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue @@ -0,0 +1,33 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + icon: { + type: String, + required: false, + default: '', + }, + tooltip: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2"> + <gl-icon v-if="icon" :name="icon" /> + <!-- display tooltip as a label for screen readers --> + <span class="gl-sr-only">{{ tooltip }}</span> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue new file mode 100644 index 00000000000..212ad5fa5a0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue @@ -0,0 +1,92 @@ +<script> +import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; +import RegistrationToken from './registration_token.vue'; +import RegistrationTokenResetDropdownItem from './registration_token_reset_dropdown_item.vue'; + +export default { + i18n: { + showInstallationInstructions: s__( + 'Runners|Show runner installation and registration instructions', + ), + }, + components: { + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlDropdownDivider, + RegistrationToken, + RunnerInstructionsModal, + RegistrationTokenResetDropdownItem, + }, + props: { + registrationToken: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + validator(type) { + return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); + }, + }, + }, + data() { + return { + currentRegistrationToken: this.registrationToken, + }; + }, + computed: { + dropdownText() { + switch (this.type) { + case INSTANCE_TYPE: + return s__('Runners|Register an instance runner'); + case GROUP_TYPE: + return s__('Runners|Register a group runner'); + case PROJECT_TYPE: + return s__('Runners|Register a project runner'); + default: + return s__('Runners|Register a runner'); + } + }, + }, + methods: { + onShowInstructionsClick() { + this.$refs.runnerInstructionsModal.show(); + }, + onTokenReset(token) { + this.currentRegistrationToken = token; + + this.$refs.runnerRegistrationDropdown.hide(true); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="runnerRegistrationDropdown" + menu-class="gl-w-auto!" + :text="dropdownText" + variant="confirm" + v-bind="$attrs" + > + <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick"> + {{ $options.i18n.showInstallationInstructions }} + <runner-instructions-modal + ref="runnerInstructionsModal" + :registration-token="currentRegistrationToken" + data-testid="runner-instructions-modal" + /> + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-form class="gl-p-4!"> + <registration-token input-id="token-value" :value="currentRegistrationToken" /> + </gl-dropdown-form> + <gl-dropdown-divider /> + <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue new file mode 100644 index 00000000000..6b4e6a929b7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue @@ -0,0 +1,49 @@ +<script> +import { s__ } from '~/locale'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +export default { + components: { + InputCopyToggleVisibility, + }, + i18n: { + registrationToken: s__('Runners|Registration token'), + }, + props: { + inputId: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: '', + }, + }, + computed: { + formInputGroupProps() { + return { + id: this.inputId, + }; + }, + }, + methods: { + onCopy() { + // value already in the clipboard, simply notify the user + this.$toast?.show(s__('Runners|Registration token copied!')); + }, + }, + I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'), +}; +</script> +<template> + <input-copy-toggle-visibility + class="gl-m-0" + :value="value" + :label="$options.i18n.registrationToken" + :label-for="inputId" + :copy-button-title="$options.I18N_COPY_BUTTON_TITLE" + :form-input-group-props="formInputGroupProps" + @copy="onCopy" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue new file mode 100644 index 00000000000..6740065e860 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -0,0 +1,141 @@ +<script> +import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants'; + +const i18n = { + modalAction: s__('Runners|Reset token'), + modalCancel: __('Cancel'), + modalCopy: __('Are you sure you want to reset the registration token?'), + modalTitle: __('Reset registration token'), +}; + +export default { + name: 'RunnerRegistrationTokenReset', + i18n, + components: { + GlDropdownItem, + GlLoadingIcon, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + groupId: { + default: null, + }, + projectId: { + default: null, + }, + }, + modalId: 'token-reset-modal', + props: { + type: { + type: String, + required: true, + validator(type) { + return [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE].includes(type); + }, + }, + }, + data() { + return { + loading: false, + }; + }, + computed: { + resetTokenInput() { + switch (this.type) { + case INSTANCE_TYPE: + return { + type: this.type, + }; + case GROUP_TYPE: + return { + id: convertToGraphQLId(TYPE_GROUP, this.groupId), + type: this.type, + }; + case PROJECT_TYPE: + return { + id: convertToGraphQLId(TYPE_PROJECT, this.projectId), + type: this.type, + }; + default: + return null; + } + }, + actionPrimary() { + return { + text: i18n.modalAction, + attributes: [{ variant: 'danger' }], + }; + }, + actionSecondary() { + return { + text: i18n.modalCancel, + attributes: [{ variant: 'default' }], + }; + }, + }, + methods: { + handleModalPrimary() { + this.resetToken(); + }, + async resetToken() { + this.loading = true; + try { + const { + data: { + runnersRegistrationTokenReset: { token, errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnersRegistrationTokenResetMutation, + variables: { + input: this.resetTokenInput, + }, + }); + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + this.onSuccess(token); + } catch (e) { + this.onError(e); + } finally { + this.loading = false; + } + }, + onError(error) { + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + onSuccess(token) { + this.$toast?.show(s__('Runners|New registration token generated!')); + this.$emit('tokenReset', token); + }, + }, +}; +</script> +<template> + <gl-dropdown-item v-gl-modal="$options.modalId"> + {{ __('Reset registration token') }} + <gl-modal + size="sm" + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-secondary="actionSecondary" + :title="$options.i18n.modalTitle" + @primary="handleModalPrimary" + > + <p>{{ $options.i18n.modalCopy }}</p> + </gl-modal> + <gl-loading-icon v-if="loading" inline /> + </gl-dropdown-item> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue new file mode 100644 index 00000000000..2fa87bdd776 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue @@ -0,0 +1,63 @@ +<script> +import { GlAvatar, GlBadge, GlLink } from '@gitlab/ui'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; + +export default { + components: { + GlAvatar, + GlBadge, + GlLink, + }, + props: { + href: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + fullName: { + type: String, + required: true, + }, + avatarUrl: { + type: String, + required: false, + default: null, + }, + description: { + type: String, + required: false, + default: null, + }, + isOwner: { + type: Boolean, + required: false, + default: false, + }, + }, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-py-5"> + <gl-link :href="href" data-testid="item-avatar" class="gl-text-decoration-none! gl-mr-3"> + <gl-avatar + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :entity-name="name" + :alt="name" + :src="avatarUrl" + :size="48" + /> + </gl-link> + <div> + <div class="gl-mb-1"> + <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link> + <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge> + </div> + <div v-if="description">{{ description }}</div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue new file mode 100644 index 00000000000..1ec3f8da7c3 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue @@ -0,0 +1,199 @@ +<script> +import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { __, s__, n__, sprintf } from '~/locale'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; +import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql'; +import { RUNNER_TYPENAME } from '../constants'; + +export default { + components: { + GlButton, + GlModal, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: ['localMutations'], + props: { + runners: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + isDeleting: false, + checkedRunnerIds: [], + }; + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + }, + }, + computed: { + currentCheckedRunnerIds() { + return this.runners + .map(({ id }) => id) + .filter((id) => this.checkedRunnerIds.indexOf(id) >= 0); + }, + checkedCount() { + return this.currentCheckedRunnerIds.length || 0; + }, + bannerMessage() { + return sprintf( + n__( + 'Runners|%{strongStart}%{count}%{strongEnd} runner selected', + 'Runners|%{strongStart}%{count}%{strongEnd} runners selected', + this.checkedCount, + ), + { + count: this.checkedCount, + }, + ); + }, + modalTitle() { + return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount); + }, + modalActionPrimary() { + return { + text: n__( + 'Runners|Permanently delete %d runner', + 'Runners|Permanently delete %d runners', + this.checkedCount, + ), + attributes: { + loading: this.isDeleting, + variant: 'danger', + }, + }; + }, + modalActionCancel() { + return { + text: __('Cancel'), + attributes: { + loading: this.isDeleting, + }, + }; + }, + modalMessage() { + return sprintf( + n__( + 'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + 'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', + this.checkedCount, + ), + { count: this.checkedCount }, + ); + }, + }, + methods: { + toastConfirmationMessage(deletedCount) { + return n__( + 'Runners|%d selected runner deleted', + 'Runners|%d selected runners deleted', + deletedCount, + ); + }, + onClearChecked() { + this.localMutations.clearChecked(); + }, + async onConfirmDelete(e) { + this.isDeleting = true; + e.preventDefault(); // don't close modal until deletion is complete + + try { + await this.$apollo.mutate({ + mutation: BulkRunnerDelete, + variables: { + input: { + ids: this.currentCheckedRunnerIds, + }, + }, + update: (cache, { data }) => { + const { errors, deletedIds } = data.bulkRunnerDelete; + + if (errors?.length) { + createAlert({ + message: s__( + 'Runners|An error occurred while deleting. Some runners may not have been deleted.', + ), + captureError: true, + error: new Error(errors.join(' ')), + }); + } + + if (deletedIds?.length) { + this.$emit('deleted', { + message: this.toastConfirmationMessage(deletedIds.length), + }); + + // Remove deleted runners from the cache + deletedIds.forEach((id) => { + const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id }); + cache.evict({ id: cacheId }); + }); + cache.gc(); + } + }, + }); + } catch (error) { + this.onError(error); + } finally { + this.isDeleting = false; + this.$refs.modal.hide(); + } + }, + onError(error) { + createAlert({ + message: s__( + 'Runners|Something went wrong while deleting. Please refresh the page to try again.', + ), + captureError: true, + error, + }); + }, + }, + BULK_DELETE_MODAL_ID: 'bulk-delete-modal', +}; +</script> + +<template> + <div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"> + <div class="gl-display-flex gl-align-items-center"> + <div> + <gl-sprintf :message="bannerMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <div class="gl-ml-auto"> + <gl-button variant="default" @click="onClearChecked">{{ + s__('Runners|Clear selection') + }}</gl-button> + <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ + s__('Runners|Delete selected') + }}</gl-button> + </div> + </div> + <gl-modal + ref="modal" + size="sm" + :modal-id="$options.BULK_DELETE_MODAL_ID" + :title="modalTitle" + :action-primary="modalActionPrimary" + :action-cancel="modalActionCancel" + @primary="onConfirmDelete" + > + <gl-sprintf :message="modalMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue new file mode 100644 index 00000000000..75afb7a00bc --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete_checkbox.vue @@ -0,0 +1,67 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; + +export default { + components: { + GlFormCheckbox, + }, + inject: ['localMutations'], + props: { + runners: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + checkedRunnerIds: [], + }; + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + }, + }, + computed: { + deletableRunners() { + return this.runners.filter((runner) => runner.userPermissions?.deleteRunner); + }, + disabled() { + return !this.deletableRunners.length; + }, + checked() { + return Boolean(this.deletableRunners.length) && this.deletableRunners.every(this.isChecked); + }, + indeterminate() { + return !this.checked && this.deletableRunners.some(this.isChecked); + }, + label() { + return this.checked ? s__('Runners|Unselect all') : s__('Runners|Select all'); + }, + }, + methods: { + isChecked({ id }) { + return this.checkedRunnerIds.indexOf(id) >= 0; + }, + onChange($event) { + this.localMutations.setRunnersChecked({ + runners: this.deletableRunners, + isChecked: $event, + }); + }, + }, +}; +</script> + +<template> + <gl-form-checkbox + :aria-label="label" + :indeterminate="indeterminate" + :checked="checked" + :disabled="disabled" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue new file mode 100644 index 00000000000..32d4076b00f --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue @@ -0,0 +1,156 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql'; +import { createAlert } from '~/flash'; +import { sprintf, s__ } from '~/locale'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants'; +import RunnerDeleteModal from './runner_delete_modal.vue'; + +export default { + name: 'RunnerDeleteButton', + components: { + GlButton, + RunnerDeleteModal, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + props: { + runner: { + type: Object, + required: true, + validator: (runner) => { + return runner?.id && runner?.shortSha; + }, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['deleted'], + data() { + return { + deleting: false, + }; + }, + computed: { + runnerId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerName() { + return `#${this.runnerId} (${this.runner.shortSha})`; + }, + runnerDeleteModalId() { + return `delete-runner-modal-${this.runnerId}`; + }, + icon() { + if (this.compact) { + return 'close'; + } + return ''; + }, + buttonContent() { + if (this.compact) { + return null; + } + return I18N_DELETE_RUNNER; + }, + buttonClass() { + // Ensure a square button is shown when compact: true. + // Without this class we will have distorted/rectangular button. + if (this.compact) { + return 'btn-icon'; + } + return null; + }, + ariaLabel() { + if (this.compact) { + return I18N_DELETE_RUNNER; + } + return null; + }, + tooltip() { + // Only show basic "delete" tooltip when compact. + // Also prevent a "sticky" tooltip: If this button is + // loading, mouseout listeners don't run leaving the tooltip stuck + if (this.compact && !this.deleting) { + return I18N_DELETE_RUNNER; + } + return ''; + }, + }, + methods: { + async onDelete() { + // Deleting stays "true" until this row is removed, + // should only change back if the operation fails. + this.deleting = true; + try { + await this.$apollo.mutate({ + mutation: runnerDeleteMutation, + variables: { + input: { + id: this.runner.id, + }, + }, + update: (cache, { data }) => { + const { errors } = data.runnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + return; + } + + this.$emit('deleted', { + message: sprintf(I18N_DELETED_TOAST, { name: this.runnerName }), + }); + + // Remove deleted runner from the cache + const cacheId = cache.identify(this.runner); + cache.evict({ id: cacheId }); + cache.gc(); + }, + }); + } catch (e) { + this.onError(e); + } + }, + onError(error) { + this.deleting = false; + const { message } = error; + const title = sprintf(s__('Runner|Runner %{runnerName} failed to delete'), { + runnerName: this.runnerName, + }); + + createAlert({ title, message }); + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltip" class="btn-group"> + <gl-button + v-gl-modal="runnerDeleteModalId" + :aria-label="ariaLabel" + :icon="icon" + :class="buttonClass" + :loading="deleting" + variant="danger" + category="secondary" + v-bind="$attrs" + > + {{ buttonContent }} + </gl-button> + <runner-delete-modal + :modal-id="runnerDeleteModalId" + :runner-name="runnerName" + @primary="onDelete" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue new file mode 100644 index 00000000000..8be216a7eb5 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue @@ -0,0 +1,51 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; + +const I18N_TITLE = s__('Runners|Delete runner %{name}?'); +const I18N_BODY = s__( + 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', +); +const I18N_PRIMARY = s__('Runners|Delete runner'); +const I18N_CANCEL = __('Cancel'); + +export default { + components: { + GlModal, + }, + props: { + runnerName: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(I18N_TITLE, { name: this.runnerName }); + }, + }, + methods: { + onPrimary() { + this.$refs.modal.hide(); + }, + }, + actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } }, + actionCancel: { text: I18N_CANCEL }, + I18N_BODY, +}; +</script> + +<template> + <gl-modal + ref="modal" + size="sm" + :title="title" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + v-bind="$attrs" + v-on="$listeners" + @primary="onPrimary" + > + {{ $options.I18N_BODY }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue new file mode 100644 index 00000000000..c260670b517 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue @@ -0,0 +1,55 @@ +<script> +import { __ } from '~/locale'; + +/** + * Usage: + * + * With a `value` prop: + * + * <runner-detail label="Field Name" :value="value" /> + * + * Or a `value` slot: + * + * <runner-detail label="Field Name"> + * <template #value> + * <strong>{{ value }}</strong> + * </template> + * </runner-detail> + * + */ +export default { + props: { + label: { + type: String, + default: null, + required: false, + }, + value: { + type: String, + default: null, + required: false, + }, + emptyValue: { + type: String, + default: __('None'), + required: false, + }, + }, +}; +</script> + +<template> + <div class="gl-display-contents"> + <dt class="gl-mb-5 gl-mr-6 gl-max-w-26"> + <template v-if="label || $scopedSlots.label"> + <slot name="label">{{ label }}</slot> + </template> + </dt> + <dd class="gl-mb-5"> + <template v-if="value || $scopedSlots.value"> + <slot name="value">{{ value }}</slot> + </template> + <span v-else class="gl-text-gray-500">{{ emptyValue }}</span> + </dd> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue new file mode 100644 index 00000000000..6eba8f2e49f --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_details.vue @@ -0,0 +1,159 @@ +<script> +import { GlIntersperse, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import RunnerDetail from './runner_detail.vue'; +import RunnerGroups from './runner_groups.vue'; +import RunnerProjects from './runner_projects.vue'; +import RunnerTags from './runner_tags.vue'; + +export default { + components: { + GlIntersperse, + GlLink, + HelpPopover, + RunnerDetail, + RunnerMaintenanceNoteDetail: () => + import('ee_component/ci/runner/components/runner_maintenance_note_detail.vue'), + RunnerGroups, + RunnerProjects, + RunnerUpgradeStatusBadge: () => + import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), + RunnerUpgradeStatusAlert: () => + import('ee_component/ci/runner/components/runner_upgrade_status_alert.vue'), + RunnerTags, + TimeAgo, + }, + props: { + runner: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + maximumTimeout() { + const { maximumTimeout } = this.runner; + if (typeof maximumTimeout !== 'number') { + return null; + } + return timeIntervalInWords(maximumTimeout); + }, + configTextProtected() { + if (this.runner.accessLevel === ACCESS_LEVEL_REF_PROTECTED) { + return s__('Runners|Protected'); + } + return null; + }, + configTextUntagged() { + if (this.runner.runUntagged) { + return s__('Runners|Runs untagged jobs'); + } + return null; + }, + tagList() { + return this.runner.tagList || []; + }, + isGroupRunner() { + return this.runner?.runnerType === GROUP_TYPE; + }, + isProjectRunner() { + return this.runner?.runnerType === PROJECT_TYPE; + }, + tokenExpirationHelpPopoverOptions() { + return { + title: s__('Runners|Runner authentication token expiration'), + }; + }, + tokenExpirationHelpUrl() { + return helpPagePath('ci/runners/configure_runners', { + anchor: 'authentication-token-security', + }); + }, + }, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> + +<template> + <div> + <runner-upgrade-status-alert class="gl-my-4" :runner="runner" /> + <div class="gl-pt-4"> + <dl + class="gl-mb-0 gl-display-grid runner-details-grid-template" + data-testid="runner-details-list" + > + <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> + <runner-detail + :label="s__('Runners|Last contact')" + :empty-value="s__('Runners|Never contacted')" + > + <template v-if="runner.contactedAt" #value> + <time-ago :time="runner.contactedAt" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Version')"> + <template v-if="runner.version" #value> + {{ runner.version }} + <runner-upgrade-status-badge size="sm" :runner="runner" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" /> + <runner-detail :label="s__('Runners|Executor')" :value="runner.executorName" /> + <runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" /> + <runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" /> + <runner-detail :label="s__('Runners|Configuration')"> + <template v-if="configTextProtected || configTextUntagged" #value> + <gl-intersperse> + <span v-if="configTextProtected">{{ configTextProtected }}</span> + <span v-if="configTextUntagged">{{ configTextUntagged }}</span> + </gl-intersperse> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" /> + <runner-detail :empty-value="s__('Runners|Never expires')"> + <template #label> + {{ s__('Runners|Token expiry') }} + <help-popover :options="tokenExpirationHelpPopoverOptions"> + <p> + {{ + s__( + 'Runners|Runner authentication tokens will expire based on a set interval. They will automatically rotate once expired.', + ) + }} + </p> + <p class="gl-mb-0"> + <gl-link + :href="tokenExpirationHelpUrl" + target="_blank" + class="gl-reset-font-size" + >{{ __('Learn more') }}</gl-link + > + </p> + </help-popover> + </template> + <template v-if="runner.tokenExpiresAt" #value> + <time-ago :time="runner.tokenExpiresAt" /> + </template> + </runner-detail> + <runner-detail :label="s__('Runners|Tags')"> + <template v-if="tagList.length" #value> + <runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" /> + </template> + </runner-detail> + + <runner-maintenance-note-detail + class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid" + :value="runner.maintenanceNoteHtml" + /> + </dl> + </div> + + <runner-groups v-if="isGroupRunner" :runner="runner" /> + <runner-projects v-if="isProjectRunner" :runner="runner" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_edit_button.vue b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue new file mode 100644 index 00000000000..33e0acaf5c0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_edit_button.vue @@ -0,0 +1,24 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_EDIT } from '../constants'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + I18N_EDIT, +}; +</script> + +<template> + <gl-button + v-gl-tooltip="$options.I18N_EDIT" + v-bind="$attrs" + :aria-label="$options.I18N_EDIT" + icon="pencil" + v-on="$listeners" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue new file mode 100644 index 00000000000..ee56fea8282 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue @@ -0,0 +1,100 @@ +<script> +import { cloneDeep } from 'lodash'; +import { __ } from '~/locale'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { searchValidator } from '~/ci/runner/runner_search_utils'; +import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants'; + +const sortOptions = [ + { + id: 1, + title: __('Created date'), + sortDirection: { + descending: CREATED_DESC, + ascending: CREATED_ASC, + }, + }, + { + id: 2, + title: __('Last contact'), + sortDirection: { + descending: CONTACTED_DESC, + ascending: CONTACTED_ASC, + }, + }, +]; + +export default { + components: { + FilteredSearch, + }, + props: { + value: { + type: Object, + required: true, + validator: searchValidator, + }, + tokens: { + type: Array, + required: false, + default: () => [], + }, + namespace: { + type: String, + required: true, + }, + }, + data() { + // filtered_search_bar_root.vue may mutate the initial + // filters. Use `cloneDeep` to prevent those mutations + // from affecting this component + const { filters, sort } = cloneDeep(this.value); + return { + initialFilterValue: filters, + initialSortBy: sort, + }; + }, + computed: { + validTokens() { + // Some filters are only available in EE + // EE-only tokens are represented by `null` or `undefined` + // values when in CE + return this.tokens.filter(Boolean); + }, + }, + methods: { + onFilter(filters) { + // Apply new filters, resetting pagination + this.$emit('input', { + ...this.value, + filters, + pagination: {}, + }); + }, + onSort(sort) { + // Apply new sort, resetting pagination + this.$emit('input', { + ...this.value, + sort, + pagination: {}, + }); + }, + }, + sortOptions, +}; +</script> +<template> + <filtered-search + v-bind="$attrs" + :namespace="namespace" + recent-searches-storage-key="runners-search" + :sort-options="$options.sortOptions" + :initial-filter-value="initialFilterValue" + :tokens="validTokens" + :initial-sort-by="initialSortBy" + :search-input-placeholder="__('Search or filter results...')" + data-testid="runners-filtered-search" + @onFilter="onFilter" + @onSort="onSort" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_groups.vue b/app/assets/javascripts/ci/runner/components/runner_groups.vue new file mode 100644 index 00000000000..c3b35bd52a9 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_groups.vue @@ -0,0 +1,37 @@ +<script> +import RunnerAssignedItem from './runner_assigned_item.vue'; + +export default { + components: { + RunnerAssignedItem, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + groups() { + return this.runner.groups?.nodes || []; + }, + }, +}; +</script> + +<template> + <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"> + <h3 class="gl-font-lg gl-mt-5 gl-mb-0">{{ s__('Runners|Assigned Group') }}</h3> + <template v-if="groups.length"> + <runner-assigned-item + v-for="group in groups" + :key="group.id" + :href="group.webUrl" + :name="group.name" + :full-name="group.fullName" + :avatar-url="group.avatarUrl" + /> + </template> + <span v-else class="gl-text-gray-500">{{ __('None') }}</span> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue new file mode 100644 index 00000000000..874c234ca4c --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_header.vue @@ -0,0 +1,70 @@ +<script> +import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; +import RunnerTypeBadge from './runner_type_badge.vue'; +import RunnerStatusBadge from './runner_status_badge.vue'; + +export default { + components: { + GlIcon, + GlSprintf, + TimeAgo, + RunnerTypeBadge, + RunnerStatusBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + paused() { + return !this.runner.active; + }, + heading() { + const id = getIdFromGraphQLId(this.runner.id); + return sprintf(I18N_DETAILS_TITLE, { runner_id: id }); + }, + }, + I18N_LOCKED_RUNNER_DESCRIPTION, +}; +</script> +<template> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-gap-3 gl-flex-wrap gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap"> + <runner-status-badge :runner="runner" /> + <runner-type-badge v-if="runner" :type="runner.runnerType" /> + <span> + <template v-if="runner.createdAt"> + <gl-sprintf :message="__('%{runner} created %{timeago}')"> + <template #runner> + <strong>{{ heading }}</strong> + <gl-icon + v-if="runner.locked" + v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + /> + </template> + <template #timeago> + <time-ago :time="runner.createdAt" /> + </template> + </gl-sprintf> + </template> + <template v-else> + <strong>{{ heading }}</strong> + </template> + </span> + </div> + <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs.vue b/app/assets/javascripts/ci/runner/components/runner_jobs.vue new file mode 100644 index 00000000000..9003eba3636 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_jobs.vue @@ -0,0 +1,82 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import runnerJobsQuery from '../graphql/show/runner_jobs.query.graphql'; +import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; +import { captureException } from '../sentry_utils'; +import { getPaginationVariables } from '../utils'; +import RunnerJobsTable from './runner_jobs_table.vue'; +import RunnerPagination from './runner_pagination.vue'; + +export default { + name: 'RunnerJobs', + components: { + GlSkeletonLoader, + RunnerJobsTable, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + jobs: { + items: [], + pageInfo: {}, + }, + pagination: {}, + }; + }, + apollo: { + jobs: { + query: runnerJobsQuery, + variables() { + return this.variables; + }, + update({ runner }) { + return { + items: runner?.jobs?.nodes || [], + pageInfo: runner?.jobs?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + captureException({ error, component: this.$options.name }); + }, + }, + }, + computed: { + variables() { + const { id } = this.runner; + return { + id, + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.jobs.loading; + }, + }, + methods: { + onPaginationInput(value) { + this.pagination = value; + }, + }, + I18N_NO_JOBS_FOUND, +}; +</script> + +<template> + <div class="gl-pt-3"> + <div v-if="loading" class="gl-py-5"> + <gl-skeleton-loader /> + </div> + <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" /> + <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p> + + <runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue new file mode 100644 index 00000000000..efa7909c913 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -0,0 +1,114 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { durationTimeFormatted } from '~/lib/utils/datetime_utility'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import RunnerTags from '~/ci/runner/components/runner_tags.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { tableField } from '../utils'; +import LinkCell from './cells/link_cell.vue'; + +export default { + components: { + CiBadge, + GlTableLite, + LinkCell, + RunnerTags, + TimeAgo, + }, + props: { + jobs: { + type: Array, + required: true, + }, + }, + methods: { + trAttr(job) { + if (job?.id) { + return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` }; + } + return {}; + }, + jobId(job) { + return getIdFromGraphQLId(job.id); + }, + jobPath(job) { + return job.detailedStatus?.detailsPath; + }, + projectName(job) { + return job.pipeline?.project?.name; + }, + projectWebUrl(job) { + return job.pipeline?.project?.webUrl; + }, + commitShortSha(job) { + return job.shortSha; + }, + commitPath(job) { + return job.commitPath; + }, + duration(job) { + const { duration } = job; + return duration ? durationTimeFormatted(duration) : ''; + }, + queued(job) { + const { queuedDuration } = job; + return queuedDuration ? durationTimeFormatted(queuedDuration) : ''; + }, + }, + fields: [ + tableField({ key: 'status', label: s__('Job|Status') }), + tableField({ key: 'job', label: __('Job') }), + tableField({ key: 'project', label: __('Project') }), + tableField({ key: 'commit', label: __('Commit') }), + tableField({ key: 'finished_at', label: s__('Job|Finished at') }), + tableField({ key: 'duration', label: s__('Job|Duration') }), + tableField({ key: 'queued', label: s__('Job|Queued') }), + tableField({ key: 'tags', label: s__('Runners|Tags') }), + ], +}; +</script> + +<template> + <gl-table-lite + :items="jobs" + :fields="$options.fields" + :tbody-tr-attr="trAttr" + primary-key="id" + stacked="md" + fixed + > + <template #cell(status)="{ item = {} }"> + <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" /> + </template> + + <template #cell(job)="{ item = {} }"> + <link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell> + </template> + + <template #cell(project)="{ item = {} }"> + <link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell> + </template> + + <template #cell(commit)="{ item = {} }"> + <link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell> + </template> + + <template #cell(finished_at)="{ item = {} }"> + <time-ago v-if="item.finishedAt" :time="item.finishedAt" /> + </template> + + <template #cell(duration)="{ item = {} }"> + {{ duration(item) }} + </template> + + <template #cell(queued)="{ item = {} }"> + {{ queued(item) }} + </template> + + <template #cell(tags)="{ item = {} }"> + <runner-tags :tag-list="item.tags" /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue new file mode 100644 index 00000000000..e895537dcdc --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -0,0 +1,188 @@ +<script> +import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { s__ } from '~/locale'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; +import { formatJobCount, tableField } from '../utils'; +import RunnerBulkDelete from './runner_bulk_delete.vue'; +import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; +import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; +import RunnerStatusPopover from './runner_status_popover.vue'; +import RunnerStatusCell from './cells/runner_status_cell.vue'; +import RunnerOwnerCell from './cells/runner_owner_cell.vue'; + +const defaultFields = [ + tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }), + tableField({ key: 'summary', label: s__('Runners|Runner') }), + tableField({ key: 'owner', label: s__('Runners|Owner'), thClasses: ['gl-w-20p'] }), + tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }), +]; + +export default { + components: { + GlFormCheckbox, + GlTableLite, + GlSkeletonLoader, + HelpPopover, + RunnerBulkDelete, + RunnerBulkDeleteCheckbox, + RunnerStatusPopover, + RunnerStackedSummaryCell, + RunnerStatusCell, + RunnerOwnerCell, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + apollo: { + checkedRunnerIds: { + query: checkedRunnerIdsQuery, + skip() { + return !this.checkable; + }, + }, + }, + inject: ['localMutations'], + props: { + checkable: { + type: Boolean, + required: false, + default: false, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + runners: { + type: Array, + required: true, + }, + }, + emits: ['deleted'], + data() { + return { checkedRunnerIds: [] }; + }, + computed: { + tableClass() { + // <gl-table-lite> does not provide a busy state, add + // simple support for it. + // See http://bootstrap-vue.org/docs/components/table#table-busy-state + return { + 'gl-opacity-6': this.loading, + }; + }, + fields() { + const fields = defaultFields; + + if (this.checkable) { + const checkboxField = tableField({ + key: 'checkbox', + label: s__('Runners|Checkbox'), + thClasses: ['gl-w-9'], + tdClass: ['gl-text-center'], + }); + return [checkboxField, ...fields]; + } + return fields; + }, + }, + methods: { + canDelete(runner) { + return runner.userPermissions?.deleteRunner; + }, + onDeleted(event) { + this.$emit('deleted', event); + }, + formatJobCount(jobCount) { + return formatJobCount(jobCount); + }, + runnerTrAttr(runner) { + if (runner) { + return { + 'data-testid': `runner-row-${getIdFromGraphQLId(runner.id)}`, + }; + } + return {}; + }, + onCheckboxChange(runner, isChecked) { + this.localMutations.setRunnerChecked({ + runner, + isChecked, + }); + }, + isChecked(runner) { + return this.checkedRunnerIds.includes(runner.id); + }, + }, +}; +</script> +<template> + <div> + <runner-bulk-delete v-if="checkable" :runners="runners" @deleted="onDeleted" /> + <gl-table-lite + :aria-busy="loading" + :class="tableClass" + :items="runners" + :fields="fields" + :tbody-tr-attr="runnerTrAttr" + data-testid="runner-list" + stacked="md" + primary-key="id" + fixed + > + <template #head(checkbox)> + <runner-bulk-delete-checkbox :runners="runners" /> + </template> + + <template #cell(checkbox)="{ item }"> + <gl-form-checkbox + v-if="canDelete(item)" + :checked="isChecked(item)" + @change="onCheckboxChange(item, $event)" + /> + </template> + + <template #head(status)="{ label }"> + {{ label }} + <runner-status-popover /> + </template> + + <template #cell(status)="{ item }"> + <runner-status-cell :runner="item" /> + </template> + + <template #cell(summary)="{ item, index }"> + <runner-stacked-summary-cell :runner="item"> + <template #runner-name="{ runner }"> + <slot name="runner-name" :runner="runner" :index="index"></slot> + </template> + </runner-stacked-summary-cell> + </template> + + <template #head(owner)="{ label }"> + {{ label }} + <help-popover> + {{ + s__( + 'Runners|The project, group or instance where the runner was registered. Instance runners are always owned by Administrator.', + ) + }} + </help-popover> + </template> + + <template #cell(owner)="{ item }"> + <runner-owner-cell :runner="item" /> + </template> + + <template #cell(actions)="{ item }"> + <slot name="runner-actions-cell" :runner="item"></slot> + </template> + </gl-table-lite> + + <template v-if="!runners.length && loading"> + <gl-skeleton-loader v-for="i in 4" :key="i" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue new file mode 100644 index 00000000000..e6576c83e69 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue @@ -0,0 +1,82 @@ +<script> +import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; + +export default { + components: { + GlEmptyState, + GlLink, + GlSprintf, + RunnerInstructionsModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + isSearchFiltered: { + type: Boolean, + required: false, + default: false, + }, + svgPath: { + type: String, + required: false, + default: '', + }, + filteredSvgPath: { + type: String, + required: false, + default: '', + }, + registrationToken: { + type: String, + required: false, + default: null, + }, + }, + modalId: 'runners-empty-state-instructions-modal', + svgHeight: 145, +}; +</script> + +<template> + <gl-empty-state + v-if="isSearchFiltered" + :title="s__('Runners|No results found')" + :svg-path="filteredSvgPath" + :svg-height="$options.svgHeight" + :description="s__('Runners|Edit your search and try again')" + /> + <gl-empty-state + v-else + :title="s__('Runners|Get started with runners')" + :svg-path="svgPath" + :svg-height="$options.svgHeight" + > + <template v-if="registrationToken" #description> + <gl-sprintf + :message=" + s__( + 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.', + ) + " + > + <template #link="{ content }"> + <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link> + </template> + </gl-sprintf> + + <runner-instructions-modal + :modal-id="$options.modalId" + :registration-token="registrationToken" + /> + </template> + <template v-else #description> + {{ + s__( + 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.', + ) + }} + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue b/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue new file mode 100644 index 00000000000..2b37b1cc797 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_membership_toggle.vue @@ -0,0 +1,42 @@ +<script> +import { GlToggle } from '@gitlab/ui'; +import { + I18N_SHOW_ONLY_INHERITED, + MEMBERSHIP_DESCENDANTS, + MEMBERSHIP_ALL_AVAILABLE, +} from '../constants'; + +export default { + components: { + GlToggle, + }, + props: { + value: { + type: String, + default: MEMBERSHIP_DESCENDANTS, + required: false, + }, + }, + computed: { + toggle() { + return this.value === MEMBERSHIP_DESCENDANTS; + }, + }, + methods: { + onChange(value) { + this.$emit('input', value ? MEMBERSHIP_DESCENDANTS : MEMBERSHIP_ALL_AVAILABLE); + }, + }, + I18N_SHOW_ONLY_INHERITED, +}; +</script> + +<template> + <gl-toggle + data-testid="runner-membership-toggle" + :value="toggle" + :label="$options.I18N_SHOW_ONLY_INHERITED" + label-position="left" + @change="onChange" + /> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_name.vue b/app/assets/javascripts/ci/runner/components/runner_name.vue new file mode 100644 index 00000000000..d4ecfd2d776 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_name.vue @@ -0,0 +1,20 @@ +<script> +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + props: { + runner: { + type: Object, + required: true, + }, + }, + methods: { + getIdFromGraphQLId, + }, +}; +</script> +<template> + <span class="gl-font-weight-bold gl-vertical-align-middle" + >#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span + > +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pagination.vue b/app/assets/javascripts/ci/runner/components/runner_pagination.vue new file mode 100644 index 00000000000..a5bf3074dd1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pagination.vue @@ -0,0 +1,50 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; + +export default { + components: { + GlKeysetPagination, + }, + inheritAttrs: false, + props: { + pageInfo: { + required: false, + type: Object, + default: () => ({}), + }, + }, + computed: { + paginationProps() { + return { ...this.pageInfo, ...this.$attrs }; + }, + isShown() { + const { hasPreviousPage, hasNextPage } = this.pageInfo; + return hasPreviousPage || hasNextPage; + }, + }, + methods: { + prevPage() { + this.$emit('input', { + before: this.pageInfo.startCursor, + }); + }, + nextPage() { + this.$emit('input', { + after: this.pageInfo.endCursor, + }); + }, + }, +}; +</script> + +<template> + <div v-if="isShown" class="gl-text-center"> + <gl-keyset-pagination + v-bind="paginationProps" + :prev-text="s__('Pagination|Prev')" + :next-text="s__('Pagination|Next')" + @prev="prevPage" + @next="nextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue new file mode 100644 index 00000000000..2c80518e772 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue @@ -0,0 +1,120 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql'; +import { createAlert } from '~/flash'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants'; + +export default { + name: 'RunnerPauseButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: true, + }, + compact: { + type: Boolean, + required: false, + default: false, + }, + }, + emits: ['toggledPaused'], + data() { + return { + updating: false, + }; + }, + computed: { + isActive() { + return this.runner.active; + }, + icon() { + return this.isActive ? 'pause' : 'play'; + }, + label() { + return this.isActive ? I18N_PAUSE : I18N_RESUME; + }, + buttonContent() { + if (this.compact) { + return null; + } + return this.label; + }, + ariaLabel() { + if (this.compact) { + return this.label; + } + return null; + }, + tooltip() { + // Prevent a "sticky" tooltip: If this button is disabled, + // mouseout listeners don't run leaving the tooltip stuck + if (!this.updating) { + return this.isActive ? I18N_PAUSE_TOOLTIP : I18N_RESUME_TOOLTIP; + } + return ''; + }, + }, + methods: { + async onToggle() { + this.updating = true; + try { + const input = { + id: this.runner.id, + active: !this.isActive, + }; + + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerToggleActiveMutation, + variables: { + input, + }, + }); + + if (errors && errors.length) { + throw new Error(errors.join(' ')); + } + this.$emit('toggledPaused'); + } catch (e) { + this.onError(e); + } finally { + this.updating = false; + } + }, + onError(error) { + const { message } = error; + + createAlert({ message }); + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip="tooltip" + v-bind="$attrs" + :aria-label="ariaLabel" + :icon="icon" + :loading="updating" + @click="onToggle" + v-on="$listeners" + > + <!-- + Use <template v-if> to ensure a square button is shown when compact: true. + Sending empty content will still show a distorted/rectangular button. + --> + <template v-if="buttonContent">{{ buttonContent }}</template> + </gl-button> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_paused_badge.vue b/app/assets/javascripts/ci/runner/components/runner_paused_badge.vue new file mode 100644 index 00000000000..00fd84a48d8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_paused_badge.vue @@ -0,0 +1,25 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { I18N_PAUSED, I18N_PAUSED_DESCRIPTION } from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + I18N_PAUSED, + I18N_PAUSED_DESCRIPTION, +}; +</script> +<template> + <gl-badge + v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" + variant="warning" + icon="status-paused" + v-bind="$attrs" + > + {{ $options.I18N_PAUSED }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue new file mode 100644 index 00000000000..84008e8eee8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue @@ -0,0 +1,144 @@ +<script> +import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { sprintf, formatNumber } from '~/locale'; +import { createAlert } from '~/flash'; +import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql'; +import { + I18N_ASSIGNED_PROJECTS, + I18N_CLEAR_FILTER_PROJECTS, + I18N_FILTER_PROJECTS, + I18N_NO_PROJECTS_FOUND, + I18N_FETCH_ERROR, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, +} from '../constants'; +import { getPaginationVariables } from '../utils'; +import { captureException } from '../sentry_utils'; +import RunnerAssignedItem from './runner_assigned_item.vue'; +import RunnerPagination from './runner_pagination.vue'; + +const SHORT_SEARCH_LENGTH = 3; + +export default { + name: 'RunnerProjects', + components: { + GlSearchBoxByType, + GlSkeletonLoader, + RunnerAssignedItem, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + projects: { + ownerProjectId: null, + items: [], + pageInfo: {}, + count: 0, + }, + search: '', + pagination: {}, + }; + }, + apollo: { + projects: { + query: runnerProjectsQuery, + variables() { + return this.variables; + }, + update(data) { + const { runner } = data; + return { + ownerProjectId: runner?.ownerProject?.id, + count: runner?.projectCount || 0, + items: runner?.projects?.nodes || [], + pageInfo: runner?.projects?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + captureException({ error, component: this.$options.name }); + }, + }, + }, + computed: { + variables() { + const { search, runner } = this; + return { + id: runner.id, + search: search.length >= SHORT_SEARCH_LENGTH ? search : '', + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.projects.loading; + }, + heading() { + return sprintf(I18N_ASSIGNED_PROJECTS, { + projectCount: formatNumber(this.projects.count), + }); + }, + }, + methods: { + isOwner(projectId) { + return projectId === this.projects.ownerProjectId; + }, + onSearchInput(search) { + this.search = search; + this.pagination = {}; + }, + onPaginationInput(value) { + this.pagination = value; + }, + }, + RUNNER_DETAILS_PROJECTS_PAGE_SIZE, + I18N_CLEAR_FILTER_PROJECTS, + I18N_FILTER_PROJECTS, + I18N_NO_PROJECTS_FOUND, +}; +</script> + +<template> + <div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"> + <h3 class="gl-font-lg gl-mt-5"> + {{ heading }} + </h3> + <gl-search-box-by-type + :is-loading="loading" + :clear-button-title="$options.I18N_CLEAR_FILTER_PROJECTS" + :placeholder="$options.I18N_FILTER_PROJECTS" + debounce="500" + class="gl-w-28" + :value="search" + @input="onSearchInput" + /> + + <div v-if="!projects.items.length && loading" class="gl-py-5"> + <gl-skeleton-loader v-for="i in $options.RUNNER_DETAILS_PROJECTS_PAGE_SIZE" :key="i" /> + </div> + <template v-else-if="projects.items.length"> + <runner-assigned-item + v-for="(project, i) in projects.items" + :key="project.id" + :class="{ 'gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid': i !== 0 }" + :href="project.webUrl" + :name="project.name" + :full-name="project.nameWithNamespace" + :avatar-url="project.avatarUrl" + :description="project.description" + :is-owner="isOwner(project.id)" + /> + </template> + <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> + + <runner-pagination + :disabled="loading" + :page-info="projects.pageInfo" + @input="onPaginationInput" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue new file mode 100644 index 00000000000..d084408781e --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue @@ -0,0 +1,97 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { getTimeago } from '~/lib/utils/datetime_utility'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_NEVER_CONTACTED, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + I18N_ONLINE_TIMEAGO_TOOLTIP, + I18N_NEVER_CONTACTED_TOOLTIP, + I18N_OFFLINE_TIMEAGO_TOOLTIP, + I18N_STALE_TIMEAGO_TOOLTIP, + I18N_STALE_NEVER_CONTACTED_TOOLTIP, + STATUS_ONLINE, + STATUS_NEVER_CONTACTED, + STATUS_OFFLINE, + STATUS_STALE, +} from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + required: true, + type: Object, + }, + }, + computed: { + contactedAtTimeAgo() { + if (this.runner.contactedAt) { + return getTimeago().format(this.runner.contactedAt); + } + // Prevent "just now" from being rendered, in case data is missing. + return __('never'); + }, + badge() { + switch (this.runner?.status) { + case STATUS_ONLINE: + return { + icon: 'status-active', + variant: 'success', + label: I18N_STATUS_ONLINE, + tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP), + }; + case STATUS_NEVER_CONTACTED: + return { + icon: 'time-out', + variant: 'muted', + label: I18N_STATUS_NEVER_CONTACTED, + tooltip: I18N_NEVER_CONTACTED_TOOLTIP, + }; + case STATUS_OFFLINE: + return { + icon: 'time-out', + variant: 'muted', + label: I18N_STATUS_OFFLINE, + tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP), + }; + case STATUS_STALE: + return { + icon: 'time-out', + variant: 'warning', + label: I18N_STATUS_STALE, + // runner may have contacted (or not) and be stale: consider both cases. + tooltip: this.runner.contactedAt + ? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP) + : I18N_STALE_NEVER_CONTACTED_TOOLTIP, + }; + default: + return null; + } + }, + }, + methods: { + timeAgoTooltip(text) { + return sprintf(text, { timeAgo: this.contactedAtTimeAgo }); + }, + }, +}; +</script> +<template> + <gl-badge + v-if="badge" + v-gl-tooltip="badge.tooltip" + :variant="badge.variant" + :icon="badge.icon" + v-bind="$attrs" + > + {{ badge.label }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_status_popover.vue b/app/assets/javascripts/ci/runner/components/runner_status_popover.vue new file mode 100644 index 00000000000..06174d39a59 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_status_popover.vue @@ -0,0 +1,75 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { duration } from '~/lib/utils/datetime/timeago_utility'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { + I18N_STATUS_POPOVER_TITLE, + I18N_STATUS_POPOVER_NEVER_CONTACTED, + I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION, + I18N_STATUS_POPOVER_ONLINE, + I18N_STATUS_POPOVER_ONLINE_DESCRIPTION, + I18N_STATUS_POPOVER_OFFLINE, + I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION, + I18N_STATUS_POPOVER_STALE, + I18N_STATUS_POPOVER_STALE_DESCRIPTION, +} from '~/ci/runner/constants'; + +export default { + name: 'RunnerStatusPopover', + components: { + GlSprintf, + HelpPopover, + }, + inject: ['onlineContactTimeoutSecs', 'staleTimeoutSecs'], + computed: { + onlineContactTimeoutDuration() { + return duration(this.onlineContactTimeoutSecs * 1000); + }, + staleTimeoutDuration() { + return duration(this.staleTimeoutSecs * 1000); + }, + }, + I18N_STATUS_POPOVER_TITLE, + I18N_STATUS_POPOVER_NEVER_CONTACTED, + I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION, + I18N_STATUS_POPOVER_ONLINE, + I18N_STATUS_POPOVER_ONLINE_DESCRIPTION, + I18N_STATUS_POPOVER_OFFLINE, + I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION, + I18N_STATUS_POPOVER_STALE, + I18N_STATUS_POPOVER_STALE_DESCRIPTION, +}; +</script> + +<template> + <help-popover> + <template #title>{{ $options.I18N_STATUS_POPOVER_TITLE }}</template> + + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_NEVER_CONTACTED }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_NEVER_CONTACTED_DESCRIPTION"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_ONLINE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_ONLINE_DESCRIPTION"> + <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_OFFLINE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_OFFLINE_DESCRIPTION"> + <template #elapsedTime>{{ onlineContactTimeoutDuration }}</template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <strong>{{ $options.I18N_STATUS_POPOVER_STALE }}</strong> + <gl-sprintf :message="$options.I18N_STATUS_POPOVER_STALE_DESCRIPTION"> + <template #elapsedTime>{{ staleTimeoutDuration }}</template> + </gl-sprintf> + </p> + </help-popover> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_tag.vue b/app/assets/javascripts/ci/runner/components/runner_tag.vue new file mode 100644 index 00000000000..6ad2023a866 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_tag.vue @@ -0,0 +1,56 @@ +<script> +import { GlBadge, GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui'; +import { RUNNER_TAG_BADGE_VARIANT } from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, + props: { + tag: { + type: String, + required: true, + }, + size: { + type: String, + required: false, + default: 'sm', + }, + }, + data() { + return { + overflowing: false, + }; + }, + computed: { + tooltip() { + if (this.overflowing) { + return this.tag; + } + return ''; + }, + }, + methods: { + onResize() { + const { scrollWidth, offsetWidth } = this.$el; + this.overflowing = scrollWidth > offsetWidth; + }, + }, + RUNNER_TAG_BADGE_VARIANT, +}; +</script> +<template> + <gl-badge + v-gl-tooltip="tooltip" + v-gl-resize-observer="onResize" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + :size="size" + :variant="$options.RUNNER_TAG_BADGE_VARIANT" + > + {{ tag }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_tags.vue b/app/assets/javascripts/ci/runner/components/runner_tags.vue new file mode 100644 index 00000000000..38e566f9f53 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_tags.vue @@ -0,0 +1,32 @@ +<script> +import RunnerTag from './runner_tag.vue'; + +export default { + components: { + RunnerTag, + }, + props: { + tagList: { + type: Array, + required: false, + default: () => [], + }, + size: { + type: String, + required: false, + default: 'sm', + }, + }, +}; +</script> +<template> + <span v-if="tagList && tagList.length"> + <runner-tag + v-for="tag in tagList" + :key="tag" + class="gl-display-inline gl-mr-1" + :tag="tag" + :size="size" + /> + </span> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_type_badge.vue b/app/assets/javascripts/ci/runner/components/runner_type_badge.vue new file mode 100644 index 00000000000..f568f914004 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_type_badge.vue @@ -0,0 +1,67 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_INSTANCE_TYPE, + I18N_INSTANCE_RUNNER_DESCRIPTION, + I18N_GROUP_TYPE, + I18N_GROUP_RUNNER_DESCRIPTION, + I18N_PROJECT_TYPE, + I18N_PROJECT_RUNNER_DESCRIPTION, +} from '../constants'; + +const BADGE_DATA = { + [INSTANCE_TYPE]: { + icon: 'users', + text: I18N_INSTANCE_TYPE, + tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION, + }, + [GROUP_TYPE]: { + icon: 'group', + text: I18N_GROUP_TYPE, + tooltip: I18N_GROUP_RUNNER_DESCRIPTION, + }, + [PROJECT_TYPE]: { + icon: 'project', + text: I18N_PROJECT_TYPE, + tooltip: I18N_PROJECT_RUNNER_DESCRIPTION, + }, +}; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + type: { + type: String, + required: false, + default: null, + validator(type) { + return Boolean(BADGE_DATA[type]); + }, + }, + }, + computed: { + badge() { + return BADGE_DATA[this.type]; + }, + }, +}; +</script> +<template> + <gl-badge + v-if="badge" + v-gl-tooltip="badge.tooltip" + variant="muted" + :icon="badge.icon" + v-bind="$attrs" + > + {{ badge.text }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue new file mode 100644 index 00000000000..584236168ac --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue @@ -0,0 +1,123 @@ +<script> +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import { searchValidator } from '~/ci/runner/runner_search_utils'; +import { formatNumber } from '~/locale'; +import { + INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_ALL_TYPES, + I18N_INSTANCE_TYPE, + I18N_GROUP_TYPE, + I18N_PROJECT_TYPE, +} from '../constants'; +import RunnerCount from './stat/runner_count.vue'; + +const I18N_TAB_TITLES = { + [INSTANCE_TYPE]: I18N_INSTANCE_TYPE, + [GROUP_TYPE]: I18N_GROUP_TYPE, + [PROJECT_TYPE]: I18N_PROJECT_TYPE, +}; + +const TAB_COUNT_REF = 'tab-count'; + +export default { + components: { + GlBadge, + GlTabs, + GlTab, + RunnerCount, + }, + props: { + runnerTypes: { + type: Array, + required: false, + default: () => [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE], + }, + value: { + type: Object, + required: true, + validator: searchValidator, + }, + countScope: { + type: String, + required: true, + }, + countVariables: { + type: Object, + required: true, + }, + }, + computed: { + tabs() { + const tabs = this.runnerTypes.map((runnerType) => ({ + title: I18N_TAB_TITLES[runnerType], + runnerType, + })); + + // Always add a "All" tab that resets filters + return [ + { + title: I18N_ALL_TYPES, + runnerType: null, + }, + ...tabs, + ]; + }, + }, + methods: { + onTabSelected({ runnerType }) { + this.$emit('input', { + ...this.value, + runnerType, + pagination: { page: 1 }, + }); + }, + isTabActive({ runnerType }) { + return runnerType === this.value.runnerType; + }, + tabBadgeCountVariables(runnerType) { + return { ...this.countVariables, type: runnerType }; + }, + tabCount(count) { + if (typeof count === 'number') { + return formatNumber(count); + } + return ''; + }, + + // Component API + refetch() { + // Refresh all of the counts here, can be called by parent component + this.$refs[TAB_COUNT_REF].forEach((countComponent) => { + countComponent.refetch(); + }); + }, + }, + TAB_COUNT_REF, +}; +</script> +<template> + <gl-tabs v-bind="$attrs" data-testid="runner-type-tabs"> + <gl-tab + v-for="tab in tabs" + :key="`${tab.runnerType}`" + :active="isTabActive(tab)" + @click="onTabSelected(tab)" + > + <template #title> + {{ tab.title }} + <runner-count + #default="{ count }" + :ref="$options.TAB_COUNT_REF" + :scope="countScope" + :variables="tabBadgeCountVariables(tab.runnerType)" + > + <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm"> + {{ tabCount(count) }} + </gl-badge> + </runner-count> + </template> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue new file mode 100644 index 00000000000..a9790d06ca7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue @@ -0,0 +1,225 @@ +<script> +import { + GlButton, + GlIcon, + GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInputGroup, + GlSkeletonLoader, + GlTooltipDirective, +} from '@gitlab/ui'; +import { + modelToUpdateMutationVariables, + runnerToModel, +} from 'ee_else_ce/ci/runner/runner_update_form_utils'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; +import runnerUpdateMutation from '../graphql/edit/runner_update.mutation.graphql'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; + +export default { + name: 'RunnerUpdateForm', + components: { + GlButton, + GlIcon, + GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInputGroup, + GlSkeletonLoader, + RunnerMaintenanceNoteField: () => + import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), + RunnerUpdateCostFactorFields: () => + import('ee_component/ci/runner/components/runner_update_cost_factor_fields.vue'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + runner: { + type: Object, + required: false, + default: null, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + runnerPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + saving: false, + model: runnerToModel(this.runner), + }; + }, + computed: { + canBeLockedToProject() { + return this.runner?.runnerType === PROJECT_TYPE; + }, + }, + watch: { + runner(newVal, oldVal) { + if (oldVal === null) { + this.model = runnerToModel(newVal); + } + }, + }, + methods: { + async onSubmit() { + this.saving = true; + + try { + const { + data: { + runnerUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: runnerUpdateMutation, + variables: modelToUpdateMutationVariables(this.model), + }); + + if (errors?.length) { + this.onError(errors[0]); + } else { + this.onSuccess(); + } + } catch (error) { + const { message } = error; + this.onError(message); + captureException({ error, component: this.$options.name }); + } + }, + onSuccess() { + saveAlertToLocalStorage({ message: __('Changes saved.'), variant: VARIANT_SUCCESS }); + redirectTo(this.runnerPath); + }, + onError(message) { + this.saving = false; + createAlert({ message }); + }, + }, + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, +}; +</script> +<template> + <gl-form @submit.prevent="onSubmit"> + <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Details') }}</h4> + + <gl-skeleton-loader v-if="loading" /> + + <template v-else> + <gl-form-group :label="__('Description')" data-testid="runner-field-description"> + <gl-form-input-group v-model="model.description" /> + </gl-form-group> + <runner-maintenance-note-field v-model="model.maintenanceNote" /> + </template> + + <hr /> + + <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Configuration') }}</h4> + + <template v-if="loading"> + <gl-skeleton-loader v-for="i in 3" :key="i" /> + </template> + <template v-else> + <div class="gl-mb-5"> + <gl-form-checkbox + v-model="model.active" + data-testid="runner-field-paused" + :value="false" + :unchecked-value="true" + > + {{ __('Paused') }} + <template #help> + {{ s__('Runners|Stop the runner from accepting new jobs.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox + v-model="model.accessLevel" + data-testid="runner-field-protected" + :value="$options.ACCESS_LEVEL_REF_PROTECTED" + :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED" + > + {{ __('Protected') }} + <template #help> + {{ s__('Runners|Use the runner on pipelines for protected branches only.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged"> + {{ __('Run untagged jobs') }} + <template #help> + {{ s__('Runners|Use the runner for jobs without tags, in addition to tagged jobs.') }} + </template> + </gl-form-checkbox> + + <gl-form-checkbox + v-if="canBeLockedToProject" + v-model="model.locked" + data-testid="runner-field-locked" + > + {{ __('Lock to current projects') }} <gl-icon name="lock" /> + <template #help> + {{ + s__( + 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.', + ) + }} + </template> + </gl-form-checkbox> + </div> + + <gl-form-group + data-testid="runner-field-max-timeout" + :label="__('Maximum job timeout')" + :description=" + s__( + 'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.', + ) + " + > + <gl-form-input-group v-model.number="model.maximumTimeout" type="number" /> + </gl-form-group> + + <gl-form-group + data-testid="runner-field-tags" + :label="__('Tags')" + :description=" + __( + 'You can set up jobs to only use runners with specific tags. Separate tags with commas.', + ) + " + > + <gl-form-input-group v-model="model.tagList" /> + </gl-form-group> + + <runner-update-cost-factor-fields v-model="model" /> + </template> + + <div class="gl-mt-6"> + <gl-button + type="submit" + variant="confirm" + class="js-no-auto-disable" + :loading="loading || saving" + > + {{ __('Save changes') }} + </gl-button> + <gl-button :href="runnerPath"> + {{ __('Cancel') }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js new file mode 100644 index 00000000000..97ee8ec3eef --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js @@ -0,0 +1,28 @@ +import { __ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants'; + +const options = [ + { value: 'true', title: __('Yes') }, + { value: 'false', title: __('No') }, +]; + +export const pausedTokenConfig = { + icon: 'pause', + title: I18N_PAUSED, + type: PARAM_KEY_PAUSED, + token: BaseToken, + unique: true, + options: options.map(({ value, title }) => ({ + value, + // Replace whitespace with a special character to avoid + // splitting this value. + // Replacing in each option, as translations may also + // contain spaces! + // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + title: title.replace(/\s/g, '\u00a0'), + })), + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js new file mode 100644 index 00000000000..117a630719e --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js @@ -0,0 +1,42 @@ +import { + OPERATOR_IS_ONLY, + TOKEN_TITLE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_NEVER_CONTACTED, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_NEVER_CONTACTED, + STATUS_STALE, + PARAM_KEY_STATUS, +} from '../../constants'; + +const options = [ + { value: STATUS_ONLINE, title: I18N_STATUS_ONLINE }, + { value: STATUS_OFFLINE, title: I18N_STATUS_OFFLINE }, + { value: STATUS_NEVER_CONTACTED, title: I18N_STATUS_NEVER_CONTACTED }, + { value: STATUS_STALE, title: I18N_STATUS_STALE }, +]; + +export const statusTokenConfig = { + icon: 'status', + title: TOKEN_TITLE_STATUS, + type: PARAM_KEY_STATUS, + token: BaseToken, + unique: true, + options: options.map(({ value, title }) => ({ + value, + // Replace whitespace with a special character to avoid + // splitting this value. + // Replacing in each option, as translations may also + // contain spaces! + // see: https://gitlab.com/gitlab-org/gitlab/-/issues/344142 + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + title: title.replace(/\s/g, '\u00a0'), + })), + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue new file mode 100644 index 00000000000..6e7c41885f8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue @@ -0,0 +1,91 @@ +<script> +import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; + +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { RUNNER_TAG_BG_CLASS } from '../../constants'; + +// TODO This should be implemented via a GraphQL API +// The API should +// 1) scope to the rights of the user +// 2) stay up to date to the removal of old tags +// 3) consider the scope of search, like searching within the tags of a group +// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 +export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json'; + +export default { + components: { + BaseToken, + GlFilteredSearchSuggestion, + GlToken, + }, + props: { + config: { + type: Object, + required: true, + }, + }, + data() { + return { + tags: [], + loading: false, + }; + }, + methods: { + getTagsOptions(search) { + return axios + .get(TAG_SUGGESTIONS_PATH, { + params: { + search, + }, + }) + .then(({ data }) => { + return data.map(({ id, name }) => ({ id, value: name, text: name })); + }); + }, + async fetchTags(searchTerm) { + // Note: Suggestions should only be enabled for admin users + if (this.config.suggestionsDisabled) { + this.tags = []; + return; + } + + this.loading = true; + try { + this.tags = await this.getTagsOptions(searchTerm); + } catch { + createAlert({ + message: s__('Runners|Something went wrong while fetching the tags suggestions'), + }); + } finally { + this.loading = false; + } + }, + }, + RUNNER_TAG_BG_CLASS, +}; +</script> + +<template> + <base-token + v-bind="$attrs" + :config="config" + :suggestions-loading="loading" + :suggestions="tags" + @fetch-suggestions="fetchTags" + v-on="$listeners" + > + <template #view-token="{ viewTokenProps: { listeners, inputValue, activeTokenValue } }"> + <gl-token variant="search-value" :class="$options.RUNNER_TAG_BG_CLASS" v-on="listeners"> + {{ activeTokenValue ? activeTokenValue.text : inputValue }} + </gl-token> + </template> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion v-for="tag in suggestions" :key="tag.id" :value="tag.value"> + {{ tag.text }} + </gl-filtered-search-suggestion> + </template> + </base-token> +</template> diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js new file mode 100644 index 00000000000..fdeba714385 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js @@ -0,0 +1,12 @@ +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { PARAM_KEY_TAG } from '../../constants'; +import TagToken from './tag_token.vue'; + +export const tagTokenConfig = { + icon: 'tag', + title: s__('Runners|Tags'), + type: PARAM_KEY_TAG, + token: TagToken, + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js new file mode 100644 index 00000000000..17ee7073360 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/upgrade_status_token_config.js @@ -0,0 +1,2 @@ +// Overridden in EE +export const upgradeStatusTokenConfig = null; diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue new file mode 100644 index 00000000000..4ad9259f59d --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue @@ -0,0 +1,104 @@ +<script> +import { fetchPolicies } from '~/lib/graphql'; +import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql'; +import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; + +import { captureException } from '../../sentry_utils'; +import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; + +/** + * Renderless component that wraps a "count" query for the + * number of runners that follow a filter criteria. + * + * Example usage: + * + * Render the count of "online" runners in the instance in a + * <strong/> tag. + * + * ```vue + * <runner-count-stat + * #default="{ count }" + * :scope="INSTANCE_TYPE" + * :variables="{ status: 'ONLINE' }" + * > + * <strong>{{ count }}</strong> + * </runner-count-stat> + * ``` + * + * Use `:skip="true"` to prevent data from being fetched and + * even rendered. + */ +export default { + name: 'RunnerCount', + props: { + scope: { + type: String, + required: true, + validator: (val) => [INSTANCE_TYPE, GROUP_TYPE].includes(val), + }, + variables: { + type: Object, + required: false, + default: () => ({}), + }, + skip: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { count: null }; + }, + apollo: { + count: { + query() { + if (this.scope === INSTANCE_TYPE) { + return allRunnersCountQuery; + } else if (this.scope === GROUP_TYPE) { + return groupRunnersCountQuery; + } + return null; + }, + fetchPolicy: fetchPolicies.NETWORK_ONLY, + variables() { + return this.variables; + }, + skip() { + if (this.skip) { + // Don't show data for skipped stats + this.count = null; + } + return this.skip; + }, + update(data) { + if (this.scope === INSTANCE_TYPE) { + return data?.runners?.count; + } else if (this.scope === GROUP_TYPE) { + return data?.group?.runners?.count; + } + return null; + }, + error(error) { + this.reportToSentry(error); + }, + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + + // Component API + refetch() { + // Parent components can use this method to refresh the count + this.$apollo.queries.count.refetch(); + }, + }, + render() { + return this.$scopedSlots.default({ + count: this.count, + }); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue b/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue new file mode 100644 index 00000000000..ae732b052ac --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/stat/runner_single_stat.vue @@ -0,0 +1,41 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { formatNumber } from '~/locale'; +import RunnerCount from './runner_count.vue'; + +export default { + components: { + GlSingleStat, + RunnerCount, + }, + props: { + scope: { + type: String, + required: true, + }, + variables: { + type: Object, + required: false, + default: () => ({}), + }, + skip: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + formattedValue(value) { + if (typeof value === 'number') { + return formatNumber(value); + } + return '-'; + }, + }, +}; +</script> +<template> + <runner-count #default="{ count }" :scope="scope" :variables="variables" :skip="skip"> + <gl-single-stat v-bind="$attrs" :value="formattedValue(count)" /> + </runner-count> +</template> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue new file mode 100644 index 00000000000..3965e5551f1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -0,0 +1,89 @@ +<script> +import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue'; +import { + I18N_STATUS_ONLINE, + I18N_STATUS_OFFLINE, + I18N_STATUS_STALE, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_STALE, +} from '../../constants'; + +export default { + components: { + RunnerSingleStat, + RunnerUpgradeStatusStats: () => + import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'), + }, + props: { + scope: { + type: String, + required: true, + }, + variables: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + stats() { + return [ + { + key: STATUS_ONLINE, + props: { + skip: this.statusCountSkip(STATUS_ONLINE), + variables: { ...this.variables, status: STATUS_ONLINE }, + variant: 'success', + title: I18N_STATUS_ONLINE, + metaIcon: 'status-active', + }, + }, + { + key: STATUS_OFFLINE, + props: { + skip: this.statusCountSkip(STATUS_OFFLINE), + variables: { ...this.variables, status: STATUS_OFFLINE }, + variant: 'muted', + title: I18N_STATUS_OFFLINE, + metaIcon: 'status-waiting', + }, + }, + { + key: STATUS_STALE, + props: { + skip: this.statusCountSkip(STATUS_STALE), + variables: { ...this.variables, status: STATUS_STALE }, + variant: 'warning', + title: I18N_STATUS_STALE, + metaIcon: 'time-out', + }, + }, + ]; + }, + }, + methods: { + statusCountSkip(status) { + // Show an empty result when we already filter by another status + return this.variables.status && this.variables.status !== status; + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-flex-wrap gl-py-6"> + <runner-single-stat + v-for="stat in stats" + :key="stat.key" + :scope="scope" + v-bind="stat.props" + class="gl-px-5" + /> + + <runner-upgrade-status-stats + class="gl-display-contents" + :scope="scope" + :variables="variables" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js new file mode 100644 index 00000000000..dfc5f0c4152 --- /dev/null +++ b/app/assets/javascripts/ci/runner/constants.js @@ -0,0 +1,161 @@ +import { __, s__ } from '~/locale'; + +export const RUNNER_TYPENAME = 'CiRunner'; // __typename + +export const RUNNER_PAGE_SIZE = 20; +export const RUNNER_JOB_COUNT_LIMIT = 1000; + +export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5; +export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30; + +export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); +export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); + +export const FILTER_CSS_CLASSES = + 'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1'; + +// Type + +export const I18N_ALL_TYPES = s__('Runners|All'); +export const I18N_INSTANCE_TYPE = s__('Runners|Instance'); +export const I18N_GROUP_TYPE = s__('Runners|Group'); +export const I18N_PROJECT_TYPE = s__('Runners|Project'); +export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects'); +export const I18N_GROUP_RUNNER_DESCRIPTION = s__( + 'Runners|Available to all projects and subgroups in the group', +); +export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); + +// Status +export const I18N_STATUS_ONLINE = s__('Runners|Online'); +export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted'); +export const I18N_STATUS_OFFLINE = s__('Runners|Offline'); +export const I18N_STATUS_STALE = s__('Runners|Stale'); + +// 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_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_STALE_TIMEAGO_TOOLTIP = s__( + 'Runners|Runner is stale; last contact was %{timeAgo}', +); +export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__( + 'Runners|Runner is stale; it has never contacted this instance', +); + +// Actions +export const I18N_EDIT = __('Edit'); + +export const I18N_PAUSE = __('Pause'); +export const I18N_PAUSED = s__('Runners|Paused'); +export const I18N_PAUSE_TOOLTIP = s__('Runners|Pause from accepting jobs'); +export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs'); + +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_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); + +// List +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.', +); +export const I18N_VERSION_LABEL = s__('Runners|Version %{version}'); +export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}'); +export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}'); +export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited'); +export const I18N_ADMIN = s__('Runners|Administrator'); + +// Runner details + +export const I18N_DETAILS = s__('Runners|Details'); +export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); +export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects'); +export const I18N_CLEAR_FILTER_PROJECTS = __('Clear'); +export const I18N_NONE = __('None'); +export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.'); +export const I18N_NO_PROJECTS_FOUND = __('No projects found'); + +// Styles + +export const RUNNER_TAG_BADGE_VARIANT = 'info'; +export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100'; + +// Filtered search parameter names +// - Used for URL params names +// - 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'; +export const PARAM_KEY_MEMBERSHIP = 'membership'; + +export const PARAM_KEY_SORT = 'sort'; +export const PARAM_KEY_AFTER = 'after'; +export const PARAM_KEY_BEFORE = 'before'; + +// CiRunnerType + +export const INSTANCE_TYPE = 'INSTANCE_TYPE'; +export const GROUP_TYPE = 'GROUP_TYPE'; +export const PROJECT_TYPE = 'PROJECT_TYPE'; + +// CiRunnerStatus + +export const STATUS_ONLINE = 'ONLINE'; +export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; +export const STATUS_OFFLINE = 'OFFLINE'; +export const STATUS_STALE = 'STALE'; + +// CiRunnerAccessLevel + +export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED'; +export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED'; + +// CiRunnerSort + +export const CREATED_DESC = 'CREATED_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; +export const CONTACTED_DESC = 'CONTACTED_DESC'; +export const CONTACTED_ASC = 'CONTACTED_ASC'; + +export const DEFAULT_SORT = CREATED_DESC; + +// CiRunnerMembershipFilter + +export const MEMBERSHIP_DESCENDANTS = 'DESCENDANTS'; +export const MEMBERSHIP_ALL_AVAILABLE = 'ALL_AVAILABLE'; + +export const DEFAULT_MEMBERSHIP = MEMBERSHIP_DESCENDANTS; + +// Local storage namespaces + +export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners'; +export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners'; diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields.fragment.graphql new file mode 100644 index 00000000000..b732d587d70 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields.fragment.graphql @@ -0,0 +1,5 @@ +#import "./runner_fields_shared.fragment.graphql" + +fragment RunnerFields on CiRunner { + ...RunnerFieldsShared +} diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql new file mode 100644 index 00000000000..29abddf84f5 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql @@ -0,0 +1,14 @@ +fragment RunnerFieldsShared on CiRunner { + id + shortSha + runnerType + active + accessLevel + runUntagged + locked + description + maximumTimeout + tagList + createdAt + status(legacyMode: null) +} diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_form.query.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_form.query.graphql new file mode 100644 index 00000000000..5599c147c56 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_form.query.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/ci/runner/graphql/edit/runner_fields.fragment.graphql" + +query getRunnerForm($id: CiRunnerID!) { + runner(id: $id) { + ...RunnerFields + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_update.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_update.mutation.graphql new file mode 100644 index 00000000000..9469078c317 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_update.mutation.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/ci/runner/graphql/edit/runner_fields.fragment.graphql" + +# Mutation for updates from the runner form, loads +# attributes shown in the runner details. + +mutation runnerUpdate($input: RunnerUpdateInput!) { + runnerUpdate(input: $input) { + runner { + ...RunnerFields + } + errors + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql new file mode 100644 index 00000000000..15401c25c64 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql @@ -0,0 +1,29 @@ +#import "~/ci/runner/graphql/list/all_runners_connection.fragment.graphql" + +query getAllRunners( + $before: String + $after: String + $first: Int + $last: Int + $paused: Boolean + $status: CiRunnerStatus + $type: CiRunnerType + $tagList: [String!] + $search: String + $sort: CiRunnerSort +) { + runners( + before: $before + after: $after + first: $first + last: $last + paused: $paused + status: $status + type: $type + tagList: $tagList + search: $search + sort: $sort + ) { + ...AllRunnersConnection + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/all_runners_connection.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners_connection.fragment.graphql new file mode 100644 index 00000000000..39d79df02e7 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners_connection.fragment.graphql @@ -0,0 +1,13 @@ +#import "ee_else_ce/ci/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/ci/runner/graphql/list/all_runners_count.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql new file mode 100644 index 00000000000..82591b88d3e --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql @@ -0,0 +1,11 @@ +query getAllRunnersCount( + $paused: Boolean + $status: CiRunnerStatus + $type: CiRunnerType + $tagList: [String!] + $search: String +) { + runners(paused: $paused, status: $status, type: $type, tagList: $tagList, search: $search) { + count + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/list/bulk_runner_delete.mutation.graphql new file mode 100644 index 00000000000..b73c016b1de --- /dev/null +++ b/app/assets/javascripts/ci/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/ci/runner/graphql/list/checked_runner_ids.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/checked_runner_ids.query.graphql new file mode 100644 index 00000000000..c01f1edb451 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/checked_runner_ids.query.graphql @@ -0,0 +1,3 @@ +query getCheckedRunnerIds { + checkedRunnerIds @client +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/group_runner_connection.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/group_runner_connection.fragment.graphql new file mode 100644 index 00000000000..53be8fdc613 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/group_runner_connection.fragment.graphql @@ -0,0 +1,16 @@ +#import "ee_else_ce/ci/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/ci/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/group_runners.query.graphql new file mode 100644 index 00000000000..08fd8974826 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/group_runners.query.graphql @@ -0,0 +1,35 @@ +#import "~/ci/runner/graphql/list/group_runner_connection.fragment.graphql" + +query getGroupRunners( + $groupFullPath: ID! + $membership: CiRunnerMembershipFilter + $before: String + $after: String + $first: Int + $last: Int + $paused: Boolean + $status: CiRunnerStatus + $type: CiRunnerType + $tagList: [String!] + $search: String + $sort: CiRunnerSort +) { + group(fullPath: $groupFullPath) { + id # Apollo required + runners( + membership: $membership + before: $before + after: $after + first: $first + last: $last + paused: $paused + status: $status + type: $type + tagList: $tagList + search: $search + sort: $sort + ) { + ...GroupRunnerConnection + } + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/group_runners_count.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/group_runners_count.query.graphql new file mode 100644 index 00000000000..e88a2c2e7e6 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/group_runners_count.query.graphql @@ -0,0 +1,23 @@ +query getGroupRunnersCount( + $groupFullPath: ID! + $membership: CiRunnerMembershipFilter + $paused: Boolean + $status: CiRunnerStatus + $type: CiRunnerType + $tagList: [String!] + $search: String +) { + group(fullPath: $groupFullPath) { + id # Apollo required + runners( + membership: $membership + paused: $paused + status: $status + type: $type + tagList: $tagList + search: $search + ) { + count + } + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item.fragment.graphql new file mode 100644 index 00000000000..19a5a48ea75 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item.fragment.graphql @@ -0,0 +1,5 @@ +#import "./list_item_shared.fragment.graphql" + +fragment ListItem on CiRunner { + ...ListItemShared +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql new file mode 100644 index 00000000000..0dff011daaa --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -0,0 +1,33 @@ +fragment ListItemShared on CiRunner { + id + description + runnerType + shortSha + version + ipAddress + active + locked + jobCount + tagList + createdAt + contactedAt + status(legacyMode: null) + userPermissions { + updateRunner + deleteRunner + } + groups(first: 1) { + nodes { + id + name + fullName + webUrl + } + } + ownerProject { + id + name + nameWithNamespace + webUrl + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/local_state.js b/app/assets/javascripts/ci/runner/graphql/list/local_state.js new file mode 100644 index 00000000000..ab53bfdbd5b --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/local_state.js @@ -0,0 +1,75 @@ +import { makeVar } from '@apollo/client/core'; +import { RUNNER_TYPENAME } from '../../constants'; +import typeDefs from './typedefs.graphql'; + +/** + * Local state for checkable runner items. + * + * Usage: + * + * ``` + * import { createLocalState } from '~/ci/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( ... ) + * ``` + * + * @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(_, { 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(([id]) => id); + }, + }, + }, + }, + }; + + const localMutations = { + setRunnerChecked({ runner, isChecked }) { + const { id, userPermissions } = runner; + if (userPermissions?.deleteRunner) { + checkedRunnerIdsVar({ + ...checkedRunnerIdsVar(), + [id]: isChecked, + }); + } + }, + setRunnersChecked({ runners, isChecked }) { + const newVal = runners + .filter(({ userPermissions }) => userPermissions?.deleteRunner) + .reduce((acc, { id }) => ({ ...acc, [id]: isChecked }), checkedRunnerIdsVar()); + checkedRunnerIdsVar(newVal); + }, + clearChecked() { + checkedRunnerIdsVar({}); + }, + }; + + return { + cacheConfig, + typeDefs, + localMutations, + }; +}; diff --git a/app/assets/javascripts/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql new file mode 100644 index 00000000000..9c2797732ad --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql @@ -0,0 +1,6 @@ +mutation runnersRegistrationTokenReset($input: RunnersRegistrationTokenResetInput!) { + runnersRegistrationTokenReset(input: $input) { + token + errors + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/list/typedefs.graphql b/app/assets/javascripts/ci/runner/graphql/list/typedefs.graphql new file mode 100644 index 00000000000..24e9e20cc8c --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/list/typedefs.graphql @@ -0,0 +1,3 @@ +extend type Query { + checkedRunnerIds: [ID!]! +} diff --git a/app/assets/javascripts/ci/runner/graphql/shared/runner_delete.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/shared/runner_delete.mutation.graphql new file mode 100644 index 00000000000..d580ea2785e --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/shared/runner_delete.mutation.graphql @@ -0,0 +1,5 @@ +mutation runnerDelete($input: RunnerDeleteInput!) { + runnerDelete(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql new file mode 100644 index 00000000000..9b15570dbc0 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql @@ -0,0 +1,12 @@ +# Mutation executed for the pause/resume button in the +# runner list and details views. + +mutation runnerToggleActive($input: RunnerUpdateInput!) { + runnerUpdate(input: $input) { + runner { + id + active + } + errors + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner.query.graphql new file mode 100644 index 00000000000..6375b4f35a4 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner.query.graphql @@ -0,0 +1,7 @@ +#import "ee_else_ce/ci/runner/graphql/show/runner_details.fragment.graphql" + +query getRunner($id: CiRunnerID!) { + runner(id: $id) { + ...RunnerDetails + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_details.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details.fragment.graphql new file mode 100644 index 00000000000..2449ee0fc0f --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details.fragment.graphql @@ -0,0 +1,5 @@ +#import "./runner_details_shared.fragment.graphql" + +fragment RunnerDetails on CiRunner { + ...RunnerDetailsShared +} diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql new file mode 100644 index 00000000000..b5689ff7687 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql @@ -0,0 +1,39 @@ +fragment RunnerDetailsShared on CiRunner { + id + shortSha + runnerType + active + accessLevel + runUntagged + locked + ipAddress + executorName + architectureName + platformName + description + maximumTimeout + jobCount + tagList + createdAt + status(legacyMode: null) + contactedAt + tokenExpiresAt + version + editAdminUrl + userPermissions { + updateRunner + deleteRunner + } + groups { + # Only a single group can be loaded here, while projects + # are loaded separately using the query with pagination + # parameters `runner_projects.query.graphql`. + nodes { + id + avatarUrl + name + fullName + webUrl + } + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql new file mode 100644 index 00000000000..edfc22f644b --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql @@ -0,0 +1,38 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) { + runner(id: $id) { + id + projectCount + jobs(before: $before, after: $after, first: $first, last: $last) { + nodes { + id + detailedStatus { + # fields for `<ci-badge>` + id + detailsPath + group + icon + text + } + pipeline { + id + project { + id + name + webUrl + } + } + shortSha + commitPath + finishedAt + duration + queuedDuration + tags + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_projects.query.graphql new file mode 100644 index 00000000000..e42648b3079 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_projects.query.graphql @@ -0,0 +1,31 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getRunnerProjects( + $id: CiRunnerID! + $search: String + $first: Int + $last: Int + $before: String + $after: String +) { + runner(id: $id) { + id + ownerProject { + id + } + projectCount + projects(search: $search, first: $first, last: $last, before: $before, after: $after) { + nodes { + id + avatarUrl + description + name + nameWithNamespace + webUrl + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue new file mode 100644 index 00000000000..75138b1bd81 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue @@ -0,0 +1,94 @@ +<script> +import { createAlert, VARIANT_SUCCESS } from '~/flash'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { redirectTo } from '~/lib/utils/url_utility'; +import RunnerDeleteButton from '../components/runner_delete_button.vue'; +import RunnerEditButton from '../components/runner_edit_button.vue'; +import RunnerPauseButton from '../components/runner_pause_button.vue'; +import RunnerHeader from '../components/runner_header.vue'; +import RunnerDetails from '../components/runner_details.vue'; +import { I18N_FETCH_ERROR } from '../constants'; +import runnerQuery from '../graphql/show/runner.query.graphql'; +import { captureException } from '../sentry_utils'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; + +export default { + name: 'GroupRunnerShowApp', + components: { + RunnerDeleteButton, + RunnerEditButton, + RunnerPauseButton, + RunnerHeader, + RunnerDetails, + }, + props: { + runnerId: { + type: String, + required: true, + }, + runnersPath: { + type: String, + required: true, + }, + editGroupRunnerPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + runner: null, + }; + }, + apollo: { + runner: { + query: runnerQuery, + variables() { + return { + id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + canUpdate() { + return this.runner.userPermissions?.updateRunner; + }, + canDelete() { + return this.runner.userPermissions?.deleteRunner; + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + onDeleted({ message }) { + saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS }); + redirectTo(this.runnersPath); + }, + }, +}; +</script> +<template> + <div> + <runner-header v-if="runner" :runner="runner"> + <template #actions> + <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" /> + <runner-pause-button v-if="canUpdate" :runner="runner" /> + <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> + </template> + </runner-header> + + <runner-details v-if="runner" :runner="runner" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/group_runner_show/index.js b/app/assets/javascripts/ci/runner/group_runner_show/index.js new file mode 100644 index 00000000000..e75f337b38e --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_runner_show/index.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; +import GroupRunnerShowApp from './group_runner_show_app.vue'; + +Vue.use(VueApollo); + +export const initGroupRunnerShow = (selector = '#js-group-runner-show') => { + showAlertFromLocalStorage(); + + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { runnerId, runnersPath, editGroupRunnerPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(GroupRunnerShowApp, { + props: { + runnerId, + runnersPath, + editGroupRunnerPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue new file mode 100644 index 00000000000..91c22923075 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -0,0 +1,278 @@ +<script> +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/ci/runner/components/search_tokens/upgrade_status_token_config'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, + isSearchFiltered, +} from 'ee_else_ce/ci/runner/runner_search_utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; +import groupRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners.query.graphql'; + +import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; +import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; +import RunnerList from '../components/runner_list.vue'; +import RunnerListEmptyState from '../components/runner_list_empty_state.vue'; +import RunnerName from '../components/runner_name.vue'; +import RunnerStats from '../components/stat/runner_stats.vue'; +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 RunnerMembershipToggle from '../components/runner_membership_toggle.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 { + GROUP_FILTERED_SEARCH_NAMESPACE, + GROUP_TYPE, + PROJECT_TYPE, + I18N_FETCH_ERROR, + FILTER_CSS_CLASSES, +} from '../constants'; +import { captureException } from '../sentry_utils'; + +export default { + name: 'GroupRunnersApp', + components: { + GlLink, + RegistrationDropdown, + RunnerFilteredSearchBar, + RunnerList, + RunnerListEmptyState, + RunnerName, + RunnerMembershipToggle, + RunnerStats, + RunnerPagination, + RunnerTypeTabs, + RunnerActionsCell, + }, + mixins: [glFeatureFlagMixin()], + inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], + props: { + registrationToken: { + type: String, + required: false, + default: null, + }, + groupFullPath: { + type: String, + required: true, + }, + groupRunnersLimitedCount: { + type: Number, + required: true, + }, + }, + data() { + return { + search: fromUrlQueryToSearch(), + runners: { + items: [], + urlsById: {}, + pageInfo: {}, + }, + }; + }, + apollo: { + runners: { + query: groupRunnersQuery, + fetchPolicy: fetchPolicies.NETWORK_ONLY, + variables() { + return this.variables; + }, + update(data) { + const { edges = [], pageInfo = {} } = data?.group?.runners || {}; + + const items = []; + const urlsById = {}; + + edges.forEach(({ node, webUrl, editUrl }) => { + items.push(node); + urlsById[node.id] = { + web: webUrl, + edit: editUrl, + }; + }); + + return { + items, + urlsById, + pageInfo, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + return { + ...fromSearchToVariables(this.search), + groupFullPath: this.groupFullPath, + }; + }, + countVariables() { + // Exclude pagination variables, leave only filters variables + const { sort, before, last, after, first, ...countVariables } = this.variables; + return countVariables; + }, + runnersLoading() { + return this.$apollo.queries.runners.loading; + }, + noRunnersFound() { + return !this.runnersLoading && !this.runners.items.length; + }, + filteredSearchNamespace() { + return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; + }, + searchTokens() { + return [ + pausedTokenConfig, + statusTokenConfig, + { + ...tagTokenConfig, + suggestionsDisabled: true, + }, + upgradeStatusTokenConfig, + ]; + }, + isSearchFiltered() { + return isSearchFiltered(this.search); + }, + }, + watch: { + search: { + deep: true, + handler() { + // TODO Implement back button reponse using onpopstate + // See https://gitlab.com/gitlab-org/gitlab/-/issues/333804 + updateHistory({ + url: fromSearchToUrl(this.search), + title: document.title, + }); + }, + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + webUrl(runner) { + return this.runners.urlsById[runner.id]?.web; + }, + editUrl(runner) { + return this.runners.urlsById[runner.id]?.edit; + }, + refetchCounts() { + this.$apollo.getClient().refetchQueries({ include: [groupRunnersCountQuery] }); + }, + onToggledPaused() { + // When a runner becomes Paused, the tab count can + // become stale, refetch outdated counts. + this.refetchCounts(); + }, + onDeleted({ message }) { + this.$root.$toast?.show(message); + this.refetchCounts(); + }, + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + onPaginationInput(value) { + this.search.pagination = value; + }, + }, + TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE], + GROUP_TYPE, + FILTER_CSS_CLASSES, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-align-items-center"> + <runner-type-tabs + ref="runner-type-tabs" + v-model="search" + :count-scope="$options.GROUP_TYPE" + :count-variables="countVariables" + :runner-types="$options.TABS_RUNNER_TYPES" + class="gl-w-full" + content-class="gl-display-none" + nav-class="gl-border-none!" + /> + + <registration-dropdown + v-if="registrationToken" + class="gl-ml-auto" + :registration-token="registrationToken" + :type="$options.GROUP_TYPE" + right + /> + </div> + + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3" + :class="$options.FILTER_CSS_CLASSES" + > + <runner-filtered-search-bar + v-model="search" + :tokens="searchTokens" + :namespace="filteredSearchNamespace" + class="gl-flex-grow-1 gl-align-self-stretch" + /> + <runner-membership-toggle + v-model="search.membership" + class="gl-align-self-end gl-md-align-self-center" + /> + </div> + + <runner-stats :scope="$options.GROUP_TYPE" :variables="countVariables" /> + + <runner-list-empty-state + v-if="noRunnersFound" + :registration-token="registrationToken" + :is-search-filtered="isSearchFiltered" + :svg-path="emptyStateSvgPath" + :filtered-svg-path="emptyStateFilteredSvgPath" + /> + <template v-else> + <runner-list + :runners="runners.items" + :checkable="true" + :loading="runnersLoading" + @deleted="onDeleted" + > + <template #runner-name="{ runner }"> + <gl-link :href="webUrl(runner)"> + <runner-name :runner="runner" /> + </gl-link> + </template> + <template #runner-actions-cell="{ runner }"> + <runner-actions-cell + :runner="runner" + :edit-url="editUrl(runner)" + @toggledPaused="onToggledPaused" + @deleted="onDeleted" + /> + </template> + </runner-list> + </template> + + <runner-pagination + class="gl-mt-3" + :disabled="runnersLoading" + :page-info="runners.pageInfo" + @input="onPaginationInput" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js new file mode 100644 index 00000000000..0e7efd2b8a1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/group_runners/index.js @@ -0,0 +1,58 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { createLocalState } from '../graphql/list/local_state'; +import GroupRunnersApp from './group_runners_app.vue'; + +Vue.use(GlToast); +Vue.use(VueApollo); + +export const initGroupRunners = (selector = '#js-group-runners') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { + registrationToken, + runnerInstallHelpPage, + groupId, + groupFullPath, + groupRunnersLimitedCount, + onlineContactTimeoutSecs, + staleTimeoutSecs, + emptyStateSvgPath, + emptyStateFilteredSvgPath, + } = el.dataset; + + const { cacheConfig, typeDefs, localMutations } = createLocalState(); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + runnerInstallHelpPage, + localMutations, + groupId, + onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10), + staleTimeoutSecs: parseInt(staleTimeoutSecs, 10), + emptyStateSvgPath, + emptyStateFilteredSvgPath, + }, + render(h) { + return h(GroupRunnersApp, { + props: { + registrationToken, + groupFullPath, + groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/local_storage_alert/constants.js b/app/assets/javascripts/ci/runner/local_storage_alert/constants.js new file mode 100644 index 00000000000..69b7418f898 --- /dev/null +++ b/app/assets/javascripts/ci/runner/local_storage_alert/constants.js @@ -0,0 +1 @@ +export const LOCAL_STORAGE_ALERT_KEY = 'local-storage-alert'; diff --git a/app/assets/javascripts/ci/runner/local_storage_alert/save_alert_to_local_storage.js b/app/assets/javascripts/ci/runner/local_storage_alert/save_alert_to_local_storage.js new file mode 100644 index 00000000000..ca7c627459a --- /dev/null +++ b/app/assets/javascripts/ci/runner/local_storage_alert/save_alert_to_local_storage.js @@ -0,0 +1,8 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { LOCAL_STORAGE_ALERT_KEY } from './constants'; + +export const saveAlertToLocalStorage = (alertOptions) => { + if (AccessorUtilities.canUseLocalStorage()) { + localStorage.setItem(LOCAL_STORAGE_ALERT_KEY, JSON.stringify(alertOptions)); + } +}; diff --git a/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js b/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js new file mode 100644 index 00000000000..d768a06494a --- /dev/null +++ b/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js @@ -0,0 +1,18 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { LOCAL_STORAGE_ALERT_KEY } from './constants'; + +export const showAlertFromLocalStorage = async () => { + if (AccessorUtilities.canUseLocalStorage()) { + const alertOptions = localStorage.getItem(LOCAL_STORAGE_ALERT_KEY); + + if (alertOptions) { + try { + const { createAlert } = await import('~/flash'); + createAlert(JSON.parse(alertOptions)); + } catch { + // ignore when the alert data cannot be parsed + } + } + localStorage.removeItem(LOCAL_STORAGE_ALERT_KEY); + } +}; diff --git a/app/assets/javascripts/ci/runner/runner_edit/index.js b/app/assets/javascripts/ci/runner/runner_edit/index.js new file mode 100644 index 00000000000..5b2ddb8f68e --- /dev/null +++ b/app/assets/javascripts/ci/runner/runner_edit/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import RunnerEditApp from './runner_edit_app.vue'; + +Vue.use(VueApollo); + +export const initRunnerEdit = (selector) => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { runnerId, runnerPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(RunnerEditApp, { + props: { + runnerId, + runnerPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue new file mode 100644 index 00000000000..879162916a9 --- /dev/null +++ b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue @@ -0,0 +1,73 @@ +<script> +import { createAlert } from '~/flash'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import RunnerHeader from '../components/runner_header.vue'; +import RunnerUpdateForm from '../components/runner_update_form.vue'; +import { I18N_FETCH_ERROR } from '../constants'; +import runnerFormQuery from '../graphql/edit/runner_form.query.graphql'; +import { captureException } from '../sentry_utils'; + +export default { + name: 'RunnerEditApp', + components: { + RunnerHeader, + RunnerUpdateForm, + }, + props: { + runnerId: { + type: String, + required: true, + }, + runnerPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + runner: null, + }; + }, + apollo: { + runner: { + query: runnerFormQuery, + variables() { + return { + id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId), + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + loading() { + return this.$apollo.queries.runner.loading; + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, +}; +</script> +<template> + <div> + <runner-header v-if="runner" :runner="runner" /> + <runner-update-form + :loading="loading" + :runner="runner" + :runner-path="runnerPath" + class="gl-my-5" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js new file mode 100644 index 00000000000..adc832b0600 --- /dev/null +++ b/app/assets/javascripts/ci/runner/runner_search_utils.js @@ -0,0 +1,267 @@ +import { isEmpty } from 'lodash'; +import { queryToObject, setUrlParams } from '~/lib/utils/url_utility'; +import { + filterToQueryObject, + processFilters, + 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, + PARAM_KEY_SEARCH, + PARAM_KEY_MEMBERSHIP, + PARAM_KEY_SORT, + PARAM_KEY_AFTER, + PARAM_KEY_BEFORE, + DEFAULT_SORT, + DEFAULT_MEMBERSHIP, + RUNNER_PAGE_SIZE, +} from './constants'; +import { getPaginationVariables } from './utils'; + +/** + * The filters and sorting of the runners are built around + * an object called "search" that contains the current state + * of search in the UI. For example: + * + * ``` + * const search = { + * // The current tab + * runnerType: 'INSTANCE_TYPE', + * + * // Filters in the search bar + * filters: [ + * { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, + * { type: 'filtered-search-term', value: { data: '' } }, + * ], + * + * // Current sorting value + * sort: 'CREATED_DESC', + * + * // Pagination information + * pagination: { "after": "..." }, + * }; + * ``` + * + * An object in this format can be used to generate URLs + * with the search parameters or by runner components + * a input using a v-model. + * + * @module runner_search_utils + */ + +/** + * Validates a search value + * @param {Object} search + * @returns {boolean} True if the value follows the search format. + */ +export const searchValidator = ({ runnerType, membership, filters, sort }) => { + return ( + (runnerType === null || typeof runnerType === 'string') && + (membership === null || typeof membership === 'string') && + Array.isArray(filters) && + typeof sort === 'string' + ); +}; + +const getPaginationFromParams = (params) => { + return { + 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 + * + * @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); +}; + +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. + * + * Use for redirecting users to currently used URLs. + * + * @param {String?} URL + * @returns Updated URL if outdated, `null` otherwise + */ +export const updateOutdatedUrl = (url = window.location.href) => { + const urlObj = new URL(url); + const query = urlObj.search; + const params = queryToObject(query, { gatherArrays: true }); + + // 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; +}; + +/** + * Takes a URL query and transforms it into a "search" object + * @param {String?} query + * @returns {Object} A search object + */ +export const fromUrlQueryToSearch = (query = window.location.search) => { + const params = queryToObject(query, { gatherArrays: true }); + const runnerType = params[PARAM_KEY_RUNNER_TYPE]?.[0] || null; + const membership = params[PARAM_KEY_MEMBERSHIP]?.[0] || null; + + return { + runnerType, + membership: membership || DEFAULT_MEMBERSHIP, + filters: prepareTokens( + urlQueryToFilter(query, { + filterNamesAllowList: [PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG], + filteredSearchTermKey: PARAM_KEY_SEARCH, + }), + ), + sort: params[PARAM_KEY_SORT] || DEFAULT_SORT, + pagination: getPaginationFromParams(params), + }; +}; + +/** + * Takes a "search" object and transforms it into a URL. + * + * @param {Object} search + * @param {String} url + * @returns {String} New URL for the page + */ +export const fromSearchToUrl = ( + { runnerType = null, membership = null, filters = [], sort = null, pagination = {} }, + url = window.location.href, +) => { + const filterParams = { + // Defaults + [PARAM_KEY_STATUS]: [], + [PARAM_KEY_RUNNER_TYPE]: [], + [PARAM_KEY_MEMBERSHIP]: [], + [PARAM_KEY_TAG]: [], + // Current filters + ...filterToQueryObject(processFilters(filters), { + filteredSearchTermKey: PARAM_KEY_SEARCH, + }), + }; + + if (runnerType) { + filterParams[PARAM_KEY_RUNNER_TYPE] = [runnerType]; + } + + if (membership && membership !== DEFAULT_MEMBERSHIP) { + filterParams[PARAM_KEY_MEMBERSHIP] = [membership]; + } + + if (!filterParams[PARAM_KEY_SEARCH]) { + filterParams[PARAM_KEY_SEARCH] = null; + } + + const isDefaultSort = sort !== DEFAULT_SORT; + const otherParams = { + // Sorting & Pagination + [PARAM_KEY_SORT]: isDefaultSort ? sort : null, + [PARAM_KEY_BEFORE]: pagination?.before || null, + [PARAM_KEY_AFTER]: pagination?.after || null, + }; + + return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true); +}; + +/** + * Takes a "search" object and transforms it into variables for runner a GraphQL query. + * + * @param {Object} search + * @returns {Object} Hash of filter values + */ +export const fromSearchToVariables = ({ + runnerType = null, + membership = null, + filters = [], + sort = null, + pagination = {}, +} = {}) => { + const filterVariables = {}; + + const queryObj = filterToQueryObject(processFilters(filters), { + filteredSearchTermKey: PARAM_KEY_SEARCH, + }); + + [filterVariables.status] = queryObj[PARAM_KEY_STATUS] || []; + 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; + } + if (membership) { + filterVariables.membership = membership; + } + if (sort) { + filterVariables.sort = sort; + } + + const paginationVariables = getPaginationVariables(pagination, RUNNER_PAGE_SIZE); + + return { + ...filterVariables, + ...paginationVariables, + }; +}; + +/** + * Decides whether or not a search object is the "default" or empty. + * + * A search is filtered if the user has entered filtering criteria. + * + * @param {Object} search + * @returns true if this search is filtered, false otherwise + */ +export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => { + return Boolean( + runnerType !== null || filters?.length !== 0 || pagination?.before || pagination?.after, + ); +}; diff --git a/app/assets/javascripts/ci/runner/runner_update_form_utils.js b/app/assets/javascripts/ci/runner/runner_update_form_utils.js new file mode 100644 index 00000000000..3b519fa7d71 --- /dev/null +++ b/app/assets/javascripts/ci/runner/runner_update_form_utils.js @@ -0,0 +1,38 @@ +export const runnerToModel = (runner) => { + const { + id, + description, + maximumTimeout, + accessLevel, + active, + locked, + runUntagged, + tagList = [], + } = runner || {}; + + return { + id, + description, + maximumTimeout, + accessLevel, + active, + locked, + runUntagged, + tagList: tagList.join(', '), + }; +}; + +export const modelToUpdateMutationVariables = (model) => { + const { maximumTimeout, tagList } = model; + + return { + input: { + ...model, + maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null, + tagList: tagList + ?.split(',') + .map((tag) => tag.trim()) + .filter((tag) => Boolean(tag)), + }, + }; +}; diff --git a/app/assets/javascripts/ci/runner/sentry_utils.js b/app/assets/javascripts/ci/runner/sentry_utils.js new file mode 100644 index 00000000000..29de1f9adae --- /dev/null +++ b/app/assets/javascripts/ci/runner/sentry_utils.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +const COMPONENT_TAG = 'vue_component'; + +/** + * Captures an error in a Vue component and sends it + * to Sentry + * + * @param {Object} options + * @param {Error} options.error - Exception or error + * @param {String} options.component - Component name in CamelCase format + */ +export const captureException = ({ error, component }) => { + Sentry.withScope((scope) => { + if (component) { + scope.setTag(COMPONENT_TAG, component); + } + Sentry.captureException(error); + }); +}; diff --git a/app/assets/javascripts/ci/runner/utils.js b/app/assets/javascripts/ci/runner/utils.js new file mode 100644 index 00000000000..1ca0a9e86b5 --- /dev/null +++ b/app/assets/javascripts/ci/runner/utils.js @@ -0,0 +1,83 @@ +import { formatNumber } from '~/locale'; +import { RUNNER_JOB_COUNT_LIMIT } from './constants'; + +/** + * Formats a job count, limited to a max number + * + * @param {Number} jobCount + * @returns Formatted string + */ +export const formatJobCount = (jobCount) => { + if (typeof jobCount !== 'number') { + return ''; + } + if (jobCount > RUNNER_JOB_COUNT_LIMIT) { + return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; + } + return formatNumber(jobCount); +}; + +/** + * Returns a GlTable fields with a given key and label + * + * @param {Object} options + * @returns Field object to add to GlTable fields + */ +export const tableField = ({ key, label = '', thClasses = [], ...options }) => { + return { + key, + label, + thClass: thClasses, + tdAttr: { + 'data-testid': `td-${key}`, + }, + ...options, + }; +}; + +/** + * Returns variables for a GraphQL query that uses keyset + * pagination. + * + * https://docs.gitlab.com/ee/development/graphql_guide/pagination.html#keyset-pagination + * + * @param {Object} pagination - Contains before, after, page + * @param {Number} pageSize + * @returns Variables + */ +export const getPaginationVariables = (pagination, pageSize = 10) => { + const { before, after } = pagination; + + // first + after: Next page + // Get the first N items after item X + if (after) { + return { + after, + first: pageSize, + }; + } + + // last + before: Prev page + // Get the first N items before item X, when you click on Prev + if (before) { + return { + before, + last: pageSize, + }; + } + + // first page + // Get the first N items + return { first: pageSize }; +}; + +/** + * Turns a server-provided interval integer represented as a string into an + * integer that the frontend can use. + * + * @param {String} interval - String to convert + * @returns Parsed integer + */ +export const parseInterval = (interval) => { + return typeof interval === 'string' ? parseInt(interval, 10) : null; +}; |