diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-28 18:09:44 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-28 18:09:44 +0300 |
commit | 34b3acb5a3a9b21490e45b81b81dca600b66521c (patch) | |
tree | 81deb74283f931cdbf65b8878b41085b0213a9e6 /app | |
parent | effda22b3e6367cefd12666463b8409bf7e24cef (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
30 files changed, 695 insertions, 98 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index c6605452616..beb636ee8fa 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -1,15 +1,16 @@ <script> -/* eslint-disable vue/no-v-html */ import * as Sentry from '@sentry/browser'; import { GlAlert, GlBadge, GlIcon, + GlLink, GlLoadingIcon, GlSprintf, GlTabs, GlTab, GlButton, + GlSafeHtmlDirective, } from '@gitlab/ui'; import { s__ } from '~/locale'; import alertQuery from '../graphql/queries/details.query.graphql'; @@ -28,6 +29,7 @@ import SystemNote from './system_notes/system_note.vue'; import AlertSidebar from './alert_sidebar.vue'; import AlertMetrics from './alert_metrics.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import AlertSummaryRow from './alert_summary_row.vue'; const containerEl = document.querySelector('.page-with-contextual-sidebar'); @@ -39,6 +41,9 @@ export default { reportedAt: s__('AlertManagement|Reported %{when}'), reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, severityLabels: ALERTS_SEVERITY_LABELS, tabsConfig: [ { @@ -56,9 +61,11 @@ export default { ], components: { AlertDetailsTable, + AlertSummaryRow, GlBadge, GlAlert, GlIcon, + GlLink, GlLoadingIcon, GlSprintf, GlTab, @@ -211,7 +218,7 @@ export default { <template> <div> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> - <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> + <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> </gl-alert> <gl-alert v-if="createIncidentError" @@ -283,54 +290,66 @@ export default { </div> <gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs"> <gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title"> - <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Severity') }}: - </div> - <div class="gl-pl-2" data-testid="severity"> - <span> - <gl-icon - class="gl-vertical-align-middle" - :size="12" - :name="`severity-${alert.severity.toLowerCase()}`" - :class="`icon-${alert.severity.toLowerCase()}`" - /> - </span> + <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`"> + <span data-testid="severity"> + <gl-icon + class="gl-vertical-align-middle" + :size="12" + :name="`severity-${alert.severity.toLowerCase()}`" + :class="`icon-${alert.severity.toLowerCase()}`" + /> {{ $options.severityLabels[alert.severity] }} - </div> - </div> - <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Start time') }}: - </div> - <div class="gl-pl-2"> - <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> - </div> - </div> - <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Events') }}: - </div> - <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div> - </div> - <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex"> - <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Tool') }}: - </div> - <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div> - </div> - <div v-if="alert.service" class="gl-my-5 gl-display-flex"> - <div class="bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Service') }}: - </div> - <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div> - </div> - <div v-if="alert.runbook" class="gl-my-5 gl-display-flex"> - <div class="bold gl-w-13 gl-text-right gl-pr-3"> - {{ s__('AlertManagement|Runbook') }}: - </div> - <div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div> - </div> + </span> + </alert-summary-row> + <alert-summary-row + v-if="alert.environment" + :label="`${s__('AlertManagement|Environment')}:`" + > + <gl-link + v-if="alert.environmentUrl" + class="gl-display-inline-block" + data-testid="environmentUrl" + :href="alert.environmentUrl" + target="_blank" + > + {{ alert.environment }} + </gl-link> + <span v-else data-testid="environment">{{ alert.environment }}</span> + </alert-summary-row> + <alert-summary-row + v-if="alert.startedAt" + :label="`${s__('AlertManagement|Start time')}:`" + > + <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> + </alert-summary-row> + <alert-summary-row + v-if="alert.eventCount" + :label="`${s__('AlertManagement|Events')}:`" + data-testid="eventCount" + > + {{ alert.eventCount }} + </alert-summary-row> + <alert-summary-row + v-if="alert.monitoringTool" + :label="`${s__('AlertManagement|Tool')}:`" + data-testid="monitoringTool" + > + {{ alert.monitoringTool }} + </alert-summary-row> + <alert-summary-row + v-if="alert.service" + :label="`${s__('AlertManagement|Service')}:`" + data-testid="service" + > + {{ alert.service }} + </alert-summary-row> + <alert-summary-row + v-if="alert.runbook" + :label="`${s__('AlertManagement|Runbook')}:`" + data-testid="runbook" + > + {{ alert.runbook }} + </alert-summary-row> <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> <gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title"> diff --git a/app/assets/javascripts/alert_management/components/alert_summary_row.vue b/app/assets/javascripts/alert_management/components/alert_summary_row.vue new file mode 100644 index 00000000000..13835b7e2fa --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_summary_row.vue @@ -0,0 +1,18 @@ +<script> +export default { + props: { + label: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="gl-my-5 gl-display-flex"> + <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div> + <div class="gl-pl-2"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e77120fd12a..4bbb30450ff 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -112,6 +112,12 @@ const Api = { }); }, + inviteGroupMember(id, data) { + const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data); + }, + groupMilestones(id, options) { const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id)); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index f6bad5dce41..4cccabca28b 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -36,12 +36,6 @@ export default () => { 'stage-review-component': stageReviewComponent, 'stage-staging-component': stageStagingComponent, 'stage-production-component': stageComponent, - GroupsDropdownFilter: () => - import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'), - ProjectsDropdownFilter: () => - import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'), - DateRangeDropdown: () => - import('ee_component/analytics/shared/components/date_range_dropdown.vue'), 'stage-nav-item': stageNavItem, }, data() { diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue new file mode 100644 index 00000000000..d2ea14a658b --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -0,0 +1,224 @@ +<script> +import { + GlModal, + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlLink, + GlSprintf, + GlSearchBoxByType, + GlButton, + GlFormInput, +} from '@gitlab/ui'; +import eventHub from '../event_hub'; +import { s__, sprintf } from '~/locale'; +import Api from '~/api'; + +export default { + name: 'InviteMembersModal', + components: { + GlDatepicker, + GlLink, + GlModal, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlSearchBoxByType, + GlButton, + GlFormInput, + }, + props: { + groupId: { + type: String, + required: true, + }, + groupName: { + type: String, + required: true, + }, + accessLevels: { + type: Object, + required: true, + }, + defaultAccessLevel: { + type: String, + required: true, + }, + helpLink: { + type: String, + required: true, + }, + }, + data() { + return { + visible: true, + modalId: 'invite-members-modal', + selectedAccessLevel: this.defaultAccessLevel, + newUsersToInvite: '', + selectedDate: undefined, + }; + }, + computed: { + introText() { + return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), { + group_name: this.groupName, + }); + }, + toastOptions() { + return { + onComplete: () => { + this.selectedAccessLevel = this.defaultAccessLevel; + this.newUsersToInvite = ''; + }, + }; + }, + postData() { + return { + user_id: this.newUsersToInvite, + access_level: this.selectedAccessLevel, + expires_at: this.selectedDate, + format: 'json', + }; + }, + selectedRoleName() { + return Object.keys(this.accessLevels).find( + key => this.accessLevels[key] === Number(this.selectedAccessLevel), + ); + }, + }, + mounted() { + eventHub.$on('openModal', this.openModal); + }, + methods: { + openModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + closeModal() { + this.$root.$emit('bv::hide::modal', this.modalId); + }, + sendInvite() { + this.submitForm(this.postData); + this.closeModal(); + }, + cancelInvite() { + this.selectedAccessLevel = this.defaultAccessLevel; + this.selectedDate = undefined; + this.newUsersToInvite = ''; + this.closeModal(); + }, + changeSelectedItem(item) { + this.selectedAccessLevel = item; + }, + submitForm(formData) { + return Api.inviteGroupMember(this.groupId, formData) + .then(() => { + this.showToastMessageSuccess(); + }) + .catch(error => { + this.showToastMessageError(error); + }); + }, + showToastMessageSuccess() { + this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + }, + showToastMessageError(error) { + const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful; + + this.$toast.show(message, this.toastOptions); + }, + }, + labels: { + modalTitle: s__('InviteMembersModal|Invite team members'), + userToInvite: s__('InviteMembersModal|GitLab member or Email address'), + userPlaceholder: s__('InviteMembersModal|Search for members to invite'), + accessLevel: s__('InviteMembersModal|Choose a role permission'), + accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), + toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'), + toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'), + readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), + inviteButtonText: s__('InviteMembersModal|Invite'), + cancelButtonText: s__('InviteMembersModal|Cancel'), + }, +}; +</script> +<template> + <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle"> + <div class="gl-ml-5 gl-mr-5"> + <div>{{ introText }}</div> + + <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label> + <div class="gl-mt-2"> + <gl-search-box-by-type + v-model="newUsersToInvite" + :placeholder="$options.labels.userPlaceholder" + type="text" + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" + /> + </div> + + <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> + <gl-dropdown + menu-class="dropdown-menu-selectable" + class="gl-shadow-none gl-w-full" + v-bind="$attrs" + :text="selectedRoleName" + > + <template v-for="(key, item) in accessLevels"> + <gl-dropdown-item + :key="key" + active-class="is-active" + :is-checked="key === selectedAccessLevel" + @click="changeSelectedItem(key)" + > + <div>{{ item }}</div> + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> + + <div class="gl-mt-2"> + <gl-sprintf :message="$options.labels.readMoreText"> + <template #link="{content}"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{ + $options.labels.accessExpireDate + }}</label> + <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-datepicker + v-model="selectedDate" + class="gl-display-inline!" + :min-date="new Date()" + :target="null" + > + <template #default="{ formattedDate }"> + <gl-form-input + class="gl-w-full" + :value="formattedDate" + :placeholder="__(`YYYY-MM-DD`)" + /> + </template> + </gl-datepicker> + </div> + </div> + + <template #modal-footer> + <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3"> + <gl-button ref="cancelButton" @click="cancelInvite"> + {{ $options.labels.cancelButtonText }} + </gl-button> + <div class="gl-mr-3"></div> + <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{ + $options.labels.inviteButtonText + }}</gl-button> + </div> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue new file mode 100644 index 00000000000..d133e3655e3 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -0,0 +1,38 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlLink, + GlIcon, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('InviteMembers|Invite team members'), + }, + icon: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal'); + }, + }, +}; +</script> + +<template> + <gl-link @click="openModal"> + <div v-if="icon" class="nav-icon-container"> + <gl-icon :size="16" :name="icon" /> + </div> + <span class="nav-item-name"> {{ displayText }} </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/invite_members/event_hub.js b/app/assets/javascripts/invite_members/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/invite_members/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js new file mode 100644 index 00000000000..92aa3187fc3 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; + +Vue.use(GlToast); + +export default function initInviteMembersModal() { + const el = document.querySelector('.js-invite-members-modal'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: createElement => + createElement(InviteMembersModal, { + props: { + ...el.dataset, + accessLevels: JSON.parse(el.dataset.accessLevels), + groupName: el.dataset.groupName.toUpperCase(), + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js new file mode 100644 index 00000000000..bee4f1c0f72 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; + +export default function initInviteMembersTrigger() { + const el = document.querySelector('.js-invite-members-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: createElement => + createElement(InviteMembersTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql new file mode 100644 index 00000000000..224e0ed9472 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql @@ -0,0 +1,8 @@ +fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy { + cadence + enabled + keepN + nameRegex + nameRegexKeep + olderThan +} diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/registry/settings/graphql/index.js new file mode 100644 index 00000000000..16152eb81f6 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), +}); diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql new file mode 100644 index 00000000000..c40cd115ab0 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql @@ -0,0 +1,10 @@ +#import "../fragments/container_expiration_policy.fragment.graphql" + +mutation updateContainerExpirationPolicy($input: UpdateContainerExpirationPolicyInput!) { + updateContainerExpirationPolicy(input: $input) { + containerExpirationPolicy { + ...ContainerExpirationPolicyFields + } + errors + } +} diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql new file mode 100644 index 00000000000..c171be0ad07 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql @@ -0,0 +1,9 @@ +#import "../fragments/container_expiration_policy.fragment.graphql" + +query getProjectExpirationPolicy($projectPath: ID!) { + project(fullPath: $projectPath) { + containerExpirationPolicy { + ...ContainerExpirationPolicyFields + } + } +} diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js new file mode 100644 index 00000000000..88067d52b51 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js @@ -0,0 +1,22 @@ +import { produce } from 'immer'; +import expirationPolicyQuery from '../queries/get_expiration_policy.graphql'; + +export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => { + const queryAndParams = { + query: expirationPolicyQuery, + variables: { projectPath }, + }; + const sourceData = client.readQuery(queryAndParams); + + const data = produce(sourceData, draftState => { + // eslint-disable-next-line no-param-reassign + draftState.project.containerExpirationPolicy = { + ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy, + }; + }); + + client.writeQuery({ + ...queryAndParams, + data, + }); +}; diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index a318aa2a694..418483fdb41 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -3,6 +3,7 @@ import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; import store from './store'; import RegistrySettingsApp from './components/registry_settings_app.vue'; +import { apolloProvider } from './graphql/index'; Vue.use(GlToast); Vue.use(Translate); @@ -13,12 +14,20 @@ export default () => { return null; } store.dispatch('setInitialState', el.dataset); + const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset; return new Vue({ el, store, + apolloProvider, components: { RegistrySettingsApp, }, + provide: { + projectPath, + isAdmin, + adminSettingsPath, + enableHistoricEntries, + }, render(createElement) { return createElement('registry-settings-app', {}); }, diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 36d55c7610e..735d72972e6 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -43,3 +43,27 @@ export const NAME_REGEX_KEEP_PLACEHOLDER = ''; export const NAME_REGEX_KEEP_DESCRIPTION = s__( 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', ); + +export const KEEP_N_OPTIONS = [ + { variable: 1, key: 'ONE_TAG', default: false }, + { variable: 5, key: 'FIVE_TAGS', default: false }, + { variable: 10, key: 'TEN_TAGS', default: true }, + { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false }, + { variable: 50, key: 'FIFTY_TAGS', default: false }, + { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false }, +]; + +export const CADENCE_OPTIONS = [ + { key: 'EVERY_DAY', label: __('Every day'), default: true }, + { key: 'EVERY_WEEK', label: __('Every week'), default: false }, + { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false }, + { key: 'EVERY_MONTH', label: __('Every month'), default: false }, + { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false }, +]; + +export const OLDER_THAN_OPTIONS = [ + { key: 'SEVEN_DAYS', variable: 7, default: false }, + { key: 'FOURTEEN_DAYS', variable: 14, default: false }, + { key: 'THIRTY_DAYS', variable: 30, default: false }, + { key: 'NINETY_DAYS', variable: 90, default: true }, +]; diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index a7377773842..f84325cd438 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -1,3 +1,6 @@ +import { n__ } from '~/locale'; +import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants'; + export const findDefaultOption = options => { const item = options.find(o => o.default); return item ? item.key : null; @@ -17,3 +20,21 @@ export const mapComputedToEvent = (list, root) => { }); return result; }; + +export const optionLabelGenerator = (collection, singularSentence, pluralSentence) => + collection.map(option => ({ + ...option, + label: n__(singularSentence, pluralSentence, option.variable), + })); + +export const formOptionsGenerator = () => { + return { + olderThan: optionLabelGenerator( + OLDER_THAN_OPTIONS, + '%d days until tags are automatically removed', + '%d day until tags are automatically removed', + ), + cadence: CADENCE_OPTIONS, + keepN: optionLabelGenerator(KEEP_N_OPTIONS, '%d tag per image name', '%d tags per image name'), + }; +}; diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue index 3b05f76d663..a70b8e11a83 100644 --- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue +++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { reduce } from 'lodash'; import { s__ } from '~/locale'; import { capitalizeFirstCharacter, @@ -21,10 +22,10 @@ const allowedFields = [ 'description', 'endedAt', 'details', + 'environment', ]; -const filterAllowedFields = ([fieldName]) => allowedFields.includes(fieldName); -const arrayToObject = ([fieldName, value]) => ({ fieldName, value }); +const isAllowed = fieldName => allowedFields.includes(fieldName); export default { components: { @@ -62,9 +63,16 @@ export default { if (!this.alert) { return []; } - return Object.entries(this.alert) - .filter(filterAllowedFields) - .map(arrayToObject); + return reduce( + this.alert, + (allowedItems, value, fieldName) => { + if (isAllowed(fieldName)) { + return [...allowedItems, { fieldName, value }]; + } + return allowedItems; + }, + [], + ); }, }, }; diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb new file mode 100644 index 00000000000..cbd08cb82ed --- /dev/null +++ b/app/helpers/invite_members_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module InviteMembersHelper + def invite_members_allowed?(group) + Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group) + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 99580a52e96..6b4a71d4e28 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -327,6 +327,8 @@ module Ci after_transition any => [:success, :failed, :canceled] do |build| build.run_after_commit do + build.run_status_commit_hooks! + BuildFinishedWorker.perform_async(id) end end @@ -963,8 +965,24 @@ module Ci pending_state.try(:delete) end + def run_on_status_commit(&block) + status_commit_hooks.push(block) + end + + protected + + def run_status_commit_hooks! + status_commit_hooks.reverse_each do |hook| + instance_eval(&hook) + end + end + private + def status_commit_hooks + @status_commit_hooks ||= [] + end + def auto_retry strong_memoize(:auto_retry) do Gitlab::Ci::Build::AutoRetry.new(self) diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 444742062d9..f18da30e092 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -3,6 +3,7 @@ module Ci class BuildTraceChunk < ApplicationRecord extend ::Gitlab::Ci::Model + include ::Comparable include ::FastDestroyAll include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers @@ -29,6 +30,7 @@ module Ci } scope :live, -> { redis } + scope :persisted, -> { not_redis.order(:chunk_index) } class << self def all_stores @@ -63,12 +65,24 @@ module Ci get_store_class(store).delete_keys(value) end end + + ## + # Sometimes we do not want to read raw data. This method makes it easier + # to find attributes that are just metadata excluding raw data. + # + def metadata_attributes + attribute_names - %w[raw_data] + end end def data @data ||= get_data.to_s end + def crc32 + checksum.to_i + end + def truncate(offset = 0) raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 return if offset == size # Skip the following process as it doesn't affect anything @@ -130,6 +144,12 @@ module Ci build.trace_chunks.maximum(:chunk_index).to_i == chunk_index end + def <=>(other) + return unless self.build_id == other.build_id + + self.chunk_index <=> other.chunk_index + end + private def get_data @@ -150,7 +170,7 @@ module Ci self.raw_data = nil self.data_store = new_store - self.checksum = crc32(current_data) + self.checksum = self.class.crc32(current_data) ## # We need to so persist data then save a new store identifier before we diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb index ea8072099c6..7448afba4c2 100644 --- a/app/models/ci/build_trace_chunks/database.rb +++ b/app/models/ci/build_trace_chunks/database.rb @@ -17,6 +17,8 @@ module Ci def data(model) model.raw_data + rescue ActiveModel::MissingAttributeError + model.reset.raw_data end def set_data(model, new_data) diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb index d6d17bfc604..056abafd0ce 100644 --- a/app/models/concerns/checksummable.rb +++ b/app/models/concerns/checksummable.rb @@ -3,11 +3,11 @@ module Checksummable extend ActiveSupport::Concern - def crc32(data) - Zlib.crc32(data) - end - class_methods do + def crc32(data) + Zlib.crc32(data) + end + def hexdigest(path) ::Digest::SHA256.file(path).hexdigest end diff --git a/app/models/iteration.rb b/app/models/iteration.rb index d223c80fca0..bd245de411c 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -94,13 +94,25 @@ class Iteration < ApplicationRecord private + def parent_group + group || project.group + end + def start_or_due_dates_changed? start_date_changed? || due_date_changed? end - # ensure dates do not overlap with other Iterations in the same group/project + # ensure dates do not overlap with other Iterations in the same group/project tree def dates_do_not_overlap - return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists? + iterations = if parent_group.present? && resource_parent.is_a?(Project) + Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations) + elsif parent_group.present? + Iteration.where(group: parent_group.self_and_ancestors) + else + project.iterations + end + + return unless iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists? errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations")) end diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb index b73d2708b14..50d7f1136f2 100644 --- a/app/services/ci/update_build_state_service.rb +++ b/app/services/ci/update_build_state_service.rb @@ -2,6 +2,9 @@ module Ci class UpdateBuildStateService + include ::Gitlab::Utils::StrongMemoize + include ::Gitlab::ExclusiveLeaseHelpers + Result = Struct.new(:status, :backoff, keyword_init: true) ACCEPT_TIMEOUT = 5.minutes.freeze @@ -17,48 +20,63 @@ module Ci def execute overwrite_trace! if has_trace? - if accept_request? - accept_build_state! - else - check_migration_state - update_build_state! + unless accept_available? + return update_build_state! + end + + ensure_pending_state! + + in_build_trace_lock do + process_build_state! end end private - def accept_build_state! - state_created = ensure_pending_state.created_at + def overwrite_trace! + metrics.increment_trace_operation(operation: :overwrite) + + build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite? + end - if Time.current - state_created > ACCEPT_TIMEOUT - metrics.increment_trace_operation(operation: :discarded) + def ensure_pending_state! + pending_state.created_at + end - return update_build_state! + def process_build_state! + if live_chunks_pending? + if pending_state_outdated? + discard_build_trace! + update_build_state! + else + accept_build_state! + end + else + validate_build_trace! + update_build_state! end + end + def accept_build_state! build.trace_chunks.live.find_each do |chunk| chunk.schedule_to_persist! end metrics.increment_trace_operation(operation: :accepted) - ::Gitlab::Ci::Runner::Backoff.new(state_created).then do |backoff| + ::Gitlab::Ci::Runner::Backoff.new(pending_state.created_at).then do |backoff| Result.new(status: 202, backoff: backoff.to_seconds) end end - def overwrite_trace! - metrics.increment_trace_operation(operation: :overwrite) - - build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite? - end - - def check_migration_state - return unless accept_available? - - if has_chunks? && !live_chunks_pending? + def validate_build_trace! + if chunks_persisted? metrics.increment_trace_operation(operation: :finalized) end + + unless ::Gitlab::Ci::Trace::Checksum.new(build).valid? + metrics.increment_trace_operation(operation: :invalid) + end end def update_build_state! @@ -80,12 +98,24 @@ module Ci end end + def discard_build_trace! + metrics.increment_trace_operation(operation: :discarded) + end + def accept_available? !build_running? && has_checksum? && chunks_migration_enabled? end - def accept_request? - accept_available? && live_chunks_pending? + def live_chunks_pending? + build.trace_chunks.live.any? + end + + def chunks_persisted? + build.trace_chunks.any? && !live_chunks_pending? + end + + def pending_state_outdated? + Time.current - pending_state.created_at > ACCEPT_TIMEOUT end def build_state @@ -100,18 +130,14 @@ module Ci params.dig(:checksum).present? end - def has_chunks? - build.trace_chunks.any? - end - - def live_chunks_pending? - build.trace_chunks.live.any? - end - def build_running? build_state == 'running' end + def pending_state + strong_memoize(:pending_state) { ensure_pending_state } + end + def ensure_pending_state Ci::BuildPendingState.create_or_find_by!( build_id: build.id, @@ -125,6 +151,32 @@ module Ci build.pending_state end + ## + # This method is releasing an exclusive lock on a build trace the moment we + # conclude that build status has been written and the build state update + # has been committed to the database. + # + # Because a build state machine schedules a bunch of workers to run after + # build status transition to complete, we do not want to keep the lease + # until all the workers are scheduled because it opens a possibility of + # race conditions happening. + # + # Instead of keeping the lease until the transition is fully done and + # workers are scheduled, we immediately release the lock after the database + # commit happens. + # + def in_build_trace_lock(&block) + build.trace.lock do |_, lease| # rubocop:disable CodeReuse/ActiveRecord + build.run_on_status_commit { lease.cancel } + + yield + end + rescue ::Gitlab::Ci::Trace::LockedError + metrics.increment_trace_operation(operation: :locked) + + accept_build_state! + end + def chunks_migration_enabled? ::Gitlab::Ci::Features.accept_trace?(build.project) end diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml new file mode 100644 index 00000000000..51f41d58029 --- /dev/null +++ b/app/views/groups/_invite_members_modal.html.haml @@ -0,0 +1,6 @@ +- if invite_members_allowed?(group) + .js-invite-members-modal{ data: { group_id: group.id, + group_name: group.name, + access_levels: GroupMember.access_level_roles.to_json, + default_access_level: Gitlab::Access::GUEST, + help_link: help_page_url('user/permissions') } } diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml new file mode 100644 index 00000000000..1c90eaee992 --- /dev/null +++ b/app/views/groups/_invite_members_side_nav_link.html.haml @@ -0,0 +1,3 @@ +- if invite_members_allowed?(group) && body_data_page == 'groups:show' + %li + .js-invite-members-trigger{ data: { icon: 'plus', display_text: 'Invite team members' } } diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index ec4ab603d22..fa560942c5d 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -23,6 +23,8 @@ = render_if_exists 'groups/group_activity_analytics', group: @group += render_if_exists 'groups/invite_members_modal', group: @group + .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } .top-area.group-nav-container.justify-content-between .scrolling-tabs-container.inner-page-scroll-tabs diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 9e9e6493e5b..5f4b1f8ad45 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -139,6 +139,8 @@ %strong.fly-out-top-item-name = _('Members') + = render_if_exists 'groups/invite_members_side_nav_link', group: @group + - if group_sidebar_link?(:settings) = nav_link(path: group_settings_nav_link_paths) do = link_to edit_group_path(@group) do diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml index c0cef8503e0..b53fac83830 100644 --- a/app/views/projects/registry/settings/_index.haml +++ b/app/views/projects/registry/settings/_index.haml @@ -1,4 +1,5 @@ #js-registry-settings{ data: { project_id: @project.id, + project_path: @project.full_path, cadence_options: cadence_options.to_json, keep_n_options: keep_n_options.to_json, older_than_options: older_than_options.to_json, |