diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 18:10:58 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 18:10:58 +0300 |
commit | 049d16d168fdee408b78f5f38619c092fd3b2265 (patch) | |
tree | 22d1db5ab4fae0967a4da4b1a6b097ef9e5d7aa2 /app/assets/javascripts/artifacts | |
parent | bf18f3295b550c564086efd0a32d9a25435ce216 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/artifacts')
9 files changed, 686 insertions, 0 deletions
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/artifacts/components/artifact_row.vue new file mode 100644 index 00000000000..92044a3641a --- /dev/null +++ b/app/assets/javascripts/artifacts/components/artifact_row.vue @@ -0,0 +1,88 @@ +<script> +import { GlButtonGroup, GlButton, GlBadge } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE } from '../constants'; + +export default { + name: 'ArtifactRow', + components: { + GlButtonGroup, + GlButton, + GlBadge, + }, + props: { + artifact: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + isLastRow: { + type: Boolean, + required: true, + }, + }, + computed: { + isExpired() { + if (!this.artifact.expireAt) { + return false; + } + return Date.now() > new Date(this.artifact.expireAt).getTime(); + }, + artifactSize() { + return numberToHumanSize(this.artifact.size); + }, + }, + i18n: { + expired: I18N_EXPIRED, + download: I18N_DOWNLOAD, + delete: I18N_DELETE, + }, +}; +</script> +<template> + <div + class="gl-py-4" + :class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': !isLastRow }" + > + <div class="gl-display-inline-flex gl-align-items-center gl-w-full"> + <span class="gl-w-half gl-pl-8 gl-display-flex" data-testid="job-artifact-row-name"> + {{ artifact.name }} + <gl-badge size="sm" variant="neutral" class="gl-ml-2"> + {{ artifact.fileType.toLowerCase() }} + </gl-badge> + <gl-badge v-if="isExpired" size="sm" variant="warning" icon="expire" class="gl-ml-2"> + {{ $options.i18n.expired }} + </gl-badge> + </span> + + <span class="gl-w-quarter gl-text-right gl-pr-5" data-testid="job-artifact-row-size"> + {{ artifactSize }} + </span> + + <span class="gl-w-quarter gl-text-right gl-pr-5"> + <gl-button-group> + <gl-button + category="tertiary" + icon="download" + :title="$options.i18n.download" + :aria-label="$options.i18n.download" + :href="artifact.downloadPath" + data-testid="job-artifact-row-download-button" + /> + <gl-button + category="tertiary" + icon="remove" + :title="$options.i18n.delete" + :aria-label="$options.i18n.delete" + :loading="isLoading" + data-testid="job-artifact-row-delete-button" + @click="$emit('delete')" + /> + </gl-button-group> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue new file mode 100644 index 00000000000..089bfd80222 --- /dev/null +++ b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue @@ -0,0 +1,89 @@ +<script> +import { createAlert } from '~/flash'; +import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; +import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; +import destroyArtifactMutation from '../graphql/mutations/destroy_artifact.mutation.graphql'; +import { removeArtifactFromStore } from '../graphql/cache_update'; +import { + I18N_DESTROY_ERROR, + ARTIFACT_ROW_HEIGHT, + ARTIFACTS_SHOWN_WITHOUT_SCROLLING, +} from '../constants'; +import ArtifactRow from './artifact_row.vue'; + +export default { + name: 'ArtifactsTableRowDetails', + components: { + DynamicScroller, + DynamicScrollerItem, + ArtifactRow, + }, + props: { + artifacts: { + type: Object, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + }, + data() { + return { + deletingArtifactId: null, + }; + }, + computed: { + scrollContainerStyle() { + /* + limit the height of the expanded artifacts container to a number of artifacts + if a job has more artifacts than ARTIFACTS_SHOWN_WITHOUT_SCROLLING, scroll to see the rest + add one pixel to row height to account for borders + */ + return { maxHeight: `${ARTIFACTS_SHOWN_WITHOUT_SCROLLING * (ARTIFACT_ROW_HEIGHT + 1)}px` }; + }, + }, + methods: { + isLastRow(index) { + return index === this.artifacts.nodes.length - 1; + }, + destroyArtifact(id) { + this.deletingArtifactId = id; + this.$apollo + .mutate({ + mutation: destroyArtifactMutation, + variables: { id }, + update: (store) => { + removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables); + }, + }) + .catch(() => { + createAlert({ + message: I18N_DESTROY_ERROR, + }); + this.$emit('refetch'); + }) + .finally(() => { + this.deletingArtifactId = null; + }); + }, + }, + ARTIFACT_ROW_HEIGHT, +}; +</script> +<template> + <div :style="scrollContainerStyle"> + <dynamic-scroller :items="artifacts.nodes" :min-item-size="$options.ARTIFACT_ROW_HEIGHT"> + <template #default="{ item, index, active }"> + <dynamic-scroller-item :item="item" :active="active" :class="{ active }"> + <artifact-row + :artifact="item" + :is-last-row="isLastRow(index)" + :is-loading="item.id === deletingArtifactId" + @delete="destroyArtifact(item.id)" + /> + </dynamic-scroller-item> + </template> + </dynamic-scroller> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue new file mode 100644 index 00000000000..7b11e4f17f3 --- /dev/null +++ b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue @@ -0,0 +1,314 @@ +<script> +import { + GlLoadingIcon, + GlTable, + GlLink, + GlButtonGroup, + GlButton, + GlBadge, + GlIcon, + GlPagination, +} from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql'; +import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils'; +import { + STATUS_BADGE_VARIANTS, + I18N_DOWNLOAD, + I18N_BROWSE, + I18N_DELETE, + I18N_EXPIRED, + I18N_DESTROY_ERROR, + I18N_FETCH_ERROR, + I18N_ARTIFACTS, + I18N_JOB, + I18N_SIZE, + I18N_CREATED, + I18N_ARTIFACTS_COUNT, + INITIAL_CURRENT_PAGE, + INITIAL_PREVIOUS_PAGE_CURSOR, + INITIAL_NEXT_PAGE_CURSOR, + JOBS_PER_PAGE, + INITIAL_LAST_PAGE_SIZE, +} from '../constants'; +import ArtifactsTableRowDetails from './artifacts_table_row_details.vue'; + +const INITIAL_PAGINATION_STATE = { + currentPage: INITIAL_CURRENT_PAGE, + prevPageCursor: INITIAL_PREVIOUS_PAGE_CURSOR, + nextPageCursor: INITIAL_NEXT_PAGE_CURSOR, + firstPageSize: JOBS_PER_PAGE, + lastPageSize: INITIAL_LAST_PAGE_SIZE, +}; + +export default { + name: 'JobArtifactsTable', + components: { + GlLoadingIcon, + GlTable, + GlLink, + GlButtonGroup, + GlButton, + GlBadge, + GlIcon, + GlPagination, + CiIcon, + TimeAgo, + ArtifactsTableRowDetails, + }, + inject: ['projectPath'], + apollo: { + jobArtifacts: { + query: getJobArtifactsQuery, + variables() { + return this.queryVariables; + }, + update({ project: { jobs: { nodes = [], pageInfo = {}, count = 0 } = {} } }) { + return { + nodes: nodes.map(mapArchivesToJobNodes).map(mapBooleansToJobNodes), + count, + pageInfo, + }; + }, + error() { + createAlert({ + message: I18N_FETCH_ERROR, + }); + }, + }, + }, + data() { + return { + jobArtifacts: { + nodes: [], + count: 0, + pageInfo: {}, + }, + pagination: INITIAL_PAGINATION_STATE, + }; + }, + computed: { + queryVariables() { + return { + projectPath: this.projectPath, + firstPageSize: this.pagination.firstPageSize, + lastPageSize: this.pagination.lastPageSize, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, + }; + }, + showPagination() { + return this.jobArtifacts.count > JOBS_PER_PAGE; + }, + prevPage() { + return Number(this.jobArtifacts.pageInfo.hasPreviousPage); + }, + nextPage() { + return Number(this.jobArtifacts.pageInfo.hasNextPage); + }, + }, + methods: { + refetchArtifacts() { + this.$apollo.queries.jobArtifacts.refetch(); + }, + artifactsSize(item) { + return totalArtifactsSizeForJob(item); + }, + pipelineId(item) { + const id = getIdFromGraphQLId(item.pipeline.id); + return `#${id}`; + }, + handlePageChange(page) { + const { startCursor, endCursor } = this.jobArtifacts.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + ...INITIAL_PAGINATION_STATE, + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + lastPageSize: JOBS_PER_PAGE, + firstPageSize: null, + prevPageCursor: startCursor, + currentPage: page, + }; + } + }, + handleRowToggle(toggleDetails, hasArtifacts) { + if (!hasArtifacts) return; + toggleDetails(); + }, + }, + fields: [ + { + key: 'artifacts', + label: I18N_ARTIFACTS, + thClass: 'gl-w-quarter', + }, + { + key: 'job', + label: I18N_JOB, + thClass: 'gl-w-35p', + }, + { + key: 'size', + label: I18N_SIZE, + thClass: 'gl-w-15p gl-text-right', + tdClass: 'gl-text-right', + }, + { + key: 'created', + label: I18N_CREATED, + thClass: 'gl-w-eighth gl-text-center', + tdClass: 'gl-text-center', + }, + { + key: 'actions', + label: '', + thClass: 'gl-w-eighth', + tdClass: 'gl-text-right', + }, + ], + STATUS_BADGE_VARIANTS, + i18n: { + download: I18N_DOWNLOAD, + browse: I18N_BROWSE, + delete: I18N_DELETE, + expired: I18N_EXPIRED, + destroyArtifactError: I18N_DESTROY_ERROR, + fetchArtifactsError: I18N_FETCH_ERROR, + artifactsLabel: I18N_ARTIFACTS, + jobLabel: I18N_JOB, + sizeLabel: I18N_SIZE, + createdLabel: I18N_CREATED, + artifactsCount: I18N_ARTIFACTS_COUNT, + }, +}; +</script> +<template> + <div> + <gl-table + :items="jobArtifacts.nodes" + :fields="$options.fields" + :busy="$apollo.queries.jobArtifacts.loading" + stacked="sm" + details-td-class="gl-bg-gray-10! gl-p-0! gl-overflow-auto" + > + <template #table-busy> + <gl-loading-icon size="lg" /> + </template> + <template + #cell(artifacts)="{ item: { artifacts, hasArtifacts }, toggleDetails, detailsShowing }" + > + <span + :class="{ 'gl-cursor-pointer': hasArtifacts }" + data-testid="job-artifacts-count" + @click="handleRowToggle(toggleDetails, hasArtifacts)" + > + <gl-icon + v-if="hasArtifacts" + :name="detailsShowing ? 'chevron-down' : 'chevron-right'" + class="gl-mr-2" + /> + <strong> + {{ $options.i18n.artifactsCount(artifacts.nodes.length) }} + </strong> + </span> + </template> + <template #cell(job)="{ item }"> + <span class="gl-display-inline-flex gl-align-items-center gl-w-full gl-mb-4"> + <span data-testid="job-artifacts-job-status"> + <ci-icon v-if="item.succeeded" :status="item.detailedStatus" class="gl-mr-3" /> + <gl-badge + v-else + :icon="item.detailedStatus.icon" + :variant="$options.STATUS_BADGE_VARIANTS[item.detailedStatus.group]" + class="gl-mr-3" + > + {{ item.detailedStatus.label }} + </gl-badge> + </span> + <gl-link :href="item.webPath" class="gl-font-weight-bold"> + {{ item.name }} + </gl-link> + </span> + <span class="gl-display-inline-flex"> + <gl-icon name="pipeline" class="gl-mr-2" /> + <gl-link + :href="item.pipeline.path" + class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" + > + {{ pipelineId(item) }} + </gl-link> + <gl-icon name="branch" class="gl-mr-2" /> + <gl-link + :href="item.refPath" + class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" + > + {{ item.refName }} + </gl-link> + <gl-icon name="commit" class="gl-mr-2" /> + <gl-link + :href="item.commitPath" + class="gl-text-black-normal gl-text-decoration-underline gl-mr-4" + > + {{ item.shortSha }} + </gl-link> + </span> + </template> + <template #cell(size)="{ item }"> + <span data-testid="job-artifacts-size">{{ artifactsSize(item) }}</span> + </template> + <template #cell(created)="{ item }"> + <time-ago data-testid="job-artifacts-created" :time="item.finishedAt" /> + </template> + <template #cell(actions)="{ item }"> + <gl-button-group> + <gl-button + icon="download" + :disabled="!item.archive.downloadPath" + :href="item.archive.downloadPath" + :title="$options.i18n.download" + :aria-label="$options.i18n.download" + data-testid="job-artifacts-download-button" + /> + <gl-button + icon="folder-open" + :title="$options.i18n.browse" + :aria-label="$options.i18n.browse" + data-testid="job-artifacts-browse-button" + disabled + /> + <gl-button + icon="remove" + :title="$options.i18n.delete" + :aria-label="$options.i18n.delete" + data-testid="job-artifacts-delete-button" + disabled + /> + </gl-button-group> + </template> + <template #row-details="{ item: { artifacts } }"> + <artifacts-table-row-details + :artifacts="artifacts" + :query-variables="queryVariables" + @refetch="refetchArtifacts" + /> + </template> + </gl-table> + <gl-pagination + v-if="showPagination" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-3" + @input="handlePageChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js new file mode 100644 index 00000000000..9ed0821ac2d --- /dev/null +++ b/app/assets/javascripts/artifacts/constants.js @@ -0,0 +1,47 @@ +import { __, s__, n__ } from '~/locale'; + +export const JOB_STATUS_GROUP_SUCCESS = 'success'; + +export const STATUS_BADGE_VARIANTS = { + success: 'success', + passed: 'success', + error: 'danger', + failed: 'danger', + pending: 'warning', + 'waiting-for-resource': 'warning', + 'failed-with-warnings': 'warning', + 'success-with-warnings': 'warning', + running: 'info', + canceled: 'neutral', + disabled: 'neutral', + scheduled: 'neutral', + manual: 'neutral', + notification: 'muted', + preparing: 'muted', + created: 'muted', + skipped: 'muted', + notfound: 'muted', +}; + +export const I18N_DOWNLOAD = __('Download'); +export const I18N_BROWSE = s__('Artifacts|Browse'); +export const I18N_DELETE = __('Delete'); +export const I18N_EXPIRED = __('Expired'); +export const I18N_DESTROY_ERROR = s__('Artifacts|An error occurred while deleting the artifact'); +export const I18N_FETCH_ERROR = s__('Artifacts|An error occurred while retrieving job artifacts'); +export const I18N_ARTIFACTS = __('Artifacts'); +export const I18N_JOB = __('Job'); +export const I18N_SIZE = __('Size'); +export const I18N_CREATED = __('Created'); +export const I18N_ARTIFACTS_COUNT = (count) => n__('%d file', '%d files', count); + +export const INITIAL_CURRENT_PAGE = 1; +export const INITIAL_PREVIOUS_PAGE_CURSOR = ''; +export const INITIAL_NEXT_PAGE_CURSOR = ''; +export const JOBS_PER_PAGE = 20; +export const INITIAL_LAST_PAGE_SIZE = null; + +export const ARCHIVE_FILE_TYPE = 'ARCHIVE'; + +export const ARTIFACT_ROW_HEIGHT = 56; +export const ARTIFACTS_SHOWN_WITHOUT_SCROLLING = 4; diff --git a/app/assets/javascripts/artifacts/graphql/cache_update.js b/app/assets/javascripts/artifacts/graphql/cache_update.js new file mode 100644 index 00000000000..c620e03c80d --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/cache_update.js @@ -0,0 +1,30 @@ +import produce from 'immer'; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +export function removeArtifactFromStore(store, deletedArtifactId, query, variables) { + if (!hasErrors(deletedArtifactId)) { + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + draftData.project.jobs.nodes = draftData.project.jobs.nodes.map((jobNode) => { + return { + ...jobNode, + artifacts: { + ...jobNode.artifacts, + nodes: jobNode.artifacts.nodes.filter(({ id }) => id !== deletedArtifactId), + }, + }; + }); + }); + + store.writeQuery({ + query, + variables, + data, + }); + } +} diff --git a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql new file mode 100644 index 00000000000..529224b47e6 --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql @@ -0,0 +1,7 @@ +mutation destroyArtifact($id: CiJobArtifactID!) { + artifactDestroy(input: { id: $id }) { + artifact { + id + } + } +} diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql new file mode 100644 index 00000000000..685196e28d5 --- /dev/null +++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql @@ -0,0 +1,56 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getJobArtifacts( + $projectPath: ID! + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" +) { + project(fullPath: $projectPath) { + id + jobs( + statuses: [SUCCESS, FAILED] + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ) { + count + nodes { + id + name + webPath + detailedStatus { + id + group + icon + label + } + pipeline { + id + iid + path + } + refName + refPath + shortSha + commitPath + finishedAt + artifacts { + nodes { + id + name + fileType + downloadPath + size + expireAt + } + } + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js new file mode 100644 index 00000000000..b5146e0f0e9 --- /dev/null +++ b/app/assets/javascripts/artifacts/index.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import JobArtifactsTable from './components/job_artifacts_table.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initArtifactsTable = () => { + const el = document.querySelector('#js-artifact-management'); + + if (!el) { + return false; + } + + const { projectPath } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + projectPath, + }, + render: (createElement) => createElement(JobArtifactsTable), + }); +}; diff --git a/app/assets/javascripts/artifacts/utils.js b/app/assets/javascripts/artifacts/utils.js new file mode 100644 index 00000000000..ebcf0af8d2a --- /dev/null +++ b/app/assets/javascripts/artifacts/utils.js @@ -0,0 +1,26 @@ +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { ARCHIVE_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants'; + +export const totalArtifactsSizeForJob = (job) => + numberToHumanSize( + job.artifacts.nodes + .map((artifact) => artifact.size) + .reduce((total, artifact) => total + artifact, 0), + ); + +export const mapArchivesToJobNodes = (jobNode) => { + return { + archive: { + ...jobNode.artifacts.nodes.find((artifact) => artifact.fileType === ARCHIVE_FILE_TYPE), + }, + ...jobNode, + }; +}; + +export const mapBooleansToJobNodes = (jobNode) => { + return { + succeeded: jobNode.detailedStatus.group === JOB_STATUS_GROUP_SUCCESS, + hasArtifacts: jobNode.artifacts.nodes.length > 0, + ...jobNode, + }; +}; |