Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-04-21 18:21:10 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-04-21 18:21:10 +0300
commite33f87ac0fabaab468ce4b457996cc0f1b1bb648 (patch)
tree8bf0de72a9acac014cfdaddab7d463b208294af2 /app
parent5baf990db20a75078684702782c24399ef9eb0fa (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/cluster_app_logos/fluentd.pngbin0 -> 2480 bytes
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue62
-rw-r--r--app/assets/javascripts/alert_management/list.js25
-rw-r--r--app/assets/javascripts/alert_management/services/index.js7
-rw-r--r--app/assets/javascripts/api.js10
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_content.vue9
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_key_field.vue169
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js29
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue100
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js5
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js10
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue39
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue159
-rw-r--r--app/assets/javascripts/clusters/constants.js2
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js1
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js15
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/contextual_sidebar.js4
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js6
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue8
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js20
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js17
-rw-r--r--app/assets/javascripts/diffs/store/utils.js9
-rw-r--r--app/assets/javascripts/dropzone_input.js6
-rw-r--r--app/assets/javascripts/filterable_list.js4
-rw-r--r--app/assets/javascripts/flash.js6
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js20
-rw-r--r--app/assets/javascripts/gl_dropdown.js10
-rw-r--r--app/assets/javascripts/gl_form.js6
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue14
-rw-r--r--app/assets/javascripts/importer_status.js6
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js8
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js2
-rw-r--r--app/assets/javascripts/issue.js58
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue115
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue50
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_progress.vue66
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_setup.vue23
-rw-r--r--app/assets/javascripts/jira_import/index.js3
-rw-r--r--app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql14
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql12
-rw-r--r--app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql11
-rw-r--r--app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql7
-rw-r--r--app/assets/javascripts/jira_import/utils.js10
-rw-r--r--app/assets/javascripts/labels_select.js24
-rw-r--r--app/assets/javascripts/lazy_loader.js8
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js19
-rw-r--r--app/assets/javascripts/locale/sprintf.js2
-rw-r--r--app/assets/javascripts/main.js12
-rw-r--r--app/assets/javascripts/milestone_select.js14
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue286
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue300
-rw-r--r--app/assets/javascripts/monitoring/components/charts/annotations.js97
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/options.js6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue42
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue314
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue25
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/metric_embed.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue42
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue55
-rw-r--r--app/assets/javascripts/monitoring/constants.js81
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js2
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js13
-rw-r--r--app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql29
-rw-r--r--app/assets/javascripts/monitoring/services/alerts_service.js32
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js22
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js35
-rw-r--r--app/assets/javascripts/monitoring/validators.js44
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue83
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue177
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue69
-rw-r--r--app/assets/javascripts/notes/stores/actions.js15
-rw-r--r--app/assets/javascripts/notes/stores/getters.js2
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/show/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue44
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue18
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js6
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/registry/explorer/components/project_policy_alert.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/constants.js45
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue168
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js8
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutations.js13
-rw-r--r--app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue13
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/actions.js47
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/index.js14
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutations.js18
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/state.js30
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/utils.js83
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue15
-rw-r--r--app/assets/javascripts/reports/constants.js3
-rw-r--r--app/assets/javascripts/reports/store/mutations.js3
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue4
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue3
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue2
-rw-r--r--app/assets/javascripts/repository/graphql.js2
-rw-r--r--app/assets/javascripts/repository/index.js4
-rw-r--r--app/assets/javascripts/right_sidebar.js3
-rw-r--r--app/assets/javascripts/search_autocomplete.js4
-rw-r--r--app/assets/javascripts/snippet/snippet_edit.js13
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue216
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue2
-rw-r--r--app/assets/javascripts/snippets/index.js9
-rw-r--r--app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql8
-rw-r--r--app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql8
-rw-r--r--app/assets/javascripts/static_site_editor/components/static_site_editor.vue19
-rw-r--r--app/assets/javascripts/tracking.js4
-rw-r--r--app/assets/javascripts/user_popovers.js5
-rw-r--r--app/assets/javascripts/users_select.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue178
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/title.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue40
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss25
-rw-r--r--app/assets/stylesheets/framework/typography.scss11
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/themes/_dark.scss296
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss4
-rw-r--r--app/assets/stylesheets/pages/notes.scss18
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss6
-rw-r--r--app/assets/stylesheets/utilities.scss12
-rw-r--r--app/controllers/admin/runners_controller.rb10
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb8
-rw-r--r--app/controllers/concerns/integrations_actions.rb4
-rw-r--r--app/controllers/concerns/members_presentation.rb1
-rw-r--r--app/controllers/concerns/service_params.rb1
-rw-r--r--app/controllers/concerns/spammable_actions.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb22
-rw-r--r--app/controllers/explore/projects_controller.rb17
-rw-r--r--app/controllers/groups/deploy_tokens_controller.rb2
-rw-r--r--app/controllers/groups/group_members_controller.rb47
-rw-r--r--app/controllers/groups/milestones_controller.rb3
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb43
-rw-r--r--app/controllers/groups/settings/repository_controller.rb53
-rw-r--r--app/controllers/projects/alert_management_controller.rb9
-rw-r--r--app/controllers/projects/forks_controller.rb19
-rw-r--r--app/controllers/projects/import/jira_controller.rb15
-rw-r--r--app/controllers/projects/issues_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests_controller.rb13
-rw-r--r--app/controllers/projects/milestones_controller.rb3
-rw-r--r--app/controllers/projects/pipelines_controller.rb29
-rw-r--r--app/controllers/projects/project_members_controller.rb14
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/projects_controller.rb4
-rw-r--r--app/controllers/repositories/git_http_controller.rb12
-rw-r--r--app/controllers/users_controller.rb7
-rw-r--r--app/finders/autocomplete/move_to_project_finder.rb3
-rw-r--r--app/finders/group_members_finder.rb2
-rw-r--r--app/finders/members_finder.rb16
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb38
-rw-r--r--app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb28
-rw-r--r--app/graphql/types/board_list_type.rb26
-rw-r--r--app/graphql/types/board_type.rb7
-rw-r--r--app/graphql/types/metrics/dashboard_type.rb5
-rw-r--r--app/graphql/types/metrics/dashboards/annotation_type.rb31
-rw-r--r--app/graphql/types/project_type.rb2
-rw-r--r--app/graphql/types/user_type.rb2
-rw-r--r--app/helpers/analytics/navbar_helper.rb69
-rw-r--r--app/helpers/analytics_navbar_helper.rb67
-rw-r--r--app/helpers/button_helper.rb4
-rw-r--r--app/helpers/ci_variables_helper.rb2
-rw-r--r--app/helpers/environment_helper.rb2
-rw-r--r--app/helpers/environments_helper.rb4
-rw-r--r--app/helpers/groups_helper.rb1
-rw-r--r--app/helpers/preferences_helper.rb34
-rw-r--r--app/helpers/projects/alert_management_helper.rb12
-rw-r--r--app/helpers/projects_helper.rb4
-rw-r--r--app/helpers/snippets_helper.rb10
-rw-r--r--app/mailers/emails/issues.rb14
-rw-r--r--app/mailers/previews/notify_preview.rb4
-rw-r--r--app/models/ci/build.rb18
-rw-r--r--app/models/ci/job_artifact.rb5
-rw-r--r--app/models/ci/pipeline.rb8
-rw-r--r--app/models/ci/runner.rb6
-rw-r--r--app/models/clusters/applications/elastic_stack.rb28
-rw-r--r--app/models/clusters/applications/ingress.rb11
-rw-r--r--app/models/concerns/issuable.rb21
-rw-r--r--app/models/concerns/notification_branch_selection.rb14
-rw-r--r--app/models/concerns/project_features_compatibility.rb4
-rw-r--r--app/models/concerns/spammable.rb33
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/import_failure.rb7
-rw-r--r--app/models/internal_id_enums.rb2
-rw-r--r--app/models/jira_import_state.rb17
-rw-r--r--app/models/list.rb10
-rw-r--r--app/models/merge_request.rb24
-rw-r--r--app/models/merge_request_diff.rb19
-rw-r--r--app/models/namespace/root_storage_size.rb39
-rw-r--r--app/models/project.rb29
-rw-r--r--app/models/project_feature.rb17
-rw-r--r--app/models/project_import_state.rb4
-rw-r--r--app/models/project_services/chat_notification_service.rb10
-rw-r--r--app/models/project_services/discord_service.rb2
-rw-r--r--app/models/project_services/emails_on_push_service.rb2
-rw-r--r--app/models/project_services/hangouts_chat_service.rb2
-rw-r--r--app/models/project_services/issue_tracker_service.rb2
-rw-r--r--app/models/project_services/jira_service.rb5
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb4
-rw-r--r--app/models/project_services/microsoft_teams_service.rb2
-rw-r--r--app/models/project_services/pipelines_email_service.rb2
-rw-r--r--app/models/project_services/unify_circuit_service.rb2
-rw-r--r--app/models/resource_label_event.rb2
-rw-r--r--app/models/resource_milestone_event.rb6
-rw-r--r--app/models/snippet.rb6
-rw-r--r--app/models/terraform/state.rb15
-rw-r--r--app/models/user.rb17
-rw-r--r--app/models/user_type_enums.rb2
-rw-r--r--app/models/x509_certificate.rb6
-rw-r--r--app/policies/global_policy.rb7
-rw-r--r--app/policies/group_policy.rb1
-rw-r--r--app/policies/project_policy.rb1
-rw-r--r--app/presenters/ci/pipeline_presenter.rb14
-rw-r--r--app/serializers/analytics_summary_entity.rb8
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb6
-rw-r--r--app/serializers/merge_request_serializer.rb4
-rw-r--r--app/serializers/test_suite_entity.rb3
-rw-r--r--app/services/auto_merge/base_service.rb8
-rw-r--r--app/services/auto_merge/merge_when_pipeline_succeeds_service.rb4
-rw-r--r--app/services/auto_merge_service.rb37
-rw-r--r--app/services/boards/lists/list_service.rb6
-rw-r--r--app/services/ci/generate_terraform_reports_service.rb29
-rw-r--r--app/services/concerns/deploy_token_methods.rb8
-rw-r--r--app/services/concerns/spam_check_methods.rb2
-rw-r--r--app/services/emails/destroy_service.rb2
-rw-r--r--app/services/git/branch_push_service.rb2
-rw-r--r--app/services/git/process_ref_changes_service.rb8
-rw-r--r--app/services/groups/deploy_tokens/create_service.rb6
-rw-r--r--app/services/groups/transfer_service.rb22
-rw-r--r--app/services/issuable/clone/base_service.rb2
-rw-r--r--app/services/issues/export_csv_service.rb77
-rw-r--r--app/services/jira_import/start_import_service.rb10
-rw-r--r--app/services/merge_requests/merge_orchestration_service.rb40
-rw-r--r--app/services/merge_requests/pushed_branches_service.rb32
-rw-r--r--app/services/merge_requests/update_service.rb19
-rw-r--r--app/services/metrics/dashboard/transient_embed_service.rb5
-rw-r--r--app/services/namespaces/check_storage_size_service.rb60
-rw-r--r--app/services/personal_access_tokens/create_service.rb31
-rw-r--r--app/services/pod_logs/elasticsearch_service.rb5
-rw-r--r--app/services/projects/deploy_tokens/create_service.rb6
-rw-r--r--app/services/resources/create_access_token_service.rb111
-rw-r--r--app/services/snippets/create_service.rb4
-rw-r--r--app/services/snippets/update_service.rb10
-rw-r--r--app/services/spam/spam_action_service.rb (renamed from app/services/spam/spam_check_service.rb)53
-rw-r--r--app/services/spam/spam_constants.rb9
-rw-r--r--app/services/spam/spam_verdict_service.rb26
-rw-r--r--app/services/terraform/remote_state_handler.rb77
-rw-r--r--app/services/users/build_service.rb12
-rw-r--r--app/uploaders/terraform/state_uploader.rb2
-rw-r--r--app/views/admin/runners/show.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml3
-rw-r--r--app/views/groups/settings/repository/show.html.haml6
-rw-r--r--app/views/help/_shortcuts.html.haml164
-rw-r--r--app/views/layouts/_page.html.haml3
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml1
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml5
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml6
-rw-r--r--app/views/notify/issues_csv_email.html.haml9
-rw-r--r--app/views/notify/issues_csv_email.text.erb5
-rw-r--r--app/views/projects/_flash_messages.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml1
-rw-r--r--app/views/projects/alert_management/index.html.haml3
-rw-r--r--app/views/projects/buttons/_download.html.haml15
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml40
-rw-r--r--app/views/projects/import/jira/show.html.haml7
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml4
-rw-r--r--app/views/projects/issues/export_csv/_button.html.haml4
-rw-r--r--app/views/projects/issues/export_csv/_modal.html.haml22
-rw-r--r--app/views/projects/issues/show.html.haml4
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml7
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml5
-rw-r--r--app/views/shared/projects/_project.html.haml4
-rw-r--r--app/views/shared/runners/_form.html.haml11
-rw-r--r--app/views/shared/snippets/_form.html.haml112
-rw-r--r--app/views/shared/snippets/_snippet.html.haml5
-rw-r--r--app/workers/all_queues.yml18
-rw-r--r--app/workers/create_commit_signature_worker.rb13
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb4
-rw-r--r--app/workers/export_csv_worker.rb21
-rw-r--r--app/workers/gitlab/jira_import/stage/finish_import_worker.rb2
-rw-r--r--app/workers/project_daily_statistics_worker.rb1
-rw-r--r--app/workers/stage_update_worker.rb4
-rw-r--r--app/workers/x509_issuer_crl_check_worker.rb76
302 files changed, 5606 insertions, 1429 deletions
diff --git a/app/assets/images/cluster_app_logos/fluentd.png b/app/assets/images/cluster_app_logos/fluentd.png
new file mode 100644
index 00000000000..6d42578f2ce
--- /dev/null
+++ b/app/assets/images/cluster_app_logos/fluentd.png
Binary files differ
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue
new file mode 100644
index 00000000000..f7910e5d3fa
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlEmptyState, GlButton, GlLoadingIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlButton,
+ GlLoadingIcon,
+ },
+ props: {
+ indexPath: {
+ type: String,
+ required: true,
+ },
+ enableAlertManagementPath: {
+ type: String,
+ required: true,
+ },
+ emptyAlertSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ alerts: [],
+ loading: false,
+ };
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="alerts.length > 0" class="alert-management-list">
+ <div v-if="loading" class="py-3">
+ <gl-loading-icon size="md" />
+ </div>
+ </div>
+ <template v-else>
+ <gl-empty-state :title="__('Surface alerts in GitLab')" :svg-path="emptyAlertSvgPath">
+ <template #description>
+ <div class="d-block">
+ <span>{{
+ __(
+ 'Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.',
+ )
+ }}</span>
+ <a href="/help/user/project/operations/alert_management.html">
+ {{ __('More information') }}
+ </a>
+ </div>
+ <div class="d-block center pt-4">
+ <gl-button category="primary" variant="success" :href="enableAlertManagementPath">{{
+ __('Authorize external service')
+ }}</gl-button>
+ </div>
+ </template>
+ </gl-empty-state>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
new file mode 100644
index 00000000000..9f0efbc999a
--- /dev/null
+++ b/app/assets/javascripts/alert_management/list.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import AlertManagementList from './components/alert_management_list.vue';
+
+export default () => {
+ const selector = '#js-alert_management';
+
+ const domEl = document.querySelector(selector);
+ const { indexPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset;
+
+ return new Vue({
+ el: selector,
+ components: {
+ AlertManagementList,
+ },
+ render(createElement) {
+ return createElement('alert-management-list', {
+ props: {
+ indexPath,
+ enableAlertManagementPath,
+ emptyAlertSvgPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js
new file mode 100644
index 00000000000..787603d3e7a
--- /dev/null
+++ b/app/assets/javascripts/alert_management/services/index.js
@@ -0,0 +1,7 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ getAlertManagementList({ endpoint }) {
+ return axios.get(endpoint);
+ },
+};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 6301f6a3910..904bf117dc0 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -46,6 +46,7 @@ const Api = {
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
+ pipelinesPath: '/api/:version/projects/:id/pipelines/',
environmentsPath: '/api/:version/projects/:id/environments',
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
@@ -502,6 +503,15 @@ const Api = {
return axios.get(url);
},
+ // Return all pipelines for a project or filter by query params
+ pipelines(id, options = {}) {
+ const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url, {
+ params: options,
+ });
+ },
+
environments(id) {
const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 67164997bd8..8381b050900 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this, @gitlab/require-i18n-strings */
import $ from 'jquery';
-import _ from 'underscore';
+import { uniq } from 'lodash';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
import { __ } from './locale';
@@ -513,7 +513,7 @@ export class AwardsHandler {
addEmojiToFrequentlyUsedList(emoji) {
if (this.emoji.isEmojiNameValid(emoji)) {
- this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
+ this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
}
@@ -522,9 +522,7 @@ export class AwardsHandler {
return (
this.frequentlyUsedEmojis ||
(() => {
- const frequentlyUsedEmojis = _.uniq(
- (Cookies.get('frequently_used_emojis') || '').split(','),
- );
+ const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName =>
this.emoji.isEmojiNameValid(inputName),
);
diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue
index 9a30ed93330..056b4ea4aa8 100644
--- a/app/assets/javascripts/blob/components/blob_edit_content.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_content.vue
@@ -1,5 +1,6 @@
<script>
import { initEditorLite } from '~/blob/utils';
+import { debounce } from 'lodash';
export default {
props: {
@@ -32,16 +33,14 @@ export default {
});
},
methods: {
- triggerFileChange() {
+ triggerFileChange: debounce(function debouncedFileChange() {
this.$emit('input', this.editor.getValue());
- },
+ }, 250),
},
};
</script>
<template>
<div class="file-content code">
- <pre id="editor" ref="editor" data-editor-loading @focusout="triggerFileChange">{{
- value
- }}</pre>
+ <pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre>
</div>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
new file mode 100644
index 00000000000..f5c2cc57f3f
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
@@ -0,0 +1,169 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+export default {
+ name: 'CiKeyField',
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ },
+ model: {
+ prop: 'value',
+ event: 'input',
+ },
+ props: {
+ tokenList: {
+ type: Array,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ results: [],
+ arrowCounter: -1,
+ userDismissedResults: false,
+ suggestionsId: uniqueId('token-suggestions-'),
+ };
+ },
+ computed: {
+ showAutocomplete() {
+ return this.showSuggestions ? 'off' : 'on';
+ },
+ showSuggestions() {
+ return this.results.length > 0;
+ },
+ },
+ mounted() {
+ document.addEventListener('click', this.handleClickOutside);
+ },
+ destroyed() {
+ document.removeEventListener('click', this.handleClickOutside);
+ },
+ methods: {
+ closeSuggestions() {
+ this.results = [];
+ this.arrowCounter = -1;
+ },
+ handleClickOutside(event) {
+ if (!this.$el.contains(event.target)) {
+ this.closeSuggestions();
+ }
+ },
+ onArrowDown() {
+ const newCount = this.arrowCounter + 1;
+
+ if (newCount >= this.results.length) {
+ this.arrowCounter = 0;
+ return;
+ }
+
+ this.arrowCounter = newCount;
+ },
+ onArrowUp() {
+ const newCount = this.arrowCounter - 1;
+
+ if (newCount < 0) {
+ this.arrowCounter = this.results.length - 1;
+ return;
+ }
+
+ this.arrowCounter = newCount;
+ },
+ onEnter() {
+ const currentToken = this.results[this.arrowCounter] || this.value;
+ this.selectToken(currentToken);
+ },
+ onEsc() {
+ if (!this.showSuggestions) {
+ this.$emit('input', '');
+ }
+ this.closeSuggestions();
+ this.userDismissedResults = true;
+ },
+ onEntry(value) {
+ this.$emit('input', value);
+ this.userDismissedResults = false;
+
+ // short circuit so that we don't false match on empty string
+ if (value.length < 1) {
+ this.closeSuggestions();
+ return;
+ }
+
+ const filteredTokens = this.tokenList.filter(token =>
+ token.toLowerCase().includes(value.toLowerCase()),
+ );
+
+ if (filteredTokens.length) {
+ this.openSuggestions(filteredTokens);
+ } else {
+ this.closeSuggestions();
+ }
+ },
+ openSuggestions(filteredResults) {
+ this.results = filteredResults;
+ },
+ selectToken(value) {
+ this.$emit('input', value);
+ this.closeSuggestions();
+ this.$emit('key-selected');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions">
+ <gl-form-group :label="__('Key')" label-for="ci-variable-key">
+ <gl-form-input
+ id="ci-variable-key"
+ :value="value"
+ type="text"
+ role="searchbox"
+ class="form-control pl-2 js-env-input"
+ :autocomplete="showAutocomplete"
+ aria-autocomplete="list"
+ aria-controls="token-suggestions"
+ aria-haspopup="listbox"
+ :aria-expanded="showSuggestions"
+ data-qa-selector="ci_variable_key_field"
+ @input="onEntry"
+ @keydown.down="onArrowDown"
+ @keydown.up="onArrowUp"
+ @keydown.enter.prevent="onEnter"
+ @keydown.esc.stop="onEsc"
+ @keydown.tab="closeSuggestions"
+ />
+ </gl-form-group>
+
+ <div
+ v-show="showSuggestions && !userDismissedResults"
+ id="ci-variable-dropdown"
+ class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"
+ :class="{ 'd-block': showSuggestions }"
+ >
+ <div class="dropdown-content">
+ <ul :id="suggestionsId">
+ <li
+ v-for="(result, i) in results"
+ :key="i"
+ role="option"
+ :class="{ 'gl-bg-gray-100': i === arrowCounter }"
+ :aria-selected="i === arrowCounter"
+ >
+ <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{
+ result
+ }}</gl-button>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
new file mode 100644
index 00000000000..9022bf51514
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
@@ -0,0 +1,29 @@
+import { __ } from '~/locale';
+
+import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants';
+
+export const awsTokens = {
+ [AWS_ACCESS_KEY_ID]: {
+ name: AWS_ACCESS_KEY_ID,
+ /* Checks for exactly twenty characters that match key.
+ Based on greps suggested by Amazon at:
+ https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
+ */
+ validation: val => /^[A-Za-z0-9]{20}$/.test(val),
+ invalidMessage: __('This variable does not match the expected pattern.'),
+ },
+ [AWS_DEFAULT_REGION]: {
+ name: AWS_DEFAULT_REGION,
+ },
+ [AWS_SECRET_ACCESS_KEY]: {
+ name: AWS_SECRET_ACCESS_KEY,
+ /* Checks for exactly forty characters that match secret.
+ Based on greps suggested by Amazon at:
+ https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
+ */
+ validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val),
+ invalidMessage: __('This variable does not match the expected pattern.'),
+ },
+};
+
+export const awsTokenList = Object.keys(awsTokens);
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 316408adfb2..8f5acd4a0a0 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -1,8 +1,4 @@
<script>
-import { __ } from '~/locale';
-import { mapActions, mapState } from 'vuex';
-import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
-import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import {
GlDeprecatedButton,
GlModal,
@@ -14,11 +10,19 @@ import {
GlLink,
GlIcon,
} from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
+import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
+import CiKeyField from './ci_key_field.vue';
+import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
components: {
CiEnvironmentsDropdown,
+ CiKeyField,
GlDeprecatedButton,
GlModal,
GlFormSelect,
@@ -29,6 +33,9 @@ export default {
GlLink,
GlIcon,
},
+ mixins: [glFeatureFlagsMixin()],
+ tokens: awsTokens,
+ tokenList: awsTokenList,
computed: {
...mapState([
'projectId',
@@ -41,23 +48,24 @@ export default {
'selectedEnvironment',
]),
canSubmit() {
- if (this.variableData.masked && this.maskedState === false) {
- return false;
- }
- return this.variableData.key !== '' && this.variableData.secret_value !== '';
+ return (
+ this.variableValidationState &&
+ this.variableData.key !== '' &&
+ this.variableData.secret_value !== ''
+ );
},
canMask() {
const regex = RegExp(this.maskableRegex);
return regex.test(this.variableData.secret_value);
},
displayMaskedError() {
- return !this.canMask && this.variableData.masked && this.variableData.secret_value !== '';
+ return !this.canMask && this.variableData.masked;
},
maskedState() {
if (this.displayMaskedError) {
return false;
}
- return null;
+ return true;
},
variableData() {
return this.variableBeingEdited || this.variable;
@@ -66,7 +74,41 @@ export default {
return this.variableBeingEdited ? __('Update variable') : __('Add variable');
},
maskedFeedback() {
- return __('This variable can not be masked');
+ return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ },
+ tokenValidationFeedback() {
+ const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage;
+ if (!this.tokenValidationState && tokenSpecificFeedback) {
+ return tokenSpecificFeedback;
+ }
+ return '';
+ },
+ tokenValidationState() {
+ // If the feature flag is off, do not validate. Remove when flag is removed.
+ if (!this.glFeatures.ciKeyAutocomplete) {
+ return true;
+ }
+
+ const validator = this.$options.tokens?.[this.variableData.key]?.validation;
+
+ if (validator) {
+ return validator(this.variableData.secret_value);
+ }
+
+ return true;
+ },
+ variableValidationFeedback() {
+ return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
+ },
+ variableValidationState() {
+ if (
+ this.variableData.secret_value === '' ||
+ (this.tokenValidationState && this.maskedState)
+ ) {
+ return true;
+ }
+
+ return false;
},
},
methods: {
@@ -82,14 +124,13 @@ export default {
'resetSelectedEnvironment',
'setSelectedEnvironment',
]),
- updateOrAddVariable() {
- if (this.variableBeingEdited) {
- this.updateVariable(this.variableBeingEdited);
- } else {
- this.addVariable();
- }
+ deleteVarAndClose() {
+ this.deleteVariable(this.variableBeingEdited);
this.hideModal();
},
+ hideModal() {
+ this.$refs.modal.hide();
+ },
resetModalHandler() {
if (this.variableBeingEdited) {
this.resetEditing();
@@ -98,11 +139,12 @@ export default {
}
this.resetSelectedEnvironment();
},
- hideModal() {
- this.$refs.modal.hide();
- },
- deleteVarAndClose() {
- this.deleteVariable(this.variableBeingEdited);
+ updateOrAddVariable() {
+ if (this.variableBeingEdited) {
+ this.updateVariable(this.variableBeingEdited);
+ } else {
+ this.addVariable();
+ }
this.hideModal();
},
},
@@ -119,7 +161,13 @@ export default {
@hidden="resetModalHandler"
>
<form>
- <gl-form-group :label="__('Key')" label-for="ci-variable-key">
+ <ci-key-field
+ v-if="glFeatures.ciKeyAutocomplete"
+ v-model="variableData.key"
+ :token-list="$options.tokenList"
+ />
+
+ <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key">
<gl-form-input
id="ci-variable-key"
v-model="variableData.key"
@@ -130,12 +178,14 @@ export default {
<gl-form-group
:label="__('Value')"
label-for="ci-variable-value"
- :state="maskedState"
- :invalid-feedback="maskedFeedback"
+ :state="variableValidationState"
+ :invalid-feedback="variableValidationFeedback"
>
<gl-form-textarea
id="ci-variable-value"
+ ref="valueField"
v-model="variableData.secret_value"
+ :state="variableValidationState"
rows="3"
max-rows="6"
data-qa-selector="ci_variable_value_field"
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index d22138db102..5fe1e32e37e 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -14,3 +14,8 @@ export const types = {
fileType: 'file',
allEnvironmentsType: '*',
};
+
+// AWS TOKEN CONSTANTS
+export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID';
+export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION';
+export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY';
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 1b11ec355bb..106e15d9382 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -49,6 +49,7 @@ export default class Clusters {
installElasticStackPath,
installCrossplanePath,
installPrometheusPath,
+ installFluentdPath,
managePrometheusPath,
clusterEnvironmentsPath,
hasRbac,
@@ -102,6 +103,7 @@ export default class Clusters {
updateKnativeEndpoint: updateKnativePath,
installElasticStackEndpoint: installElasticStackPath,
clusterEnvironmentsEndpoint: clusterEnvironmentsPath,
+ installFluentdEndpoint: installFluentdPath,
});
this.installApplication = this.installApplication.bind(this);
@@ -265,6 +267,7 @@ export default class Clusters {
eventHub.$on('setIngressModSecurityEnabled', data => this.setIngressModSecurityEnabled(data));
eventHub.$on('setIngressModSecurityMode', data => this.setIngressModSecurityMode(data));
eventHub.$on('resetIngressModSecurityChanges', id => this.resetIngressModSecurityChanges(id));
+ eventHub.$on('setFluentdSettings', data => this.setFluentdSettings(data));
// Add event listener to all the banner close buttons
this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
@@ -281,6 +284,7 @@ export default class Clusters {
eventHub.$off('setIngressModSecurityEnabled');
eventHub.$off('setIngressModSecurityMode');
eventHub.$off('resetIngressModSecurityChanges');
+ eventHub.$off('setFluentdSettings');
}
initPolling(method, successCallback, errorCallback) {
@@ -506,6 +510,12 @@ export default class Clusters {
});
}
+ setFluentdSettings({ id: appId, port, protocol, host }) {
+ this.store.updateAppProperty(appId, 'port', port);
+ this.store.updateAppProperty(appId, 'protocol', protocol);
+ this.store.updateAppProperty(appId, 'host', host);
+ }
+
toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) {
if (externalIp !== newExternalIp) {
this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp);
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 723030c5b8b..96c00480dfd 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -14,6 +14,7 @@ import knativeLogo from 'images/cluster_app_logos/knative.png';
import meltanoLogo from 'images/cluster_app_logos/meltano.png';
import prometheusLogo from 'images/cluster_app_logos/prometheus.png';
import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png';
+import fluentdLogo from 'images/cluster_app_logos/fluentd.png';
import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
@@ -22,6 +23,7 @@ import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../con
import eventHub from '~/clusters/event_hub';
import CrossplaneProviderStack from './crossplane_provider_stack.vue';
import IngressModsecuritySettings from './ingress_modsecurity_settings.vue';
+import FluentdOutputSettings from './fluentd_output_settings.vue';
export default {
components: {
@@ -31,6 +33,7 @@ export default {
KnativeDomainEditor,
CrossplaneProviderStack,
IngressModsecuritySettings,
+ FluentdOutputSettings,
},
props: {
type: {
@@ -102,6 +105,7 @@ export default {
meltanoLogo,
prometheusLogo,
elasticStackLogo,
+ fluentdLogo,
}),
computed: {
isProjectCluster() {
@@ -670,6 +674,41 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
</p>
</div>
</application-row>
+
+ <application-row
+ id="fluentd"
+ :logo-url="fluentdLogo"
+ :title="applications.fluentd.title"
+ :status="applications.fluentd.status"
+ :status-reason="applications.fluentd.statusReason"
+ :request-status="applications.fluentd.requestStatus"
+ :request-reason="applications.fluentd.requestReason"
+ :installed="applications.fluentd.installed"
+ :install-failed="applications.fluentd.installFailed"
+ :install-application-request-params="{
+ host: applications.fluentd.host,
+ port: applications.fluentd.port,
+ protocol: applications.fluentd.protocol,
+ }"
+ :uninstallable="applications.fluentd.uninstallable"
+ :uninstall-successful="applications.fluentd.uninstallSuccessful"
+ :uninstall-failed="applications.fluentd.uninstallFailed"
+ :disabled="!helmInstalled"
+ :updateable="false"
+ title-link="https://github.com/helm/charts/tree/master/stable/fluentd"
+ >
+ <div slot="description">
+ <p>
+ {{
+ s__(
+ `ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. Export Web Application Firewall logs to your favorite SIEM.`,
+ )
+ }}
+ </p>
+
+ <fluentd-output-settings :fluentd="applications.fluentd" />
+ </div>
+ </application-row>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
new file mode 100644
index 00000000000..97b030927df
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -0,0 +1,159 @@
+<script>
+import { __ } from '~/locale';
+import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
+import { GlAlert, GlDeprecatedButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import eventHub from '~/clusters/event_hub';
+
+const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS;
+
+export default {
+ components: {
+ GlAlert,
+ GlDeprecatedButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ fluentd: {
+ type: Object,
+ required: true,
+ },
+ protocols: {
+ type: Array,
+ required: false,
+ default: () => ['TCP', 'UDP'],
+ },
+ },
+ computed: {
+ isSaving() {
+ return [UPDATING].includes(this.fluentd.status);
+ },
+ saveButtonDisabled() {
+ return [UNINSTALLING, UPDATING, INSTALLING].includes(this.fluentd.status);
+ },
+ saveButtonLabel() {
+ return this.isSaving ? __('Saving') : __('Save changes');
+ },
+ /**
+ * Returns true either when:
+ * - The application is getting updated.
+ * - The user has changed some of the settings for an application which is
+ * neither getting installed nor updated.
+ */
+ showButtons() {
+ return (
+ this.isSaving ||
+ (this.fluentd.isEditingSettings && [INSTALLED, UPDATED].includes(this.fluentd.status))
+ );
+ },
+ protocolName() {
+ if (this.fluentd.protocol !== null && this.fluentd.protocol !== undefined) {
+ return this.fluentd.protocol.toUpperCase();
+ }
+ return __('Protocol');
+ },
+ fluentdPort: {
+ get() {
+ return this.fluentd.port;
+ },
+ set(port) {
+ this.setFluentSettings({ port });
+ },
+ },
+ fluentdHost: {
+ get() {
+ return this.fluentd.host;
+ },
+ set(host) {
+ this.setFluentSettings({ host });
+ },
+ },
+ },
+ methods: {
+ updateApplication() {
+ eventHub.$emit('updateApplication', {
+ id: FLUENTD,
+ params: {
+ port: this.fluentd.port,
+ protocol: this.fluentd.protocol,
+ host: this.fluentd.host,
+ },
+ });
+ this.resetStatus();
+ },
+ resetStatus() {
+ this.fluentd.isEditingSettings = false;
+ },
+ selectProtocol(protocol) {
+ this.setFluentSettings({ protocol });
+ },
+ setFluentSettings({ port, protocol, host }) {
+ this.fluentd.isEditingSettings = true;
+ const newPort = port !== undefined ? port : this.fluentd.port;
+ const newProtocol = protocol !== undefined ? protocol : this.fluentd.protocol;
+ const newHost = host !== undefined ? host : this.fluentd.host;
+ eventHub.$emit('setFluentdSettings', {
+ id: FLUENTD,
+ port: newPort,
+ protocol: newProtocol,
+ host: newHost,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="fluentd.updateFailed" class="mb-3" variant="danger" :dismissible="false">
+ {{
+ s__(
+ 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.',
+ )
+ }}
+ </gl-alert>
+ <div class="form-horizontal">
+ <div class="form-group">
+ <label for="fluentd-host">
+ <strong>{{ s__('ClusterIntegration|SIEM Hostname') }}</strong>
+ </label>
+ <input id="fluentd-host" v-model="fluentdHost" type="text" class="form-control" />
+ </div>
+ <div class="form-group">
+ <label for="fluentd-port">
+ <strong>{{ s__('ClusterIntegration|SIEM Port') }}</strong>
+ </label>
+ <input id="fluentd-port" v-model="fluentdPort" type="text" class="form-control" />
+ </div>
+ <div class="form-group">
+ <label for="fluentd-protocol">
+ <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong>
+ </label>
+ <gl-dropdown :text="protocolName" class="w-100">
+ <gl-dropdown-item
+ v-for="(value, index) in protocols"
+ :key="index"
+ @click="selectProtocol(value)"
+ >
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ <div v-if="showButtons" class="mt-3">
+ <gl-deprecated-button
+ ref="saveBtn"
+ class="mr-1"
+ variant="success"
+ :loading="isSaving"
+ :disabled="saveButtonDisabled"
+ @click="updateApplication"
+ >
+ {{ saveButtonLabel }}
+ </gl-deprecated-button>
+ <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus">
+ {{ __('Cancel') }}
+ </gl-deprecated-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 6c3046fc56b..60e179c54eb 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -53,6 +53,7 @@ export const CERT_MANAGER = 'cert_manager';
export const CROSSPLANE = 'crossplane';
export const PROMETHEUS = 'prometheus';
export const ELASTIC_STACK = 'elastic_stack';
+export const FLUENTD = 'fluentd';
export const APPLICATIONS = [
HELM,
@@ -63,6 +64,7 @@ export const APPLICATIONS = [
CERT_MANAGER,
PROMETHEUS,
ELASTIC_STACK,
+ FLUENTD,
];
export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 333fb293a15..2a6c6965dab 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -13,6 +13,7 @@ export default class ClusterService {
jupyter: this.options.installJupyterEndpoint,
knative: this.options.installKnativeEndpoint,
elastic_stack: this.options.installElasticStackEndpoint,
+ fluentd: this.options.installFluentdEndpoint,
};
this.appUpdateEndpointMap = {
knative: this.options.updateKnativeEndpoint,
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index b09fd6800b6..ca96eb0acea 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -13,6 +13,7 @@ import {
UPDATE_EVENT,
UNINSTALL_EVENT,
ELASTIC_STACK,
+ FLUENTD,
} from '../constants';
import transitionApplicationState from '../services/application_state_machine';
@@ -103,6 +104,14 @@ export default class ClusterStore {
...applicationInitialState,
title: s__('ClusterIntegration|Elastic Stack'),
},
+ fluentd: {
+ ...applicationInitialState,
+ title: s__('ClusterIntegration|Fluentd'),
+ host: null,
+ port: null,
+ protocol: null,
+ isEditingSettings: false,
+ },
},
environments: [],
fetchingEnvironments: false,
@@ -253,6 +262,12 @@ export default class ClusterStore {
} else if (appId === ELASTIC_STACK) {
this.state.applications.elastic_stack.version = version;
this.state.applications.elastic_stack.updateAvailable = updateAvailable;
+ } else if (appId === FLUENTD) {
+ if (!this.state.applications.fluentd.isEditingSettings) {
+ this.state.applications.fluentd.port = serverAppEntry.port;
+ this.state.applications.fluentd.host = serverAppEntry.host;
+ this.state.applications.fluentd.protocol = serverAppEntry.protocol;
+ }
}
});
}
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index ad0f6cc1496..e0d012cef23 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -1,4 +1,3 @@
-import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 51879f280e0..41988f321e5 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
+import { debounce } from 'lodash';
import Cookies from 'js-cookie';
-import _ from 'underscore';
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -43,7 +43,7 @@ export default class ContextualSidebar {
$(document).trigger('content.resize');
});
- $(window).on('resize', () => _.debounce(this.render(), 100));
+ $(window).on('resize', debounce(() => this.render(), 100));
}
// See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 229612f5e9d..ba585444ba5 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -1,5 +1,5 @@
/* eslint-disable no-new */
-import _ from 'underscore';
+import { debounce } from 'lodash';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
import DropLab from './droplab/drop_lab';
@@ -55,7 +55,7 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest = false;
this.isGettingRef = false;
this.mergeRequestCreated = false;
- this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
+ this.refDebounce = debounce((value, target) => this.getRef(value, target), 500);
this.refIsValid = true;
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 6d2b11e39d3..f609ca5f22d 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -59,16 +59,10 @@ export default () => {
service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath),
};
},
- defaultNumberOfSummaryItems: 3,
computed: {
currentStage() {
return this.store.currentActiveStage();
},
- summaryTableColumnClass() {
- return this.state.summary.length === this.$options.defaultNumberOfSummaryItems
- ? 'col-sm-3'
- : 'col-sm-4';
- },
},
created() {
// Conditional check placed here to prevent this method from being called on the
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index 9544fbe9fc5..514d26862a3 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -99,8 +99,12 @@ export default {
return this.showCommentButton && this.hasDiscussions;
},
shouldRenderCommentButton() {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead && this.isLoggedIn && this.showCommentButton;
+ if (this.isLoggedIn && this.showCommentButton) {
+ const isDiffHead = parseBoolean(getParameterByName('diff_head'));
+ return !isDiffHead || gon.features?.mergeRefHeadComments;
+ }
+
+ return false;
},
isMatchLine() {
return this.line.type === MATCH_LINE_TYPE;
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index b07dfe5f33d..40e1aec42ed 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -60,3 +60,4 @@ export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines';
export const DIFFS_PER_PAGE = 20;
export const DIFF_COMPARE_BASE_VERSION_INDEX = -1;
+export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2;
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index 14c51602f28..dd682060b4b 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -1,5 +1,6 @@
import { __, n__, sprintf } from '~/locale';
-import { DIFF_COMPARE_BASE_VERSION_INDEX } from '../constants';
+import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants';
export const selectedTargetIndex = state =>
state.startVersion?.version_index || DIFF_COMPARE_BASE_VERSION_INDEX;
@@ -9,12 +10,25 @@ export const selectedSourceIndex = state => state.mergeRequestDiff.version_index
export const diffCompareDropdownTargetVersions = (state, getters) => {
// startVersion only exists if the user has selected a version other
// than "base" so if startVersion is null then base must be selected
+
+ const diffHead = parseBoolean(getParameterByName('diff_head'));
+ const isBaseSelected = !state.startVersion && !diffHead;
+ const isHeadSelected = !state.startVersion && diffHead;
+
const baseVersion = {
versionName: state.targetBranchName,
version_index: DIFF_COMPARE_BASE_VERSION_INDEX,
href: state.mergeRequestDiff.base_version_path,
isBase: true,
- selected: !state.startVersion,
+ selected: isBaseSelected,
+ };
+
+ const headVersion = {
+ versionName: state.targetBranchName,
+ version_index: DIFF_COMPARE_HEAD_VERSION_INDEX,
+ href: state.mergeRequestDiff.head_version_path,
+ isHead: true,
+ selected: isHeadSelected,
};
// Appended properties here are to make the compare_dropdown_layout easier to reason about
const formatVersion = v => {
@@ -25,7 +39,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => {
...v,
};
};
- return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion];
+ return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion];
};
export const diffCompareDropdownSourceVersions = (state, getters) => {
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index cc9bfa2e174..104686993a8 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -182,15 +182,18 @@ export default {
[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
const { latestDiff } = state;
- const discussionLineCode = discussion.line_code;
+ const discussionLineCodes = [discussion.line_code, ...(discussion.line_codes || [])];
const fileHash = discussion.diff_file.file_hash;
const lineCheck = line =>
- line.line_code === discussionLineCode &&
- isDiscussionApplicableToLine({
- discussion,
- diffPosition: diffPositionByLineCode[line.line_code],
- latestDiff,
- });
+ discussionLineCodes.some(
+ discussionLineCode =>
+ line.line_code === discussionLineCode &&
+ isDiscussionApplicableToLine({
+ discussion,
+ diffPosition: diffPositionByLineCode[line.line_code],
+ latestDiff,
+ }),
+ );
const mapDiscussions = (line, extraCheck = () => true) => ({
...line,
discussions: extraCheck()
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 07879ebf7d5..dd8dec49a37 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -440,10 +440,13 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD
const { line_code, ...diffPositionCopy } = diffPosition;
if (discussion.original_position && discussion.position) {
- const originalRefs = discussion.original_position;
- const refs = discussion.position;
+ const discussionPositions = [
+ discussion.original_position,
+ discussion.position,
+ ...(discussion.positions || []),
+ ];
- return isEqual(refs, diffPositionCopy) || isEqual(originalRefs, diffPositionCopy);
+ return discussionPositions.some(position => isEqual(position, diffPositionCopy));
}
// eslint-disable-next-line
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index f839e9acf04..490f2330012 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import Dropzone from 'dropzone';
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
import './behaviors/preview_markdown';
import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table';
import csrf from './lib/utils/csrf';
@@ -16,7 +16,7 @@ Dropzone.autoDiscover = false;
* @param {String|Object} res
*/
function getErrorMessage(res) {
- if (!res || _.isString(res)) {
+ if (!res || typeof res === 'string') {
return res;
}
@@ -233,7 +233,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
};
addFileToForm = path => {
- $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`);
+ $(form).append(`<input type="hidden" name="files[]" value="${esc(path)}">`);
};
const showSpinner = () => $uploadingProgressContainer.removeClass('hide');
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index be2eee828ff..4aad54bed55 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { debounce } from 'lodash';
import axios from './lib/utils/axios_utils';
/**
@@ -29,7 +29,7 @@ export default class FilterableList {
initSearch() {
// Wrap to prevent passing event arguments to .filterResults;
- this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500);
+ this.debounceFilter = debounce(this.onFilterInput.bind(this), 500);
this.unbindEvents();
this.bindEvents();
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 4d62ec6e385..40d820b1ed5 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
@@ -39,14 +39,14 @@ const createAction = config => `
class="flash-action"
${config.href ? '' : 'role="button"'}
>
- ${_.escape(config.title)}
+ ${esc(config.title)}
</a>
`;
const createFlashEl = (message, type) => `
<div class="flash-${type}">
<div class="flash-text">
- ${_.escape(message)}
+ ${esc(message)}
<div class="close-icon-wrapper js-close-icon">
${spriteIcon('close', 'close-icon')}
</div>
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b6deedfa5e4..c40b0949e70 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '@gitlab/at.js';
-import _ from 'underscore';
+import { escape as esc, template } from 'lodash';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
@@ -11,7 +11,7 @@ function sanitize(str) {
}
export function membersBeforeSave(members) {
- return _.map(members, member => {
+ return members.map(member => {
const GROUP_TYPE = 'Group';
let title = '';
@@ -122,7 +122,7 @@ class GfmAutoComplete {
cssClasses.push('has-warning');
}
- return _.template(tpl)({
+ return template(tpl)({
...value,
className: cssClasses.join(' '),
});
@@ -137,7 +137,7 @@ class GfmAutoComplete {
tpl += '<%- referencePrefix %>';
}
}
- return _.template(tpl)({ referencePrefix });
+ return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
},
suffix: '',
callbacks: {
@@ -692,14 +692,14 @@ GfmAutoComplete.Emoji = {
// Team Members
GfmAutoComplete.Members = {
templateFunction({ avatarTag, username, title, icon }) {
- return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`;
+ return `<li>${avatarTag} ${username} <small>${esc(title)}</small> ${icon}</li>`;
},
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
- return `<li><span class="dropdown-label-box" style="background: ${_.escape(
- color,
- )}"></span> ${_.escape(title)}</li>`;
+ return `<li><span class="dropdown-label-box" style="background: ${esc(color)}"></span> ${esc(
+ title,
+ )}</li>`;
},
};
// Issues, MergeRequests and Snippets
@@ -709,13 +709,13 @@ GfmAutoComplete.Issues = {
return value.reference || '${atwho-at}${id}';
},
templateFunction({ id, title, reference }) {
- return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`;
+ return `<li><small>${reference || id}</small> ${esc(title)}</li>`;
},
};
// Milestones
GfmAutoComplete.Milestones = {
templateFunction(title) {
- return `<li>${_.escape(title)}</li>`;
+ return `<li>${esc(title)}</li>`;
},
};
GfmAutoComplete.Loading = {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 918276ce329..d9191d48d8f 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file, one-var, consistent-return */
import $ from 'jquery';
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from './lib/utils/axios_utils';
import { visitUrl } from './lib/utils/url_utility';
@@ -145,7 +145,7 @@ class GitLabDropdownFilter {
// { prop: 'foo' },
// { prop: 'baz' }
// ]
- if (_.isArray(data)) {
+ if (Array.isArray(data)) {
results = fuzzaldrinPlus.filter(data, searchText, {
key: this.options.keys,
});
@@ -261,14 +261,14 @@ class GitLabDropdown {
// If no input is passed create a default one
self = this;
// If selector was passed
- if (_.isString(this.filterInput)) {
+ if (typeof this.filterInput === 'string') {
this.filterInput = this.getElement(this.filterInput);
}
const searchFields = this.options.search ? this.options.search.fields : [];
if (this.options.data) {
// If we provided data
// data could be an array of objects or a group of arrays
- if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
+ if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) {
this.fullData = this.options.data;
currentIndex = -1;
this.parseData(this.options.data);
@@ -610,7 +610,7 @@ class GitLabDropdown {
// eslint-disable-next-line class-methods-use-this
highlightTemplate(text, template) {
- return `"<b>${_.escape(text)}</b>" ${template}`;
+ return `"<b>${esc(text)}</b>" ${template}`;
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 1811a942beb..ced10fff129 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -29,6 +29,10 @@ export default class GLForm {
if (this.autoComplete) {
this.autoComplete.destroy();
}
+ if (this.formDropzone) {
+ this.formDropzone.destroy();
+ }
+
this.form.data('glForm', null);
}
@@ -45,7 +49,7 @@ export default class GLForm {
);
this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM);
- dropzoneInput(this.form, { parallelUploads: 1 });
+ this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 });
autosize(this.textarea);
}
// form and textarea event listeners
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index f0f5b8395c9..c7acc21378b 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -32,7 +32,7 @@ export default {
},
methods: {
change(page) {
- const filterGroupsParam = getParameterByName('filter_groups');
+ const filterGroupsParam = getParameterByName('filter');
const sortParam = getParameterByName('sort');
const archivedParam = getParameterByName('archived');
eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam);
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 7ebcacc530f..40d36063391 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -12,6 +12,7 @@ import RepoEditor from './repo_editor.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -26,6 +27,7 @@ export default {
GlDeprecatedButton,
GlLoadingIcon,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
rightPaneComponent: {
type: Vue.Component,
@@ -52,10 +54,17 @@ export default {
'allBlobs',
'emptyRepo',
'currentTree',
+ 'editorTheme',
]),
+ themeName() {
+ return this.glFeatures.webideDarkTheme && window.gon?.user_color_scheme;
+ },
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
+
+ if (this.themeName)
+ document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
},
methods: {
...mapActions(['toggleFileFinder', 'openNewEntryModal']),
@@ -77,7 +86,10 @@ export default {
</script>
<template>
- <article class="ide position-relative d-flex flex-column align-items-stretch">
+ <article
+ class="ide position-relative d-flex flex-column align-items-stretch"
+ :class="{ [`theme-${themeName}`]: themeName }"
+ >
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
<find-file
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 1ffd5c61282..35d54816350 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
import { __, sprintf } from './locale';
import axios from './lib/utils/axios_utils';
import flash from './flash';
@@ -73,9 +73,9 @@ class ImporterStatus {
const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing');
job.find('.import-actions').html(
sprintf(
- _.escape(__('%{loadingIcon} Started')),
+ esc(__('%{loadingIcon} Started')),
{
- loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape(
+ loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${esc(
connectingVerb,
)}"></i>`,
},
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index 45de287d44d..95e10cc75cc 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,7 +1,7 @@
/* eslint-disable consistent-return, func-names, array-callback-return */
import $ from 'jquery';
-import _ from 'underscore';
+import { intersection } from 'lodash';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
import { __ } from './locale';
@@ -111,7 +111,7 @@ export default {
this.getElement('.selected-issuable:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
- return _.intersection.apply(this, labelIds);
+ return intersection.apply(this, labelIds);
},
// From issuable's initial bulk selection
@@ -120,7 +120,7 @@ export default {
this.getElement('.selected-issuable:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
- return _.intersection.apply(this, labelIds);
+ return intersection.apply(this, labelIds);
},
// From issuable's initial bulk selection
@@ -144,7 +144,7 @@ export default {
// Add uniqueIds to add it as argument for _.intersection
labelIds.unshift(uniqueIds);
// Return IDs that are present but not in all selected issueables
- return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
+ return uniqueIds.filter(x => !intersection.apply(this, labelIds).includes(x));
},
getElement(selector) {
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index bd6e8433544..50562688c53 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery';
-import { property } from 'underscore';
+import { property } from 'lodash';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select';
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 9136a47d542..f0967e77faf 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -12,6 +12,8 @@ export default class Issue {
constructor() {
if ($('a.btn-close').length) this.initIssueBtnEventListeners();
+ if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener();
+
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
@@ -89,7 +91,7 @@ export default class Issue {
return $(document).on(
'click',
- '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen',
+ '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen, a.btn-close-anyway',
e => {
e.preventDefault();
e.stopImmediatePropagation();
@@ -99,19 +101,30 @@ export default class Issue {
Issue.submitNoteForm($button.closest('form'));
}
- this.disableCloseReopenButton($button);
-
- const url = $button.attr('href');
- return axios
- .put(url)
- .then(({ data }) => {
- const isClosed = $button.hasClass('btn-close');
- this.updateTopState(isClosed, data);
- })
- .catch(() => flash(issueFailMessage))
- .then(() => {
- this.disableCloseReopenButton($button, false);
- });
+ const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked');
+ const warningBanner = $('.js-close-blocked-issue-warning');
+ if (shouldDisplayBlockedWarning) {
+ this.toggleWarningAndCloseButton();
+ } else {
+ this.disableCloseReopenButton($button);
+
+ const url = $button.attr('href');
+ return axios
+ .put(url)
+ .then(({ data }) => {
+ const isClosed = $button.is('.btn-close, .btn-close-anyway');
+ this.updateTopState(isClosed, data);
+ if ($button.hasClass('btn-close-anyway')) {
+ warningBanner.addClass('hidden');
+ if (this.closeReopenReportToggle)
+ $('.js-issuable-close-dropdown').removeClass('hidden');
+ }
+ })
+ .catch(() => flash(issueFailMessage))
+ .then(() => {
+ this.disableCloseReopenButton($button, false);
+ });
+ }
},
);
}
@@ -137,6 +150,23 @@ export default class Issue {
this.reopenButtons.toggleClass('hidden', !isClosed);
}
+ toggleWarningAndCloseButton() {
+ const warningBanner = $('.js-close-blocked-issue-warning');
+ warningBanner.toggleClass('hidden');
+ $('.btn-close').toggleClass('hidden');
+ if (this.closeReopenReportToggle) {
+ $('.js-issuable-close-dropdown').toggleClass('hidden');
+ }
+ }
+
+ initIssueWarningBtnEventListener() {
+ return $(document).on('click', '.js-close-blocked-issue-warning button.btn-secondary', e => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ this.toggleWarningAndCloseButton();
+ });
+ }
+
static submitNoteForm(form) {
const noteText = form.find('textarea.js-note-text').val();
if (noteText && noteText.trim().length > 0) {
diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue
index 437239ce0be..b71c06e4217 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_app.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue
@@ -1,12 +1,20 @@
<script>
-import getJiraProjects from '../queries/getJiraProjects.query.graphql';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
+import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql';
+import { IMPORT_STATE, isInProgress } from '../utils';
import JiraImportForm from './jira_import_form.vue';
+import JiraImportProgress from './jira_import_progress.vue';
import JiraImportSetup from './jira_import_setup.vue';
export default {
name: 'JiraImportApp',
components: {
+ GlAlert,
+ GlLoadingIcon,
JiraImportForm,
+ JiraImportProgress,
JiraImportSetup,
},
props: {
@@ -14,6 +22,18 @@ export default {
type: Boolean,
required: true,
},
+ inProgressIllustration: {
+ type: String,
+ required: true,
+ },
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ jiraProjects: {
+ type: Array,
+ required: true,
+ },
projectPath: {
type: String,
required: true,
@@ -23,26 +43,111 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ errorMessage: '',
+ showAlert: false,
+ };
+ },
apollo: {
- getJiraImports: {
- query: getJiraProjects,
+ jiraImportDetails: {
+ query: getJiraImportDetailsQuery,
variables() {
return {
fullPath: this.projectPath,
};
},
- update: data => data.project.jiraImports,
+ update: ({ project }) => ({
+ status: project.jiraImportStatus,
+ import: project.jiraImports.nodes[0],
+ }),
skip() {
return !this.isJiraConfigured;
},
},
},
+ computed: {
+ isImportInProgress() {
+ return isInProgress(this.jiraImportDetails?.status);
+ },
+ jiraProjectsOptions() {
+ return this.jiraProjects.map(([text, value]) => ({ text, value }));
+ },
+ },
+ methods: {
+ dismissAlert() {
+ this.showAlert = false;
+ },
+ initiateJiraImport(project) {
+ this.$apollo
+ .mutate({
+ mutation: initiateJiraImportMutation,
+ variables: {
+ input: {
+ projectPath: this.projectPath,
+ jiraProjectKey: project,
+ },
+ },
+ update: (store, { data }) => {
+ if (data.jiraImportStart.errors.length) {
+ return;
+ }
+
+ store.writeQuery({
+ query: getJiraImportDetailsQuery,
+ variables: {
+ fullPath: this.projectPath,
+ },
+ data: {
+ project: {
+ jiraImportStatus: IMPORT_STATE.SCHEDULED,
+ jiraImports: {
+ nodes: [data.jiraImportStart.jiraImport],
+ __typename: 'JiraImportConnection',
+ },
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Project',
+ },
+ },
+ });
+ },
+ })
+ .then(({ data }) => {
+ if (data.jiraImportStart.errors.length) {
+ this.setAlertMessage(data.jiraImportStart.errors.join('. '));
+ }
+ })
+ .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.')));
+ },
+ setAlertMessage(message) {
+ this.errorMessage = message;
+ this.showAlert = true;
+ },
+ },
};
</script>
<template>
<div>
+ <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert">
+ {{ errorMessage }}
+ </gl-alert>
+
<jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" />
- <jira-import-form v-else />
+ <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" />
+ <jira-import-progress
+ v-else-if="isImportInProgress"
+ :illustration="inProgressIllustration"
+ :import-initiator="jiraImportDetails.import.scheduledBy.name"
+ :import-project="jiraImportDetails.import.jiraProjectKey"
+ :import-time="jiraImportDetails.import.scheduledAt"
+ :issues-path="issuesPath"
+ />
+ <jira-import-form
+ v-else
+ :issues-path="issuesPath"
+ :jira-projects="jiraProjectsOptions"
+ @initiateJiraImport="initiateJiraImport"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index 26e51c02b41..0146f564260 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -12,6 +12,39 @@ export default {
},
currentUserAvatarUrl: gon.current_user_avatar_url,
currentUsername: gon.current_username,
+ props: {
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ jiraProjects: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedOption: null,
+ selectState: null,
+ };
+ },
+ methods: {
+ initiateJiraImport(event) {
+ event.preventDefault();
+ if (!this.selectedOption) {
+ this.showValidationError();
+ } else {
+ this.hideValidationError();
+ this.$emit('initiateJiraImport', this.selectedOption);
+ }
+ },
+ hideValidationError() {
+ this.selectState = null;
+ },
+ showValidationError() {
+ this.selectState = false;
+ },
+ },
};
</script>
@@ -19,14 +52,21 @@ export default {
<div>
<h3 class="page-title">{{ __('New Jira import') }}</h3>
<hr />
- <form>
+ <form @submit="initiateJiraImport">
<gl-form-group
class="row align-items-center"
+ :invalid-feedback="__('Please select a Jira project')"
:label="__('Import from')"
label-cols-sm="2"
label-for="jira-project-select"
>
- <gl-form-select id="jira-project-select" class="mb-2" />
+ <gl-form-select
+ id="jira-project-select"
+ v-model="selectedOption"
+ class="mb-2"
+ :options="jiraProjects"
+ :state="selectState"
+ />
</gl-form-group>
<gl-form-group
@@ -86,8 +126,10 @@ export default {
</gl-form-group>
<div class="footer-block row-content-block d-flex justify-content-between">
- <gl-button category="primary" variant="success">{{ __('Next') }}</gl-button>
- <gl-button>{{ __('Cancel') }}</gl-button>
+ <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable">
+ {{ __('Next') }}
+ </gl-button>
+ <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
new file mode 100644
index 00000000000..2d610224658
--- /dev/null
+++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+
+export default {
+ name: 'JiraImportProgress',
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ illustration: {
+ type: String,
+ required: true,
+ },
+ importInitiator: {
+ type: String,
+ required: true,
+ },
+ importProject: {
+ type: String,
+ required: true,
+ },
+ importTime: {
+ type: String,
+ required: true,
+ },
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ importInitiatorText() {
+ return sprintf(__('Import started by: %{importInitiator}'), {
+ importInitiator: this.importInitiator,
+ });
+ },
+ importProjectText() {
+ return sprintf(__('Jira project: %{importProject}'), {
+ importProject: this.importProject,
+ });
+ },
+ importTimeText() {
+ return sprintf(__('Time of import: %{importTime}'), {
+ importTime: formatDate(this.importTime),
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :svg-path="illustration"
+ :title="__('Import in progress')"
+ :primary-button-text="__('View issues')"
+ :primary-button-link="issuesPath"
+ >
+ <template #description>
+ <p class="mb-0">{{ importInitiatorText }}</p>
+ <p class="mb-0">{{ importTimeText }}</p>
+ <p class="mb-0">{{ importProjectText }}</p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
index 917930397f4..44773a773d5 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
@@ -1,6 +1,11 @@
<script>
+import { GlEmptyState } from '@gitlab/ui';
+
export default {
name: 'JiraImportSetup',
+ components: {
+ GlEmptyState,
+ },
props: {
illustration: {
type: String,
@@ -11,15 +16,11 @@ export default {
</script>
<template>
- <div class="empty-state">
- <div class="svg-content">
- <img :src="illustration" :alt="__('Set up Jira Integration illustration')" />
- </div>
- <div class="text-content d-flex flex-column align-items-center">
- <p>{{ __('You will first need to set up Jira Integration to use this feature.') }}</p>
- <a class="btn btn-success" href="../services/jira/edit">
- {{ __('Set up Jira Integration') }}
- </a>
- </div>
- </div>
+ <gl-empty-state
+ :svg-path="illustration"
+ title=""
+ :description="__('You will first need to set up Jira Integration to use this feature.')"
+ :primary-button-text="__('Set up Jira Integration')"
+ primary-button-link="../services/jira/edit"
+ />
</template>
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 13b16b81c49..8bd70e4e277 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -24,7 +24,10 @@ export default function mountJiraImportApp() {
render(createComponent) {
return createComponent(App, {
props: {
+ inProgressIllustration: el.dataset.inProgressIllustration,
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
+ issuesPath: el.dataset.issuesPath,
+ jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [],
projectPath: el.dataset.projectPath,
setupIllustration: el.dataset.setupIllustration,
},
diff --git a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql b/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql
deleted file mode 100644
index 13100eac221..00000000000
--- a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql
+++ /dev/null
@@ -1,14 +0,0 @@
-query getJiraProjects($fullPath: ID!) {
- project(fullPath: $fullPath) {
- jiraImportStatus
- jiraImports {
- nodes {
- jiraProjectKey
- scheduledAt
- scheduledBy {
- username
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
new file mode 100644
index 00000000000..0eaaad580fc
--- /dev/null
+++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
@@ -0,0 +1,12 @@
+#import "./jira_import.fragment.graphql"
+
+query($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ jiraImportStatus
+ jiraImports(last: 1) {
+ nodes {
+ ...JiraImport
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
new file mode 100644
index 00000000000..8fda8287988
--- /dev/null
+++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
@@ -0,0 +1,11 @@
+#import "./jira_import.fragment.graphql"
+
+mutation($input: JiraImportStartInput!) {
+ jiraImportStart(input: $input) {
+ clientMutationId
+ jiraImport {
+ ...JiraImport
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql
new file mode 100644
index 00000000000..fde2ebeff91
--- /dev/null
+++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql
@@ -0,0 +1,7 @@
+fragment JiraImport on JiraImport {
+ jiraProjectKey
+ scheduledAt
+ scheduledBy {
+ name
+ }
+}
diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js
new file mode 100644
index 00000000000..504cf19e44e
--- /dev/null
+++ b/app/assets/javascripts/jira_import/utils.js
@@ -0,0 +1,10 @@
+export const IMPORT_STATE = {
+ FAILED: 'failed',
+ FINISHED: 'finished',
+ NONE: 'none',
+ SCHEDULED: 'scheduled',
+ STARTED: 'started',
+};
+
+export const isInProgress = state =>
+ state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED;
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 7107c970457..47d5a8253dd 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -3,7 +3,7 @@
/* global ListLabel */
import $ from 'jquery';
-import _ from 'underscore';
+import { isEqual, escape as esc, sortBy, template } from 'lodash';
import { sprintf, s__, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -76,7 +76,7 @@ export default class LabelsSelect {
})
.get();
- if (_.isEqual(initialSelected, selected)) return;
+ if (isEqual(initialSelected, selected)) return;
initialSelected = selected;
const data = {};
@@ -101,7 +101,7 @@ export default class LabelsSelect {
let labelCount = 0;
if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({
- labels: _.sortBy(data.labels, 'title'),
+ labels: sortBy(data.labels, 'title'),
issueUpdateURL,
enableScopedLabels: scopedLabels,
scopedLabelsDocumentationLink,
@@ -269,7 +269,7 @@ export default class LabelsSelect {
}
linkEl.className = selectedClass.join(' ');
- linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`;
+ linkEl.innerHTML = `${colorEl} ${esc(label.title)}`;
const listItemEl = document.createElement('li');
listItemEl.appendChild(linkEl);
@@ -436,7 +436,7 @@ export default class LabelsSelect {
if (isScopedLabel(label)) {
const prevIds = oldLabels.map(label => label.id);
const newIds = boardsStore.detail.issue.labels.map(label => label.id);
- const differentIds = _.difference(prevIds, newIds);
+ const differentIds = prevIds.filter(x => !newIds.includes(x));
$dropdown.data('marked', newIds);
$dropdownMenu
.find(differentIds.map(id => `[data-label-id="${id}"]`).join(','))
@@ -483,7 +483,7 @@ export default class LabelsSelect {
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">';
const spanOpenTag =
'<span class="gl-label-text" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">';
- const labelTemplate = _.template(
+ const labelTemplate = template(
[
'<span class="gl-label">',
linkOpenTag,
@@ -499,7 +499,7 @@ export default class LabelsSelect {
return escapeStr(label.text_color === '#FFFFFF' ? label.color : label.text_color);
};
- const infoIconTemplate = _.template(
+ const infoIconTemplate = template(
[
'<a href="<%= scopedLabelsDocumentationLink %>" class="gl-link gl-label-icon" target="_blank" rel="noopener">',
'<i class="fa fa-question-circle"></i>',
@@ -507,7 +507,7 @@ export default class LabelsSelect {
].join(''),
);
- const scopedLabelTemplate = _.template(
+ const scopedLabelTemplate = template(
[
'<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">',
linkOpenTag,
@@ -523,7 +523,7 @@ export default class LabelsSelect {
].join(''),
);
- const tooltipTitleTemplate = _.template(
+ const tooltipTitleTemplate = template(
[
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
"<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
@@ -535,9 +535,9 @@ export default class LabelsSelect {
].join(''),
);
- const tpl = _.template(
+ const tpl = template(
[
- '<% _.each(labels, function(label){ %>',
+ '<% labels.forEach(function(label){ %>',
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
'<span class="d-inline-block position-relative scoped-label-wrapper">',
'<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, infoIconTemplate, scopedLabelsDocumentationLink, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
@@ -557,7 +557,7 @@ export default class LabelsSelect {
scopedLabelTemplate,
tooltipTitleTemplate,
isScopedLabel,
- escapeStr: _.escape,
+ escapeStr: esc,
});
}
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index 9e8edd05b88..a464290ffb5 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { debounce, throttle } from 'lodash';
export const placeholderImage =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
@@ -82,7 +82,7 @@ export default class LazyLoader {
}
startIntersectionObserver = () => {
- this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300);
+ this.throttledElementsInView = throttle(() => this.checkElementsInView(), 300);
this.intersectionObserver = new IntersectionObserver(this.onIntersection, {
rootMargin: `${SCROLL_THRESHOLD}px 0px`,
thresholds: 0.1,
@@ -102,8 +102,8 @@ export default class LazyLoader {
};
startLegacyObserver() {
- this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300);
- this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300);
+ this.throttledScrollCheck = throttle(() => this.scrollCheck(), 300);
+ this.debouncedElementsInView = debounce(() => this.checkElementsInView(), 300);
window.addEventListener('scroll', this.throttledScrollCheck);
window.addEventListener('resize', this.debouncedElementsInView);
}
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index d3aea37e677..adf374db66c 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -1,3 +1,4 @@
+import { engineeringNotation } from '@gitlab/ui/src/utils/number_utils';
import { s__ } from '~/locale';
import {
@@ -39,15 +40,18 @@ export const SUPPORTED_FORMATS = {
gibibytes: 'gibibytes',
tebibytes: 'tebibytes',
pebibytes: 'pebibytes',
+
+ // Engineering Notation
+ engineering: 'engineering',
};
/**
* Returns a function that formats number to different units
- * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number.
+ * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
*
*
*/
-export const getFormatter = (format = SUPPORTED_FORMATS.number) => {
+export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
// Number
if (format === SUPPORTED_FORMATS.number) {
@@ -252,6 +256,17 @@ export const getFormatter = (format = SUPPORTED_FORMATS.number) => {
return scaledBinaryFormatter('B', 5);
}
+ if (format === SUPPORTED_FORMATS.engineering) {
+ /**
+ * Formats via engineering notation
+ *
+ * @function
+ * @param {Number} value - Value to format
+ * @param {Number} fractionDigits - precision decimals - Defaults to 2
+ */
+ return engineeringNotation;
+ }
+
// Fail so client library addresses issue
throw TypeError(`${format} is not a valid number format`);
};
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index 7ab4e725d99..b4658a159d7 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -5,7 +5,7 @@ import { escape } from 'lodash';
@param input (translated) text with parameters (e.g. '%{num_users} users use us')
@param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 })
- @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape)
+ @param {Boolean} escapeParameters whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape)
@returns {String} the text with parameters replaces (e.g. '5 users use us')
@see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 81b2e9f13a5..6c8f6372795 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -298,6 +298,18 @@ document.addEventListener('DOMContentLoaded', () => {
if ($gutterIcon.hasClass('fa-angle-double-right')) {
$sidebarGutterToggle.trigger('click');
}
+
+ const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle');
+
+ // Sidebar has an icon which corresponds to collapsing the sidebar
+ // only then trigger the click.
+ if (sidebarGutterVueToggleEl) {
+ const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right');
+
+ if (collapseIcon) {
+ collapseIcon.click();
+ }
+ }
}
});
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index d15e4ecb537..5d2825e3cd2 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -3,7 +3,7 @@
/* global ListMilestone */
import $ from 'jquery';
-import _ from 'underscore';
+import { template, escape as esc } from 'lodash';
import { __ } from '~/locale';
import '~/gl_dropdown';
import axios from './lib/utils/axios_utils';
@@ -60,7 +60,7 @@ export default class MilestoneSelect {
selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
- milestoneLinkTemplate = _.template(
+ milestoneLinkTemplate = template(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
@@ -106,12 +106,12 @@ export default class MilestoneSelect {
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
- $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active');
+ $(`[data-milestone-id="${esc(selectedMilestone)}"] > a`).addClass('is-active');
}),
renderRow: milestone => `
- <li data-milestone-id="${_.escape(milestone.name)}">
+ <li data-milestone-id="${esc(milestone.name)}">
<a href='#' class='dropdown-menu-milestone-link'>
- ${_.escape(milestone.title)}
+ ${esc(milestone.title)}
</a>
</li>
`,
@@ -129,7 +129,7 @@ export default class MilestoneSelect {
},
defaultLabel,
fieldName: $dropdown.data('fieldName'),
- text: milestone => _.escape(milestone.title),
+ text: milestone => esc(milestone.title),
id: milestone => {
if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
return milestone.name;
@@ -148,7 +148,7 @@ export default class MilestoneSelect {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
}
$('a.is-active', $el).removeClass('is-active');
- $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
+ $(`[data-milestone-id="${esc(selectedMilestone)}"] > a`, $el).addClass('is-active');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: clickEvent => {
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue
new file mode 100644
index 00000000000..2c6223c5dd7
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/alert_widget.vue
@@ -0,0 +1,286 @@
+<script>
+import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import createFlash from '~/flash';
+import AlertWidgetForm from './alert_widget_form.vue';
+import AlertsService from '../services/alerts_service';
+import { alertsValidator, queriesValidator } from '../validators';
+import { OPERATORS } from '../constants';
+import { values, get } from 'lodash';
+
+export default {
+ components: {
+ AlertWidgetForm,
+ GlBadge,
+ GlLoadingIcon,
+ GlIcon,
+ GlTooltip,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ alertsEndpoint: {
+ type: String,
+ required: true,
+ },
+ showLoadingState: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls.
+ // Includes only the metrics/alerts to be managed by this widget.
+ alertsToManage: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ validator: alertsValidator,
+ },
+ // [{ metric+query_attributes }]. Represents queries (and alerts) we know about
+ // on intial fetch. Essentially used for reference.
+ relevantQueries: {
+ type: Array,
+ required: true,
+ validator: queriesValidator,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ service: null,
+ errorMessage: null,
+ isLoading: false,
+ apiAction: 'create',
+ };
+ },
+ i18n: {
+ alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'),
+ singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'),
+ multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'),
+ firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'),
+ },
+ computed: {
+ singleAlertSummary() {
+ return {
+ message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0],
+ alert: this.thresholds[0],
+ };
+ },
+ multipleAlertsSummary() {
+ return {
+ message: this.isFiring
+ ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}`
+ : this.$options.i18n.alertsCountMsg,
+ count: this.thresholds.length,
+ firingCount: this.firingAlerts.length,
+ };
+ },
+ shouldShowLoadingIcon() {
+ return this.showLoadingState && this.isLoading;
+ },
+ thresholds() {
+ const alertsToManage = Object.keys(this.alertsToManage);
+ return alertsToManage.map(this.formatAlertSummary);
+ },
+ hasAlerts() {
+ return Boolean(Object.keys(this.alertsToManage).length);
+ },
+ hasMultipleAlerts() {
+ return this.thresholds.length > 1;
+ },
+ isFiring() {
+ return Boolean(this.firingAlerts.length);
+ },
+ firingAlerts() {
+ return values(this.alertsToManage).filter(alert =>
+ this.passedAlertThreshold(this.getQueryData(alert), alert),
+ );
+ },
+ formattedFiringAlerts() {
+ return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path));
+ },
+ configuredAlert() {
+ return this.hasAlerts ? values(this.alertsToManage)[0].metricId : '';
+ },
+ },
+ created() {
+ this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint });
+ this.fetchAlertData();
+ },
+ methods: {
+ fetchAlertData() {
+ this.isLoading = true;
+
+ const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path);
+
+ return Promise.all(
+ queriesWithAlerts.map(query =>
+ this.service
+ .readAlert(query.alert_path)
+ .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)),
+ ),
+ )
+ .then(() => {
+ this.isLoading = false;
+ })
+ .catch(() => {
+ createFlash(s__('PrometheusAlerts|Error fetching alert'));
+ this.isLoading = false;
+ });
+ },
+ setAlert(alertAttributes, metricId) {
+ this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId });
+ },
+ removeAlert(alertPath) {
+ this.$emit('setAlerts', alertPath, null);
+ },
+ formatAlertSummary(alertPath) {
+ const alert = this.alertsToManage[alertPath];
+ const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
+
+ return `${alertQuery.label} ${alert.operator} ${alert.threshold}`;
+ },
+ passedAlertThreshold(data, alert) {
+ const { threshold, operator } = alert;
+
+ switch (operator) {
+ case OPERATORS.greaterThan:
+ return data.some(value => value > threshold);
+ case OPERATORS.lessThan:
+ return data.some(value => value < threshold);
+ case OPERATORS.equalTo:
+ return data.some(value => value === threshold);
+ default:
+ return false;
+ }
+ },
+ getQueryData(alert) {
+ const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
+
+ return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null));
+ },
+ showModal() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ hideModal() {
+ this.errorMessage = null;
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ },
+ handleSetApiAction(apiAction) {
+ this.apiAction = apiAction;
+ },
+ handleCreate({ operator, threshold, prometheus_metric_id }) {
+ const newAlert = { operator, threshold, prometheus_metric_id };
+ this.isLoading = true;
+ this.service
+ .createAlert(newAlert)
+ .then(alertAttributes => {
+ this.setAlert(alertAttributes, prometheus_metric_id);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error creating alert');
+ this.isLoading = false;
+ });
+ },
+ handleUpdate({ alert, operator, threshold }) {
+ const updatedAlert = { operator, threshold };
+ this.isLoading = true;
+ this.service
+ .updateAlert(alert, updatedAlert)
+ .then(alertAttributes => {
+ this.setAlert(alertAttributes, this.alertsToManage[alert].metricId);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error saving alert');
+ this.isLoading = false;
+ });
+ },
+ handleDelete({ alert }) {
+ this.isLoading = true;
+ this.service
+ .deleteAlert(alert)
+ .then(() => {
+ this.removeAlert(alert);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error deleting alert');
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden">
+ <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" />
+ <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{
+ errorMessage
+ }}</span>
+ <span
+ v-else-if="hasAlerts"
+ ref="alertCurrentSetting"
+ class="alert-current-setting cursor-pointer d-flex"
+ @click="showModal"
+ >
+ <gl-badge
+ :variant="isFiring ? 'danger' : 'secondary'"
+ pill
+ class="d-flex-center text-truncate"
+ >
+ <gl-icon name="warning" :size="16" class="flex-shrink-0" />
+ <span class="text-truncate gl-pl-1">
+ <gl-sprintf
+ :message="
+ hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message
+ "
+ >
+ <template #alert>
+ {{ singleAlertSummary.alert }}
+ </template>
+ <template #count>
+ {{ multipleAlertsSummary.count }}
+ </template>
+ <template #firingCount>
+ {{ multipleAlertsSummary.firingCount }}
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-badge>
+ <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting">
+ <gl-sprintf :message="$options.i18n.firingAlertsTooltip">
+ <template #alerts>
+ <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path">
+ {{ alert }}
+ </div>
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </span>
+ <alert-widget-form
+ ref="widgetForm"
+ :disabled="isLoading"
+ :alerts-to-manage="alertsToManage"
+ :relevant-queries="relevantQueries"
+ :error-message="errorMessage"
+ :configured-alert="configuredAlert"
+ :modal-id="modalId"
+ @create="handleCreate"
+ @update="handleUpdate"
+ @delete="handleDelete"
+ @cancel="hideModal"
+ @setAction="handleSetApiAction"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
new file mode 100644
index 00000000000..860d854b5ae
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -0,0 +1,300 @@
+<script>
+import { isEmpty, findKey } from 'lodash';
+import Vue from 'vue';
+import {
+ GlLink,
+ GlDeprecatedButton,
+ GlButtonGroup,
+ GlFormGroup,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import Translate from '~/vue_shared/translate';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import Icon from '~/vue_shared/components/icon.vue';
+import { alertsValidator, queriesValidator } from '../validators';
+import { OPERATORS } from '../constants';
+
+Vue.use(Translate);
+
+const SUBMIT_ACTION_TEXT = {
+ create: __('Add'),
+ update: __('Save'),
+ delete: __('Delete'),
+};
+
+const SUBMIT_BUTTON_CLASS = {
+ create: 'btn-success',
+ update: 'btn-success',
+ delete: 'btn-remove',
+};
+
+export default {
+ components: {
+ GlDeprecatedButton,
+ GlButtonGroup,
+ GlFormGroup,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlLink,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ errorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ configuredAlert: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ alertsToManage: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ validator: alertsValidator,
+ },
+ relevantQueries: {
+ type: Array,
+ required: true,
+ validator: queriesValidator,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ operators: OPERATORS,
+ operator: null,
+ threshold: null,
+ prometheusMetricId: null,
+ selectedAlert: {},
+ alertQuery: '',
+ };
+ },
+ computed: {
+ isValidQuery() {
+ // TODO: Add query validation check (most likely via http request)
+ return this.alertQuery.length ? true : null;
+ },
+ currentQuery() {
+ return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {};
+ },
+ formDisabled() {
+ // We need a prometheusMetricId to determine whether we're
+ // creating/updating/deleting
+ return this.disabled || !(this.prometheusMetricId || this.isValidQuery);
+ },
+ supportsComputedAlerts() {
+ return this.glFeatures.prometheusComputedAlerts;
+ },
+ queryDropdownLabel() {
+ return this.currentQuery.label || s__('PrometheusAlerts|Select query');
+ },
+ haveValuesChanged() {
+ return (
+ this.operator &&
+ this.threshold === Number(this.threshold) &&
+ (this.operator !== this.selectedAlert.operator ||
+ this.threshold !== this.selectedAlert.threshold)
+ );
+ },
+ submitAction() {
+ if (isEmpty(this.selectedAlert)) return 'create';
+ if (this.haveValuesChanged) return 'update';
+ return 'delete';
+ },
+ submitActionText() {
+ return SUBMIT_ACTION_TEXT[this.submitAction];
+ },
+ submitButtonClass() {
+ return SUBMIT_BUTTON_CLASS[this.submitAction];
+ },
+ isSubmitDisabled() {
+ return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged);
+ },
+ dropdownTitle() {
+ return this.submitAction === 'create'
+ ? s__('PrometheusAlerts|Add alert')
+ : s__('PrometheusAlerts|Edit alert');
+ },
+ },
+ watch: {
+ alertsToManage() {
+ this.resetAlertData();
+ },
+ submitAction() {
+ this.$emit('setAction', this.submitAction);
+ },
+ },
+ methods: {
+ selectQuery(queryId) {
+ const existingAlertPath = findKey(this.alertsToManage, alert => alert.metricId === queryId);
+ const existingAlert = this.alertsToManage[existingAlertPath];
+
+ if (existingAlert) {
+ this.selectedAlert = existingAlert;
+ this.operator = existingAlert.operator;
+ this.threshold = existingAlert.threshold;
+ } else {
+ this.selectedAlert = {};
+ this.operator = this.operators.greaterThan;
+ this.threshold = null;
+ }
+
+ this.prometheusMetricId = queryId;
+ },
+ handleHidden() {
+ this.resetAlertData();
+ this.$emit('cancel');
+ },
+ handleSubmit(e) {
+ e.preventDefault();
+ this.$emit(this.submitAction, {
+ alert: this.selectedAlert.alert_path,
+ operator: this.operator,
+ threshold: this.threshold,
+ prometheus_metric_id: this.prometheusMetricId,
+ });
+ },
+ resetAlertData() {
+ this.operator = null;
+ this.threshold = null;
+ this.prometheusMetricId = null;
+ this.selectedAlert = {};
+ },
+ getAlertFormActionTrackingOption() {
+ const label = `${this.submitAction}_alert`;
+ return {
+ category: document.body.dataset.page,
+ action: 'click_button',
+ label,
+ };
+ },
+ },
+ alertQueryText: {
+ label: __('Query'),
+ validFeedback: __('Query is valid'),
+ invalidFeedback: __('Invalid query'),
+ descriptionTooltip: __(
+ 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="alertModal"
+ :title="dropdownTitle"
+ :modal-id="modalId"
+ :ok-variant="submitAction === 'delete' ? 'danger' : 'success'"
+ :ok-disabled="formDisabled"
+ @ok="handleSubmit"
+ @hidden="handleHidden"
+ @shown="selectQuery(configuredAlert)"
+ >
+ <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div>
+ <div class="alert-form">
+ <gl-form-group
+ v-if="supportsComputedAlerts"
+ :label="$options.alertQueryText.label"
+ label-for="alert-query-input"
+ :valid-feedback="$options.alertQueryText.validFeedback"
+ :invalid-feedback="$options.alertQueryText.invalidFeedback"
+ :state="isValidQuery"
+ >
+ <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" />
+ <template #description>
+ <div class="d-flex align-items-center">
+ {{ __('Single or combined queries') }}
+ <icon
+ v-gl-tooltip="$options.alertQueryText.descriptionTooltip"
+ name="question"
+ class="prepend-left-4"
+ />
+ </div>
+ </template>
+ </gl-form-group>
+ <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label">
+ <gl-dropdown
+ id="alert-query-dropdown"
+ :text="queryDropdownLabel"
+ toggle-class="dropdown-menu-toggle qa-alert-query-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="query in relevantQueries"
+ :key="query.metricId"
+ data-qa-selector="alert_query_option"
+ @click="selectQuery(query.metricId)"
+ >
+ {{ query.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </gl-form-group>
+ <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')">
+ <gl-deprecated-button
+ :class="{ active: operator === operators.greaterThan }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.greaterThan"
+ >
+ {{ operators.greaterThan }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ :class="{ active: operator === operators.equalTo }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.equalTo"
+ >
+ {{ operators.equalTo }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ :class="{ active: operator === operators.lessThan }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.lessThan"
+ >
+ {{ operators.lessThan }}
+ </gl-deprecated-button>
+ </gl-button-group>
+ <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold">
+ <gl-form-input
+ id="alerts-threshold"
+ v-model.number="threshold"
+ :disabled="formDisabled"
+ type="number"
+ data-qa-selector="alert_threshold_field"
+ />
+ </gl-form-group>
+ </div>
+ <template #modal-ok>
+ <gl-link
+ v-track-event="getAlertFormActionTrackingOption()"
+ class="text-reset text-decoration-none"
+ >
+ {{ submitActionText }}
+ </gl-link>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js
index 947750b3721..418107c4126 100644
--- a/app/assets/javascripts/monitoring/components/charts/annotations.js
+++ b/app/assets/javascripts/monitoring/components/charts/annotations.js
@@ -1,20 +1,20 @@
-import { graphTypes, symbolSizes, colorValues } from '../../constants';
+import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants';
/**
* Annotations and deployments are decoration layers on
* top of the actual chart data. We use a scatter plot to
* display this information. Each chart has its coordinate
- * system based on data and irresptive of the data, these
+ * system based on data and irrespective of the data, these
* decorations have to be placed in specific locations.
* For this reason, annotations have their own coordinate system,
*
* As of %12.9, only deployment icons, a type of annotations, need
* to be displayed on the chart.
*
- * After https://gitlab.com/gitlab-org/gitlab/-/issues/211418,
- * annotations and deployments will co-exist in the same
- * series as they logically belong together. Annotations will be
- * passed as markLine objects.
+ * Annotations and deployments co-exist in the same series as
+ * they logically belong together. Annotations are passed as
+ * markLines and markPoints while deployments are passed as
+ * data points with custom icons.
*/
/**
@@ -45,42 +45,49 @@ export const annotationsYAxis = {
* Fetched list of annotations are parsed into a
* format the eCharts accepts to draw markLines
*
- * If Annotation is a single line, the `starting_at` property
- * has a value and the `ending_at` is null. Because annotations
- * only supports lines the `ending_at` value does not exist yet.
- *
+ * If Annotation is a single line, the `startingAt` property
+ * has a value and the `endingAt` is null. Because annotations
+ * only supports lines the `endingAt` value does not exist yet.
*
* @param {Object} annotation object
* @returns {Object} markLine object
*/
-export const parseAnnotations = ({ starting_at = '', color = colorValues.primaryColor }) => ({
- xAxis: starting_at,
- lineStyle: {
- color,
- },
-});
+export const parseAnnotations = annotations =>
+ annotations.reduce(
+ (acc, annotation) => {
+ acc.lines.push({
+ xAxis: annotation.startingAt,
+ lineStyle: {
+ color: colorValues.primaryColor,
+ },
+ });
+
+ acc.points.push({
+ name: 'annotations',
+ xAxis: annotation.startingAt,
+ yAxis: annotationsYAxisCoords.min,
+ tooltipData: {
+ title: annotation.startingAt,
+ content: annotation.description,
+ },
+ });
+
+ return acc;
+ },
+ { lines: [], points: [] },
+ );
/**
- * This method currently generates deployments and annotations
- * but are not used in the chart. The method calling
- * generateAnnotationsSeries will not pass annotations until
- * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is
- * implemented.
- *
- * This method is extracted out of the charts so that
- * annotation lines can be easily supported in
- * the future.
- *
- * In order to make hover work, hidden annotation data points
- * are created along with the markLines. These data points have
- * the necessart metadata that is used to display in the tooltip.
+ * This method generates a decorative series that has
+ * deployments as data points with custom icons and
+ * annotations as markLines and markPoints
*
* @param {Array} deployments deployments data
* @returns {Object} annotation series object
*/
export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => {
// deployment data points
- const deploymentsData = deployments.map(deployment => {
+ const data = deployments.map(deployment => {
return {
name: 'deployments',
value: [deployment.createdAt, annotationsYAxisCoords.pos],
@@ -98,31 +105,29 @@ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] }
};
});
- // annotation data points
- const annotationsData = annotations.map(annotation => {
- return {
- name: 'annotations',
- value: [annotation.starting_at, annotationsYAxisCoords.pos],
- // style options
- symbol: 'none',
- // metadata that are accessible in `formatTooltipText` method
- tooltipData: {
- description: annotation.description,
- },
- };
- });
+ const parsedAnnotations = parseAnnotations(annotations);
- // annotation markLine option
+ // markLine option draws the annotations dotted line
const markLine = {
symbol: 'none',
silent: true,
- data: annotations.map(parseAnnotations),
+ data: parsedAnnotations.lines,
+ };
+
+ // markPoints are the arrows under the annotations lines
+ const markPoint = {
+ symbol: annotationsSymbolIcon,
+ symbolSize: '8',
+ symbolOffset: [0, ' 60%'],
+ data: parsedAnnotations.points,
};
return {
+ name: 'annotations',
type: graphTypes.annotationsData,
yAxisIndex: 1, // annotationsYAxis index
- data: [...deploymentsData, ...annotationsData],
+ data,
markLine,
+ markPoint,
};
};
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 447f8845506..b3b6f9e7b55 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -3,7 +3,7 @@ import { flattenDeep, isNumber } from 'lodash';
import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { hexToRgb } from '~/lib/utils/color_utils';
-import { areaOpacityValues, symbolSizes, colorValues } from '../../constants';
+import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants';
import { graphDataValidatorForAnomalyValues } from '../../utils';
import MonitorTimeSeriesChart from './time_series.vue';
@@ -91,7 +91,7 @@ export default {
]);
return {
...this.graphData,
- type: 'line-chart',
+ type: panelTypes.LINE_CHART,
metrics: [metricQuery],
};
},
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
index 5588d9ac060..e015ef32d8c 100644
--- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
+++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
@@ -3,12 +3,6 @@ import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-e
import { chartHeight } from '../../constants';
export default {
- props: {
- graphTitle: {
- type: String,
- required: true,
- },
- },
data() {
return {
height: chartHeight,
diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js
index d9f49bd81f5..09b03774580 100644
--- a/app/assets/javascripts/monitoring/components/charts/options.js
+++ b/app/assets/javascripts/monitoring/components/charts/options.js
@@ -6,9 +6,8 @@ const yAxisBoundaryGap = [0.1, 0.1];
* Max string length of formatted axis tick
*/
const maxDataAxisTickLength = 8;
-
// Defaults
-const defaultFormat = SUPPORTED_FORMATS.number;
+const defaultFormat = SUPPORTED_FORMATS.engineering;
const defaultYAxisFormat = defaultFormat;
const defaultYAxisPrecision = 2;
@@ -26,8 +25,7 @@ const chartGridLeft = 75;
* @param {Object} param - Dashboard .yml definition options
*/
const getDataAxisOptions = ({ format, precision, name }) => {
- const formatter = getFormatter(format);
-
+ const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui
return {
name,
nameLocation: 'center', // same as gitlab-ui's default
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 9041b01088c..547f33faaa2 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -6,7 +6,7 @@ import dateFormat from 'dateformat';
import { s__, __ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
-import { chartHeight, lineTypes, lineWidths, dateFormats, tooltipTypes } from '../../constants';
+import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { annotationsYAxis, generateAnnotationsSeries } from './annotations';
import { makeDataSeries } from '~/helpers/monitor_helper';
@@ -20,7 +20,6 @@ const events = {
};
export default {
- tooltipTypes,
components: {
GlAreaChart,
GlLineChart,
@@ -212,8 +211,8 @@ export default {
},
glChartComponent() {
const chartTypes = {
- 'area-chart': GlAreaChart,
- 'line-chart': GlLineChart,
+ [panelTypes.AREA_CHART]: GlAreaChart,
+ [panelTypes.LINE_CHART]: GlLineChart,
};
return chartTypes[this.graphData.type] || GlAreaChart;
},
@@ -262,6 +261,21 @@ export default {
isTooltipOfType(tooltipType, defaultType) {
return tooltipType === defaultType;
},
+ /**
+ * This method is triggered when hovered over a single markPoint.
+ *
+ * The annotations title timestamp should match the data tooltip
+ * title.
+ *
+ * @params {Object} params markPoint object
+ * @returns {Object}
+ */
+ formatAnnotationsTooltipText(params) {
+ return {
+ title: dateFormat(params.data?.tooltipData?.title, dateFormats.default),
+ content: params.data?.tooltipData?.content,
+ };
+ },
formatTooltipText(params) {
this.tooltip.title = dateFormat(params.value, dateFormats.default);
this.tooltip.content = [];
@@ -270,15 +284,10 @@ export default {
if (dataPoint.value) {
const [, yVal] = dataPoint.value;
this.tooltip.type = dataPoint.name;
- if (this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.deployments)) {
+ if (this.tooltip.type === 'deployments') {
const { data = {} } = dataPoint;
this.tooltip.sha = data?.tooltipData?.sha;
this.tooltip.commitUrl = data?.tooltipData?.commitUrl;
- } else if (
- this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.annotations)
- ) {
- const { data } = dataPoint;
- this.tooltip.content.push(data?.tooltipData?.description);
} else {
const { seriesName, color, dataIndex } = dataPoint;
@@ -356,6 +365,7 @@ export default {
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
+ :format-annotations-tooltip-text="formatAnnotationsTooltipText"
:thresholds="thresholds"
:width="width"
:height="height"
@@ -364,7 +374,7 @@ export default {
@created="onChartCreated"
@updated="onChartUpdated"
>
- <template v-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.deployments)">
+ <template v-if="tooltip.type === 'deployments'">
<template slot="tooltipTitle">
{{ __('Deployed') }}
</template>
@@ -373,16 +383,6 @@ export default {
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
- <template v-else-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.annotations)">
- <template slot="tooltipTitle">
- <div class="text-nowrap">
- {{ tooltip.title }}
- </div>
- </template>
- <div slot="tooltipContent" class="d-flex align-items-center">
- {{ tooltip.content.join('\n') }}
- </div>
- </template>
<template v-else>
<template slot="tooltipTitle">
<div class="text-nowrap">
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 4586ce70ad6..85306023d7d 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -8,17 +8,17 @@ import {
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
- GlFormGroup,
GlModal,
GlLoadingIcon,
GlSearchBoxByType,
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import PanelType from './panel_type_with_alerts.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import Icon from '~/vue_shared/components/icon.vue';
@@ -46,8 +46,8 @@ export default {
GlDropdownHeader,
GlDropdownDivider,
GlSearchBoxByType,
- GlFormGroup,
GlModal,
+ CustomMetricsFormFields,
DateTimePicker,
GraphGroup,
@@ -206,9 +206,6 @@ export default {
};
},
computed: {
- canAddMetrics() {
- return this.customMetricsAvailable && this.customMetricsPath.length;
- },
...mapState('monitoringDashboard', [
'dashboard',
'emptyState',
@@ -229,7 +226,11 @@ export default {
return !this.showEmptyState && this.rearrangePanelsAvailable;
},
addingMetricsAvailable() {
- return IS_EE && this.canAddMetrics && !this.showEmptyState;
+ return (
+ this.customMetricsAvailable &&
+ !this.showEmptyState &&
+ this.firstDashboard === this.selectedDashboard
+ );
},
hasHeaderButtons() {
return (
@@ -378,177 +379,164 @@ export default {
<div
v-if="showHeader"
ref="prometheusGraphsHeader"
- class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light"
+ class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
>
- <div class="row">
- <gl-form-group
- :label="__('Dashboard')"
- label-size="sm"
- label-for="monitor-dashboards-dropdown"
- class="col-sm-12 col-md-6 col-lg-2"
- >
- <dashboards-dropdown
- id="monitor-dashboards-dropdown"
- data-qa-selector="dashboards_filter_dropdown"
- class="mb-0 d-flex"
- toggle-class="dropdown-menu-toggle"
- :default-branch="defaultBranch"
- :selected-dashboard="selectedDashboard"
- @selectDashboard="selectDashboard($event)"
- />
- </gl-form-group>
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <dashboards-dropdown
+ id="monitor-dashboards-dropdown"
+ data-qa-selector="dashboards_filter_dropdown"
+ class="flex-grow-1"
+ toggle-class="dropdown-menu-toggle"
+ :default-branch="defaultBranch"
+ :selected-dashboard="selectedDashboard"
+ @selectDashboard="selectDashboard($event)"
+ />
+ </div>
- <gl-form-group
- :label="s__('Metrics|Environment')"
- label-size="sm"
- label-for="monitor-environments-dropdown"
- class="col-sm-6 col-md-6 col-lg-2"
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <gl-dropdown
+ id="monitor-environments-dropdown"
+ ref="monitorEnvironmentsDropdown"
+ class="flex-grow-1"
+ data-qa-selector="environments_dropdown"
+ toggle-class="dropdown-menu-toggle"
+ menu-class="monitor-environment-dropdown-menu"
+ :text="currentEnvironmentName"
>
- <gl-dropdown
- id="monitor-environments-dropdown"
- ref="monitorEnvironmentsDropdown"
- data-qa-selector="environments_dropdown"
- class="mb-0 d-flex"
- toggle-class="dropdown-menu-toggle"
- menu-class="monitor-environment-dropdown-menu"
- :text="currentEnvironmentName"
- >
- <div class="d-flex flex-column overflow-hidden">
- <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{
- __('Environment')
- }}</gl-dropdown-header>
- <gl-dropdown-divider />
- <gl-search-box-by-type
- ref="monitorEnvironmentsDropdownSearch"
- class="m-2"
- @input="debouncedEnvironmentsSearch"
- />
- <gl-loading-icon
- v-if="environmentsLoading"
- ref="monitorEnvironmentsDropdownLoading"
- :inline="true"
- />
- <div v-else class="flex-fill overflow-auto">
- <gl-dropdown-item
- v-for="environment in filteredEnvironments"
- :key="environment.id"
- :active="environment.name === currentEnvironmentName"
- active-class="is-active"
- :href="environment.metrics_path"
- >{{ environment.name }}</gl-dropdown-item
- >
- </div>
- <div
- v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
- ref="monitorEnvironmentsDropdownMsg"
- class="text-secondary no-matches-message"
+ <div class="d-flex flex-column overflow-hidden">
+ <gl-dropdown-header class="monitor-environment-dropdown-header text-center">
+ {{ __('Environment') }}
+ </gl-dropdown-header>
+ <gl-dropdown-divider />
+ <gl-search-box-by-type
+ ref="monitorEnvironmentsDropdownSearch"
+ class="m-2"
+ @input="debouncedEnvironmentsSearch"
+ />
+ <gl-loading-icon
+ v-if="environmentsLoading"
+ ref="monitorEnvironmentsDropdownLoading"
+ :inline="true"
+ />
+ <div v-else class="flex-fill overflow-auto">
+ <gl-dropdown-item
+ v-for="environment in filteredEnvironments"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ :href="environment.metrics_path"
+ >{{ environment.name }}</gl-dropdown-item
>
- {{ __('No matching results') }}
- </div>
</div>
- </gl-dropdown>
- </gl-form-group>
+ <div
+ v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
+ ref="monitorEnvironmentsDropdownMsg"
+ class="text-secondary no-matches-message"
+ >
+ {{ __('No matching results') }}
+ </div>
+ </div>
+ </gl-dropdown>
+ </div>
- <gl-form-group
- :label="s__('Metrics|Show last')"
- label-size="sm"
- label-for="monitor-time-window-dropdown"
- class="col-sm-auto col-md-auto col-lg-auto"
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <date-time-picker
+ ref="dateTimePicker"
+ class="flex-grow-1 show-last-dropdown"
data-qa-selector="show_last_dropdown"
+ :value="selectedTimeRange"
+ :options="timeRanges"
+ @input="onDateTimePickerInput"
+ @invalid="onDateTimePickerInvalid"
+ />
+ </div>
+
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ ref="refreshDashboardBtn"
+ v-gl-tooltip
+ class="flex-grow-1"
+ variant="default"
+ :title="s__('Metrics|Refresh dashboard')"
+ @click="refreshDashboard"
>
- <date-time-picker
- ref="dateTimePicker"
- :value="selectedTimeRange"
- :options="timeRanges"
- @input="onDateTimePickerInput"
- @invalid="onDateTimePickerInvalid"
- />
- </gl-form-group>
+ <icon name="retry" />
+ </gl-deprecated-button>
+ </div>
+
+ <div class="flex-grow-1"></div>
- <gl-form-group class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button">
+ <div class="d-sm-flex">
+ <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
<gl-deprecated-button
- ref="refreshDashboardBtn"
- v-gl-tooltip
+ :pressed="isRearrangingPanels"
variant="default"
- :title="s__('Metrics|Refresh dashboard')"
- @click="refreshDashboard"
+ class="flex-grow-1 js-rearrange-button"
+ @click="toggleRearrangingPanels"
>
- <icon name="retry" />
+ {{ __('Arrange charts') }}
</gl-deprecated-button>
- </gl-form-group>
-
- <gl-form-group
- v-if="hasHeaderButtons"
- label-for="prometheus-graphs-dropdown-buttons"
- class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end"
- >
- <div id="prometheus-graphs-dropdown-buttons">
- <gl-deprecated-button
- v-if="showRearrangePanelsBtn"
- :pressed="isRearrangingPanels"
- variant="default"
- class="mr-2 mt-1 js-rearrange-button"
- @click="toggleRearrangingPanels"
- >{{ __('Arrange charts') }}</gl-deprecated-button
- >
- <gl-deprecated-button
- v-if="addingMetricsAvailable"
- ref="addMetricBtn"
- v-gl-modal="$options.addMetric.modalId"
- variant="outline-success"
- data-qa-selector="add_metric_button"
- class="mr-2 mt-1"
- >{{ $options.addMetric.title }}</gl-deprecated-button
- >
- <gl-modal
- v-if="addingMetricsAvailable"
- ref="addMetricModal"
- :modal-id="$options.addMetric.modalId"
- :title="$options.addMetric.title"
- >
- <form ref="customMetricsForm" :action="customMetricsPath" method="post">
- <custom-metrics-form-fields
- :validate-query-path="validateQueryPath"
- form-operation="post"
- @formValidation="setFormValidity"
- />
- </form>
- <div slot="modal-footer">
- <gl-deprecated-button @click="hideAddMetricModal">{{
- __('Cancel')
- }}</gl-deprecated-button>
- <gl-deprecated-button
- ref="submitCustomMetricsFormBtn"
- v-track-event="getAddMetricTrackingOptions()"
- :disabled="!formIsValid"
- variant="success"
- @click="submitCustomMetricsForm"
- >{{ __('Save changes') }}</gl-deprecated-button
- >
- </div>
- </gl-modal>
+ </div>
+ <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ ref="addMetricBtn"
+ v-gl-modal="$options.addMetric.modalId"
+ variant="outline-success"
+ data-qa-selector="add_metric_button"
+ class="flex-grow-1"
+ >
+ {{ $options.addMetric.title }}
+ </gl-deprecated-button>
+ <gl-modal
+ ref="addMetricModal"
+ :modal-id="$options.addMetric.modalId"
+ :title="$options.addMetric.title"
+ >
+ <form ref="customMetricsForm" :action="customMetricsPath" method="post">
+ <custom-metrics-form-fields
+ :validate-query-path="validateQueryPath"
+ form-operation="post"
+ @formValidation="setFormValidity"
+ />
+ </form>
+ <div slot="modal-footer">
+ <gl-deprecated-button @click="hideAddMetricModal">
+ {{ __('Cancel') }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ ref="submitCustomMetricsFormBtn"
+ v-track-event="getAddMetricTrackingOptions()"
+ :disabled="!formIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
+ >
+ {{ __('Save changes') }}
+ </gl-deprecated-button>
+ </div>
+ </gl-modal>
+ </div>
- <gl-deprecated-button
- v-if="selectedDashboard.can_edit"
- class="mt-1 js-edit-link"
- :href="selectedDashboard.project_blob_path"
- data-qa-selector="edit_dashboard_button"
- >{{ __('Edit dashboard') }}</gl-deprecated-button
- >
+ <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ class="flex-grow-1 js-edit-link"
+ :href="selectedDashboard.project_blob_path"
+ data-qa-selector="edit_dashboard_button"
+ >
+ {{ __('Edit dashboard') }}
+ </gl-deprecated-button>
+ </div>
- <gl-deprecated-button
- v-if="externalDashboardUrl.length"
- class="mt-1 js-external-dashboard-link"
- variant="primary"
- :href="externalDashboardUrl"
- target="_blank"
- rel="noopener noreferrer"
- >
- {{ __('View full dashboard') }}
- <icon name="external-link" />
- </gl-deprecated-button>
- </div>
- </gl-form-group>
+ <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ class="flex-grow-1 js-external-dashboard-link"
+ variant="primary"
+ :href="externalDashboardUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('View full dashboard') }} <icon name="external-link" />
+ </gl-deprecated-button>
+ </div>
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue b/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue
new file mode 100644
index 00000000000..be92414fd56
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue
@@ -0,0 +1,25 @@
+<script>
+import CeDashboard from '~/monitoring/components/dashboard.vue';
+import AlertWidget from './alert_widget.vue';
+
+export default {
+ components: {
+ AlertWidget,
+ },
+ extends: CeDashboard,
+ data() {
+ return {
+ allAlerts: {},
+ };
+ },
+ methods: {
+ setAlerts(alertPath, alertAttributes) {
+ if (alertAttributes) {
+ this.$set(this.allAlerts, alertPath, alertAttributes);
+ } else {
+ this.$delete(this.allAlerts, alertPath);
+ }
+ },
+ },
+};
+</script>
diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
index 3f8b0f76997..129de6cc2f6 100644
--- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
+++ b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapActions } from 'vuex';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import PanelType from '~/monitoring/components/panel_type_with_alerts.vue';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { defaultTimeRange } from '~/vue_shared/constants';
import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils';
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index 676fc0cca64..eed41b94cd3 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -4,6 +4,7 @@ import { pickBy } from 'lodash';
import invalidUrl from '~/lib/utils/invalid_url';
import {
GlResizeObserverDirective,
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
@@ -13,7 +14,9 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { __, n__ } from '~/locale';
-import Icon from '~/vue_shared/components/icon.vue';
+import { panelTypes } from '../constants';
+
+import MonitorEmptyChart from './charts/empty_chart.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
@@ -21,7 +24,7 @@ import MonitorHeatmapChart from './charts/heatmap.vue';
import MonitorColumnChart from './charts/column.vue';
import MonitorBarChart from './charts/bar.vue';
import MonitorStackedColumnChart from './charts/stacked_column.vue';
-import MonitorEmptyChart from './charts/empty_chart.vue';
+
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
@@ -31,13 +34,13 @@ const events = {
export default {
components: {
+ MonitorEmptyChart,
MonitorSingleStatChart,
+ MonitorHeatmapChart,
MonitorColumnChart,
MonitorBarChart,
- MonitorHeatmapChart,
MonitorStackedColumnChart,
- MonitorEmptyChart,
- Icon,
+ GlIcon,
GlLoadingIcon,
GlTooltip,
GlDropdown,
@@ -68,7 +71,7 @@ export default {
groupId: {
type: String,
required: false,
- default: 'panel-type-chart',
+ default: 'dashboard-panel',
},
namespace: {
type: String,
@@ -142,7 +145,7 @@ export default {
return window.URL.createObjectURL(data);
},
timeChartComponent() {
- if (this.isPanelType('anomaly-chart')) {
+ if (this.isPanelType(panelTypes.ANOMALY_CHART)) {
return MonitorAnomalyChart;
}
return MonitorTimeSeriesChart;
@@ -150,10 +153,10 @@ export default {
isContextualMenuShown() {
return (
this.graphDataHasResult &&
- !this.isPanelType('single-stat') &&
- !this.isPanelType('heatmap') &&
- !this.isPanelType('column') &&
- !this.isPanelType('stacked-column')
+ !this.isPanelType(panelTypes.SINGLE_STAT) &&
+ !this.isPanelType(panelTypes.HEATMAP) &&
+ !this.isPanelType(panelTypes.COLUMN) &&
+ !this.isPanelType(panelTypes.STACKED_COLUMN)
);
},
editCustomMetricLink() {
@@ -198,6 +201,7 @@ export default {
this.$emit(events.timeRangeZoom, { start, end });
},
},
+ panelTypes,
};
</script>
<template>
@@ -227,7 +231,7 @@ export default {
</div>
<div
v-if="isContextualMenuShown"
- class="js-graph-widgets"
+ ref="contextualMenu"
data-qa-selector="prometheus_graph_widgets"
>
<div class="d-flex align-items-center">
@@ -240,7 +244,7 @@ export default {
:title="__('More actions')"
>
<template slot="button-content">
- <icon name="ellipsis_v" class="text-secondary" />
+ <gl-icon name="ellipsis_v" class="text-secondary" />
</template>
<gl-dropdown-item
v-if="editCustomMetricLink"
@@ -288,23 +292,23 @@ export default {
</div>
<monitor-single-stat-chart
- v-if="isPanelType('single-stat') && graphDataHasResult"
+ v-if="isPanelType($options.panelTypes.SINGLE_STAT) && graphDataHasResult"
:graph-data="graphData"
/>
<monitor-heatmap-chart
- v-else-if="isPanelType('heatmap') && graphDataHasResult"
+ v-else-if="isPanelType($options.panelTypes.HEATMAP) && graphDataHasResult"
:graph-data="graphData"
/>
<monitor-bar-chart
- v-else-if="isPanelType('bar') && graphDataHasResult"
+ v-else-if="isPanelType($options.panelTypes.BAR) && graphDataHasResult"
:graph-data="graphData"
/>
<monitor-column-chart
- v-else-if="isPanelType('column') && graphDataHasResult"
+ v-else-if="isPanelType($options.panelTypes.COLUMN) && graphDataHasResult"
:graph-data="graphData"
/>
<monitor-stacked-column-chart
- v-else-if="isPanelType('stacked-column') && graphDataHasResult"
+ v-else-if="isPanelType($options.panelTypes.STACKED_COLUMN) && graphDataHasResult"
:graph-data="graphData"
/>
<component
@@ -319,6 +323,6 @@ export default {
:group-id="groupId"
@datazoom="onDatazoom"
/>
- <monitor-empty-chart v-else :graph-title="title" v-bind="$attrs" v-on="$listeners" />
+ <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue b/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue
new file mode 100644
index 00000000000..ca81242af2e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue
@@ -0,0 +1,55 @@
+<script>
+import { mapGetters } from 'vuex';
+import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
+import CePanelType from '~/monitoring/components/panel_type.vue';
+import AlertWidget from './alert_widget.vue';
+
+export default {
+ components: {
+ AlertWidget,
+ CustomMetricsFormFields,
+ },
+ extends: CePanelType,
+ props: {
+ alertsEndpoint: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ prometheusAlertsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ allAlerts: {},
+ };
+ },
+ computed: {
+ ...mapGetters('monitoringDashboard', ['metricsSavedToDb']),
+ hasMetricsInDb() {
+ const { metrics = [] } = this.graphData;
+ return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId));
+ },
+ alertWidgetAvailable() {
+ return (
+ this.prometheusAlertsAvailable &&
+ this.alertsEndpoint &&
+ this.graphData &&
+ this.hasMetricsInDb
+ );
+ },
+ },
+ methods: {
+ setAlerts(alertPath, alertAttributes) {
+ if (alertAttributes) {
+ this.$set(this.allAlerts, alertPath, alertAttributes);
+ } else {
+ this.$delete(this.allAlerts, alertPath);
+ }
+ },
+ },
+};
+</script>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 8d821c27099..f2f0a0eac7b 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -48,6 +48,55 @@ export const metricStates = {
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
};
+/**
+ * Supported panel types in dashboards, values of `panel.type`.
+ *
+ * Values should not be changed as they correspond to
+ * values in users the `.yml` dashboard definition.
+ */
+export const panelTypes = {
+ /**
+ * Area Chart
+ *
+ * Time Series chart with an area
+ */
+ AREA_CHART: 'area-chart',
+ /**
+ * Line Chart
+ *
+ * Time Series chart with a line
+ */
+ LINE_CHART: 'line-chart',
+ /**
+ * Anomaly Chart
+ *
+ * Time Series chart with 3 metrics
+ */
+ ANOMALY_CHART: 'anomaly-chart',
+ /**
+ * Single Stat
+ *
+ * Single data point visualization
+ */
+ SINGLE_STAT: 'single-stat',
+ /**
+ * Heatmap
+ */
+ HEATMAP: 'heatmap',
+ /**
+ * Bar chart
+ */
+ BAR: 'bar',
+ /**
+ * Column chart
+ */
+ COLUMN: 'column',
+ /**
+ * Stacked column chart
+ */
+ STACKED_COLUMN: 'stacked-column',
+};
+
export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300;
@@ -120,10 +169,32 @@ export const NOT_IN_DB_PREFIX = 'NO_DB';
export const ENVIRONMENT_AVAILABLE_STATE = 'available';
/**
- * Time series charts have different types of
- * tooltip based on the hovered data point.
+ * As of %12.10, the svg icon library does not have an annotation
+ * arrow icon yet. In order to deliver annotations feature, the icon
+ * is hard coded until the icon is added. The below issue is
+ * to track the icon.
+ *
+ * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118
+ *
+ * Once the icon is merged this can be removed.
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/214540
+ */
+export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
+
+/**
+ * As of %12.10, dashboard path is required to create annotation.
+ * The FE gets the dashboard name from the URL params. It is not
+ * ideal to store the path this way but there is no other way to
+ * get this path unless annotations fetch is delayed. This could
+ * potentially be removed and have the backend send this to the FE.
+ *
+ * This technical debt is being tracked here
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/214671
*/
-export const tooltipTypes = {
- deployments: 'deployments',
- annotations: 'annotations',
+export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
+
+export const OPERATORS = {
+ greaterThan: '>',
+ equalTo: '==',
+ lessThan: '<',
};
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index d296f5b7a66..99af8ccaf05 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
+import Dashboard from '~/monitoring/components/dashboard_with_alerts.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
import store from './stores';
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
new file mode 100644
index 00000000000..afe5ee0938d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
@@ -0,0 +1,13 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+import initCeBundle from '~/monitoring/monitoring_bundle';
+
+export default () => {
+ const el = document.getElementById('prometheus-graphs');
+
+ if (el && el.dataset) {
+ initCeBundle({
+ customMetricsAvailable: parseBoolean(el.dataset.customMetricsAvailable),
+ prometheusAlertsAvailable: parseBoolean(el.dataset.prometheusAlertsAvailable),
+ });
+ }
+};
diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql
index 2fd698eadf9..27b49860b8a 100644
--- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql
+++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql
@@ -1,12 +1,25 @@
-query getAnnotations($projectPath: ID!) {
- environment(name: $environmentName) {
- metricDashboard(id: $dashboardId) {
- annotations: nodes {
+query getAnnotations(
+ $projectPath: ID!
+ $environmentName: String
+ $dashboardPath: String!
+ $startingFrom: Time!
+) {
+ project(fullPath: $projectPath) {
+ environments(name: $environmentName) {
+ nodes {
id
- description
- starting_at
- ending_at
- panelId
+ name
+ metricsDashboard(path: $dashboardPath) {
+ annotations(from: $startingFrom) {
+ nodes {
+ id
+ description
+ startingAt
+ endingAt
+ panelId
+ }
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js
new file mode 100644
index 00000000000..4b7337972fe
--- /dev/null
+++ b/app/assets/javascripts/monitoring/services/alerts_service.js
@@ -0,0 +1,32 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default class AlertsService {
+ constructor({ alertsEndpoint }) {
+ this.alertsEndpoint = alertsEndpoint;
+ }
+
+ getAlerts() {
+ return axios.get(this.alertsEndpoint).then(resp => resp.data);
+ }
+
+ createAlert({ prometheus_metric_id, operator, threshold }) {
+ return axios
+ .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold })
+ .then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ readAlert(alertPath) {
+ return axios.get(alertPath).then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ updateAlert(alertPath, { operator, threshold }) {
+ return axios.put(alertPath, { operator, threshold }).then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ deleteAlert(alertPath) {
+ return axios.delete(alertPath).then(resp => resp.data);
+ }
+}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 3201f1d4584..f04f775761c 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -3,7 +3,12 @@ import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils';
+import {
+ gqClient,
+ parseEnvironmentsResponse,
+ parseAnnotationsResponse,
+ removeLeadingSlash,
+} from './utils';
import trackDashboardLoad from '../monitoring_tracking_helper';
import getEnvironments from '../queries/getEnvironments.query.graphql';
import getAnnotations from '../queries/getAnnotations.query.graphql';
@@ -15,7 +20,11 @@ import {
} from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
-import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants';
+import {
+ PROMETHEUS_TIMEOUT,
+ ENVIRONMENT_AVAILABLE_STATE,
+ DEFAULT_DASHBOARD_PATH,
+} from '../constants';
function prometheusMetricQueryParams(timeRange) {
const { start, end } = convertToFixedRange(timeRange);
@@ -283,16 +292,21 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => {
};
export const fetchAnnotations = ({ state, dispatch }) => {
+ const { start } = convertToFixedRange(state.timeRange);
+ const dashboardPath =
+ state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard;
return gqClient
.mutate({
mutation: getAnnotations,
variables: {
projectPath: removeLeadingSlash(state.projectPath),
- dashboardId: state.currentDashboard,
environmentName: state.currentEnvironmentName,
+ dashboardPath,
+ startingFrom: start,
},
})
- .then(resp => resp.data?.project?.environment?.metricDashboard?.annotations)
+ .then(resp => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes)
+ .then(parseAnnotationsResponse)
.then(annotations => {
if (!annotations) {
createFlash(s__('Metrics|There was an error fetching annotations. Please try again.'));
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index a212e9be703..9f06d18c46f 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -58,6 +58,31 @@ export const parseEnvironmentsResponse = (response = [], projectPath) =>
});
/**
+ * Annotation API returns time in UTC. This method
+ * converts time to local time.
+ *
+ * startingAt always exists but endingAt does not.
+ * If endingAt does not exist, a threshold line is
+ * drawn.
+ *
+ * If endingAt exists, a threshold range is drawn.
+ * But this is not supported as of %12.10
+ *
+ * @param {Array} response annotations response
+ * @returns {Array} parsed responses
+ */
+export const parseAnnotationsResponse = response => {
+ if (!response) {
+ return [];
+ }
+ return response.map(annotation => ({
+ ...annotation,
+ startingAt: new Date(annotation.startingAt),
+ endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null,
+ }));
+};
+
+/**
* Maps metrics to its view model
*
* This function difers from other in that is maps all
@@ -95,15 +120,19 @@ const mapXAxisToViewModel = ({ name = '' }) => ({ name });
/**
* Maps Y-axis view model
*
- * Defaults to a 2 digit precision and `number` format. It only allows
+ * Defaults to a 2 digit precision and `engineering` format. It only allows
* formats in the SUPPORTED_FORMATS array.
*
* @param {Object} axis
*/
-const mapYAxisToViewModel = ({ name = '', format = SUPPORTED_FORMATS.number, precision = 2 }) => {
+const mapYAxisToViewModel = ({
+ name = '',
+ format = SUPPORTED_FORMATS.engineering,
+ precision = 2,
+}) => {
return {
name,
- format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.number,
+ format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering,
precision,
};
};
diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js
new file mode 100644
index 00000000000..cd426f1a221
--- /dev/null
+++ b/app/assets/javascripts/monitoring/validators.js
@@ -0,0 +1,44 @@
+// Prop validator for alert information, expecting an object like the example below.
+//
+// {
+// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': {
+// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37",
+// metricId: '1',
+// operator: ">",
+// query: "rate(http_requests_total[5m])[30m:1m]",
+// threshold: 0.002,
+// title: "Core Usage (Total)",
+// }
+// }
+export function alertsValidator(value) {
+ return Object.keys(value).every(key => {
+ const alert = value[key];
+ return (
+ alert.alert_path &&
+ key === alert.alert_path &&
+ alert.metricId &&
+ typeof alert.metricId === 'string' &&
+ alert.operator &&
+ typeof alert.threshold === 'number'
+ );
+ });
+}
+
+// Prop validator for query information, expecting an array like the example below.
+//
+// [
+// {
+// metricId: '16',
+// label: 'Total Cores'
+// },
+// {
+// metricId: '17',
+// label: 'Sub-total Cores'
+// }
+// ]
+export function queriesValidator(value) {
+ return value.every(
+ query =>
+ query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string',
+ );
+}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 9a809b71a58..a070cf8866a 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -3,6 +3,7 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash';
import Autosize from 'autosize';
+import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
@@ -34,6 +35,10 @@ export default {
userAvatarLink,
loadingButton,
TimelineEntryItem,
+ GlAlert,
+ GlIntersperse,
+ GlLink,
+ GlSprintf,
},
mixins: [issuableStateMixin],
props: {
@@ -57,8 +62,9 @@ export default {
'getNoteableData',
'getNotesData',
'openState',
+ 'getBlockedByIssues',
]),
- ...mapState(['isToggleStateButtonLoading']),
+ ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']),
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -159,6 +165,7 @@ export default {
'reopenIssue',
'toggleIssueLocalState',
'toggleStateButtonLoading',
+ 'toggleBlockedIssueWarning',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!isEmpty(note) && !isSubmitting) {
@@ -220,22 +227,17 @@ export default {
this.isSubmitting = false;
},
toggleIssueState() {
+ if (
+ this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE &&
+ this.isOpen &&
+ this.getBlockedByIssues &&
+ this.getBlockedByIssues.length > 0
+ ) {
+ this.toggleBlockedIssueWarning(true);
+ return;
+ }
if (this.isOpen) {
- this.closeIssue()
- .then(() => {
- this.enableButton();
- refreshUserMergeRequestCounts();
- })
- .catch(() => {
- this.enableButton();
- this.toggleStateButtonLoading(false);
- Flash(
- sprintf(
- __('Something went wrong while closing the %{issuable}. Please try again later'),
- { issuable: this.noteableDisplayName },
- ),
- );
- });
+ this.forceCloseIssue();
} else {
this.reopenIssue()
.then(() => {
@@ -258,6 +260,23 @@ export default {
});
}
},
+ forceCloseIssue() {
+ this.closeIssue()
+ .then(() => {
+ this.enableButton();
+ refreshUserMergeRequestCounts();
+ })
+ .catch(() => {
+ this.enableButton();
+ this.toggleStateButtonLoading(false);
+ Flash(
+ sprintf(
+ __('Something went wrong while closing the %{issuable}. Please try again later'),
+ { issuable: this.noteableDisplayName },
+ ),
+ );
+ });
+ },
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
// `focus` is needed to remain cursor in the textarea.
@@ -361,6 +380,36 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
>
</textarea>
</markdown-field>
+ <gl-alert
+ v-if="isToggleBlockedIssueWarning"
+ class="prepend-top-16"
+ :title="__('Are you sure you want to close this blocked issue?')"
+ :primary-button-text="__('Yes, close issue')"
+ :secondary-button-text="__('Cancel')"
+ variant="warning"
+ :dismissible="false"
+ @primaryAction="forceCloseIssue"
+ @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()"
+ >
+ <p>
+ <gl-sprintf
+ :message="
+ __('This issue is currently blocked by the following issues: %{issues}.')
+ "
+ >
+ <template #issues>
+ <gl-intersperse>
+ <gl-link
+ v-for="blockingIssue in getBlockedByIssues"
+ :key="blockingIssue.web_url"
+ :href="blockingIssue.web_url"
+ >#{{ blockingIssue.iid }}</gl-link
+ >
+ </gl-intersperse>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-alert>
<div class="note-form-actions">
<div
class="float-left btn-group
@@ -427,7 +476,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
</div>
<loading-button
- v-if="canToggleIssueState"
+ v-if="canToggleIssueState && !isToggleBlockedIssueWarning"
:loading="isToggleStateButtonLoading"
:container-class="[
actionButtonClassNames,
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index df62e379017..5181b5f26ee 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,17 +1,12 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import tooltip from '~/vue_shared/directives/tooltip';
-import Icon from '~/vue_shared/components/icon.vue';
+import AwardsList from '~/vue_shared/components/awards_list.vue';
import Flash from '../../flash';
-import { glEmojiTag } from '../../emoji';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
export default {
components: {
- Icon,
- },
- directives: {
- tooltip,
+ AwardsList,
},
props: {
awards: {
@@ -37,130 +32,20 @@ export default {
},
computed: {
...mapGetters(['getUserData']),
- // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
- // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
- // This method will group emojis by their name as an Object. See below.
- // {
- // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
- // bar: [ { name: bar, user: user1 } ]
- // }
- // We need to do this otherwise we will render the same emoji over and over again.
- groupedAwards() {
- const awards = this.awards.reduce((acc, award) => {
- if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
- acc[award.name].push(award);
- } else {
- Object.assign(acc, { [award.name]: [award] });
- }
-
- return acc;
- }, {});
-
- const orderedAwards = {};
- const { thumbsdown, thumbsup } = awards;
- // Always show thumbsup and thumbsdown first
- if (thumbsup) {
- orderedAwards.thumbsup = thumbsup;
- delete awards.thumbsup;
- }
- if (thumbsdown) {
- orderedAwards.thumbsdown = thumbsdown;
- delete awards.thumbsdown;
- }
-
- return Object.assign({}, orderedAwards, awards);
- },
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
+ addButtonClass() {
+ return this.isAuthoredByMe ? 'js-user-authored' : '';
+ },
},
methods: {
...mapActions(['toggleAwardRequest']),
- getAwardHTML(name) {
- return glEmojiTag(name);
- },
- getAwardClassBindings(awardList) {
- return {
- active: this.hasReactionByCurrentUser(awardList),
- disabled: !this.canInteractWithEmoji(),
- };
- },
- canInteractWithEmoji() {
- return this.getUserData.id;
- },
- hasReactionByCurrentUser(awardList) {
- return awardList.filter(award => award.user.id === this.getUserData.id).length;
- },
- awardTitle(awardsList) {
- const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
- const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
- let awardList = awardsList;
-
- // Filter myself from list if I am awarded.
- if (hasReactionByCurrentUser) {
- awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
- }
-
- // Get only 9-10 usernames to show in tooltip text.
- const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
-
- // Get the remaining list to use in `and x more` text.
- const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
-
- // Add myself to the beginning of the list so title will start with You.
- if (hasReactionByCurrentUser) {
- namesToShow.unshift(__('You'));
- }
-
- let title = '';
-
- // We have 10+ awarded user, join them with comma and add `and x more`.
- if (remainingAwardList.length) {
- title = sprintf(
- __(`%{listToShow}, and %{awardsListLength} more.`),
- {
- listToShow: namesToShow.join(', '),
- awardsListLength: remainingAwardList.length,
- },
- false,
- );
- } else if (namesToShow.length > 1) {
- // Join all names with comma but not the last one, it will be added with and text.
- title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
- // If we have more than 2 users we need an extra comma before and text.
- title += namesToShow.length > 2 ? ',' : '';
- title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text
- } else {
- // We have only 2 users so join them with and.
- title = namesToShow.join(__(' and '));
- }
-
- return title;
- },
handleAward(awardName) {
- if (!this.canAwardEmoji) {
- return;
- }
-
- let parsedName;
-
- // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
- switch (awardName) {
- case '100':
- parsedName = 100;
- break;
- case '1234':
- parsedName = 1234;
- break;
- default:
- parsedName = awardName;
- break;
- }
-
const data = {
endpoint: this.toggleAwardPath,
noteId: this.noteId,
- awardName: parsedName,
+ awardName,
};
this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.')));
@@ -171,46 +56,12 @@ export default {
<template>
<div class="note-awards">
- <div class="awards js-awards-block">
- <button
- v-for="(awardList, awardName, index) in groupedAwards"
- :key="index"
- v-tooltip
- :class="getAwardClassBindings(awardList)"
- :title="awardTitle(awardList)"
- data-boundary="viewport"
- class="btn award-control"
- type="button"
- @click="handleAward(awardName)"
- >
- <span v-html="getAwardHTML(awardName)"></span>
- <span class="award-control-text js-counter">{{ awardList.length }}</span>
- </button>
- <div v-if="canAwardEmoji" class="award-menu-holder">
- <button
- v-tooltip
- :class="{ 'js-user-authored': isAuthoredByMe }"
- class="award-control btn js-add-award"
- title="Add reaction"
- :aria-label="__('Add reaction')"
- data-boundary="viewport"
- type="button"
- >
- <span class="award-control-icon award-control-icon-neutral">
- <icon name="slight-smile" />
- </span>
- <span class="award-control-icon award-control-icon-positive">
- <icon name="smiley" />
- </span>
- <span class="award-control-icon award-control-icon-super-positive">
- <icon name="smiley" />
- </span>
- <i
- aria-hidden="true"
- class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
- ></i>
- </button>
- </div>
- </div>
+ <awards-list
+ :awards="awards"
+ :can-award-emoji="canAwardEmoji"
+ :current-user-id="getUserData.id"
+ :add-button-class="addButtonClass"
+ @award="handleAward($event)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index f82b3554cac..74a0b69bc54 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -45,6 +45,13 @@ export default {
default: true,
},
},
+ data() {
+ return {
+ isUsernameLinkHovered: false,
+ emojiTitle: '',
+ authorStatusHasTooltip: false,
+ };
+ },
computed: {
toggleChevronClass() {
return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
@@ -58,6 +65,28 @@ export default {
showGitlabTeamMemberBadge() {
return this.author?.is_gitlab_employee;
},
+ authorLinkClasses() {
+ return {
+ hover: this.isUsernameLinkHovered,
+ 'text-underline': this.isUsernameLinkHovered,
+ 'author-name-link': true,
+ 'js-user-link': true,
+ };
+ },
+ authorStatus() {
+ return this.author.status_tooltip_html;
+ },
+ emojiElement() {
+ return this.$refs?.authorStatus?.querySelector('gl-emoji');
+ },
+ },
+ mounted() {
+ this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : '';
+
+ const authorStatusTitle = this.$refs?.authorStatus
+ ?.querySelector('.user-status-emoji')
+ ?.getAttribute('title');
+ this.authorStatusHasTooltip = authorStatusTitle && authorStatusTitle !== '';
},
methods: {
...mapActions(['setTargetNoteHash']),
@@ -69,6 +98,20 @@ export default {
this.setTargetNoteHash(this.noteTimestampLink);
}
},
+ removeEmojiTitle() {
+ this.emojiElement.removeAttribute('title');
+ },
+ addEmojiTitle() {
+ this.emojiElement.setAttribute('title', this.emojiTitle);
+ },
+ handleUsernameMouseEnter() {
+ this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter'));
+ this.isUsernameLinkHovered = true;
+ },
+ handleUsernameMouseLeave() {
+ this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave'));
+ this.isUsernameLinkHovered = false;
+ },
},
};
</script>
@@ -87,18 +130,34 @@ export default {
</div>
<template v-if="hasAuthor">
<a
- v-once
+ ref="authorNameLink"
:href="author.path"
- class="js-user-link"
+ :class="authorLinkClasses"
:data-user-id="author.id"
:data-username="author.username"
>
<slot name="note-header-info"></slot>
<span class="note-header-author-name bold">{{ author.name }}</span>
- <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
- <span class="note-headline-light">@{{ author.username }}</span>
</a>
- <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" />
+ <span
+ v-if="authorStatus"
+ ref="authorStatus"
+ v-on="
+ authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
+ "
+ v-html="authorStatus"
+ ></span>
+ <span class="text-nowrap author-username">
+ <a
+ ref="authorUsernameLink"
+ class="author-username-link"
+ :href="author.path"
+ @mouseenter="handleUsernameMouseEnter"
+ @mouseleave="handleUsernameMouseLeave"
+ ><span class="note-headline-light">@{{ author.username }}</span>
+ </a>
+ <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" />
+ </span>
</template>
<span v-else>{{ __('A deleted user') }}</span>
<span class="note-headline-light note-headline-meta">
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 1b80b59621a..a358515c2ec 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -185,12 +185,27 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
});
};
+export const toggleBlockedIssueWarning = ({ commit }, value) => {
+ commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value);
+ // Hides Close issue button at the top of issue page
+ const closeDropdown = document.querySelector('.js-issuable-close-dropdown');
+ if (closeDropdown) {
+ closeDropdown.classList.toggle('d-none');
+ } else {
+ const closeButton = document.querySelector(
+ '.detail-page-header-actions .btn-close.btn-grouped',
+ );
+ closeButton.classList.toggle('d-md-block');
+ }
+};
+
export const closeIssue = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return axios.put(state.notesData.closePath).then(({ data }) => {
commit(types.CLOSE_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
+ dispatch('toggleBlockedIssueWarning', false);
});
};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index eb877083bca..85997b44bcc 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -35,6 +35,8 @@ export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+export const getBlockedByIssues = state => state.noteableData.blocked_by_issues;
+
export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note);
export const openState = state => state.noteableData.state;
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 81844ad6e98..2e5e7f47099 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -14,6 +14,7 @@ export default () => ({
// View layer
isToggleStateButtonLoading: false,
+ isToggleBlockedIssueWarning: false,
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 5b7225bb3d2..2f7b2788d8a 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -33,6 +33,7 @@ export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT';
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
+export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING';
// Description version
export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index dab09d1d05c..f06874991f0 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -249,6 +249,10 @@ export default {
Object.assign(state, { isToggleStateButtonLoading: value });
},
+ [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) {
+ Object.assign(state, { isToggleBlockedIssueWarning: value });
+ },
+
[types.SET_NOTES_FETCHED_STATE](state, value) {
Object.assign(state, { isNotesFetched: value });
},
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index 1ef18b356f2..479c82265f2 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -1,13 +1,10 @@
import initSettingsPanels from '~/settings_panels';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import initVariableList from '~/ci_variable_list';
-import DueDateSelectors from '~/due_date_select';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
initSettingsPanels();
- // eslint-disable-next-line no-new
- new DueDateSelectors();
if (gon.features.newVariablesUi) {
initVariableList();
diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
new file mode 100644
index 00000000000..f4b26ba81fe
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
@@ -0,0 +1,9 @@
+import initSettingsPanels from '~/settings_panels';
+import DueDateSelectors from '~/due_date_select';
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize expandable settings panels
+ initSettingsPanels();
+
+ new DueDateSelectors(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js
new file mode 100644
index 00000000000..1e98bcfd2eb
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js
@@ -0,0 +1,5 @@
+import AlertManagementList from '~/alert_management/list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ AlertManagementList();
+});
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
index 0d69a689316..31ec4e29ad2 100644
--- a/app/assets/javascripts/pages/projects/environments/metrics/index.js
+++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js
@@ -1,3 +1,3 @@
-import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle';
+import monitoringBundle from '~/monitoring/monitoring_bundle_with_alerts';
document.addEventListener('DOMContentLoaded', monitoringBundle);
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index a3743ded601..6efddec1172 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -3,6 +3,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
import { s__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectFeatureSetting from './project_feature_setting.vue';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue';
@@ -24,7 +25,7 @@ export default {
GlSprintf,
GlLink,
},
- mixins: [settingsMixin],
+ mixins: [settingsMixin, glFeatureFlagsMixin()],
props: {
currentSettings: {
@@ -116,6 +117,8 @@ export default {
const defaults = {
visibilityOptions,
visibilityLevel: visibilityOptions.PUBLIC,
+ // TODO: Change all of these to use the visibilityOptions constants
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/214667
issuesAccessLevel: 20,
repositoryAccessLevel: 20,
forkingAccessLevel: 20,
@@ -124,11 +127,14 @@ export default {
wikiAccessLevel: 20,
snippetsAccessLevel: 20,
pagesAccessLevel: 20,
+ metricsAccessLevel: visibilityOptions.PRIVATE,
containerRegistryEnabled: true,
lfsEnabled: true,
requestAccessEnabled: true,
highlightChangesClass: false,
emailsDisabled: false,
+ featureAccessLevelEveryone,
+ featureAccessLevelMembers,
};
return { ...defaults, ...this.currentSettings };
@@ -189,6 +195,10 @@ export default {
'ProjectSettings|View and edit files in this project. Non-project members will only have read access',
);
},
+
+ metricsDashboardVisibilitySwitchingAvailable() {
+ return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable;
+ },
},
watch: {
@@ -462,6 +472,38 @@ export default {
name="project[project_feature_attributes][pages_access_level]"
/>
</project-setting-row>
+ <project-setting-row
+ v-if="metricsDashboardVisibilitySwitchingAvailable"
+ ref="metrics-visibility-settings"
+ :label="__('Metrics Dashboard')"
+ :help-text="
+ s__(
+ 'ProjectSettings|With Metrics Dashboard you can visualize this project performance metrics',
+ )
+ "
+ >
+ <div class="project-feature-controls">
+ <div class="select-wrapper">
+ <select
+ v-model="metricsAccessLevel"
+ name="project[project_feature_attributes][metrics_dashboard_access_level]"
+ class="form-control select-control"
+ >
+ <option
+ :value="visibilityOptions.PRIVATE"
+ :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ >{{ featureAccessLevelMembers[1] }}</option
+ >
+ <option
+ :value="visibilityOptions.PUBLIC"
+ :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+ >{{ featureAccessLevelEveryone[1] }}</option
+ >
+ </select>
+ <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
+ </div>
+ </div>
+ </project-setting-row>
</div>
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
<label class="js-emails-disabled">
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 388b300b39d..06ab45adf80 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -21,7 +21,8 @@ export default {
return this.selectedSuite.total_count > 0;
},
showTests() {
- return this.testReports.total_count > 0;
+ const { test_suites: testSuites = [] } = this.testReports;
+ return testSuites.length > 0;
},
},
methods: {
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 6effd6e949d..4dfb67dd8e8 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -1,14 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import store from '~/pipelines/stores/test_reports';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
export default {
name: 'TestsSummaryTable',
components: {
+ GlIcon,
SmartVirtualList,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
store,
props: {
heading: {
@@ -75,7 +80,10 @@ export default {
v-for="(testSuite, index) in getTestSuites"
:key="index"
role="row"
- class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row"
+ class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row"
+ :class="{
+ 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error,
+ }"
@click="tableRowClick(testSuite)"
>
<div class="table-section section-25">
@@ -84,6 +92,14 @@ export default {
</div>
<div class="table-mobile-content underline cgray pl-3">
{{ testSuite.name }}
+ <gl-icon
+ v-if="testSuite.suite_error"
+ ref="suiteErrorIcon"
+ v-gl-tooltip
+ name="error"
+ :title="testSuite.suite_error"
+ class="vertical-align-middle"
+ />
</div>
</div>
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index daeae071d6a..a3a53c2f975 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
@@ -26,6 +27,9 @@ export default {
},
})
.then(({ data }) => dispatch('receiveAuthorsSuccess', data))
- .catch(() => dispatch('receiveAuthorsError'));
+ .catch(error => {
+ Sentry.captureException(error);
+ dispatch('receiveAuthorsError');
+ });
},
};
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index e5898c3b047..2d321ead33e 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -53,6 +53,10 @@ export default {
text: s__('ProjectTemplates|Pages/Hexo'),
icon: '.template-option .icon-hexo',
},
+ sse_middleman: {
+ text: s__('ProjectTemplates|Static Site Editor/Middleman'),
+ icon: '.template-option .icon-sse_middleman',
+ },
nfhugo: {
text: s__('ProjectTemplates|Netlify/Hugo'),
icon: '.template-option .icon-nfhugo',
diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
index 6acf366e531..88a0710574f 100644
--- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
+++ b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
@@ -53,7 +53,6 @@ export default {
:primary-button-text="alertConfiguration.primaryButton"
:primary-button-link="config.settingsPath"
:title="alertConfiguration.title"
- class="my-2"
>
<gl-sprintf :message="alertConfiguration.message">
<template #days>
diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js
index 586231d19c7..d4b9d25b212 100644
--- a/app/assets/javascripts/registry/explorer/constants.js
+++ b/app/assets/javascripts/registry/explorer/constants.js
@@ -1,16 +1,44 @@
import { s__ } from '~/locale';
+// List page
+
+export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
+export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
+export const CONNECTION_ERROR_MESSAGE = s__(
+ `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
+);
+export const LIST_INTRO_TEXT = s__(
+ `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
+);
+
+export const LIST_DELETE_BUTTON_DISABLED = s__(
+ 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
+);
+export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
+export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
+ 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
+);
+export const ROW_SCHEDULED_FOR_DELETION = s__(
+ `ContainerRegistry|This image repository is scheduled for deletion`,
+);
export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while fetching the packages list.',
+ 'ContainerRegistry|Something went wrong while fetching the repository list.',
);
export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the tags list.',
);
-
export const DELETE_IMAGE_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while deleting the image.',
+ 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.',
);
-export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully');
+export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
+ `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`,
+);
+export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|%{title} was successfully scheduled for deletion',
+);
+
+// Image details page
+
export const DELETE_TAG_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while deleting the tag.',
);
@@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
+// Expiration policies
+
export const EXPIRATION_POLICY_ALERT_TITLE = s__(
'ContainerRegistry|Retention policy has been Enabled',
);
@@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__(
'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}',
);
+// Quick Start
+
export const QUICK_START = s__('ContainerRegistry|Quick Start');
export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
@@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
+
+// Image state
+
+export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
+export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 7204cbd90eb..8923c305b2d 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -9,16 +9,28 @@ import {
GlModal,
GlSprintf,
GlLink,
+ GlAlert,
GlSkeletonLoader,
} from '@gitlab/ui';
import Tracking from '~/tracking';
-import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue';
import ProjectPolicyAlert from '../components/project_policy_alert.vue';
import QuickstartDropdown from '../components/quickstart_dropdown.vue';
-import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants';
+import {
+ DELETE_IMAGE_SUCCESS_MESSAGE,
+ DELETE_IMAGE_ERROR_MESSAGE,
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ CONTAINER_REGISTRY_TITLE,
+ CONNECTION_ERROR_TITLE,
+ CONNECTION_ERROR_MESSAGE,
+ LIST_INTRO_TEXT,
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ REMOVE_REPOSITORY_MODAL_TEXT,
+ ROW_SCHEDULED_FOR_DELETION,
+} from '../constants';
export default {
name: 'RegistryListApp',
@@ -35,6 +47,7 @@ export default {
GlModal,
GlSprintf,
GlLink,
+ GlAlert,
GlSkeletonLoader,
},
directives: {
@@ -47,25 +60,20 @@ export default {
height: 40,
},
i18n: {
- containerRegistryTitle: s__('ContainerRegistry|Container Registry'),
- connectionErrorTitle: s__('ContainerRegistry|Docker connection error'),
- connectionErrorMessage: s__(
- `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
- ),
- introText: s__(
- `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
- ),
- deleteButtonDisabled: s__(
- 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
- ),
- removeRepositoryLabel: s__('ContainerRegistry|Remove repository'),
- removeRepositoryModalText: s__(
- 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
- ),
+ containerRegistryTitle: CONTAINER_REGISTRY_TITLE,
+ connectionErrorTitle: CONNECTION_ERROR_TITLE,
+ connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
+ introText: LIST_INTRO_TEXT,
+ deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED,
+ removeRepositoryLabel: REMOVE_REPOSITORY_LABEL,
+ removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT,
+ rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION,
+ asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
},
data() {
return {
itemToDelete: {},
+ deleteAlertType: null,
};
},
computed: {
@@ -86,43 +94,61 @@ export default {
showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
+ showDeleteAlert() {
+ return this.deleteAlertType && this.itemToDelete?.path;
+ },
+ deleteImageAlertMessage() {
+ return this.deleteAlertType === 'success'
+ ? DELETE_IMAGE_SUCCESS_MESSAGE
+ : DELETE_IMAGE_ERROR_MESSAGE;
+ },
},
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
deleteImage(item) {
- // This event is already tracked in the system and so the name must be kept to aggregate the data
this.track('click_button');
this.itemToDelete = item;
this.$refs.deleteModal.show();
},
handleDeleteImage() {
this.track('confirm_delete');
- return this.requestDeleteImage(this.itemToDelete.destroy_path)
- .then(() =>
- this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, {
- type: 'success',
- }),
- )
- .catch(() =>
- this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, {
- type: 'error',
- }),
- )
- .finally(() => {
- this.itemToDelete = {};
+ return this.requestDeleteImage(this.itemToDelete)
+ .then(() => {
+ this.deleteAlertType = 'success';
+ })
+ .catch(() => {
+ this.deleteAlertType = 'danger';
});
},
encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
return window.btoa(params);
},
+ dismissDeleteAlert() {
+ this.deleteAlertType = null;
+ this.itemToDelete = {};
+ },
},
};
</script>
<template>
<div class="w-100 slide-enter-from-element">
- <project-policy-alert v-if="!config.isGroupPage" />
+ <gl-alert
+ v-if="showDeleteAlert"
+ :variant="deleteAlertType"
+ class="mt-2"
+ dismissible
+ @dismiss="dismissDeleteAlert"
+ >
+ <gl-sprintf :message="deleteImageAlertMessage">
+ <template #title>
+ {{ itemToDelete.path }}
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <project-policy-alert v-if="!config.isGroupPage" class="mt-2" />
<gl-empty-state
v-if="config.characterError"
@@ -178,41 +204,57 @@ export default {
v-for="(listItem, index) in images"
:key="index"
ref="rowItem"
- :class="{ 'border-top': index === 0 }"
- class="d-flex justify-content-between align-items-center py-2 border-bottom"
+ v-gl-tooltip="{
+ placement: 'left',
+ disabled: !listItem.deleting,
+ title: $options.i18n.rowScheduledForDeletion,
+ }"
>
- <div>
- <router-link
- ref="detailsLink"
- :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
- >
- {{ listItem.path }}
- </router-link>
- <clipboard-button
- v-if="listItem.location"
- ref="clipboardButton"
- :text="listItem.location"
- :title="listItem.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
- </div>
<div
- v-gl-tooltip="{ disabled: listItem.destroy_path }"
- class="d-none d-sm-block"
- :title="$options.i18n.deleteButtonDisabled"
+ class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom"
+ :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
>
- <gl-deprecated-button
- ref="deleteImageButton"
- v-gl-tooltip
- :disabled="!listItem.destroy_path"
- :title="$options.i18n.removeRepositoryLabel"
- :aria-label="$options.i18n.removeRepositoryLabel"
- class="btn-inverted"
- variant="danger"
- @click="deleteImage(listItem)"
+ <div class="d-felx align-items-center">
+ <router-link
+ ref="detailsLink"
+ :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
+ >
+ {{ listItem.path }}
+ </router-link>
+ <clipboard-button
+ v-if="listItem.location"
+ ref="clipboardButton"
+ :disabled="listItem.deleting"
+ :text="listItem.location"
+ :title="listItem.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ <gl-icon
+ v-if="listItem.failedDelete"
+ v-gl-tooltip
+ :title="$options.i18n.asyncDeleteErrorMessage"
+ name="warning"
+ class="text-warning align-middle"
+ />
+ </div>
+ <div
+ v-gl-tooltip="{ disabled: listItem.destroy_path }"
+ class="d-none d-sm-block"
+ :title="$options.i18n.deleteButtonDisabled"
>
- <gl-icon name="remove" />
- </gl-deprecated-button>
+ <gl-deprecated-button
+ ref="deleteImageButton"
+ v-gl-tooltip
+ :disabled="!listItem.destroy_path || listItem.deleting"
+ :title="$options.i18n.removeRepositoryLabel"
+ :aria-label="$options.i18n.removeRepositoryLabel"
+ class="btn-inverted"
+ variant="danger"
+ @click="deleteImage(listItem)"
+ >
+ <gl-icon name="remove" />
+ </gl-deprecated-button>
+ </div>
</div>
</div>
<gl-pagination
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
index 2abd72cb9a8..b4f66dbbcd6 100644
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params })
});
};
-export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => {
+export const requestDeleteImage = ({ commit }, image) => {
commit(types.SET_MAIN_LOADING, true);
-
return axios
- .delete(destroyPath)
+ .delete(image.destroy_path)
.then(() => {
- dispatch('setShowGarbageCollectionTip', true);
- dispatch('requestImagesList', { pagination: state.pagination });
+ commit(types.UPDATE_IMAGE, { ...image, deleting: true });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
index 86eaa0dd2f1..f32cdf90783 100644
--- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js
+++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
@@ -1,6 +1,7 @@
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
+export const UPDATE_IMAGE = 'UPDATE_IMAGE';
export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js
index fda788051c0..b25a0221dc1 100644
--- a/app/assets/javascripts/registry/explorer/stores/mutations.js
+++ b/app/assets/javascripts/registry/explorer/stores/mutations.js
@@ -1,5 +1,6 @@
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants';
export default {
[types.SET_INITIAL_STATE](state, config) {
@@ -12,7 +13,17 @@ export default {
},
[types.SET_IMAGES_LIST_SUCCESS](state, images) {
- state.images = images;
+ state.images = images.map(i => ({
+ ...i,
+ status: undefined,
+ deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS,
+ failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS,
+ }));
+ },
+
+ [types.UPDATE_IMAGE](state, image) {
+ const index = state.images.findIndex(i => i.id === image.id);
+ state.images.splice(index, 1, { ...image });
},
[types.SET_TAGS_LIST_SUCCESS](state, tags) {
diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
index 6aae9195be1..256b0e33e79 100644
--- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
+++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
@@ -26,18 +26,11 @@ export default {
* The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation.
* Here we simply split the string on `.` and get the code in the 5th position
*/
- if (this.issue.code === undefined) {
- return null;
- }
-
- return this.issue.code.split('.')[4] || null;
+ return this.issue.code?.split('.')[4];
},
learnMoreUrl() {
- if (this.parsedTECHSCode === null) {
- return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html';
- }
-
- return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`;
},
},
};
diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js
new file mode 100644
index 00000000000..f145b352e7d
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/actions.js
@@ -0,0 +1,47 @@
+import axios from '~/lib/utils/axios_utils';
+import * as types from './mutation_types';
+import { parseAccessibilityReport, compareAccessibilityReports } from './utils';
+import { s__ } from '~/locale';
+
+export const fetchReport = ({ state, dispatch, commit }) => {
+ commit(types.REQUEST_REPORT);
+
+ // If we don't have both endpoints, throw an error.
+ if (!state.baseEndpoint || !state.headEndpoint) {
+ commit(
+ types.RECEIVE_REPORT_ERROR,
+ s__('AccessibilityReport|Accessibility report artifact not found'),
+ );
+ return;
+ }
+
+ Promise.all([
+ axios.get(state.baseEndpoint).then(response => ({
+ ...response.data,
+ isHead: false,
+ })),
+ axios.get(state.headEndpoint).then(response => ({
+ ...response.data,
+ isHead: true,
+ })),
+ ])
+ .then(responses => dispatch('receiveReportSuccess', responses))
+ .catch(() =>
+ commit(
+ types.RECEIVE_REPORT_ERROR,
+ s__('AccessibilityReport|Failed to retrieve accessibility report'),
+ ),
+ );
+};
+
+export const receiveReportSuccess = ({ commit }, responses) => {
+ const parsedReports = responses.map(response => ({
+ isHead: response.isHead,
+ issues: parseAccessibilityReport(response),
+ }));
+ const report = compareAccessibilityReports(parsedReports);
+ commit(types.RECEIVE_REPORT_SUCCESS, report);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/reports/accessibility_report/store/index.js b/app/assets/javascripts/reports/accessibility_report/store/index.js
new file mode 100644
index 00000000000..c1413499802
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: state(initialState),
+ });
diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js
new file mode 100644
index 00000000000..381736bbd38
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js
@@ -0,0 +1,3 @@
+export const REQUEST_REPORT = 'REQUEST_REPORT';
+export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';
+export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR';
diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutations.js b/app/assets/javascripts/reports/accessibility_report/store/mutations.js
new file mode 100644
index 00000000000..66cf9f3d69d
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/mutations.js
@@ -0,0 +1,18 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_REPORT](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_REPORT_SUCCESS](state, report) {
+ state.hasError = false;
+ state.isLoading = false;
+ state.report = report;
+ },
+ [types.RECEIVE_REPORT_ERROR](state, message) {
+ state.isLoading = false;
+ state.hasError = true;
+ state.errorMessage = message;
+ state.report = {};
+ },
+};
diff --git a/app/assets/javascripts/reports/accessibility_report/store/state.js b/app/assets/javascripts/reports/accessibility_report/store/state.js
new file mode 100644
index 00000000000..7d560a9f419
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/state.js
@@ -0,0 +1,30 @@
+export default (initialState = {}) => ({
+ baseEndpoint: initialState.baseEndpoint || '',
+ headEndpoint: initialState.headEndpoint || '',
+
+ isLoading: initialState.isLoading || false,
+ hasError: initialState.hasError || false,
+
+ /**
+ * Report will have the following format:
+ * {
+ * status: {String},
+ * summary: {
+ * total: {Number},
+ * notes: {Number},
+ * warnings: {Number},
+ * errors: {Number},
+ * },
+ * existing_errors: {Array.<Object>},
+ * existing_notes: {Array.<Object>},
+ * existing_warnings: {Array.<Object>},
+ * new_errors: {Array.<Object>},
+ * new_notes: {Array.<Object>},
+ * new_warnings: {Array.<Object>},
+ * resolved_errors: {Array.<Object>},
+ * resolved_notes: {Array.<Object>},
+ * resolved_warnings: {Array.<Object>},
+ * }
+ */
+ report: initialState.report || {},
+});
diff --git a/app/assets/javascripts/reports/accessibility_report/store/utils.js b/app/assets/javascripts/reports/accessibility_report/store/utils.js
new file mode 100644
index 00000000000..f2de65445b0
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/utils.js
@@ -0,0 +1,83 @@
+import { difference, intersection } from 'lodash';
+import {
+ STATUS_FAILED,
+ STATUS_SUCCESS,
+ ACCESSIBILITY_ISSUE_ERROR,
+ ACCESSIBILITY_ISSUE_WARNING,
+} from '../../constants';
+
+export const parseAccessibilityReport = data => {
+ // Combine all issues into one array
+ return Object.keys(data.results)
+ .map(key => [...data.results[key]])
+ .flat()
+ .map(issue => JSON.stringify(issue)); // stringify to help with comparisons
+};
+
+export const compareAccessibilityReports = reports => {
+ const result = {
+ status: '',
+ summary: {
+ total: 0,
+ notes: 0,
+ errors: 0,
+ warnings: 0,
+ },
+ new_errors: [],
+ new_notes: [],
+ new_warnings: [],
+ resolved_errors: [],
+ resolved_notes: [],
+ resolved_warnings: [],
+ existing_errors: [],
+ existing_notes: [],
+ existing_warnings: [],
+ };
+
+ const headReport = reports.filter(report => report.isHead)[0];
+ const baseReport = reports.filter(report => !report.isHead)[0];
+
+ // existing issues are those that exist in both the head report and the base report
+ const existingIssues = intersection(headReport.issues, baseReport.issues);
+ // new issues are those that exist in only the head report
+ const newIssues = difference(headReport.issues, baseReport.issues);
+ // resolved issues are those that exist in only the base report
+ const resolvedIssues = difference(baseReport.issues, headReport.issues);
+
+ const parseIssues = (issue, issueType, shouldCount) => {
+ const parsedIssue = JSON.parse(issue);
+ switch (parsedIssue.type) {
+ case ACCESSIBILITY_ISSUE_ERROR:
+ result[`${issueType}_errors`].push(parsedIssue);
+ if (shouldCount) {
+ result.summary.errors += 1;
+ }
+ break;
+ case ACCESSIBILITY_ISSUE_WARNING:
+ result[`${issueType}_warnings`].push(parsedIssue);
+ if (shouldCount) {
+ result.summary.warnings += 1;
+ }
+ break;
+ default:
+ result[`${issueType}_notes`].push(parsedIssue);
+ if (shouldCount) {
+ result.summary.notes += 1;
+ }
+ break;
+ }
+ };
+
+ existingIssues.forEach(issue => parseIssues(issue, 'existing', true));
+ newIssues.forEach(issue => parseIssues(issue, 'new', true));
+ resolvedIssues.forEach(issue => parseIssues(issue, 'resolved', false));
+
+ result.summary.total = result.summary.errors + result.summary.warnings + result.summary.notes;
+ const hasErrorsOrWarnings = result.summary.errors > 0 || result.summary.warnings > 0;
+ result.status = hasErrorsOrWarnings ? STATUS_FAILED : STATUS_SUCCESS;
+
+ return result;
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
index 88d174f96ed..0f7a0e60dc0 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import { componentNames } from './issue_body';
import ReportSection from './report_section.vue';
import SummaryRow from './summary_row.vue';
@@ -52,8 +52,17 @@ export default {
methods: {
...mapActions(['setEndpoint', 'fetchReports']),
reportText(report) {
- const summary = report.summary || {};
- return reportTextBuilder(report.name, summary);
+ const { name, summary } = report || {};
+
+ if (report.status === 'error') {
+ return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name });
+ }
+
+ if (!report.name) {
+ return s__('Reports|An error occured while loading report');
+ }
+
+ return reportTextBuilder(name, summary);
},
getReportIcon(report) {
return statusIcon(report.status);
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 1845b51e6b2..b3905cbfcfb 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -22,3 +22,6 @@ export const status = {
ERROR: 'ERROR',
SUCCESS: 'SUCCESS',
};
+
+export const ACCESSIBILITY_ISSUE_ERROR = 'error';
+export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
index 68f6de3a7ee..35ab72bf694 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -8,8 +8,7 @@ export default {
state.isLoading = true;
},
[types.RECEIVE_REPORTS_SUCCESS](state, response) {
- // Make sure to clean previous state in case it was an error
- state.hasError = false;
+ state.hasError = response.suites.some(suite => suite.status === 'error');
state.isLoading = false;
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 6c58f48dc74..d78b2d9d962 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -108,14 +108,14 @@ export default {
return acc.concat({
name,
path,
- to: `/-/tree/${joinPaths(escape(this.ref), path)}`,
+ to: `/-/tree/${joinPaths(escapeFileUrl(this.ref), path)}`,
});
},
[
{
name: this.projectShortPath,
path: '/',
- to: `/-/tree/${escape(this.ref)}/`,
+ to: `/-/tree/${escapeFileUrl(this.ref)}/`,
},
],
);
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
index f9fcbc356e8..0a8ee5f2fc5 100644
--- a/app/assets/javascripts/repository/components/table/parent_row.vue
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+import { escapeFileUrl } from '~/lib/utils/url_utility';
export default {
components: {
@@ -28,7 +29,7 @@ export default {
return splitArray.map(p => encodeURIComponent(p)).join('/');
},
parentRoute() {
- return { path: `/-/tree/${escape(this.commitRef)}/${this.parentPath}` };
+ return { path: `/-/tree/${escapeFileUrl(this.commitRef)}/${this.parentPath}` };
},
},
methods: {
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 00ccc49d770..6bd1c702a82 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -99,7 +99,7 @@ export default {
computed: {
routerLinkTo() {
return this.isFolder
- ? { path: `/-/tree/${escape(this.ref)}/${escapeFileUrl(this.path)}` }
+ ? { path: `/-/tree/${escapeFileUrl(this.ref)}/${escapeFileUrl(this.path)}` }
: null;
},
isFolder() {
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 0c68b5a599b..6640b636597 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -48,7 +48,7 @@ const defaultClient = createDefaultClient(
case 'TreeEntry':
case 'Submodule':
case 'Blob':
- return `${escape(obj.flatPath)}-${obj.id}`;
+ return `${encodeURIComponent(obj.flatPath)}-${obj.id}`;
default:
// If the type doesn't match any of the above we fallback
// to using the default Apollo ID
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 637060f6ed9..05783fc3b5d 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -100,7 +100,9 @@ export default function setupVueRepositoryList() {
render(h) {
return h(TreeActionLink, {
props: {
- path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`,
+ path: `${historyLink}/${
+ this.$route.params.path ? encodeURIComponent(this.$route.params.path) : ''
+ }`,
text: __('History'),
},
});
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 550ec3cb0d1..0bb33de0234 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,7 +1,6 @@
/* eslint-disable func-names, consistent-return, no-param-reassign */
import $ from 'jquery';
-import _ from 'underscore';
import Cookies from 'js-cookie';
import flash from './flash';
import axios from './lib/utils/axios_utils';
@@ -142,7 +141,7 @@ Sidebar.prototype.sidebarCollapseClicked = function(e) {
};
Sidebar.prototype.openDropdown = function(blockOrName) {
- const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
+ const $block = typeof blockOrName === 'string' ? this.getBlock(blockOrName) : blockOrName;
if (!this.isOpen()) {
this.setCollapseAfterUpdate($block);
this.toggleSidebar('open');
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 3eaa34c8a93..0e32bb5e49f 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,7 +1,7 @@
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
-import { escape, throttle } from 'underscore';
+import { escape as esc, throttle } from 'lodash';
import { s__, __ } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
@@ -448,7 +448,7 @@ export class SearchAutocomplete {
const avatar = avatarUrl
? `<img class="search-item-avatar" src="${avatarUrl}" />`
: `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
- escape(label),
+ esc(label),
)}</div>`;
return avatar;
diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js
index a098d17a226..b0d373b1a4b 100644
--- a/app/assets/javascripts/snippet/snippet_edit.js
+++ b/app/assets/javascripts/snippet/snippet_edit.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import initSnippet from '~/snippet/snippet_bundle';
import ZenMode from '~/zen_mode';
import GLForm from '~/gl_form';
+import { SnippetEditInit } from '~/snippets';
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('.snippet-form');
@@ -17,9 +18,15 @@ document.addEventListener('DOMContentLoaded', () => {
const projectSnippetOptions = {};
const options =
- form.dataset.snippetType === 'project' ? projectSnippetOptions : personalSnippetOptions;
+ form.dataset.snippetType === 'project' || form.dataset.projectPath
+ ? projectSnippetOptions
+ : personalSnippetOptions;
- initSnippet();
+ if (gon?.features?.snippetsEditVue) {
+ SnippetEditInit();
+ } else {
+ initSnippet();
+ new GLForm($(form), options); // eslint-disable-line no-new
+ }
new ZenMode(); // eslint-disable-line no-new
- new GLForm($(form), options); // eslint-disable-line no-new
});
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
new file mode 100644
index 00000000000..7f93014b93b
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -0,0 +1,216 @@
+<script>
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+
+import Flash from '~/flash';
+import { __, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import TitleField from '~/vue_shared/components/form/title.vue';
+import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility';
+import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
+
+import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
+import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
+import { getSnippetMixin } from '../mixins/snippets';
+import { SNIPPET_VISIBILITY_PRIVATE } from '../constants';
+import SnippetBlobEdit from './snippet_blob_edit.vue';
+import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
+import SnippetDescriptionEdit from './snippet_description_edit.vue';
+
+export default {
+ components: {
+ SnippetDescriptionEdit,
+ SnippetVisibilityEdit,
+ SnippetBlobEdit,
+ TitleField,
+ FormFooterActions,
+ GlButton,
+ GlLoadingIcon,
+ },
+ mixins: [getSnippetMixin],
+ props: {
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ visibilityHelpLink: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ projectPath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ },
+ data() {
+ return {
+ blob: {},
+ fileName: '',
+ content: '',
+ isContentLoading: true,
+ isUpdating: false,
+ newSnippet: false,
+ };
+ },
+ computed: {
+ updatePrevented() {
+ return this.snippet.title === '' || this.content === '' || this.isUpdating;
+ },
+ isProjectSnippet() {
+ return Boolean(this.projectPath);
+ },
+ apiData() {
+ return {
+ id: this.snippet.id,
+ title: this.snippet.title,
+ description: this.snippet.description,
+ visibilityLevel: this.snippet.visibilityLevel,
+ fileName: this.fileName,
+ content: this.content,
+ };
+ },
+ saveButtonLabel() {
+ if (this.newSnippet) {
+ return __('Create snippet');
+ }
+ return this.isUpdating ? __('Saving') : __('Save changes');
+ },
+ cancelButtonHref() {
+ if (this.newSnippet) {
+ return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`;
+ }
+ return this.snippet.webUrl;
+ },
+ titleFieldId() {
+ return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`;
+ },
+ descriptionFieldId() {
+ return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`;
+ },
+ },
+ methods: {
+ updateFileName(newName) {
+ this.fileName = newName;
+ },
+ flashAPIFailure(err) {
+ Flash(sprintf(__("Can't update snippet: %{err}"), { err }));
+ },
+ onNewSnippetFetched() {
+ this.newSnippet = true;
+ this.snippet = this.$options.newSnippetSchema;
+ this.blob = this.snippet.blob;
+ this.isContentLoading = false;
+ },
+ onExistingSnippetFetched() {
+ this.newSnippet = false;
+ const { blob } = this.snippet;
+ this.blob = blob;
+ this.fileName = blob.name;
+ const baseUrl = getBaseURL();
+ const url = joinPaths(baseUrl, blob.rawPath);
+
+ axios
+ .get(url)
+ .then(res => {
+ this.content = res.data;
+ this.isContentLoading = false;
+ })
+ .catch(e => this.flashAPIFailure(e));
+ },
+ onSnippetFetch(snippetRes) {
+ if (snippetRes.data.snippets.edges.length === 0) {
+ this.onNewSnippetFetched();
+ } else {
+ this.onExistingSnippetFetched();
+ }
+ },
+ handleFormSubmit() {
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation,
+ variables: {
+ input: {
+ ...this.apiData,
+ projectPath: this.newSnippet ? this.projectPath : undefined,
+ },
+ },
+ })
+ .then(({ data }) => {
+ const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
+
+ const errors = baseObj?.errors;
+ if (errors.length) {
+ this.flashAPIFailure(errors[0]);
+ }
+ redirectTo(baseObj.snippet.webUrl);
+ })
+ .catch(e => {
+ this.isUpdating = false;
+ this.flashAPIFailure(e);
+ });
+ },
+ },
+ newSnippetSchema: {
+ title: '',
+ description: '',
+ visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
+ blob: {},
+ },
+};
+</script>
+<template>
+ <form
+ class="snippet-form js-requires-input js-quick-submit common-note-form"
+ :data-snippet-type="isProjectSnippet ? 'project' : 'personal'"
+ >
+ <gl-loading-icon
+ v-if="isLoading"
+ :label="__('Loading snippet')"
+ size="lg"
+ class="loading-animation prepend-top-20 append-bottom-20"
+ />
+ <template v-else>
+ <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" />
+ <snippet-description-edit
+ :id="descriptionFieldId"
+ v-model="snippet.description"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ />
+ <snippet-blob-edit
+ v-model="content"
+ :file-name="fileName"
+ :is-loading="isContentLoading"
+ @name-change="updateFileName"
+ />
+ <snippet-visibility-edit
+ v-model="snippet.visibilityLevel"
+ :help-link="visibilityHelpLink"
+ :is-project-snippet="isProjectSnippet"
+ />
+ <form-footer-actions>
+ <template #prepend>
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="success"
+ :disabled="updatePrevented"
+ @click="handleFormSubmit"
+ >{{ saveButtonLabel }}</gl-button
+ >
+ </template>
+ <template #append>
+ <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{
+ __('Cancel')
+ }}</gl-button>
+ </template>
+ </form-footer-actions>
+ </template>
+ </form>
+</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index 68810f8ab3f..6f3a86be8d7 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -50,7 +50,6 @@ export default {
:markdown-docs-path="markdownDocsPath"
>
<textarea
- id="snippet-description"
slot="textarea"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
@@ -59,6 +58,7 @@ export default {
:value="value"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
+ v-bind="$attrs"
@input="$emit('input', $event.target.value)"
>
</textarea>
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index b826110117c..1c79492957d 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -3,7 +3,8 @@ import Translate from '~/vue_shared/translate';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import SnippetsApp from './components/show.vue';
+import SnippetsShow from './components/show.vue';
+import SnippetsEdit from './components/edit.vue';
Vue.use(VueApollo);
Vue.use(Translate);
@@ -31,7 +32,11 @@ function appFactory(el, Component) {
}
export const SnippetShowInit = () => {
- appFactory(document.getElementById('js-snippet-view'), SnippetsApp);
+ appFactory(document.getElementById('js-snippet-view'), SnippetsShow);
+};
+
+export const SnippetEditInit = () => {
+ appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit);
};
export default () => {};
diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
new file mode 100644
index 00000000000..f688868d1b9
--- /dev/null
+++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
@@ -0,0 +1,8 @@
+mutation CreateSnippet($input: CreateSnippetInput!) {
+ createSnippet(input: $input) {
+ errors
+ snippet {
+ webUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
new file mode 100644
index 00000000000..548725f7357
--- /dev/null
+++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
@@ -0,0 +1,8 @@
+mutation UpdateSnippet($input: UpdateSnippetInput!) {
+ updateSnippet(input: $input) {
+ errors
+ snippet {
+ webUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
index f8cc6d1630b..82917319fc3 100644
--- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
+++ b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
@@ -4,6 +4,7 @@ import { GlSkeletonLoader } from '@gitlab/ui';
import EditArea from './edit_area.vue';
import EditHeader from './edit_header.vue';
+import SavedChangesMessage from './saved_changes_message.vue';
import Toolbar from './publish_toolbar.vue';
import InvalidContentMessage from './invalid_content_message.vue';
import SubmitChangesError from './submit_changes_error.vue';
@@ -14,6 +15,7 @@ export default {
EditHeader,
InvalidContentMessage,
GlSkeletonLoader,
+ SavedChangesMessage,
Toolbar,
SubmitChangesError,
},
@@ -27,6 +29,7 @@ export default {
'returnUrl',
'title',
'submitChangesError',
+ 'savedContentMeta',
]),
...mapGetters(['contentChanged']),
},
@@ -41,8 +44,18 @@ export default {
};
</script>
<template>
- <div class="d-flex justify-content-center h-100 pt-2">
- <template v-if="isSupportedContent">
+ <div class="d-flex justify-content-center h-100 pt-2">
+ <!-- Success view -->
+ <saved-changes-message
+ v-if="savedContentMeta"
+ :branch="savedContentMeta.branch"
+ :commit="savedContentMeta.commit"
+ :merge-request="savedContentMeta.mergeRequest"
+ :return-url="returnUrl"
+ />
+
+ <!-- Main view -->
+ <template v-else-if="isSupportedContent">
<div v-if="isLoadingContent" class="w-50 h-50">
<gl-skeleton-loader :width="500" :height="102">
<rect width="500" height="16" rx="4" />
@@ -75,6 +88,8 @@ export default {
/>
</div>
</template>
+
+ <!-- Error view -->
<invalid-content-message v-else class="w-75" />
</div>
</template>
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 09fe952e5f0..42ab44aa03c 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { omitBy, isUndefined } from 'lodash';
const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
@@ -29,7 +29,7 @@ const eventHandler = (e, func, opts = {}) => {
context: el.dataset.trackContext,
};
- func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined));
+ func(opts.category, action + (opts.suffix || ''), omitBy(data, isUndefined));
};
const eventHandlers = (category, func) => {
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index f8c1c3634c2..bde00d72620 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -38,8 +38,7 @@ const populateUserInfo = user => {
name: userData.name,
location: userData.location,
bio: userData.bio,
- organization: userData.organization,
- jobTitle: userData.job_title,
+ workInformation: userData.work_information,
loaded: true,
});
}
@@ -71,7 +70,7 @@ export default (elements = document.querySelectorAll('.js-user-link')) => {
const user = {
location: null,
bio: null,
- organization: null,
+ workInformation: null,
status: null,
loaded: false,
};
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 6821df57b5a..debf8c57b43 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -3,7 +3,7 @@
/* global emitSidebarEvent */
import $ from 'jquery';
-import _ from 'underscore';
+import { escape as esc, template, uniqBy } from 'lodash';
import axios from './lib/utils/axios_utils';
import { s__, __, sprintf } from './locale';
import ModalStore from './boards/stores/modal_store';
@@ -81,7 +81,7 @@ function UsersSelect(currentUser, els, options = {}) {
const userName = currentUserInfo.name;
const userId = currentUserInfo.id || currentUser.id;
- const inputHtmlString = _.template(`
+ const inputHtmlString = template(`
<input type="hidden" name="<%- fieldName %>"
data-meta="<%- userName %>"
value="<%- userId %>" />
@@ -205,7 +205,7 @@ function UsersSelect(currentUser, els, options = {}) {
username: data.assignee.username,
avatar: data.assignee.avatar_url,
};
- tooltipTitle = _.escape(user.name);
+ tooltipTitle = esc(user.name);
} else {
user = {
name: s__('UsersSelect|Unassigned'),
@@ -219,10 +219,10 @@ function UsersSelect(currentUser, els, options = {}) {
return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
});
};
- collapsedAssigneeTemplate = _.template(
+ collapsedAssigneeTemplate = template(
'<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
);
- assigneeTemplate = _.template(
+ assigneeTemplate = template(
`<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
openingTag: '<a href="#" class="js-assign-yourself">',
@@ -248,7 +248,7 @@ function UsersSelect(currentUser, els, options = {}) {
// Potential duplicate entries when dealing with issue board
// because issue board is also managed by vue
- const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
+ const selectedUsers = uniqBy(selectedInputs, a => a.value)
.filter(input => {
const userId = parseInt(input.value, 10);
const inUsersArray = users.find(u => u.id === userId);
@@ -543,7 +543,7 @@ function UsersSelect(currentUser, els, options = {}) {
let img = '';
if (user.beforeDivider != null) {
- `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${esc(
user.name,
)}</a></li>`;
} else {
@@ -672,10 +672,10 @@ UsersSelect.prototype.formatResult = function(user) {
</div>
<div class='user-info'>
<div class='user-name dropdown-menu-user-full-name'>
- ${_.escape(user.name)}
+ ${esc(user.name)}
</div>
<div class='user-username dropdown-menu-user-username text-secondary'>
- ${!user.invite ? `@${_.escape(user.username)}` : ''}
+ ${!user.invite ? `@${esc(user.username)}` : ''}
</div>
</div>
</div>
@@ -683,7 +683,7 @@ UsersSelect.prototype.formatResult = function(user) {
};
UsersSelect.prototype.formatSelection = function(user) {
- return _.escape(user.name);
+ return esc(user.name);
};
UsersSelect.prototype.user = function(user_id, callback) {
@@ -746,7 +746,7 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam
${this.renderRowAvatar(issuableType, user, img)}
<span class="d-flex flex-column overflow-hidden">
<strong class="dropdown-menu-user-full-name">
- ${_.escape(user.name)}
+ ${esc(user.name)}
</strong>
${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''}
</span>
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
new file mode 100644
index 00000000000..848295cc984
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -0,0 +1,178 @@
+<script>
+import { groupBy } from 'lodash';
+import { GlIcon } from '@gitlab/ui';
+import tooltip from '~/vue_shared/directives/tooltip';
+import { glEmojiTag } from '../../emoji';
+import { __, sprintf } from '~/locale';
+
+// Internal constant, specific to this component, used when no `currentUserId` is given
+const NO_USER_ID = -1;
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ awards: {
+ type: Array,
+ required: true,
+ },
+ canAwardEmoji: {
+ type: Boolean,
+ required: true,
+ },
+ currentUserId: {
+ type: Number,
+ required: false,
+ default: NO_USER_ID,
+ },
+ addButtonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ groupedAwards() {
+ const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name);
+
+ return [
+ ...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []),
+ ...(thumbsdown ? [this.createAwardList('thumbsdown', thumbsdown)] : []),
+ ...Object.entries(rest).map(([name, list]) => this.createAwardList(name, list)),
+ ];
+ },
+ isAuthoredByMe() {
+ return this.noteAuthorId === this.currentUserId;
+ },
+ },
+ methods: {
+ getAwardClassBindings(awardList) {
+ return {
+ active: this.hasReactionByCurrentUser(awardList),
+ disabled: this.currentUserId === NO_USER_ID,
+ };
+ },
+ hasReactionByCurrentUser(awardList) {
+ if (this.currentUserId === NO_USER_ID) {
+ return false;
+ }
+
+ return awardList.some(award => award.user.id === this.currentUserId);
+ },
+ createAwardList(name, list) {
+ return {
+ name,
+ list,
+ title: this.getAwardListTitle(list),
+ classes: this.getAwardClassBindings(list),
+ html: glEmojiTag(name),
+ };
+ },
+ getAwardListTitle(awardsList) {
+ const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
+ const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
+ let awardList = awardsList;
+
+ // Filter myself from list if I am awarded.
+ if (hasReactionByCurrentUser) {
+ awardList = awardList.filter(award => award.user.id !== this.currentUserId);
+ }
+
+ // Get only 9-10 usernames to show in tooltip text.
+ const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
+
+ // Get the remaining list to use in `and x more` text.
+ const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
+
+ // Add myself to the beginning of the list so title will start with You.
+ if (hasReactionByCurrentUser) {
+ namesToShow.unshift(__('You'));
+ }
+
+ let title = '';
+
+ // We have 10+ awarded user, join them with comma and add `and x more`.
+ if (remainingAwardList.length) {
+ title = sprintf(
+ __(`%{listToShow}, and %{awardsListLength} more.`),
+ {
+ listToShow: namesToShow.join(', '),
+ awardsListLength: remainingAwardList.length,
+ },
+ false,
+ );
+ } else if (namesToShow.length > 1) {
+ // Join all names with comma but not the last one, it will be added with and text.
+ title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
+ // If we have more than 2 users we need an extra comma before and text.
+ title += namesToShow.length > 2 ? ',' : '';
+ title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text
+ } else {
+ // We have only 2 users so join them with and.
+ title = namesToShow.join(__(' and '));
+ }
+
+ return title;
+ },
+ handleAward(awardName) {
+ if (!this.canAwardEmoji) {
+ return;
+ }
+
+ // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
+ const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
+
+ this.$emit('award', parsedName);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="awards js-awards-block">
+ <button
+ v-for="awardList in groupedAwards"
+ :key="awardList.name"
+ v-tooltip
+ :class="awardList.classes"
+ :title="awardList.title"
+ data-boundary="viewport"
+ data-testid="award-button"
+ class="btn award-control"
+ type="button"
+ @click="handleAward(awardList.name)"
+ >
+ <span data-testid="award-html" v-html="awardList.html"></span>
+ <span class="award-control-text js-counter">{{ awardList.list.length }}</span>
+ </button>
+ <div v-if="canAwardEmoji" class="award-menu-holder">
+ <button
+ v-tooltip
+ :class="addButtonClass"
+ class="award-control btn js-add-award"
+ title="Add reaction"
+ :aria-label="__('Add reaction')"
+ data-boundary="viewport"
+ type="button"
+ >
+ <span class="award-control-icon award-control-icon-neutral">
+ <gl-icon aria-hidden="true" name="slight-smile" />
+ </span>
+ <span class="award-control-icon award-control-icon-positive">
+ <gl-icon aria-hidden="true" name="smiley" />
+ </span>
+ <span class="award-control-icon award-control-icon-super-positive">
+ <gl-icon aria-hidden="true" name="smiley" />
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
+ ></i>
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index cdcd5cdef7f..ffc616d7309 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -158,7 +158,7 @@ export default {
<template>
<tooltip-on-truncate
:title="timeWindowText"
- :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')"
+ :truncate-target="elem => elem.querySelector('.gl-dropdown-toggle-text')"
placement="top"
class="d-inline-block"
>
diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue
index f8f70529bd1..fad69dc1e24 100644
--- a/app/assets/javascripts/vue_shared/components/form/title.vue
+++ b/app/assets/javascripts/vue_shared/components/form/title.vue
@@ -10,6 +10,6 @@ export default {
</script>
<template>
<gl-form-group :label="__('Title')" label-for="title-field-edit">
- <gl-form-input id="title-field-edit" v-bind="$attrs" v-on="$listeners" />
+ <gl-form-input v-bind="$attrs" v-on="$listeners" />
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index 913c971a512..040a15406e0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -37,7 +37,7 @@ export default {
:title="tooltipLabel"
:class="cssClasses"
type="button"
- class="btn btn-blank gutter-toggle btn-sidebar-action"
+ class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle"
data-container="body"
data-placement="left"
data-boundary="viewport"
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 602d4ab89e1..595baeeb14f 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,10 +1,8 @@
<script>
-import { GlPopover, GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
+import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
-import { s__ } from '~/locale';
-import { isString } from 'lodash';
export default {
name: 'UserPopover',
@@ -12,7 +10,6 @@ export default {
Icon,
GlPopover,
GlSkeletonLoading,
- GlSprintf,
UserAvatarImage,
},
props: {
@@ -49,26 +46,7 @@ export default {
return !this.user.name;
},
workInformationIsLoading() {
- return !this.user.loaded && this.workInformation === null;
- },
- workInformation() {
- const { jobTitle, organization } = this.user;
-
- if (organization && jobTitle) {
- return {
- message: s__('Profile|%{job_title} at %{organization}'),
- placeholders: { job_title: jobTitle, organization },
- };
- } else if (organization) {
- return organization;
- } else if (jobTitle) {
- return jobTitle;
- }
-
- return null;
- },
- workInformationShouldUseSprintf() {
- return !isString(this.workInformation);
+ return !this.user.loaded && this.user.workInformation === null;
},
locationIsLoading() {
return !this.user.loaded && this.user.location === null;
@@ -98,23 +76,13 @@ export default {
<icon name="profile" class="category-icon flex-shrink-0" />
<span ref="bio" class="ml-1">{{ user.bio }}</span>
</div>
- <div v-if="workInformation" class="d-flex mb-1">
+ <div v-if="user.workInformation" class="d-flex mb-1">
<icon
v-show="!workInformationIsLoading"
name="work"
class="category-icon flex-shrink-0"
/>
- <span ref="workInformation" class="ml-1">
- <gl-sprintf v-if="workInformationShouldUseSprintf" :message="workInformation.message">
- <template
- v-for="(placeholder, slotName) in workInformation.placeholders"
- v-slot:[slotName]
- >
- <span :key="slotName">{{ placeholder }}</span>
- </template>
- </gl-sprintf>
- <span v-else>{{ workInformation }}</span>
- </span>
+ <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span>
</div>
<gl-skeleton-loading
v-if="workInformationIsLoading"
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 6820bdca2fa..ce1039832d3 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -73,6 +73,11 @@ $item-weight-max-width: 48px;
.issue-token-state-icon-closed {
display: none;
}
+
+ .sortable-link {
+ color: $gray-900;
+ font-weight: normal;
+ }
}
.item-path-id .path-id-text,
@@ -249,6 +254,12 @@ $item-weight-max-width: 48px;
line-height: 0;
}
+@include media-breakpoint-down(xs) {
+ .btn-sm.dropdown-toggle-split {
+ max-width: 40px;
+ }
+}
+
@include media-breakpoint-up(sm) {
.item-info-area {
flex-basis: 100%;
@@ -296,10 +307,6 @@ $item-weight-max-width: 48px;
.item-meta {
.item-meta-child {
flex-basis: unset;
-
- ~ .item-assignees {
- margin-left: $gl-padding-4;
- }
}
}
@@ -353,7 +360,7 @@ $item-weight-max-width: 48px;
}
.item-title-wrapper {
- max-width: calc(100% - 440px);
+ max-width: calc(100% - 500px);
}
.item-info-area {
@@ -407,7 +414,7 @@ $item-weight-max-width: 48px;
}
}
-@media only screen and (min-width: 1400px) {
+@media only screen and (min-width: 1500px) {
.card-header,
.item-body {
.health-label-short {
@@ -419,7 +426,9 @@ $item-weight-max-width: 48px;
}
}
- .item-body .item-title-wrapper {
- max-width: calc(100% - 570px);
+ .item-body {
+ .item-title-wrapper {
+ max-width: calc(100% - 640px);
+ }
}
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 816dbc6931c..aaad640b7f0 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -533,6 +533,17 @@
margin: 0;
font-size: $gl-font-size-small;
}
+
+ ul.dropdown-menu {
+ margin-top: 4px;
+ margin-bottom: 24px;
+ padding: 8px 0;
+
+ li {
+ margin: 0;
+ padding: 0 1px;
+ }
+ }
}
}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 024c1781bf8..c5869880af9 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -3,6 +3,8 @@
@import './ide_mixins';
@import './ide_monaco_overrides';
+@import './themes/dark';
+
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
$ide-context-header-padding: 10px;
diff --git a/app/assets/stylesheets/page_bundles/themes/_dark.scss b/app/assets/stylesheets/page_bundles/themes/_dark.scss
new file mode 100644
index 00000000000..faadf31a87e
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/themes/_dark.scss
@@ -0,0 +1,296 @@
+.ide.theme-dark {
+ $border-color: #1d1f21;
+ $highlight-accent: #fff;
+ $text-color: #ccc;
+ $background: #333;
+ $background-hover: #2d2d2d;
+ $highlight-background: #252526;
+ $link-color: #428fdc;
+ $footer-background: #060606;
+
+ $input-border: #868686;
+ $input-background: transparent;
+ $input-color: $highlight-accent;
+
+ $btn-default-background: transparent;
+ $btn-default-border: #bfbfbf;
+ $btn-default-hover-border: #d8d8d8;
+
+ $btn-primary-background: #1068bf;
+ $btn-primary-border: #428fdc;
+ $btn-primary-hover-border: #63a6e9;
+
+ $btn-success-background: #217645;
+ $btn-success-border: #108548;
+ $btn-success-hover-border: #2da160;
+
+ $btn-disabled-border: rgba(223, 223, 223, 0.24);
+ $btn-disabled-color: rgba(145, 145, 145, 0.48);
+
+ a {
+ color: $link-color;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4:not(.modal-title),
+ h5,
+ h6,
+ .md,
+ .md p,
+ .ide-view,
+ .context-header > a,
+ .ide-sidebar-link,
+ .multi-file-tab-close,
+ .ide-tree-header button,
+ .ide-status-bar,
+ input,
+ textarea,
+ .md-area.is-focused,
+ .ide-entry-dropdown-toggle,
+ .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover {
+ color: $text-color;
+ }
+
+ .modal-body {
+ color: $gl-text-color;
+ }
+
+ .dropdown-menu-toggle svg,
+ .dropdown-menu-toggle svg:hover,
+ .ide-tree-header svg,
+ .file-row .file-row-icon svg,
+ .file-row:hover .file-row-icon svg {
+ fill: $text-color;
+ }
+
+ .multi-file-tab-close:hover {
+ background-color: $input-border;
+ }
+
+ .ide-review-sub-header:hover {
+ color: $input-border;
+ }
+
+ .text-secondary {
+ color: $text-color !important;
+ }
+
+ input[type='text']::placeholder,
+ textarea::placeholder {
+ color: $input-border;
+ }
+
+ .ide-staged-action-btn {
+ background-color: transparent;
+ }
+
+ .multi-file-commit-panel,
+ .multi-file-tabs,
+ .multi-file-tabs li,
+ .file-row:hover,
+ .file-row:focus,
+ .multi-file-commit-list-path:hover,
+ .multi-file-commit-list-path:focus,
+ .multi-file-commit-list-path.is-active,
+ .file-row.is-active,
+ .ide-commit-editor-header,
+ .ide-file-templates,
+ .ide-entry-dropdown-toggle,
+ .ide-staged-action-btn {
+ background-color: $background;
+ }
+
+ .ide-sidebar-link:hover {
+ background-color: $background-hover;
+ }
+
+ .common-note-form .md-area {
+ border-color: $input-border;
+ }
+
+ &,
+ .multi-file-commit-panel-inner-content,
+ .multi-file-commit-form,
+ .multi-file-tabs li.active,
+ .ide-sidebar-link.active,
+ .ide-sidebar-link.active::after,
+ .ide-right-sidebar .multi-file-commit-panel-inner,
+ .common-note-form .md-area,
+ .ide-commit-message-field {
+ background-color: $highlight-background;
+ }
+
+ .multi-file-commit-panel {
+ padding-right: 0;
+ }
+
+ .ide-mode-tabs,
+ .multi-file-commit-panel-inner,
+ .multi-file-commit-panel-inner-content,
+ .multi-file-commit-form,
+ .multi-file-edit-pane,
+ .ide-right-sidebar .ide-activity-bar,
+ .ide-sidebar-link.active,
+ .multi-file-tabs li.active,
+ .multi-file-tabs li,
+ .ide-status-bar,
+ .ide-commit-editor-header,
+ .ide-file-templates {
+ border-color: $border-color;
+ }
+
+ .multi-file-commit-form > .commit-form-compact,
+ .ide-tree-header,
+ .multi-file-commit-panel-header,
+ .multi-file-commit-form > form,
+ .multi-file-commit-form hr,
+ .ide-commit-list-container.is-first,
+ .multi-file-commit-form .nav-links:not(.quick-links) {
+ border-color: $background;
+ }
+
+ .multi-file-tabs li.active {
+ border-bottom-color: $highlight-background;
+ }
+
+ .multi-file-tabs,
+ .ide-commit-editor-header {
+ box-shadow: inset 0 -1px $border-color;
+ }
+
+ .ide-sidebar-link.active {
+ color: $highlight-accent;
+ box-shadow: inset 3px 0 $highlight-accent;
+
+ &.is-right {
+ box-shadow: inset -3px 0 $highlight-accent;
+ }
+ }
+
+ .nav-links li.active a {
+ border-color: $highlight-accent;
+ color: $text-color;
+ }
+
+ .avatar-container {
+ &,
+ .avatar {
+ color: $text-color;
+ background-color: $highlight-background;
+ border-color: $highlight-background;
+ }
+ }
+
+ .ide-status-bar {
+ background-color: $footer-background;
+ }
+
+ input[type='text'] {
+ border-color: $input-border;
+ background: $input-background;
+ }
+
+ input[type='text'],
+ textarea {
+ color: $input-color !important;
+ }
+
+ .ide-entry-dropdown-toggle:hover {
+ background: $gray-800;
+ }
+
+ .btn:hover {
+ border-width: 2px;
+ padding: 5px 9px;
+ }
+
+ .btn.btn-sm:hover {
+ padding: 3px 9px;
+ }
+
+ .btn.btn-block:hover {
+ padding: 5px 0;
+ }
+
+ .btn-inverted,
+ .btn-default,
+ .dropdown,
+ .dropdown-menu-toggle {
+ background-color: $input-background !important;
+ color: $input-color !important;
+ border-color: $btn-default-border;
+ }
+
+ .btn-inverted,
+ .btn-default {
+ &:hover,
+ &:focus {
+ border-color: $btn-default-hover-border !important;
+ }
+ }
+
+ .dropdown,
+ .dropdown-menu-toggle {
+ &:hover,
+ &:focus {
+ background-color: $gray-900 !important;
+ border-color: $gray-200 !important;
+ }
+ }
+
+ .btn-primary {
+ background-color: $btn-primary-background;
+ border-color: $btn-primary-border !important;
+
+ &:hover,
+ &:focus {
+ border-color: $btn-primary-hover-border !important;
+ }
+ }
+
+ .btn-success {
+ background-color: $btn-success-background;
+ border-color: $btn-success-border !important;
+
+ &:hover,
+ &:focus {
+ border-color: $btn-success-hover-border !important;
+ }
+ }
+
+ .btn[disabled] {
+ background: $btn-default-background !important;
+ border: 1px solid $btn-disabled-border !important;
+ color: $btn-disabled-color !important;
+ }
+
+ .md-previewer,
+ .ide-empty-state {
+ background-color: $border-color;
+ }
+
+ .ide-tree-header svg:focus,
+ .ide-tree-header svg:hover {
+ color: $blue-600;
+ }
+
+ .animation-container {
+ [class^='skeleton-line-'] {
+ background-color: $gray-800;
+
+ &::after {
+ background-image: linear-gradient(to right,
+ $gray-800 0%,
+ $gray-700 20%,
+ $gray-800 40%,
+ $gray-800 100%);
+ }
+ }
+ }
+}
+
+.navbar.theme-dark {
+ border-bottom-color: transparent;
+}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index b1d79a41ba7..0292919ea50 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -105,10 +105,6 @@
}
}
- .js-ca-dropdown {
- top: $gl-padding-top;
- }
-
.stage-panel-body {
display: flex;
flex-wrap: wrap;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 8b51ba7ae62..c60e3c6b2b1 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -588,7 +588,8 @@ $note-form-margin-left: 72px;
a {
color: inherit;
- &:hover {
+ &:hover,
+ &.hover {
color: $blue-600;
}
@@ -605,6 +606,21 @@ $note-form-margin-left: 72px;
.author-link {
color: $gl-text-color;
}
+
+ // Prevent flickering of link when hovering between `author-name-link` and `.author-username-link`
+ .author-name-link + .author-username .author-username-link {
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ right: 100%;
+ width: 0.25rem;
+ height: 100%;
+ top: 0;
+ bottom: 0;
+ }
+ }
}
.discussion-header {
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index af0afa9cc3b..f61245bed24 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -64,6 +64,12 @@
padding: $gl-padding-8 $gl-padding-12;
}
}
+
+ .show-last-dropdown {
+ // same as in .dropdown-menu-toggle
+ // see app/assets/stylesheets/framework/dropdowns.scss
+ width: 160px;
+ }
}
.prometheus-panel {
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 2a811e08fd3..3fcf9a74cb2 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -85,8 +85,14 @@
.gl-bg-blue-50 { @include gl-bg-blue-50; }
.gl-bg-red-100 { @include gl-bg-red-100; }
.gl-bg-orange-100 { @include gl-bg-orange-100; }
+.gl-bg-gray-50 { @include gl-bg-gray-50; }
.gl-bg-gray-100 { @include gl-bg-gray-100; }
.gl-bg-green-100 { @include gl-bg-green-100;}
+.gl-bg-blue-500 { @include gl-bg-blue-500; }
+.gl-bg-green-500 { @include gl-bg-green-500; }
+.gl-bg-theme-indigo-500 { @include gl-bg-theme-indigo-500; }
+.gl-bg-red-500 { @include gl-bg-red-500; }
+.gl-bg-orange-500 { @include gl-bg-orange-500; }
.gl-text-blue-500 { @include gl-text-blue-500; }
.gl-text-gray-500 { @include gl-text-gray-500; }
@@ -102,8 +108,14 @@
.gl-text-green-700 { @include gl-text-green-700; }
.gl-align-items-center { @include gl-align-items-center; }
+
.d-sm-table-column {
@include media-breakpoint-up(sm) {
display: table-column !important;
}
}
+
+.gl-white-space-normal { @include gl-white-space-normal; }
+.gl-word-break-all { @include gl-word-break-all; }
+.gl-line-height-inherit { line-height: inherit; }
+.gl-text-align-inherit { text-align: inherit; }
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 9eaa55039c8..4639d8adfe0 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -61,7 +61,15 @@ class Admin::RunnersController < Admin::ApplicationController
end
def runner_params
- params.require(:runner).permit(Ci::Runner::FORM_EDITABLE)
+ params.require(:runner).permit(permitted_attrs)
+ end
+
+ def permitted_attrs
+ if Gitlab.com?
+ Ci::Runner::FORM_EDITABLE + Ci::Runner::MINUTES_COST_FACTOR_FIELDS
+ else
+ Ci::Runner::FORM_EDITABLE
+ end
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index b2496427924..26ef6117e1c 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -150,7 +150,7 @@ class ApplicationController < ActionController::Base
payload[:username] = logged_user.try(:username)
end
- payload[:queue_duration] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
+ payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
end
##
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 825181568ad..d486d734db8 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication
end
def check_two_factor_requirement
- if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor?
+ if two_factor_authentication_required? && current_user_requires_two_factor?
redirect_to profile_two_factor_auth_path
end
end
@@ -27,6 +27,10 @@ module EnforcesTwoFactorAuthentication
current_user.try(:ultraauth_user?)
end
+ def current_user_requires_two_factor?
+ current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor?
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def two_factor_authentication_reason(global: -> {}, group: -> {})
if two_factor_authentication_required?
@@ -61,3 +65,5 @@ module EnforcesTwoFactorAuthentication
session[:skip_two_factor] && session[:skip_two_factor] > Time.current
end
end
+
+EnforcesTwoFactorAuthentication.prepend_if_ee('EE::EnforcesTwoFactorAuthentication')
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index 4c998055a5d..ff283f9bb62 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -15,9 +15,7 @@ module IntegrationsActions
end
def update
- integration.attributes = service_params[:service]
-
- saved = integration.save(context: :manual_change)
+ saved = integration.update(service_params[:service])
respond_to do |format|
format.html do
diff --git a/app/controllers/concerns/members_presentation.rb b/app/controllers/concerns/members_presentation.rb
index 0a9d3d86245..ceccef8113f 100644
--- a/app/controllers/concerns/members_presentation.rb
+++ b/app/controllers/concerns/members_presentation.rb
@@ -5,6 +5,7 @@ module MembersPresentation
def present_members(members)
preload_associations(members)
+
Gitlab::View::Presenter::Factory.new(
members,
current_user: current_user,
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 3ccf227c431..e2c83f9a069 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -19,6 +19,7 @@ module ServiceParams
:color,
:colorize_messages,
:comment_on_event_enabled,
+ :comment_detail,
:confidential_issues_events,
:default_irc_uri,
:description,
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index 46ba270f328..50c93441dd4 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -82,6 +82,6 @@ module SpammableActions
return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors
return false unless Gitlab::Recaptcha.enabled?
- spammable.spam
+ spammable.needs_recaptcha?
end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 039991e07a2..25c48fadf49 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -61,31 +61,35 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def load_projects(finder_params)
@total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
@total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
finder_params[:use_cte] = true if use_cte_for_finder?
- projects = ProjectsFinder
- .new(params: finder_params, current_user: current_user)
- .execute
- .includes(:route, :creator, :group, namespace: [:route, :owner])
- .preload(:project_feature)
- .page(finder_params[:page])
+ projects = ProjectsFinder.new(params: finder_params, current_user: current_user).execute
+
+ projects = preload_associations(projects)
+ projects = projects.page(finder_params[:page])
prepare_projects_for_rendering(projects)
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def preload_associations(projects)
+ projects.includes(:route, :creator, :group, namespace: [:route, :owner]).preload(:project_feature)
+ end
# rubocop: enable CodeReuse/ActiveRecord
def use_cte_for_finder?
# The starred action loads public projects, which causes the CTE to be less efficient
- action_name == 'index' && Feature.enabled?(:use_cte_for_projects_finder, default_enabled: true)
+ action_name == 'index'
end
def load_events
- projects = load_projects(params.merge(non_public: true))
+ projects = ProjectsFinder
+ .new(params: params.merge(non_public: true), current_user: current_user)
+ .execute
@events = EventCollection
.new(projects, offset: params[:offset].to_i, filter: event_filter)
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index a8a76b47bbe..705a586d614 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -66,18 +66,21 @@ class Explore::ProjectsController < Explore::ApplicationController
@total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
end
- # rubocop: disable CodeReuse/ActiveRecord
def load_projects
load_project_counts
- projects = ProjectsFinder.new(current_user: current_user, params: params)
- .execute
- .includes(:route, :creator, :group, namespace: [:route, :owner])
- .page(params[:page])
- .without_count
+ projects = ProjectsFinder.new(current_user: current_user, params: params).execute
+
+ projects = preload_associations(projects)
+ projects = projects.page(params[:page]).without_count
prepare_projects_for_rendering(projects)
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def preload_associations(projects)
+ projects.includes(:route, :creator, :group, namespace: [:route, :owner])
+ end
# rubocop: enable CodeReuse/ActiveRecord
def set_sorting
@@ -110,3 +113,5 @@ class Explore::ProjectsController < Explore::ApplicationController
end
end
end
+
+Explore::ProjectsController.prepend_if_ee('EE::Explore::ProjectsController')
diff --git a/app/controllers/groups/deploy_tokens_controller.rb b/app/controllers/groups/deploy_tokens_controller.rb
index a765922fc54..6bb075fd115 100644
--- a/app/controllers/groups/deploy_tokens_controller.rb
+++ b/app/controllers/groups/deploy_tokens_controller.rb
@@ -7,6 +7,6 @@ class Groups::DeployTokensController < Groups::ApplicationController
@token = @group.deploy_tokens.find(params[:id])
@token.revoke!
- redirect_to group_settings_ci_cd_path(@group, anchor: 'js-deploy-tokens')
+ redirect_to group_settings_repository_path(@group, anchor: 'js-deploy-tokens')
end
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 664c58e8b7a..63311ab983b 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -21,19 +21,26 @@ class Groups::GroupMembersController < Groups::ApplicationController
def index
@sort = params[:sort].presence || sort_value_name
+
@project = @group.projects.find(params[:project_id]) if params[:project_id]
- @members = find_members
+
+ @members = GroupMembersFinder
+ .new(@group, current_user, params: filter_params)
+ .execute(include_relations: requested_relations)
if can_manage_members
@skip_groups = @group.related_group_ids
- @invited_members = present_invited_members(@members)
+
+ @invited_members = @members.invite
+ @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present?
+ @invited_members = present_invited_members(@invited_members)
end
- @members = @members.non_invite
- @members = present_group_members(@members)
+ @members = present_group_members(@members.non_invite)
@requesters = present_members(
- AccessRequestsFinder.new(@group).execute(current_user))
+ AccessRequestsFinder.new(@group).execute(current_user)
+ )
@group_member = @group.group_members.new
end
@@ -43,30 +50,24 @@ class Groups::GroupMembersController < Groups::ApplicationController
private
- def present_invited_members(members)
- invited_members = members.invite
-
- if params[:search_invited].present?
- invited_members = invited_members.search_invite_email(params[:search_invited])
- end
-
- present_members(invited_members
- .page(params[:invited_members_page])
- .per(MEMBER_PER_PAGE_LIMIT))
+ def can_manage_members
+ can?(current_user, :admin_group_member, @group)
end
- def find_members
- filter_params = params.slice(:two_factor, :search).merge(sort: @sort)
- GroupMembersFinder.new(@group, current_user, params: filter_params).execute(include_relations: requested_relations)
+ def present_invited_members(invited_members)
+ present_members(invited_members
+ .page(params[:invited_members_page])
+ .per(MEMBER_PER_PAGE_LIMIT))
end
- def can_manage_members
- can?(current_user, :admin_group_member, @group)
+ def present_group_members(members)
+ present_members(members
+ .page(params[:page])
+ .per(MEMBER_PER_PAGE_LIMIT))
end
- def present_group_members(original_members)
- members = original_members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT)
- present_members(members)
+ def filter_params
+ params.permit(:two_factor, :search).merge(sort: @sort)
end
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index a478e9fffb8..8cfbd293597 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -5,6 +5,9 @@ class Groups::MilestonesController < Groups::ApplicationController
before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy]
+ before_action do
+ push_frontend_feature_flag(:burnup_charts)
+ end
def index
respond_to do |format|
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 6b842fc9fe1..18f336eae78 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -7,10 +7,9 @@ module Groups
before_action :authorize_admin_group!
before_action :authorize_update_max_artifacts_size!, only: [:update]
before_action do
- push_frontend_feature_flag(:new_variables_ui, @group)
- push_frontend_feature_flag(:ajax_new_deploy_token, @group)
+ push_frontend_feature_flag(:new_variables_ui, @group, default_enabled: true)
end
- before_action :define_variables, only: [:show, :create_deploy_token]
+ before_action :define_variables, only: [:show]
def show
end
@@ -42,38 +41,10 @@ module Groups
redirect_to group_settings_ci_cd_path
end
- def create_deploy_token
- result = Projects::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute
- @new_deploy_token = result[:deploy_token]
-
- if result[:status] == :success
- respond_to do |format|
- format.json do
- # IMPORTANT: It's a security risk to expose the token value more than just once here!
- json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json
- render json: json, status: result[:http_status]
- end
- format.html do
- flash.now[:notice] = s_('DeployTokens|Your new group deploy token has been created.')
- render :show
- end
- end
- else
- respond_to do |format|
- format.json { render json: { message: result[:message] }, status: result[:http_status] }
- format.html do
- flash.now[:alert] = result[:message]
- render :show
- end
- end
- end
- end
-
private
def define_variables
define_ci_variables
- define_deploy_token_variables
end
def define_ci_variables
@@ -83,12 +54,6 @@ module Groups
.map { |variable| variable.present(current_user: current_user) }
end
- def define_deploy_token_variables
- @deploy_tokens = @group.deploy_tokens.active
-
- @new_deploy_token = DeployToken.new
- end
-
def authorize_admin_group!
return render_404 unless can?(current_user, :admin_group, group)
end
@@ -112,10 +77,6 @@ module Groups
def update_group_params
params.require(:group).permit(:max_artifacts_size)
end
-
- def deploy_token_params
- params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username)
- end
end
end
end
diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb
new file mode 100644
index 00000000000..6e8c5628d24
--- /dev/null
+++ b/app/controllers/groups/settings/repository_controller.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Groups
+ module Settings
+ class RepositoryController < Groups::ApplicationController
+ skip_cross_project_access_check :show
+ before_action :authorize_admin_group!
+ before_action :define_deploy_token_variables
+ before_action do
+ push_frontend_feature_flag(:ajax_new_deploy_token, @group)
+ end
+
+ def create_deploy_token
+ result = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute
+ @new_deploy_token = result[:deploy_token]
+
+ if result[:status] == :success
+ respond_to do |format|
+ format.json do
+ # IMPORTANT: It's a security risk to expose the token value more than just once here!
+ json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json
+ render json: json, status: result[:http_status]
+ end
+ format.html do
+ flash.now[:notice] = s_('DeployTokens|Your new group deploy token has been created.')
+ render :show
+ end
+ end
+ else
+ respond_to do |format|
+ format.json { render json: { message: result[:message] }, status: result[:http_status] }
+ format.html do
+ flash.now[:alert] = result[:message]
+ render :show
+ end
+ end
+ end
+ end
+
+ private
+
+ def define_deploy_token_variables
+ @deploy_tokens = @group.deploy_tokens.active
+
+ @new_deploy_token = DeployToken.new
+ end
+
+ def deploy_token_params
+ params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb
new file mode 100644
index 00000000000..46db87dba94
--- /dev/null
+++ b/app/controllers/projects/alert_management_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Projects::AlertManagementController < Projects::ApplicationController
+ def index
+ respond_to do |format|
+ format.html
+ end
+ end
+end
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 248b75d16ed..ebc81976529 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -13,16 +13,13 @@ class Projects::ForksController < Projects::ApplicationController
before_action :authorize_fork_project!, only: [:new, :create]
before_action :authorize_fork_namespace!, only: [:create]
- # rubocop: disable CodeReuse/ActiveRecord
def index
@total_forks_count = project.forks.size
@public_forks_count = project.forks.public_only.size
@private_forks_count = @total_forks_count - project.forks.public_and_internal_only.size
@internal_forks_count = @total_forks_count - @public_forks_count - @private_forks_count
- @forks = ForkProjectsFinder.new(project, params: params.merge(search: params[:filter_projects]), current_user: current_user).execute
- @forks = @forks.includes(:route, :creator, :group, namespace: [:route, :owner])
- .page(params[:page])
+ @forks = load_forks.page(params[:page])
prepare_projects_for_rendering(@forks)
@@ -36,7 +33,6 @@ class Projects::ForksController < Projects::ApplicationController
end
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def new
@namespaces = fork_service.valid_fork_targets - [project.namespace]
@@ -59,10 +55,19 @@ class Projects::ForksController < Projects::ApplicationController
redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
end
end
- # rubocop: enable CodeReuse/ActiveRecord
private
+ def load_forks
+ forks = ForkProjectsFinder.new(
+ project,
+ params: params.merge(search: params[:filter_projects]),
+ current_user: current_user
+ ).execute
+
+ forks.includes(:route, :creator, :group, namespace: [:route, :owner])
+ end
+
def fork_service
strong_memoize(:fork_service) do
::Projects::ForkService.new(project, current_user, namespace: fork_namespace)
@@ -83,3 +88,5 @@ class Projects::ForksController < Projects::ApplicationController
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335')
end
end
+
+Projects::ForksController.prepend_if_ee('EE::Projects::ForksController')
diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb
index 26d9b4b223f..711e23dc3ce 100644
--- a/app/controllers/projects/import/jira_controller.rb
+++ b/app/controllers/projects/import/jira_controller.rb
@@ -11,11 +11,10 @@ module Projects
before_action :authorize_admin_project!, only: [:import]
def show
- @is_jira_configured = @project.jira_service.present?
- return if Feature.enabled?(:jira_issue_import_vue, @project)
+ jira_service = @project.jira_service
- if !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project)
- jira_client = @project.jira_service.client
+ if jira_service.present? && !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project)
+ jira_client = jira_service.client
jira_projects = jira_client.Project.all
if jira_projects.present?
@@ -25,7 +24,9 @@ module Projects
end
end
- flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial?
+ unless Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
+ flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial?
+ end
end
def import
@@ -35,7 +36,7 @@ module Projects
response = ::JiraImport::StartImportService.new(current_user, @project, jira_project_key).execute
flash[:notice] = response.message if response.message.present?
else
- flash[:alert] = 'No jira project key has been provided.'
+ flash[:alert] = 'No Jira project key has been provided.'
end
redirect_to project_import_jira_path(@project)
@@ -50,7 +51,7 @@ module Projects
end
def jira_integration_configured?
- return if Feature.enabled?(:jira_issue_import_vue, @project)
+ return if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
return if @project.jira_service
flash[:notice] = _("Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page." %
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 51ad8edb012..3aae8990f07 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController
include RecordUserLastActivity
def issue_except_actions
- %i[index calendar new create bulk_update import_csv]
+ %i[index calendar new create bulk_update import_csv export_csv]
end
def set_issuables_index_only_actions
@@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
- prepend_before_action :authenticate_user!, only: [:new]
+ prepend_before_action :authenticate_user!, only: [:new, :export_csv]
# designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this
prepend_before_action :store_uri, only: [:new, :show, :designs]
@@ -189,6 +189,13 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
+ def export_csv
+ ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
+
+ index_path = project_issues_path(project)
+ redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.")
+ end
+
def import_csv
if uploader = UploadService.new(project, params[:file]).execute
ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 89de40006ff..b2bb49993d1 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -14,7 +14,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
skip_before_action :merge_request, only: [:index, :bulk_update]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
- before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts, :coverage_reports]
+ before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts, :coverage_reports, :terraform_reports]
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
@@ -25,6 +25,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
push_frontend_feature_flag(:code_navigation, @project)
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
+ push_frontend_feature_flag(:merge_ref_head_comments, @project)
end
before_action do
@@ -142,6 +143,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
end
+ def terraform_reports
+ reports_response(@merge_request.find_terraform_reports)
+ end
+
def exposed_artifacts
if @merge_request.has_exposed_artifacts?
reports_response(@merge_request.find_exposed_artifacts)
@@ -339,11 +344,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def serialize_widget(merge_request)
- serializer.represent(merge_request, serializer: 'widget')
+ cached_data = serializer.represent(merge_request, serializer: 'poll_cached_widget')
+ widget_data = serializer.represent(merge_request, serializer: 'poll_widget')
+ cached_data.merge!(widget_data)
end
def serializer
- MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
+ @serializer ||= MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
end
def define_edit_vars
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index d301a5be391..56f1f1a1019 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -6,6 +6,9 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :check_issuables_available!
before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote]
+ before_action do
+ push_frontend_feature_flag(:burnup_charts)
+ end
# Allow read any milestone
before_action :authorize_read_milestone!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 726ce8974c7..bb0381ba19d 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:junit_pipeline_view)
+ push_frontend_feature_flag(:filter_pipelines_search)
end
before_action :ensure_pipeline, only: [:show]
@@ -169,19 +170,9 @@ class Projects::PipelinesController < Projects::ApplicationController
end
format.json do
- if pipeline_test_report == :error
- render json: { status: :error_parsing_report }
- else
- test_reports = if params[:scope] == "with_attachment"
- pipeline_test_report.with_attachment!
- else
- pipeline_test_report
- end
-
- render json: TestReportSerializer
- .new(current_user: @current_user)
- .represent(test_reports, project: project)
- end
+ render json: TestReportSerializer
+ .new(current_user: @current_user)
+ .represent(pipeline_test_report, project: project)
end
end
end
@@ -189,11 +180,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def test_reports_count
return unless Feature.enabled?(:junit_pipeline_view, project)
- begin
- render json: { total_count: pipeline.test_reports_count }.to_json
- rescue Gitlab::Ci::Parsers::ParserError
- render json: { total_count: 0 }.to_json
- end
+ render json: { total_count: pipeline.test_reports_count }.to_json
end
private
@@ -269,9 +256,9 @@ class Projects::PipelinesController < Projects::ApplicationController
def pipeline_test_report
strong_memoize(:pipeline_test_report) do
- @pipeline.test_reports
- rescue Gitlab::Ci::Parsers::ParserError
- :error
+ @pipeline.test_reports.tap do |reports|
+ reports.with_attachment! if params[:scope] == 'with_attachment'
+ end
end
end
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 109c8b7005f..3e52248f292 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -17,8 +17,9 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@group_links = @project.project_group_links
@group_links = @group_links.search(params[:search]) if params[:search].present?
- @project_members = MembersFinder.new(@project, current_user)
- .execute(include_relations: requested_relations, params: params.merge(sort: @sort))
+ @project_members = MembersFinder
+ .new(@project, current_user, params: filter_params)
+ .execute(include_relations: requested_relations)
@project_members = present_members(@project_members.page(params[:page]))
@@ -43,12 +44,17 @@ class Projects::ProjectMembersController < Projects::ApplicationController
return render_404
end
- redirect_to(project_project_members_path(project),
- notice: notice)
+ redirect_to(project_project_members_path(project), notice: notice)
end
# MembershipActions concern
alias_method :membershipable, :project
+
+ private
+
+ def filter_params
+ params.permit(:search).merge(sort: @sort)
+ end
end
Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController')
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index a0f98d8f1d2..c7cd9649dac 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -6,8 +6,9 @@ module Projects
before_action :authorize_admin_pipeline!
before_action :define_variables
before_action do
- push_frontend_feature_flag(:new_variables_ui, @project)
+ push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true)
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
+ push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true)
end
def show
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 045aa38230c..bb20ea1de49 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -36,6 +36,10 @@ class ProjectsController < Projects::ApplicationController
layout :determine_layout
+ before_action do
+ push_frontend_feature_flag(:metrics_dashboard_visibility_switching_available)
+ end
+
def index
redirect_to(current_user ? root_path : explore_root_path)
end
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index 9e134ba9526..118036de230 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -23,7 +23,7 @@ module Repositories
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
- enqueue_fetch_statistics_update
+ update_fetch_statistics
render_ok
end
@@ -76,12 +76,16 @@ module Repositories
render plain: exception.message, status: :service_unavailable
end
- def enqueue_fetch_statistics_update
+ def update_fetch_statistics
+ return unless project
return if Gitlab::Database.read_only?
return unless repo_type.project?
- return unless project&.daily_statistics_enabled?
- ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker
+ if Feature.enabled?(:project_statistics_sync, project, default_enabled: true)
+ Projects::FetchStatisticsIncrementService.new(project).execute
+ else
+ ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker
+ end
end
def access
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 06374736dcf..5ee97885071 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -128,6 +128,10 @@ class UsersController < ApplicationController
@user ||= find_routable!(User, params[:username])
end
+ def personal_projects
+ PersonalProjectsFinder.new(user).execute(current_user)
+ end
+
def contributed_projects
ContributedProjectsFinder.new(user).execute(current_user)
end
@@ -147,8 +151,7 @@ class UsersController < ApplicationController
end
def load_projects
- @projects =
- PersonalProjectsFinder.new(user).execute(current_user)
+ @projects = personal_projects
.page(params[:page])
.per(params[:limit])
diff --git a/app/finders/autocomplete/move_to_project_finder.rb b/app/finders/autocomplete/move_to_project_finder.rb
index af6defc1fc6..f1c1eacafe6 100644
--- a/app/finders/autocomplete/move_to_project_finder.rb
+++ b/app/finders/autocomplete/move_to_project_finder.rb
@@ -28,7 +28,8 @@ module Autocomplete
.optionally_search(search, include_namespace: true)
.excluding_project(project_id)
.eager_load_namespace_and_owner
- .sorted_by_name_asc_limited(LIMIT)
+ .sorted_by_stars_desc
+ .limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index a56d4ebb368..949af103eb3 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -9,7 +9,6 @@ class GroupMembersFinder < UnionFinder
# search: string
# created_after: datetime
# created_before: datetime
-
attr_reader :params
def initialize(group, user = nil, params: {})
@@ -22,7 +21,6 @@ class GroupMembersFinder < UnionFinder
def execute(include_relations: [:inherited, :direct])
group_members = group.members
relations = []
- @params = params
return group_members if include_relations == [:direct]
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index 0617f34dc8c..e08ed737ca6 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -4,17 +4,19 @@ class MembersFinder
# Params can be any of the following:
# sort: string
# search: string
+ attr_reader :params
- def initialize(project, current_user)
+ def initialize(project, current_user, params: {})
@project = project
- @current_user = current_user
@group = project.group
+ @current_user = current_user
+ @params = params
end
- def execute(include_relations: [:inherited, :direct], params: {})
- members = find_members(include_relations, params)
+ def execute(include_relations: [:inherited, :direct])
+ members = find_members(include_relations)
- filter_members(members, params)
+ filter_members(members)
end
def can?(*args)
@@ -25,7 +27,7 @@ class MembersFinder
attr_reader :project, :current_user, :group
- def find_members(include_relations, params)
+ def find_members(include_relations)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
@@ -39,7 +41,7 @@ class MembersFinder
distinct_union_of_members(union_members)
end
- def filter_members(members, params)
+ def filter_members(members)
members = members.search(params[:search]) if params[:search].present?
members = members.sort_by_attribute(params[:sort]) if params[:sort].present?
members
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
new file mode 100644
index 00000000000..f8d62ba86af
--- /dev/null
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BoardListsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::BoardListType, null: true
+
+ alias_method :board, :object
+
+ def resolve(lookahead: nil)
+ authorize!(board)
+
+ lists = board_lists
+
+ if load_preferences?(lookahead)
+ List.preload_preferences_for_user(lists, context[:current_user])
+ end
+
+ Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists)
+ end
+
+ private
+
+ def board_lists
+ service = Boards::Lists::ListService.new(board.resource_parent, context[:current_user])
+ service.execute(board, create_default_lists: false)
+ end
+
+ def authorized_resource?(board)
+ Ability.allowed?(context[:current_user], :read_list, board)
+ end
+
+ def load_preferences?(lookahead)
+ lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
new file mode 100644
index 00000000000..068323a3073
--- /dev/null
+++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Metrics
+ module Dashboards
+ class AnnotationResolver < Resolvers::BaseResolver
+ argument :from, Types::TimeType,
+ required: true,
+ description: "Timestamp marking date and time from which annotations need to be fetched"
+
+ argument :to, Types::TimeType,
+ required: false,
+ description: "Timestamp marking date and time to which annotations need to be fetched"
+
+ type Types::Metrics::Dashboards::AnnotationType, null: true
+
+ alias_method :dashboard, :object
+
+ def resolve(**args)
+ return [] unless dashboard
+ return [] unless Feature.enabled?(:metrics_dashboard_annotations, dashboard.environment&.project)
+
+ ::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
new file mode 100644
index 00000000000..e94ff898807
--- /dev/null
+++ b/app/graphql/types/board_list_type.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class BoardListType < BaseObject
+ graphql_name 'BoardList'
+ description 'Represents a list for an issue board'
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID (global ID) of the list'
+ field :title, GraphQL::STRING_TYPE, null: false,
+ description: 'Title of the list'
+ field :list_type, GraphQL::STRING_TYPE, null: false,
+ description: 'Type of the list'
+ field :position, GraphQL::INT_TYPE, null: true,
+ description: 'Position of list within the board'
+ field :label, Types::LabelType, null: true,
+ description: 'Label of the list'
+ field :collapsed, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if list is collapsed for this user',
+ resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) }
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
+
+Types::BoardListType.prepend_if_ee('::EE::Types::BoardListType')
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index 9c95a987fe4..c0be782ed1e 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -11,6 +11,13 @@ module Types
description: 'ID (global ID) of the board'
field :name, type: GraphQL::STRING_TYPE, null: true,
description: 'Name of the board'
+
+ field :lists,
+ Types::BoardListType.connection_type,
+ null: true,
+ description: 'Lists of the project board',
+ resolver: Resolvers::BoardListsResolver,
+ extras: [:lookahead]
end
end
diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb
index 11e834013ca..e7d09866bb5 100644
--- a/app/graphql/types/metrics/dashboard_type.rb
+++ b/app/graphql/types/metrics/dashboard_type.rb
@@ -9,6 +9,11 @@ module Types
field :path, GraphQL::STRING_TYPE, null: true,
description: 'Path to a file with the dashboard definition'
+
+ field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true,
+ description: 'Annotations added to the dashboard. Will always return `null` ' \
+ 'if `metrics_dashboard_annotations` feature flag is disabled',
+ resolver: Resolvers::Metrics::Dashboards::AnnotationResolver
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb
new file mode 100644
index 00000000000..055d2544eff
--- /dev/null
+++ b/app/graphql/types/metrics/dashboards/annotation_type.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Types
+ module Metrics
+ module Dashboards
+ class AnnotationType < ::Types::BaseObject
+ authorize :read_metrics_dashboard_annotation
+ graphql_name 'MetricsDashboardAnnotation'
+
+ field :description, GraphQL::STRING_TYPE, null: true,
+ description: 'Description of the annotation'
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the annotation'
+
+ field :panel_id, GraphQL::STRING_TYPE, null: true,
+ description: 'ID of a dashboard panel to which the annotation should be scoped'
+
+ field :starting_at, GraphQL::STRING_TYPE, null: true,
+ description: 'Timestamp marking start of annotated time span'
+
+ field :ending_at, GraphQL::STRING_TYPE, null: true,
+ description: 'Timestamp marking end of annotated time span'
+
+ def panel_id
+ object.panel_xid
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 3115a53e053..8356e763be9 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -26,7 +26,7 @@ module Types
markdown_field :description_html, null: true
field :tag_list, GraphQL::STRING_TYPE, null: true,
- description: 'List of project tags'
+ description: 'List of project topics (not Git tags)'
field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true,
description: 'URL to connect to the project via SSH'
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index e530641d6ae..5cee0c2cf8f 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -10,6 +10,8 @@ module Types
expose_permissions Types::PermissionTypes::User
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the user'
field :name, GraphQL::STRING_TYPE, null: false,
description: 'Human-readable name of the user'
field :username, GraphQL::STRING_TYPE, null: false,
diff --git a/app/helpers/analytics/navbar_helper.rb b/app/helpers/analytics/navbar_helper.rb
new file mode 100644
index 00000000000..ddf2655c887
--- /dev/null
+++ b/app/helpers/analytics/navbar_helper.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Analytics
+ module NavbarHelper
+ class NavbarSubItem
+ attr_reader :title, :path, :link, :link_to_options
+
+ def initialize(title:, path:, link:, link_to_options: {})
+ @title = title
+ @path = path
+ @link = link
+ @link_to_options = link_to_options.merge(title: title)
+ end
+ end
+
+ def project_analytics_navbar_links(project, current_user)
+ [
+ cycle_analytics_navbar_link(project, current_user),
+ repository_analytics_navbar_link(project, current_user),
+ ci_cd_analytics_navbar_link(project, current_user)
+ ].compact
+ end
+
+ def group_analytics_navbar_links(group, current_user)
+ []
+ end
+
+ private
+
+ def navbar_sub_item(args)
+ NavbarSubItem.new(args)
+ end
+
+ def cycle_analytics_navbar_link(project, current_user)
+ return unless project_nav_tab?(:cycle_analytics)
+
+ navbar_sub_item(
+ title: _('Value Stream'),
+ path: 'cycle_analytics#show',
+ link: project_cycle_analytics_path(project),
+ link_to_options: { class: 'shortcuts-project-cycle-analytics' }
+ )
+ end
+
+ def repository_analytics_navbar_link(project, current_user)
+ return if project.empty_repo?
+
+ navbar_sub_item(
+ title: _('Repository'),
+ path: 'graphs#charts',
+ link: charts_project_graph_path(project, current_ref),
+ link_to_options: { class: 'shortcuts-repository-charts' }
+ )
+ end
+
+ def ci_cd_analytics_navbar_link(project, current_user)
+ return unless project_nav_tab?(:pipelines)
+ return unless project.feature_available?(:builds, current_user) || !project.empty_repo?
+
+ navbar_sub_item(
+ title: _('CI / CD'),
+ path: 'pipelines#charts',
+ link: charts_project_pipelines_path(project)
+ )
+ end
+ end
+end
+
+Analytics::NavbarHelper.prepend_if_ee('EE::Analytics::NavbarHelper')
diff --git a/app/helpers/analytics_navbar_helper.rb b/app/helpers/analytics_navbar_helper.rb
deleted file mode 100644
index f94119c4eef..00000000000
--- a/app/helpers/analytics_navbar_helper.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-# frozen_string_literal: true
-
-module AnalyticsNavbarHelper
- class NavbarSubItem
- attr_reader :title, :path, :link, :link_to_options
-
- def initialize(title:, path:, link:, link_to_options: {})
- @title = title
- @path = path
- @link = link
- @link_to_options = link_to_options.merge(title: title)
- end
- end
-
- def project_analytics_navbar_links(project, current_user)
- [
- cycle_analytics_navbar_link(project, current_user),
- repository_analytics_navbar_link(project, current_user),
- ci_cd_analytics_navbar_link(project, current_user)
- ].compact
- end
-
- def group_analytics_navbar_links(group, current_user)
- []
- end
-
- private
-
- def navbar_sub_item(args)
- NavbarSubItem.new(args)
- end
-
- def cycle_analytics_navbar_link(project, current_user)
- return unless project_nav_tab?(:cycle_analytics)
-
- navbar_sub_item(
- title: _('Value Stream'),
- path: 'cycle_analytics#show',
- link: project_cycle_analytics_path(project),
- link_to_options: { class: 'shortcuts-project-cycle-analytics' }
- )
- end
-
- def repository_analytics_navbar_link(project, current_user)
- return if project.empty_repo?
-
- navbar_sub_item(
- title: _('Repository'),
- path: 'graphs#charts',
- link: charts_project_graph_path(project, current_ref),
- link_to_options: { class: 'shortcuts-repository-charts' }
- )
- end
-
- def ci_cd_analytics_navbar_link(project, current_user)
- return unless project_nav_tab?(:pipelines)
- return unless project.feature_available?(:builds, current_user) || !project.empty_repo?
-
- navbar_sub_item(
- title: _('CI / CD'),
- path: 'pipelines#charts',
- link: charts_project_pipelines_path(project)
- )
- end
-end
-
-AnalyticsNavbarHelper.prepend_if_ee('EE::AnalyticsNavbarHelper')
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index e1aed5393ea..c999d1f94ad 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -93,8 +93,8 @@ module ButtonHelper
content_tag (href ? :a : :span),
(href ? button_content : title),
class: "#{title.downcase}-selector #{active_class}",
- href: (href if href),
- data: (data if data)
+ href: href,
+ data: data
end
end
diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb
index df220effd5d..cd0718c1b82 100644
--- a/app/helpers/ci_variables_helper.rb
+++ b/app/helpers/ci_variables_helper.rb
@@ -7,7 +7,7 @@ module CiVariablesHelper
def create_deploy_token_path(entity, opts = {})
if entity.is_a?(Group)
- create_deploy_token_group_settings_ci_cd_path(entity, opts)
+ create_deploy_token_group_settings_repository_path(entity, opts)
else
# TODO: change this path to 'create_deploy_token_project_settings_ci_cd_path'
# See MR comment for more detail: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27059#note_311585356
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 52f189b122f..bd400009c96 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -25,7 +25,7 @@ module EnvironmentHelper
def deployment_link(deployment, text: nil)
return unless deployment
- link_label = text ? text : "##{deployment.iid}"
+ link_label = text || "##{deployment.iid}"
link_to link_label, deployment_path(deployment)
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 5b640ea6538..3368fc7aa86 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -46,7 +46,9 @@ module EnvironmentsHelper
"environment-state" => "#{environment.state}",
"custom-metrics-path" => project_prometheus_metrics_path(project),
"validate-query-path" => validate_query_project_prometheus_metrics_path(project),
- "custom-metrics-available" => "#{custom_metrics_available?(project)}"
+ "custom-metrics-available" => "#{custom_metrics_available?(project)}",
+ "alerts-endpoint" => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json),
+ "prometheus-alerts-available" => "#{can?(current_user, :read_prometheus_alerts, project)}"
}
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 2cd685ddcd4..91f8bc33e3e 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -15,6 +15,7 @@ module GroupsHelper
groups#projects
groups#edit
badges#index
+ repository#show
ci_cd#show
integrations#index
integrations#edit
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 8a79217c929..070089d6ef8 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -9,19 +9,6 @@ module PreferencesHelper
]
end
- # Maps `dashboard` values to more user-friendly option text
- DASHBOARD_CHOICES = {
- projects: _("Your Projects (default)"),
- stars: _("Starred Projects"),
- project_activity: _("Your Projects' Activity"),
- starred_project_activity: _("Starred Projects' Activity"),
- groups: _("Your Groups"),
- todos: _("Your To-Do List"),
- issues: _("Assigned Issues"),
- merge_requests: _("Assigned Merge Requests"),
- operations: _("Operations Dashboard")
- }.with_indifferent_access.freeze
-
# Returns an Array usable by a select field for more user-friendly option text
def dashboard_choices
dashboards = User.dashboards.keys
@@ -31,10 +18,25 @@ module PreferencesHelper
dashboards.map do |key|
# Use `fetch` so `KeyError` gets raised when a key is missing
- [DASHBOARD_CHOICES.fetch(key), key]
+ [localized_dashboard_choices.fetch(key), key]
end
end
+ # Maps `dashboard` values to more user-friendly option text
+ def localized_dashboard_choices
+ {
+ projects: _("Your Projects (default)"),
+ stars: _("Starred Projects"),
+ project_activity: _("Your Projects' Activity"),
+ starred_project_activity: _("Starred Projects' Activity"),
+ groups: _("Your Groups"),
+ todos: _("Your To-Do List"),
+ issues: _("Assigned Issues"),
+ merge_requests: _("Assigned Merge Requests"),
+ operations: _("Operations Dashboard")
+ }.with_indifferent_access.freeze
+ end
+
def project_view_choices
[
['Files and Readme (default)', :files],
@@ -75,9 +77,9 @@ module PreferencesHelper
# Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too
def validate_dashboard_choices!(user_dashboards)
- if user_dashboards.size != DASHBOARD_CHOICES.size
+ if user_dashboards.size != localized_dashboard_choices.size
raise "`User` defines #{user_dashboards.size} dashboard choices," \
- " but `DASHBOARD_CHOICES` defined #{DASHBOARD_CHOICES.size}."
+ " but `localized_dashboard_choices` defined #{localized_dashboard_choices.size}."
end
end
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
new file mode 100644
index 00000000000..1b0400fbaa5
--- /dev/null
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Projects::AlertManagementHelper
+ def alert_management_data(project)
+ {
+ 'index-path' => project_alert_management_index_path(project,
+ format: :json),
+ 'enable-alert-management-path' => project_settings_operations_path(project),
+ 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg')
+ }
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index e700f0dbf2a..bd207615e7c 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -448,6 +448,7 @@ module ProjectsHelper
clusters: :read_cluster,
serverless: :read_cluster,
error_tracking: :read_sentry_issue,
+ alert_management: :read_alert_management,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
@@ -707,6 +708,7 @@ module ProjectsHelper
clusters
functions
error_tracking
+ alert_management
user
gcp
logs
@@ -737,3 +739,5 @@ module ProjectsHelper
can?(current_user, :destroy_container_image, project)
end
end
+
+ProjectsHelper.prepend_if_ee('EE::ProjectsHelper')
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index a9f90a8f5e4..fd7e58826b5 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -160,14 +160,4 @@ module SnippetsHelper
title: 'Download',
rel: 'noopener noreferrer')
end
-
- def snippet_file_name(snippet)
- blob = if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty?
- snippet.blobs.first
- else
- snippet.blob
- end
-
- blob.name
- end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 3fd865003c1..d4d93ab9795 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -91,6 +91,20 @@ module Emails
end
end
+ def issues_csv_email(user, project, csv_data, export_status)
+ @project = project
+ @issues_count = export_status.fetch(:rows_expected)
+ @written_count = export_status.fetch(:rows_written)
+ @truncated = export_status.fetch(:truncated)
+
+ filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
+ attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
+ mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format|
+ format.html { render layout: 'mailer' }
+ format.text { render layout: 'mailer' }
+ end
+ end
+
private
def setup_issue_mail(issue_id, recipient_id, closed_via: nil)
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 114737eb232..38e1d9532a6 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -80,6 +80,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true })
end
+ def issues_csv_email
+ Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message
+ end
+
def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index cbd7945a8b5..8c480969dd4 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -525,6 +525,7 @@ module Ci
strong_memoize(:variables) do
Gitlab::Ci::Variables::Collection.new
.concat(persisted_variables)
+ .concat(job_jwt_variables)
.concat(scoped_variables)
.concat(job_variables)
.concat(environment_changed_page_variables)
@@ -877,6 +878,14 @@ module Ci
coverage_report
end
+ def collect_terraform_reports!(terraform_reports)
+ each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact|
+ ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact)
+ end
+
+ terraform_reports
+ end
+
def report_artifacts
job_artifacts.with_reports
end
@@ -974,6 +983,15 @@ module Ci
def has_expiring_artifacts?
artifacts_expire_at.present? && artifacts_expire_at > Time.now
end
+
+ def job_jwt_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless Feature.enabled?(:ci_job_jwt, project, default_enabled: true)
+
+ jwt = Gitlab::Ci::Jwt.for_build(self)
+ variables.append(key: 'CI_JOB_JWT', value: jwt, public: false, masked: true)
+ end
+ end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index ef0701b3874..fdb8015ba3d 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -13,6 +13,7 @@ module Ci
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
+ TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze
DEFAULT_FILE_NAMES = {
archive: nil,
metadata: nil,
@@ -102,6 +103,10 @@ module Ci
with_file_types(COVERAGE_REPORT_FILE_TYPES)
end
+ scope :terraform_reports, -> do
+ with_file_types(TERRAFORM_REPORT_FILE_TYPES)
+ end
+
scope :erasable, -> do
types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 8a3ca2e758c..4179cdbe4a3 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -817,6 +817,14 @@ module Ci
end
end
+ def terraform_reports
+ ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports|
+ builds.latest.with_reports(::Ci::JobArtifact.terraform_reports).each do |build|
+ build.collect_terraform_reports!(terraform_reports)
+ end
+ end
+ end
+
def has_exposed_artifacts?
complete? && builds.latest.with_exposed_artifacts.exists?
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 690aa978716..d4e9217ff9f 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -35,6 +35,7 @@ module Ci
AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
+ MINUTES_COST_FACTOR_FIELDS = %i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze
ignore_column :is_shared, remove_after: '2019-12-15', remove_with: '12.6'
@@ -137,6 +138,11 @@ module Ci
numericality: { greater_than_or_equal_to: 600,
message: 'needs to be at least 10 minutes' }
+ validates :public_projects_minutes_cost_factor, :private_projects_minutes_cost_factor,
+ allow_nil: false,
+ numericality: { greater_than_or_equal_to: 0.0,
+ message: 'needs to be non-negative' }
+
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index afdc1c91c69..3ddb67d8427 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class ElasticStack < ApplicationRecord
- VERSION = '1.9.0'
+ VERSION = '2.0.0'
ELASTICSEARCH_PORT = 9200
@@ -28,6 +28,7 @@ module Clusters
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
files: files,
+ preinstall: migrate_to_2_script,
postinstall: post_install_script
)
end
@@ -69,6 +70,10 @@ module Clusters
end
end
+ def filebeat7?
+ Gem::Version.new(version) >= Gem::Version.new('2.0.0')
+ end
+
private
def post_install_script
@@ -86,6 +91,27 @@ module Clusters
def kube_client
cluster&.kubeclient&.core_client
end
+
+ def migrate_to_2_script
+ # Updating the chart to 2.0.0 includes an update of the filebeat chart from 1.7.0 to 3.1.1 https://github.com/helm/charts/pull/21640
+ # This includes the following commit that changes labels on the filebeat deployment https://github.com/helm/charts/commit/9b009170686c6f4b202c36ceb1da4bb9ba15ddd0
+ # Unfortunately those fields are immutable, and we can't use `helm upgrade` to change them. We first have to delete the associated filebeat resources
+ # The following pre-install command runs before updating to 2.0.0 and sets filebeat.enable=false so the filebeat deployment is deleted.
+ # Then the main install command re-creates them properly
+ if updating? && !filebeat7?
+ [
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name: 'elastic-stack',
+ version: version,
+ rbac: cluster.platform_kubernetes_rbac?,
+ chart: chart,
+ files: files
+ ).install_command + ' --set filebeat.enabled\\=false'
+ ]
+ else
+ []
+ end
+ end
end
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index baf34e916f8..5985e08d73e 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -30,7 +30,6 @@ module Clusters
enum modsecurity_mode: { logging: 0, blocking: 1 }
FETCH_IP_ADDRESS_DELAY = 30.seconds
- MODSEC_SIDECAR_INITIAL_DELAY_SECONDS = 10
state_machine :status do
after_transition any => [:installed] do |application|
@@ -108,11 +107,13 @@ module Clusters
"readOnly" => true
}
],
- "startupProbe" => {
+ "livenessProbe" => {
"exec" => {
- "command" => ["ls", "/var/log/modsec"]
- },
- "initialDelaySeconds" => MODSEC_SIDECAR_INITIAL_DELAY_SECONDS
+ "command" => [
+ "ls",
+ "/var/log/modsec/audit.log"
+ ]
+ }
}
}
],
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 7300283f086..37f2209b9d2 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -116,6 +116,7 @@ module Issuable
# rubocop:enable GitlabSecurity/SqlInjection
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
+ scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
scope :any_label, -> { joins(:label_links).group(:id) }
scope :join_project, -> { joins(:project) }
scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
@@ -131,8 +132,21 @@ module Issuable
strip_attributes :title
- def self.locking_enabled?
- false
+ class << self
+ def labels_hash
+ issue_labels = Hash.new { |h, k| h[k] = [] }
+
+ relation = unscoped.where(id: self.select(:id)).eager_load(:labels)
+ relation.pluck(:id, 'labels.title').each do |issue_id, label|
+ issue_labels[issue_id] << label if label.present?
+ end
+
+ issue_labels
+ end
+
+ def locking_enabled?
+ false
+ end
end
# We want to use optimistic lock for cases when only title or description are involved
@@ -478,5 +492,4 @@ module Issuable
end
end
-Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule
-Issuable::ClassMethods.prepend_if_ee('EE::Issuable::ClassMethods')
+Issuable.prepend_if_ee('EE::Issuable')
diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb
index 7f00b652530..2354335469a 100644
--- a/app/models/concerns/notification_branch_selection.rb
+++ b/app/models/concerns/notification_branch_selection.rb
@@ -6,12 +6,14 @@
module NotificationBranchSelection
extend ActiveSupport::Concern
- BRANCH_CHOICES = [
- [_('All branches'), 'all'],
- [_('Default branch'), 'default'],
- [_('Protected branches'), 'protected'],
- [_('Default branch and protected branches'), 'default_and_protected']
- ].freeze
+ def branch_choices
+ [
+ [_('All branches'), 'all'].freeze,
+ [_('Default branch'), 'default'].freeze,
+ [_('Protected branches'), 'protected'].freeze,
+ [_('Default branch and protected branches'), 'default_and_protected'].freeze
+ ].freeze
+ end
def notify_for_branch?(data)
ref = if data[:ref]
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 76d26500267..cedcf164a49 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -66,6 +66,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:pages_access_level, value)
end
+ def metrics_dashboard_access_level=(value)
+ write_feature_attribute_string(:metrics_dashboard_access_level, value)
+ end
+
private
def write_feature_attribute_boolean(field, value)
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 4fbb5dcb649..9cd1a22b203 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -13,9 +13,13 @@ module Spammable
has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
attr_accessor :spam
+ attr_accessor :needs_recaptcha
attr_accessor :spam_log
+
alias_method :spam?, :spam
+ alias_method :needs_recaptcha?, :needs_recaptcha
+ # if spam errors are added before validation, they will be wiped
after_validation :invalidate_if_spam, on: [:create, :update]
cattr_accessor :spammable_attrs, instance_accessor: false do
@@ -38,24 +42,35 @@ module Spammable
end
def needs_recaptcha!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
- "Please, change the content or solve the reCAPTCHA to proceed.")
+ self.needs_recaptcha = true
end
- def unrecoverable_spam_error!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ def spam!
+ self.spam = true
end
- def invalidate_if_spam
- return unless spam?
+ def clear_spam_flags!
+ self.spam = false
+ self.needs_recaptcha = false
+ end
- if Gitlab::Recaptcha.enabled?
- needs_recaptcha!
- else
+ def invalidate_if_spam
+ if needs_recaptcha? && Gitlab::Recaptcha.enabled?
+ recaptcha_error!
+ elsif needs_recaptcha? || spam?
unrecoverable_spam_error!
end
end
+ def recaptcha_error!
+ self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
+ "Please, change the content or solve the reCAPTCHA to proceed.")
+ end
+
+ def unrecoverable_spam_error!
+ self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ end
+
def spammable_entity_type
self.class.name.underscore
end
diff --git a/app/models/group.rb b/app/models/group.rb
index f4eaa581d54..55a2c4ba9a9 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -72,7 +72,7 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :name,
format: { with: Gitlab::Regex.group_name_regex,
- message: Gitlab::Regex.group_name_regex_message }
+ message: Gitlab::Regex.group_name_regex_message }, if: :name_changed?
add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb
index a1e03218640..109c0c82487 100644
--- a/app/models/import_failure.rb
+++ b/app/models/import_failure.rb
@@ -6,4 +6,11 @@ class ImportFailure < ApplicationRecord
validates :project, presence: true, unless: :group
validates :group, presence: true, unless: :project
+
+ # Returns any `import_failures` for relations that were unrecoverable errors or failed after
+ # several retries. An import can be successful even if some relations failed to import correctly.
+ # A retry_count of 0 indicates that either no retries were attempted, or they were exceeded.
+ scope :hard_failures_by_correlation_id, ->(correlation_id) {
+ where(correlation_id_value: correlation_id, retry_count: 0).order(created_at: :desc)
+ }
end
diff --git a/app/models/internal_id_enums.rb b/app/models/internal_id_enums.rb
index 2f7d7aeff2f..1b7ad00e58b 100644
--- a/app/models/internal_id_enums.rb
+++ b/app/models/internal_id_enums.rb
@@ -3,7 +3,7 @@
module InternalIdEnums
def self.usage_resources
# when adding new resource, make sure it doesn't conflict with EE usage_resources
- { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6 }
+ { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6, operations_user_lists: 7 }
end
end
diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb
index 543ee77917c..bde2795e7b8 100644
--- a/app/models/jira_import_state.rb
+++ b/app/models/jira_import_state.rb
@@ -53,6 +53,7 @@ class JiraImportState < ApplicationRecord
before_transition any => :finished do |state, _|
InternalId.flush_records!(project: state.project)
state.project.update_project_counter_caches
+ state.store_issue_counts
end
after_transition any => :finished do |state, _|
@@ -80,4 +81,20 @@ class JiraImportState < ApplicationRecord
def non_initial?
!initial?
end
+
+ def store_issue_counts
+ import_label_id = Gitlab::JiraImport.get_import_label_id(project.id)
+
+ failed_to_import_count = Gitlab::JiraImport.issue_failures(project.id)
+ successfully_imported_count = project.issues.with_label_ids(import_label_id).count
+ total_issue_count = successfully_imported_count + failed_to_import_count
+
+ update(
+ {
+ failed_to_import_count: failed_to_import_count,
+ imported_issues_count: successfully_imported_count,
+ total_issue_count: total_issue_count
+ }
+ )
+ end
end
diff --git a/app/models/list.rb b/app/models/list.rb
index 64247fdb983..ffea86dec7c 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -74,14 +74,18 @@ class List < ApplicationRecord
label? ? label.name : list_type.humanize
end
+ def collapsed?(user)
+ preferences = preferences_for(user)
+
+ preferences.collapsed?
+ end
+
def as_json(options = {})
super(options).tap do |json|
json[:collapsed] = false
if options.key?(:collapsed)
- preferences = preferences_for(options[:current_user])
-
- json[:collapsed] = preferences.collapsed?
+ json[:collapsed] = collapsed?(options[:current_user])
end
if options.key?(:label)
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index c47f1af2a73..efc9e80c72d 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -163,7 +163,7 @@ class MergeRequest < ApplicationRecord
state_machine :merge_status, initial: :unchecked do
event :mark_as_unchecked do
transition [:can_be_merged, :checking, :unchecked] => :unchecked
- transition [:cannot_be_merged, :cannot_be_merged_recheck] => :cannot_be_merged_recheck
+ transition [:cannot_be_merged, :cannot_be_merged_rechecking, :cannot_be_merged_recheck] => :cannot_be_merged_recheck
end
event :mark_as_checking do
@@ -200,7 +200,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def check_state?(merge_status)
- [:unchecked, :cannot_be_merged_recheck, :checking].include?(merge_status.to_sym)
+ [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking].include?(merge_status.to_sym)
end
end
@@ -577,13 +577,13 @@ class MergeRequest < ApplicationRecord
merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size
end
- def modified_paths(past_merge_request_diff: nil)
+ def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false)
if past_merge_request_diff
- past_merge_request_diff.modified_paths
+ past_merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow)
elsif compare
diff_stats&.paths || compare.modified_paths
else
- merge_request_diff.modified_paths
+ merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow)
end
end
@@ -1325,6 +1325,10 @@ class MergeRequest < ApplicationRecord
actual_head_pipeline&.has_reports?(Ci::JobArtifact.coverage_reports)
end
+ def has_terraform_reports?
+ actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports)
+ end
+
# TODO: this method and compare_test_reports use the same
# result type, which is handled by the controller's #reports_response.
# we should minimize mistakes by isolating the common parts.
@@ -1337,9 +1341,15 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::GenerateCoverageReportsService)
end
- def has_exposed_artifacts?
- return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true)
+ def find_terraform_reports
+ unless has_terraform_reports?
+ return { status: :error, status_reason: 'This merge request does not have terraform reports' }
+ end
+ compare_reports(Ci::GenerateTerraformReportsService)
+ end
+
+ def has_exposed_artifacts?
actual_head_pipeline&.has_exposed_artifacts?
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 9136c6cc5d4..7b15d21c095 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -366,9 +366,22 @@ class MergeRequestDiff < ApplicationRecord
end
# rubocop: enable CodeReuse/ServiceClass
- def modified_paths
- strong_memoize(:modified_paths) do
- merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq
+ def modified_paths(fallback_on_overflow: false)
+ if fallback_on_overflow && overflow?
+ # This is an extremely slow means to find the modified paths for a given
+ # MergeRequestDiff. This should be avoided, except where the limit of
+ # 1_000 (as of %12.10) entries returned by the default behavior is an
+ # issue.
+ strong_memoize(:overflowed_modified_paths) do
+ project.repository.diff_stats(
+ base_commit_sha,
+ head_commit_sha
+ ).paths
+ end
+ else
+ strong_memoize(:modified_paths) do
+ merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq
+ end
end
end
diff --git a/app/models/namespace/root_storage_size.rb b/app/models/namespace/root_storage_size.rb
new file mode 100644
index 00000000000..56e615205c4
--- /dev/null
+++ b/app/models/namespace/root_storage_size.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class Namespace::RootStorageSize
+ ALERT_USAGE_THRESHOLD = 0.5
+
+ def initialize(root_namespace)
+ @root_namespace = root_namespace
+ end
+
+ def above_size_limit?
+ return false if limit == 0
+
+ usage_ratio > 1
+ end
+
+ def usage_ratio
+ return 0 if limit == 0
+
+ current_size.to_f / limit.to_f
+ end
+
+ def current_size
+ @current_size ||= root_namespace.root_storage_statistics&.storage_size
+ end
+
+ def limit
+ @limit ||= Gitlab::CurrentSettings.namespace_storage_size_limit.megabytes
+ end
+
+ def show_alert?
+ return false if limit == 0
+
+ usage_ratio >= ALERT_USAGE_THRESHOLD
+ end
+
+ private
+
+ attr_reader :root_namespace
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 3168def7dd8..3ff782a9643 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -340,7 +340,7 @@ class Project < ApplicationRecord
:pages_enabled?, :public_pages?, :private_pages?,
:merge_requests_access_level, :forking_access_level, :issues_access_level,
:wiki_access_level, :snippets_access_level, :builds_access_level,
- :repository_access_level, :pages_access_level,
+ :repository_access_level, :pages_access_level, :metrics_dashboard_access_level,
to: :project_feature, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
@@ -415,7 +415,6 @@ class Project < ApplicationRecord
scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) }
scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) }
scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) }
- scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) }
# Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name
scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) }
@@ -774,10 +773,6 @@ class Project < ApplicationRecord
{ scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) }
end
- def daily_statistics_enabled?
- Feature.enabled?(:project_daily_statistics, self, default_enabled: true)
- end
-
def unlink_forks_upon_visibility_decrease_enabled?
Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true)
end
@@ -866,6 +861,16 @@ class Project < ApplicationRecord
latest_jira_import&.status || 'initial'
end
+ def validate_jira_import_settings!(user: nil)
+ raise Projects::ImportService::Error, _('Jira import feature is disabled.') unless jira_issues_import_feature_flag_enabled?
+ raise Projects::ImportService::Error, _('Jira integration not configured.') unless jira_service&.active?
+
+ return unless user
+
+ raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user)
+ raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self)
+ end
+
def human_import_status_name
import_state&.human_status_name || 'none'
end
@@ -1174,11 +1179,7 @@ class Project < ApplicationRecord
end
def issues_tracker
- if external_issue_tracker
- external_issue_tracker
- else
- default_issue_tracker
- end
+ external_issue_tracker || default_issue_tracker
end
def external_issue_reference_pattern
@@ -1323,11 +1324,7 @@ class Project < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def owner
- if group
- group
- else
- namespace.try(:owner)
- end
+ group || namespace.try(:owner)
end
def to_ability_name
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index a9753c3c53a..31a3fa12c00 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord
ENABLED = 20
PUBLIC = 30
- FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze
+ FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze
STRING_OPTIONS = HashWithIndifferentAccess.new({
@@ -90,13 +90,14 @@ class ProjectFeature < ApplicationRecord
validate :repository_children_level
validate :allowed_access_levels
- default_value_for :builds_access_level, value: ENABLED, allows_nil: false
- default_value_for :issues_access_level, value: ENABLED, allows_nil: false
- default_value_for :forking_access_level, value: ENABLED, allows_nil: false
- default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
- default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
- default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
- default_value_for :repository_access_level, value: ENABLED, allows_nil: false
+ default_value_for :builds_access_level, value: ENABLED, allows_nil: false
+ default_value_for :issues_access_level, value: ENABLED, allows_nil: false
+ default_value_for :forking_access_level, value: ENABLED, allows_nil: false
+ default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
+ default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
+ default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
+ default_value_for :repository_access_level, value: ENABLED, allows_nil: false
+ default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false
default_value_for(:pages_access_level, allows_nil: false) do |feature|
if ::Gitlab::Pages.access_control_is_forced?
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index f58b8dc624d..e434ea58729 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -72,6 +72,10 @@ class ProjectImportState < ApplicationRecord
end
end
+ def relation_hard_failures(limit:)
+ project.import_failures.hard_failures_by_correlation_id(correlation_id).limit(limit)
+ end
+
def mark_as_failed(error_message)
original_errors = errors.dup
sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message)
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index 1ec983223f3..c9e97efb4ac 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -59,11 +59,11 @@ class ChatNotificationService < Service
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true },
- { type: 'text', name: 'username', placeholder: 'e.g. GitLab' },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
- ]
+ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }.freeze,
+ { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }.freeze,
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' }.freeze,
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze
+ ].freeze
end
def execute(data)
diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb
index 294b286f073..941b7f64263 100644
--- a/app/models/project_services/discord_service.rb
+++ b/app/models/project_services/discord_service.rb
@@ -44,7 +44,7 @@ class DiscordService < ChatNotificationService
[
{ type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" },
{ type: "checkbox", name: "notify_only_broken_pipelines" },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index dd2f1359e76..01d8647d439 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -66,7 +66,7 @@ class EmailsOnPushService < Service
help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } },
{ type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES },
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices },
{ type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') }
]
end
diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb
index d105bd012d6..299a306add7 100644
--- a/app/models/project_services/hangouts_chat_service.rb
+++ b/app/models/project_services/hangouts_chat_service.rb
@@ -44,7 +44,7 @@ class HangoutsChatService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index 3f7e8a720aa..f5d6ae10469 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -172,7 +172,7 @@ class IssueTrackerService < Service
end
def one_issue_tracker
- return if template?
+ return if template? || instance?
return if project.blank?
if project.services.external_issue_trackers.where.not(id: id).any?
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index eaddac9cce3..f0a5d8e8cdd 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -25,6 +25,11 @@ class JiraService < IssueTrackerService
before_update :reset_password
+ enum comment_detail: {
+ standard: 1,
+ all_details: 2
+ }
+
alias_method :project_url, :url
# When these are false GitLab does not create cross reference
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
index ca324f68d2d..0fd85e3a5a9 100644
--- a/app/models/project_services/mattermost_slash_commands_service.rb
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -36,6 +36,10 @@ class MattermostSlashCommandsService < SlashCommandsService
[[], e.message]
end
+ def chat_responder
+ ::Gitlab::Chat::Responder::Mattermost
+ end
+
private
def command(params)
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
index 111d010d672..e8e12a9a206 100644
--- a/app/models/project_services/microsoft_teams_service.rb
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -42,7 +42,7 @@ class MicrosoftTeamsService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index b5e5afb6ea5..a58a264de5e 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -72,7 +72,7 @@ class PipelinesEmailService < Service
name: 'notify_only_broken_pipelines' },
{ type: 'select',
name: 'branches_to_be_notified',
- choices: BRANCH_CHOICES }
+ choices: branch_choices }
]
end
diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb
index 06f2d10f83b..1e12179e62a 100644
--- a/app/models/project_services/unify_circuit_service.rb
+++ b/app/models/project_services/unify_circuit_service.rb
@@ -38,7 +38,7 @@ class UnifyCircuitService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index 8e66310f0c5..cd47c154eef 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -56,7 +56,7 @@ class ResourceLabelEvent < ResourceEvent
end
def banzai_render_context(field)
- super.merge(pipeline: 'label', only_path: true)
+ super.merge(pipeline: :label, only_path: true)
end
def refresh_invalid_reference
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index b97c02f1713..a40af22061e 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -13,9 +13,9 @@ class ResourceMilestoneEvent < ResourceEvent
validate :exactly_one_issuable
enum action: {
- add: 1,
- remove: 2
- }
+ add: 1,
+ remove: 2
+ }
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states)
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index dbf600cf0df..7bff6d02910 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -322,6 +322,12 @@ class Snippet < ApplicationRecord
::Feature.enabled?(:version_snippets, user) && repository_exists?
end
+ def file_name_on_repo
+ return if repository.empty?
+
+ repository.ls_files(repository.root_ref).first
+ end
+
class << self
# Searches for snippets with a matching title, description or file name.
#
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 8ca4ee9239a..c4e047ff9d1 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -2,14 +2,25 @@
module Terraform
class State < ApplicationRecord
+ DEFAULT = '{"version":1}'.freeze
+ HEX_REGEXP = %r{\A\h+\z}.freeze
+ UUID_LENGTH = 32
+
belongs_to :project
+ belongs_to :locked_by_user, class_name: 'User'
validates :project_id, presence: true
+ validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
+ format: { with: HEX_REGEXP, message: 'only allows hex characters' }
+
+ default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) }
after_save :update_file_store, if: :saved_change_to_file?
mount_uploader :file, StateUploader
+ default_value_for(:file) { CarrierWaveStringFile.new(DEFAULT) }
+
def update_file_store
# The file.object_store is set during `uploader.store!`
# which happens after object is inserted/updated
@@ -19,5 +30,9 @@ module Terraform
def file_store
super || StateUploader.default_store
end
+
+ def locked?
+ self.lock_xid.present?
+ end
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 42972477d97..1b087da3a2f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -337,7 +337,8 @@ class User < ApplicationRecord
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
scope :bots, -> { where(user_type: UserTypeEnums.bots.values) }
- scope :not_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.values)) }
+ scope :bots_without_project_bot, -> { bots.where.not(user_type: UserTypeEnums.bots[:project_bot]) }
+ scope :with_project_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.except(:project_bot).values)) }
scope :humans, -> { where(user_type: nil) }
scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
@@ -657,8 +658,10 @@ class User < ApplicationRecord
UserTypeEnums.bots.has_key?(user_type)
end
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
def internal?
- ghost? || bot?
+ ghost? || (bot? && !project_bot?)
end
# We are transitioning from ghost boolean column to user_type
@@ -668,12 +671,16 @@ class User < ApplicationRecord
ghost
end
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
def self.internal
- where(ghost: true).or(bots)
+ where(ghost: true).or(bots_without_project_bot)
end
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
def self.non_internal
- without_ghosts.not_bots
+ without_ghosts.with_project_bots
end
#
@@ -1720,7 +1727,7 @@ class User < ApplicationRecord
# override, from Devise::Validatable
def password_required?
- return false if internal?
+ return false if internal? || project_bot?
super
end
diff --git a/app/models/user_type_enums.rb b/app/models/user_type_enums.rb
index 795cc4b2889..cb5aac89ed3 100644
--- a/app/models/user_type_enums.rb
+++ b/app/models/user_type_enums.rb
@@ -6,7 +6,7 @@ module UserTypeEnums
end
def self.bots
- @bots ||= { alert_bot: 2 }.with_indifferent_access
+ @bots ||= { alert_bot: 2, project_bot: 6 }.with_indifferent_access
end
end
diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb
index 75b711eab5b..428fd336a32 100644
--- a/app/models/x509_certificate.rb
+++ b/app/models/x509_certificate.rb
@@ -26,6 +26,8 @@ class X509Certificate < ApplicationRecord
validates :x509_issuer_id, presence: true
+ scope :by_x509_issuer, ->(issuer) { where(x509_issuer_id: issuer.id) }
+
after_commit :mark_commit_signatures_unverified
def self.safe_create!(attributes)
@@ -33,6 +35,10 @@ class X509Certificate < ApplicationRecord
.safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier])
end
+ def self.serial_numbers(issuer)
+ by_x509_issuer(issuer).pluck(:serial_number)
+ end
+
def mark_commit_signatures_unverified
X509CertificateRevokeWorker.perform_async(self.id) if revoked?
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 2bde7bcca08..9353b361c2a 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -17,6 +17,8 @@ class GlobalPolicy < BasePolicy
condition(:private_instance_statistics, score: 0) { Gitlab::CurrentSettings.instance_statistics_visibility_private? }
+ condition(:project_bot, scope: :user) { @user&.project_bot? }
+
rule { admin | (~private_instance_statistics & ~anonymous) }
.enable :read_instance_statistics
@@ -51,6 +53,11 @@ class GlobalPolicy < BasePolicy
prevent :use_slash_commands
end
+ rule { project_bot }.policy do
+ prevent :log_in
+ prevent :receive_notifications
+ end
+
rule { deactivated }.policy do
prevent :access_git
prevent :access_api
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index a34217d90dd..728c4b76498 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -91,6 +91,7 @@ class GroupPolicy < BasePolicy
end
rule { reporter }.policy do
+ enable :reporter_access
enable :read_container_image
enable :download_wiki_code
enable :admin_label
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 7454343a357..21fa87ea9cc 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -223,6 +223,7 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
enable :read_sentry_issue
enable :update_sentry_issue
+ enable :read_alert_management
enable :read_prometheus
enable :read_metrics_dashboard_annotation
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index ce9a3346b4b..395eaeea8de 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -36,16 +36,18 @@ module Ci
end
end
- NAMES = {
- merge_train: s_('Pipeline|Merge train pipeline'),
- merged_result: s_('Pipeline|Merged result pipeline'),
- detached: s_('Pipeline|Detached merge request pipeline')
- }.freeze
+ def localized_names
+ {
+ merge_train: s_('Pipeline|Merge train pipeline'),
+ merged_result: s_('Pipeline|Merged result pipeline'),
+ detached: s_('Pipeline|Detached merge request pipeline')
+ }.freeze
+ end
def name
# Currently, `merge_request_event_type` is the only source to name pipelines
# but this could be extended with the other types in the future.
- NAMES.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline'))
+ localized_names.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline'))
end
def ref_text
diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb
index b9797bfb021..57e9225e2da 100644
--- a/app/serializers/analytics_summary_entity.rb
+++ b/app/serializers/analytics_summary_entity.rb
@@ -4,4 +4,12 @@ class AnalyticsSummaryEntity < Grape::Entity
expose :value, safe: true
expose :title
expose :unit, if: { with_unit: true }
+
+ private
+
+ def value
+ return object.value if object.value.is_a? String
+
+ object.value&.nonzero? ? object.value.to_s : '-'
+ end
end
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 18e8ec0e7d1..08255db5cbf 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -71,6 +71,12 @@ class MergeRequestPollWidgetEntity < Grape::Entity
end
end
+ expose :terraform_reports_path do |merge_request|
+ if merge_request.has_terraform_reports?
+ terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
expose :exposed_artifacts_path do |merge_request|
if merge_request.has_exposed_artifacts?
exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json)
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index aa67cd1f39e..9fd50c8c51d 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -15,6 +15,10 @@ class MergeRequestSerializer < BaseSerializer
MergeRequestBasicEntity
when 'noteable'
MergeRequestNoteableEntity
+ when 'poll_cached_widget'
+ MergeRequestPollCachedWidgetEntity
+ when 'poll_widget'
+ MergeRequestPollWidgetEntity
else
# fallback to widget for old poll requests without `serializer` set
MergeRequestWidgetEntity
diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb
index 0f88a496c77..53fa830718a 100644
--- a/app/serializers/test_suite_entity.rb
+++ b/app/serializers/test_suite_entity.rb
@@ -9,8 +9,9 @@ class TestSuiteEntity < Grape::Entity
expose :failed_count
expose :skipped_count
expose :error_count
+ expose :suite_error
expose :test_cases, using: TestCaseEntity do |test_suite|
- test_suite.test_cases.values.flat_map(&:values)
+ test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values)
end
end
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index e08b4ac2260..1de2f31f87c 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -49,6 +49,14 @@ module AutoMerge
end
end
+ def available_for?(merge_request)
+ strong_memoize("available_for_#{merge_request.id}") do
+ merge_request.can_be_merged_by?(current_user) &&
+ merge_request.mergeable_state?(skip_ci_check: true) &&
+ yield
+ end
+ end
+
private
def strategy
diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
index 7c0e9228b28..9ae5bd1b5ec 100644
--- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
+++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
@@ -30,7 +30,9 @@ module AutoMerge
end
def available_for?(merge_request)
- merge_request.actual_head_pipeline&.active?
+ super do
+ merge_request.actual_head_pipeline&.active?
+ end
end
end
end
diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb
index eee227be202..c5cbcc7c93b 100644
--- a/app/services/auto_merge_service.rb
+++ b/app/services/auto_merge_service.rb
@@ -1,23 +1,26 @@
# frozen_string_literal: true
class AutoMergeService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds'
STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze
class << self
- def all_strategies
+ def all_strategies_ordered_by_preference
STRATEGIES
end
def get_service_class(strategy)
- return unless all_strategies.include?(strategy)
+ return unless all_strategies_ordered_by_preference.include?(strategy)
"::AutoMerge::#{strategy.camelize}Service".constantize
end
end
- def execute(merge_request, strategy)
- service = get_service_instance(strategy)
+ def execute(merge_request, strategy = nil)
+ strategy ||= preferred_strategy(merge_request)
+ service = get_service_instance(merge_request, strategy)
return :failed unless service&.available_for?(merge_request)
@@ -27,37 +30,47 @@ class AutoMergeService < BaseService
def update(merge_request)
return :failed unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).update(merge_request)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).update(merge_request)
end
def process(merge_request)
return unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).process(merge_request)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).process(merge_request)
end
def cancel(merge_request)
return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).cancel(merge_request)
end
def abort(merge_request, reason)
return error("Can't abort the automatic merge", 406) unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).abort(merge_request, reason)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).abort(merge_request, reason)
end
def available_strategies(merge_request)
- self.class.all_strategies.select do |strategy|
- get_service_instance(strategy).available_for?(merge_request)
+ self.class.all_strategies_ordered_by_preference.select do |strategy|
+ get_service_instance(merge_request, strategy).available_for?(merge_request)
end
end
+ def preferred_strategy(merge_request)
+ available_strategies(merge_request).first
+ end
+
private
- def get_service_instance(strategy)
- self.class.get_service_class(strategy)&.new(project, current_user, params)
+ def get_service_instance(merge_request, strategy)
+ strong_memoize("service_instance_#{merge_request.id}_#{strategy}") do
+ self.class.get_service_class(strategy)&.new(project, current_user, params)
+ end
end
end
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index c96ea970943..07ce58b6851 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -3,8 +3,10 @@
module Boards
module Lists
class ListService < Boards::BaseService
- def execute(board)
- board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
+ def execute(board, create_default_lists: true)
+ if create_default_lists && !board.lists.backlog.exists?
+ board.lists.create(list_type: :backlog)
+ end
board.lists.preload_associated_models
end
diff --git a/app/services/ci/generate_terraform_reports_service.rb b/app/services/ci/generate_terraform_reports_service.rb
new file mode 100644
index 00000000000..d768ce777d4
--- /dev/null
+++ b/app/services/ci/generate_terraform_reports_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ # TODO: a couple of points with this approach:
+ # + reuses existing architecture and reactive caching
+ # - it's not a report comparison and some comparing features must be turned off.
+ # see CompareReportsBaseService for more notes.
+ # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
+ class GenerateTerraformReportsService < CompareReportsBaseService
+ def execute(base_pipeline, head_pipeline)
+ {
+ status: :parsed,
+ key: key(base_pipeline, head_pipeline),
+ data: head_pipeline.terraform_reports.plans
+ }
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
+ {
+ status: :error,
+ key: key(base_pipeline, head_pipeline),
+ status_reason: _('An error occurred while fetching terraform reports.')
+ }
+ end
+
+ def latest?(base_pipeline, head_pipeline, data)
+ data&.fetch(:key, nil) == key(base_pipeline, head_pipeline)
+ end
+ end
+end
diff --git a/app/services/concerns/deploy_token_methods.rb b/app/services/concerns/deploy_token_methods.rb
index c875342a07c..f59a50d6878 100644
--- a/app/services/concerns/deploy_token_methods.rb
+++ b/app/services/concerns/deploy_token_methods.rb
@@ -14,4 +14,12 @@ module DeployTokenMethods
deploy_token.destroy
end
+
+ def create_deploy_token_payload_for(deploy_token)
+ if deploy_token.persisted?
+ success(deploy_token: deploy_token, http_status: :created)
+ else
+ error(deploy_token.errors.full_messages.to_sentence, :bad_request, pass_back: { deploy_token: deploy_token })
+ end
+ end
end
diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb
index 695bdf92b49..84a79261915 100644
--- a/app/services/concerns/spam_check_methods.rb
+++ b/app/services/concerns/spam_check_methods.rb
@@ -23,7 +23,7 @@ module SpamCheckMethods
# attribute values.
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def spam_check(spammable, user)
- Spam::SpamCheckService.new(
+ Spam::SpamActionService.new(
spammable: spammable,
request: @request
).execute(
diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb
index a0b43ad3d08..6e671f52d57 100644
--- a/app/services/emails/destroy_service.rb
+++ b/app/services/emails/destroy_service.rb
@@ -13,7 +13,7 @@ module Emails
user.update_secondary_emails!
end
- result[:status] == 'success'
+ result[:status] == :success
end
end
end
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index da45bcc7eaa..5c1ee981d0c 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -36,6 +36,8 @@ module Git
# Update merge requests that may be affected by this push. A new branch
# could cause the last commit of a merge request to change.
def enqueue_update_mrs
+ return if params[:merge_request_branches]&.exclude?(branch_name)
+
UpdateMergeRequestsWorker.perform_async(
project.id,
current_user.id,
diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb
index 387cd29d69d..6d1ff97016b 100644
--- a/app/services/git/process_ref_changes_service.rb
+++ b/app/services/git/process_ref_changes_service.rb
@@ -42,6 +42,7 @@ module Git
push_service_class = push_service_class_for(ref_type)
create_bulk_push_event = changes.size > Gitlab::CurrentSettings.push_event_activities_limit
+ merge_request_branches = merge_request_branches_for(changes)
changes.each do |change|
push_service_class.new(
@@ -49,6 +50,7 @@ module Git
current_user,
change: change,
push_options: params[:push_options],
+ merge_request_branches: merge_request_branches,
create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project),
execute_project_hooks: execute_project_hooks,
create_push_event: !create_bulk_push_event
@@ -71,5 +73,11 @@ module Git
Git::BranchPushService
end
+
+ def merge_request_branches_for(changes)
+ return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true)
+
+ @merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute
+ end
end
end
diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb
index 81f761eb61d..aee423659ef 100644
--- a/app/services/groups/deploy_tokens/create_service.rb
+++ b/app/services/groups/deploy_tokens/create_service.rb
@@ -8,11 +8,7 @@ module Groups
def execute
deploy_token = create_deploy_token_for(@group, params)
- if deploy_token.persisted?
- success(deploy_token: deploy_token, http_status: :created)
- else
- error(deploy_token.errors.full_messages.to_sentence, :bad_request)
- end
+ create_deploy_token_payload_for(deploy_token)
end
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 4e7875e0491..fe3ab884302 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -2,15 +2,6 @@
module Groups
class TransferService < Groups::BaseService
- ERROR_MESSAGES = {
- database_not_supported: s_('TransferGroup|Database is not supported.'),
- namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'),
- group_is_already_root: s_('TransferGroup|Group is already a root group.'),
- same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'),
- invalid_policies: s_("TransferGroup|You don't have enough permissions."),
- group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.')
- }.freeze
-
TransferError = Class.new(StandardError)
attr_reader :error, :new_parent_group
@@ -124,7 +115,18 @@ module Groups
end
def raise_transfer_error(message)
- raise TransferError, ERROR_MESSAGES[message]
+ raise TransferError, localized_error_messages[message]
+ end
+
+ def localized_error_messages
+ {
+ database_not_supported: s_('TransferGroup|Database is not supported.'),
+ namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'),
+ group_is_already_root: s_('TransferGroup|Group is already a root group.'),
+ same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'),
+ invalid_policies: s_("TransferGroup|You don't have enough permissions."),
+ group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.')
+ }.freeze
end
end
end
diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb
index 54576e82030..0d1640924e5 100644
--- a/app/services/issuable/clone/base_service.rb
+++ b/app/services/issuable/clone/base_service.rb
@@ -47,7 +47,7 @@ module Issuable
end
def new_parent
- new_entity.project ? new_entity.project : new_entity.group
+ new_entity.project || new_entity.group
end
def group
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
new file mode 100644
index 00000000000..1dcdfb9faea
--- /dev/null
+++ b/app/services/issues/export_csv_service.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Issues
+ class ExportCsvService
+ include Gitlab::Routing.url_helpers
+ include GitlabRoutingHelper
+
+ # Target attachment size before base64 encoding
+ TARGET_FILESIZE = 15000000
+
+ attr_reader :project
+
+ def initialize(issues_relation, project)
+ @issues = issues_relation
+ @labels = @issues.labels_hash
+ @project = project
+ end
+
+ def csv_data
+ csv_builder.render(TARGET_FILESIZE)
+ end
+
+ def email(user)
+ Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def csv_builder
+ @csv_builder ||=
+ CsvBuilder.new(@issues.preload(associations_to_preload), header_to_value_hash)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def associations_to_preload
+ %i(author assignees timelogs)
+ end
+
+ def header_to_value_hash
+ {
+ 'Issue ID' => 'iid',
+ 'URL' => -> (issue) { issue_url(issue) },
+ 'Title' => 'title',
+ 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' },
+ 'Description' => 'description',
+ 'Author' => 'author_name',
+ 'Author Username' => -> (issue) { issue.author&.username },
+ 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') },
+ 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') },
+ 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
+ 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' },
+ 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
+ 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
+ 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
+ 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) },
+ 'Milestone' => -> (issue) { issue.milestone&.title },
+ 'Weight' => -> (issue) { issue.weight },
+ 'Labels' => -> (issue) { issue_labels(issue) },
+ 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) },
+ 'Time Spent' => -> (issue) { issue_time_spent(issue) }
+ }
+ end
+
+ def issue_labels(issue)
+ @labels[issue.id].sort.join(',').presence
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def issue_time_spent(issue)
+ issue.timelogs.map(&:time_spent).sum
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
+
+Issues::ExportCsvService.prepend_if_ee('EE::Issues::ExportCsvService')
diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb
index e8d9e6734bd..59fd463022f 100644
--- a/app/services/jira_import/start_import_service.rb
+++ b/app/services/jira_import/start_import_service.rb
@@ -56,18 +56,18 @@ module JiraImport
import_start_time = Time.zone.now
jira_imports_for_project = project.jira_imports.by_jira_project_key(jira_project_key).size + 1
title = "jira-import::#{jira_project_key}-#{jira_imports_for_project}"
- description = "Label for issues that were imported from jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}"
+ description = "Label for issues that were imported from Jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}"
color = "#{Label.color_for(title)}"
{ title: title, description: description, color: color }
end
def validate
- return build_error_response(_('Jira import feature is disabled.')) unless project.jira_issues_import_feature_flag_enabled?
- return build_error_response(_('You do not have permissions to run the import.')) unless user.can?(:admin_project, project)
- return build_error_response(_('Cannot import because issues are not available in this project.')) unless project.feature_available?(:issues, user)
- return build_error_response(_('Jira integration not configured.')) unless project.jira_service&.active?
+ project.validate_jira_import_settings!(user: user)
+
return build_error_response(_('Unable to find Jira project to import data from.')) if jira_project_key.blank?
return build_error_response(_('Jira import is already running.')) if import_in_progress?
+ rescue Projects::ImportService::Error => e
+ build_error_response(e.message)
end
def build_error_response(message)
diff --git a/app/services/merge_requests/merge_orchestration_service.rb b/app/services/merge_requests/merge_orchestration_service.rb
new file mode 100644
index 00000000000..24341ef1145
--- /dev/null
+++ b/app/services/merge_requests/merge_orchestration_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MergeOrchestrationService < ::BaseService
+ def execute(merge_request)
+ return unless can_merge?(merge_request)
+
+ merge_request.update(merge_error: nil)
+
+ if can_merge_automatically?(merge_request)
+ auto_merge_service.execute(merge_request)
+ else
+ merge_request.merge_async(current_user.id, params)
+ end
+ end
+
+ def can_merge?(merge_request)
+ can_merge_automatically?(merge_request) || can_merge_immediately?(merge_request)
+ end
+
+ def preferred_auto_merge_strategy(merge_request)
+ auto_merge_service.preferred_strategy(merge_request)
+ end
+
+ private
+
+ def can_merge_immediately?(merge_request)
+ merge_request.can_be_merged_by?(current_user) &&
+ merge_request.mergeable_state?
+ end
+
+ def can_merge_automatically?(merge_request)
+ auto_merge_service.available_strategies(merge_request).any?
+ end
+
+ def auto_merge_service
+ @auto_merge_service ||= AutoMergeService.new(project, current_user, params)
+ end
+ end
+end
diff --git a/app/services/merge_requests/pushed_branches_service.rb b/app/services/merge_requests/pushed_branches_service.rb
new file mode 100644
index 00000000000..afcf0f7678a
--- /dev/null
+++ b/app/services/merge_requests/pushed_branches_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class PushedBranchesService < MergeRequests::BaseService
+ include ::Gitlab::Utils::StrongMemoize
+
+ # Skip moving this logic into models since it's too specific
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ return [] if branch_names.blank?
+
+ source_branches = project.source_of_merge_requests.opened
+ .from_source_branches(branch_names).pluck(:source_branch)
+
+ target_branches = project.merge_requests.opened
+ .by_target_branch(branch_names).distinct.pluck(:target_branch)
+
+ source_branches.concat(target_branches).to_set
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def branch_names
+ strong_memoize(:branch_names) do
+ params[:changes].map do |change|
+ Gitlab::Git.branch_name(change[:ref])
+ end.compact
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 1516e33a7c6..2d33e87bf4b 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -79,14 +79,21 @@ module MergeRequests
def merge_from_quick_action(merge_request)
last_diff_sha = params.delete(:merge)
- return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
- merge_request.update(merge_error: nil)
-
- if merge_request.head_pipeline_active?
- AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
+ if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true)
+ MergeRequests::MergeOrchestrationService
+ .new(project, current_user, { sha: last_diff_sha })
+ .execute(merge_request)
else
- merge_request.merge_async(current_user.id, { sha: last_diff_sha })
+ return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
+
+ merge_request.update(merge_error: nil)
+
+ if merge_request.head_pipeline_active?
+ AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
+ else
+ merge_request.merge_async(current_user.id, { sha: last_diff_sha })
+ end
end
end
diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb
index 035707dceb9..ce81f337e47 100644
--- a/app/services/metrics/dashboard/transient_embed_service.rb
+++ b/app/services/metrics/dashboard/transient_embed_service.rb
@@ -30,6 +30,11 @@ module Metrics
def sequence
[STAGES::EndpointInserter]
end
+
+ override :identifiers
+ def identifiers
+ Digest::SHA256.hexdigest(params[:embed_json])
+ end
end
end
end
diff --git a/app/services/namespaces/check_storage_size_service.rb b/app/services/namespaces/check_storage_size_service.rb
new file mode 100644
index 00000000000..8f06d2b7fee
--- /dev/null
+++ b/app/services/namespaces/check_storage_size_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class CheckStorageSizeService
+ include ActiveSupport::NumberHelper
+
+ def initialize(namespace)
+ @root_namespace = namespace.root_ancestor
+ @root_storage_size = Namespace::RootStorageSize.new(root_namespace)
+ end
+
+ def execute
+ return ServiceResponse.success unless Feature.enabled?(:namespace_storage_limit, root_namespace)
+ return ServiceResponse.success unless root_storage_size.show_alert?
+
+ if root_storage_size.above_size_limit?
+ ServiceResponse.error(message: above_size_limit_message, payload: payload)
+ else
+ ServiceResponse.success(message: info_message, payload: payload)
+ end
+ end
+
+ private
+
+ attr_reader :root_namespace, :root_storage_size
+
+ def payload
+ {
+ current_usage_message: current_usage_message,
+ usage_ratio: root_storage_size.usage_ratio
+ }
+ end
+
+ def current_usage_message
+ params = {
+ usage_in_percent: number_to_percentage(root_storage_size.usage_ratio * 100, precision: 0),
+ namespace_name: root_namespace.name,
+ used_storage: formatted(root_storage_size.current_size),
+ storage_limit: formatted(root_storage_size.limit)
+ }
+ s_("You reached %{usage_in_percent} of %{namespace_name}'s capacity (%{used_storage} of %{storage_limit})" % params)
+ end
+
+ def info_message
+ s_("If you reach 100%% storage capacity, you will not be able to: %{base_message}" % { base_message: base_message } )
+ end
+
+ def above_size_limit_message
+ s_("%{namespace_name} is now read-only. You cannot: %{base_message}" % { namespace_name: root_namespace.name, base_message: base_message })
+ end
+
+ def base_message
+ s_("push to your repository, create pipelines, create issues or add comments. To reduce storage capacity, delete unused repositories, artifacts, wikis, issues, and pipelines.")
+ end
+
+ def formatted(number)
+ number_to_human_size(number, delimiter: ',', precision: 2)
+ end
+ end
+end
diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb
new file mode 100644
index 00000000000..ff9bb7d6802
--- /dev/null
+++ b/app/services/personal_access_tokens/create_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module PersonalAccessTokens
+ class CreateService < BaseService
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params.dup
+ end
+
+ def execute
+ personal_access_token = current_user.personal_access_tokens.create(params.slice(*allowed_params))
+
+ if personal_access_token.persisted?
+ ServiceResponse.success(payload: { personal_access_token: personal_access_token })
+ else
+ ServiceResponse.error(message: personal_access_token.errors.full_messages.to_sentence)
+ end
+ end
+
+ private
+
+ def allowed_params
+ [
+ :name,
+ :impersonation,
+ :scopes,
+ :expires_at
+ ]
+ end
+ end
+end
diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb
index aac0fa424ca..9a9b453c554 100644
--- a/app/services/pod_logs/elasticsearch_service.rb
+++ b/app/services/pod_logs/elasticsearch_service.rb
@@ -65,6 +65,8 @@ module PodLogs
client = cluster&.application_elastic_stack&.elasticsearch_client
return error(_('Unable to connect to Elasticsearch')) unless client
+ filebeat7 = cluster.application_elastic_stack.filebeat7?
+
response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs(
namespace,
pod_name: result[:pod_name],
@@ -72,7 +74,8 @@ module PodLogs
search: result[:search],
start_time: result[:start_time],
end_time: result[:end_time],
- cursor: result[:cursor]
+ cursor: result[:cursor],
+ filebeat7: filebeat7
)
result.merge!(response)
diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb
index 2e71650b066..592198ef241 100644
--- a/app/services/projects/deploy_tokens/create_service.rb
+++ b/app/services/projects/deploy_tokens/create_service.rb
@@ -8,11 +8,7 @@ module Projects
def execute
deploy_token = create_deploy_token_for(@project, params)
- if deploy_token.persisted?
- success(deploy_token: deploy_token, http_status: :created)
- else
- error(deploy_token.errors.full_messages.to_sentence, :bad_request)
- end
+ create_deploy_token_payload_for(deploy_token)
end
end
end
diff --git a/app/services/resources/create_access_token_service.rb b/app/services/resources/create_access_token_service.rb
new file mode 100644
index 00000000000..fd3c8d78e58
--- /dev/null
+++ b/app/services/resources/create_access_token_service.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+module Resources
+ class CreateAccessTokenService < BaseService
+ attr_accessor :resource_type, :resource
+
+ def initialize(resource_type, resource, user, params = {})
+ @resource_type = resource_type
+ @resource = resource
+ @current_user = user
+ @params = params.dup
+ end
+
+ def execute
+ return unless feature_enabled?
+ return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create?
+
+ # We skip authorization by default, since the user creating the bot is not an admin
+ # and project/group bot users are not created via sign-up
+ user = create_user
+
+ return error(user.errors.full_messages.to_sentence) unless user.persisted?
+ return error("Failed to provide maintainer access") unless provision_access(resource, user)
+
+ token_response = create_personal_access_token(user)
+
+ if token_response.success?
+ success(token_response.payload[:personal_access_token])
+ else
+ error(token_response.message)
+ end
+ end
+
+ private
+
+ def feature_enabled?
+ ::Feature.enabled?(:resource_access_token, resource)
+ end
+
+ def has_permission_to_create?
+ case resource_type
+ when 'project'
+ can?(current_user, :admin_project, resource)
+ when 'group'
+ can?(current_user, :admin_group, resource)
+ else
+ false
+ end
+ end
+
+ def create_user
+ Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true)
+ end
+
+ def default_user_params
+ {
+ name: params[:name] || "#{resource.name.to_s.humanize} bot",
+ email: generate_email,
+ username: generate_username,
+ user_type: "#{resource_type}_bot".to_sym
+ }
+ end
+
+ def generate_username
+ base_username = "#{resource_type}_#{resource.id}_bot"
+
+ uniquify.string(base_username) { |s| User.find_by_username(s) }
+ end
+
+ def generate_email
+ email_pattern = "#{resource_type}#{resource.id}_bot%s@example.com"
+
+ uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s|
+ User.find_by_email(s)
+ end
+ end
+
+ def uniquify
+ Uniquify.new
+ end
+
+ def create_personal_access_token(user)
+ PersonalAccessTokens::CreateService.new(user, personal_access_token_params).execute
+ end
+
+ def personal_access_token_params
+ {
+ name: "#{resource_type}_bot",
+ impersonation: false,
+ scopes: params[:scopes] || default_scopes,
+ expires_at: params[:expires_at] || nil
+ }
+ end
+
+ def default_scopes
+ Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]
+ end
+
+ def provision_access(resource, user)
+ resource.add_maintainer(user)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success(access_token)
+ ServiceResponse.success(payload: { access_token: access_token })
+ end
+ end
+end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 0b74bd77e28..155013db344 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -38,9 +38,7 @@ module Snippets
private
def save_and_commit
- snippet_saved = @snippet.with_transaction_returning_status do
- @snippet.save && @snippet.store_mentions!
- end
+ snippet_saved = @snippet.save
if snippet_saved && Feature.enabled?(:version_snippets, current_user)
create_repository
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index e56b20c6057..e56e01cf82b 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -4,6 +4,8 @@ module Snippets
class UpdateService < Snippets::BaseService
include SpamCheckMethods
+ COMMITTABLE_ATTRIBUTES = %w(file_name content).freeze
+
UpdateError = Class.new(StandardError)
CreateRepositoryError = Class.new(StandardError)
@@ -37,6 +39,10 @@ module Snippets
def save_and_commit(snippet)
return false unless snippet.save
+ # If the updated attributes does not need to update
+ # the repository we can just return
+ return true unless committable_attributes?
+
# In order to avoid non migrated snippets scenarios,
# if the snippet does not have a repository we created it
# We don't need to check if the repository exists
@@ -104,5 +110,9 @@ module Snippets
def repository_empty?(snippet)
snippet.repository._uncached_exists? && !snippet.repository._uncached_has_visible_content?
end
+
+ def committable_attributes?
+ (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present?
+ end
end
end
diff --git a/app/services/spam/spam_check_service.rb b/app/services/spam/spam_action_service.rb
index 3269f9d687a..9ab76b27f4a 100644
--- a/app/services/spam/spam_check_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module Spam
- class SpamCheckService
- include AkismetMethods
+ class SpamActionService
+ include SpamConstants
attr_accessor :target, :request, :options
attr_reader :spam_log
@@ -28,27 +28,40 @@ module Spam
# update the spam log accordingly.
SpamLog.verify_recaptcha!(user_id: user_id, id: spam_log_id)
else
- # Otherwise, it goes to Akismet for spam check.
- # If so, it assigns spammable object as "spam" and creates a SpamLog record.
- possible_spam = check(api)
- target.spam = possible_spam unless target.allow_possible_spam?
- target.spam_log = spam_log
+ return unless request
+ return unless check_for_spam?
+
+ perform_spam_service_check(api)
end
end
+ delegate :check_for_spam?, to: :target
+
private
- def check(api)
- return unless request
- return unless check_for_spam?
- return unless akismet.spam?
+ def perform_spam_service_check(api)
+ # since we can check for spam, and recaptcha is not verified,
+ # ask the SpamVerdictService what to do with the target.
+ spam_verdict_service.execute.tap do |result|
+ case result
+ when REQUIRE_RECAPTCHA
+ create_spam_log(api)
- create_spam_log(api)
- true
- end
+ break if target.allow_possible_spam?
- def check_for_spam?
- target.check_for_spam?
+ # TODO: remove spam! declaration
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/214738
+ target.spam!
+ target.needs_recaptcha!
+ when DISALLOW
+ # TODO: remove `unless target.allow_possible_spam?` once this flag has been passed to `SpamVerdictService`
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/214739
+ target.spam! unless target.allow_possible_spam?
+ create_spam_log(api)
+ when ALLOW
+ target.clear_spam_flags!
+ end
+ end
end
def create_spam_log(api)
@@ -63,6 +76,14 @@ module Spam
via_api: api
}
)
+
+ target.spam_log = spam_log
+ end
+
+ def spam_verdict_service
+ SpamVerdictService.new(target: target,
+ request: @request,
+ options: options)
end
end
end
diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb
new file mode 100644
index 00000000000..085bac684c4
--- /dev/null
+++ b/app/services/spam/spam_constants.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Spam
+ module SpamConstants
+ REQUIRE_RECAPTCHA = :recaptcha
+ DISALLOW = :disallow
+ ALLOW = :allow
+ end
+end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
new file mode 100644
index 00000000000..2b4d5f4a984
--- /dev/null
+++ b/app/services/spam/spam_verdict_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Spam
+ class SpamVerdictService
+ include AkismetMethods
+ include SpamConstants
+
+ def initialize(target:, request:, options:)
+ @target = target
+ @request = request
+ @options = options
+ end
+
+ def execute
+ if akismet.spam?
+ Gitlab::Recaptcha.enabled? ? REQUIRE_RECAPTCHA : DISALLOW
+ else
+ ALLOW
+ end
+ end
+
+ private
+
+ attr_reader :target, :request, :options
+ end
+end
diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb
new file mode 100644
index 00000000000..5bb6f6a1dee
--- /dev/null
+++ b/app/services/terraform/remote_state_handler.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Terraform
+ class RemoteStateHandler < BaseService
+ include Gitlab::OptimisticLocking
+
+ StateLockedError = Class.new(StandardError)
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_with_lock
+ raise ArgumentError unless params[:name].present?
+
+ state = Terraform::State.find_by(project: project, name: params[:name])
+ raise ActiveRecord::RecordNotFound.new("Couldn't find state") unless state
+
+ retry_optimistic_lock(state) { |state| yield state } if state && block_given?
+ state
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def create_or_find!
+ raise ArgumentError unless params[:name].present?
+
+ Terraform::State.create_or_find_by(project: project, name: params[:name])
+ end
+
+ def handle_with_lock
+ retrieve_with_lock do |state|
+ raise StateLockedError unless lock_matches?(state)
+
+ yield state if block_given?
+
+ state.save! unless state.destroyed?
+ end
+ end
+
+ def lock!
+ raise ArgumentError if params[:lock_id].blank?
+
+ retrieve_with_lock do |state|
+ raise StateLockedError if state.locked?
+
+ state.lock_xid = params[:lock_id]
+ state.locked_by_user = current_user
+ state.locked_at = Time.now
+
+ state.save!
+ end
+ end
+
+ def unlock!
+ retrieve_with_lock do |state|
+ # force-unlock does not pass ID, so we ignore it if it is missing
+ raise StateLockedError unless params[:lock_id].nil? || lock_matches?(state)
+
+ state.lock_xid = nil
+ state.locked_by_user = nil
+ state.locked_at = nil
+
+ state.save!
+ end
+ end
+
+ private
+
+ def retrieve_with_lock
+ create_or_find!.tap { |state| retry_optimistic_lock(state) { |state| yield state } }
+ end
+
+ def lock_matches?(state)
+ return true if state.lock_xid.nil? && params[:lock_id].nil?
+
+ ActiveSupport::SecurityUtils
+ .secure_compare(state.lock_xid.to_s, params[:lock_id].to_s)
+ end
+ end
+end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 6f9f307c322..3938d675596 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -81,7 +81,8 @@ module Users
:private_profile,
:organization,
:location,
- :public_email
+ :public_email,
+ :user_type
]
end
@@ -95,7 +96,8 @@ module Users
:first_name,
:last_name,
:password,
- :username
+ :username,
+ :user_type
]
end
@@ -127,6 +129,8 @@ module Users
user_params[:external] = user_external?
end
+ user_params.delete(:user_type) unless project_bot?(user_params[:user_type])
+
user_params
end
@@ -137,6 +141,10 @@ module Users
def user_external?
user_default_internal_regex_instance.match(params[:email]).nil?
end
+
+ def project_bot?(user_type)
+ user_type&.to_sym == :project_bot
+ end
end
end
diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb
index 9c5ae8a8bdc..2306313fc82 100644
--- a/app/uploaders/terraform/state_uploader.rb
+++ b/app/uploaders/terraform/state_uploader.rb
@@ -12,7 +12,7 @@ module Terraform
encrypt(key: :key)
def filename
- "#{model.id}.tfstate"
+ "#{model.uuid}.tfstate"
end
def store_dir
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index f860b7a61a2..0120d4038b9 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -28,7 +28,7 @@
%hr
.append-bottom-20
- = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner)
+ = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com?
.row
.col-md-6
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 3fa957f38a0..4d8df4cc12a 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -5,7 +5,7 @@
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') }
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
-- if Feature.enabled?(:new_variables_ui, @project || @group)
+- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true)
- is_group = !@group.nil?
#js-ci-project-variables{ data: { endpoint: save_endpoint, project_id: @project&.id || '', group: is_group.to_s, maskable_regex: ci_variable_maskable_regex} }
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 7fc76880480..1cc68d927bd 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -17,6 +17,7 @@
install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative),
update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative),
install_elastic_stack_path: clusterable.install_applications_cluster_path(@cluster, :elastic_stack),
+ install_fluentd_path: clusterable.install_applications_cluster_path(@cluster, :fluentd),
cluster_environments_path: cluster_environments_path,
toggle_status: @cluster.enabled? ? 'true': 'false',
has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false',
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 4aef30622cd..8c9b859e127 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -3,7 +3,6 @@
- expanded = expanded_by_default?
- general_expanded = @group.errors.empty? ? expanded : true
-- deploy_token_description = s_('DeployTokens|Group deploy tokens allow read-only access to the repositories and registry images within the group.')
-# Given we only have one field in this form which is also admin-only,
-# we don't want to show an empty section to non-admin users,
@@ -25,8 +24,6 @@
.settings-content
= render 'ci/variables/index', save_endpoint: group_variables_path
-= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
-
%section.settings#runners-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
new file mode 100644
index 00000000000..1f1d7779267
--- /dev/null
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -0,0 +1,6 @@
+- breadcrumb_title _('Repository Settings')
+- page_title _('Repository')
+
+- deploy_token_description = s_('DeployTokens|Group deploy tokens allow read-only access to the repositories and registry images within the group.')
+
+= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index 4b9304cfdb9..aa63127049c 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -65,25 +65,23 @@
%tbody
%tr
%th
- %th= _('Web IDE')
+ %th= _('Editing')
%tr
%td.shortcut
- if browser.platform.mac?
- %kbd &#8984; p
+ %kbd &#8984; shift p
- else
- %kbd ctrl p
- %td= _('Go to file')
+ %kbd ctrl shift p
+ %td= _('Toggle Markdown preview')
%tr
%td.shortcut
- - if browser.platform.mac?
- %kbd &#8984; enter
- - else
- %kbd ctrl enter
- %td= _('Commit (when editing commit message)')
+ %kbd
+ %i.fa.fa-arrow-up
+ %td= _('Edit your most recent comment in a thread (from an empty textarea)')
%tbody
%tr
%th
- %th= _('Wiki pages')
+ %th= _('Wiki')
%tr
%td.shortcut
%kbd e
@@ -91,19 +89,49 @@
%tbody
%tr
%th
- %th= _('Editing')
+ %th= _('Repository Graph')
%tr
%td.shortcut
- - if browser.platform.mac?
- %kbd &#8984; shift p
- - else
- %kbd ctrl shift p
- %td= _('Toggle Markdown preview')
+ %kbd
+ %i.fa.fa-arrow-left
+ \/
+ %kbd h
+ %td= _('Scroll left')
+ %tr
+ %td.shortcut
+ %kbd
+ %i.fa.fa-arrow-right
+ \/
+ %kbd l
+ %td= _('Scroll right')
%tr
%td.shortcut
%kbd
%i.fa.fa-arrow-up
- %td= _('Edit your most recent comment in a thread (from an empty textarea)')
+ \/
+ %kbd k
+ %td= _('Scroll up')
+ %tr
+ %td.shortcut
+ %kbd
+ %i.fa.fa-arrow-down
+ \/
+ %kbd j
+ %td= _('Scroll down')
+ %tr
+ %td.shortcut
+ %kbd
+ shift
+ %i.fa.fa-arrow-up
+ \/ k
+ %td= _('Scroll to top')
+ %tr
+ %td.shortcut
+ %kbd
+ shift
+ %i.fa.fa-arrow-down
+ \/ j
+ %td= _('Scroll to bottom')
.col-lg-4
%table.shortcut-mappings.text-2
%tbody
@@ -229,15 +257,7 @@
%tbody
%tr
%th
- %th= _('Issues / Merge Requests')
- %tr
- %td.shortcut
- %kbd a
- %td= _('Change assignee')
- %tr
- %td.shortcut
- %kbd m
- %td= _('Change milestone')
+ %th= _('Epics, Issues, and Merge Requests')
%tr
%td.shortcut
%kbd r
@@ -250,92 +270,64 @@
%td.shortcut
%kbd l
%td= _('Change label')
+ %tbody
+ %tr
+ %th
+ %th= _('Issues and Merge Requests')
+ %tr
+ %td.shortcut
+ %kbd a
+ %td= _('Change assignee')
+ %tr
+ %td.shortcut
+ %kbd m
+ %td= _('Change milestone')
+ %tbody
+ %tr
+ %th
+ %th= _('Merge Requests')
%tr
%td.shortcut
%kbd ]
\/
%kbd j
- %td= _('Next file in diff (MRs only)')
+ %td= _('Next file in diff')
%tr
%td.shortcut
%kbd [
\/
%kbd k
- %td= _('Previous file in diff (MRs only)')
+ %td= _('Previous file in diff')
%tr
%td.shortcut
- if browser.platform.mac?
%kbd &#8984; p
- else
%kbd ctrl p
- %td= _('Go to file (MRs only)')
+ %td= _('Go to file')
%tr
%td.shortcut
%kbd n
- %td= _('Next unresolved discussion (MRs only)')
+ %td= _('Next unresolved discussion')
%tr
%td.shortcut
%kbd p
- %td= _('Previous unresolved discussion (MRs only)')
+ %td= _('Previous unresolved discussion')
%tbody
%tr
%th
- %th= _('Epics (Ultimate / Gold license only)')
- %tr
- %td.shortcut
- %kbd r
- %td= _('Comment/Reply (quoting selected text)')
- %tr
- %td.shortcut
- %kbd e
- %td= _('Edit epic description')
- %tr
- %td.shortcut
- %kbd l
- %td= _('Change label')
- %tbody
- %tr
- %th
- %th= _('Repository Graph')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-left
- \/
- %kbd h
- %td= _('Scroll left')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-right
- \/
- %kbd l
- %td= _('Scroll right')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-up
- \/
- %kbd k
- %td= _('Scroll up')
- %tr
- %td.shortcut
- %kbd
- %i.fa.fa-arrow-down
- \/
- %kbd j
- %td= _('Scroll down')
+ %th= _('Web IDE')
%tr
%td.shortcut
- %kbd
- shift
- %i.fa.fa-arrow-up
- \/ k
- %td= _('Scroll to top')
+ - if browser.platform.mac?
+ %kbd &#8984; p
+ - else
+ %kbd ctrl p
+ %td= _('Go to file')
%tr
%td.shortcut
- %kbd
- shift
- %i.fa.fa-arrow-down
- \/ j
- %td= _('Scroll to bottom')
+ - if browser.platform.mac?
+ %kbd &#8984; enter
+ - else
+ %kbd ctrl enter
+ %td= _('Commit (when editing commit message)')
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 06e3bca99a1..80a14412968 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -5,7 +5,8 @@
.mobile-overlay
.alert-wrapper
= render 'shared/outdated_browser'
- = render_if_exists "layouts/header/ee_license_banner"
+ - if Feature.enabled?(:subscribable_banner_license)
+ = render_if_exists "layouts/header/ee_subscribable_banner"
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
= render "layouts/nav/classification_level_banner"
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index c6299f244ec..410b120396d 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -26,7 +26,7 @@
- if current_user_menu?(:settings)
%li
= link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' }
- = render_if_exists 'layouts/header/buy_ci_minutes'
+ = render_if_exists 'layouts/header/buy_ci_minutes', project: @project, namespace: @group
- if current_user_menu?(:help)
%li.divider.d-md-none
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index a003d6f8903..2b3f5d266b0 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -1,5 +1,6 @@
%ul
- if current_user_menu?(:help)
+ = render_if_exists 'layouts/header/whats_new_dropdown_item'
%li
= link_to _("Help"), help_path
%li
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 8115c713a4f..f63a7b3a664 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -155,6 +155,11 @@
%span
= _('Projects')
+ = nav_link(controller: :repository) do
+ = link_to group_settings_repository_path(@group), title: _('Repository') do
+ %span
+ = _('Repository')
+
= nav_link(controller: :ci_cd) do
= link_to group_settings_ci_cd_path(@group), title: _('CI / CD') do
%span
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index c11d1256d21..3aa056fad7b 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -222,6 +222,12 @@
%span
= _('Metrics')
+ - if project_nav_tab?(:alert_management)
+ = nav_link(controller: :alert_management) do
+ = link_to project_alert_management_index_path(@project), title: _('Alerts'), class: 'shortcuts-tracking qa-operations-tracking-link' do
+ %span
+ = _('Alerts')
+
= render_if_exists "layouts/nav/sidebar/tracing_link"
= nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do
diff --git a/app/views/notify/issues_csv_email.html.haml b/app/views/notify/issues_csv_email.html.haml
new file mode 100644
index 00000000000..b777ca1e57d
--- /dev/null
+++ b/app/views/notify/issues_csv_email.html.haml
@@ -0,0 +1,9 @@
+-# haml-lint:disable NoPlainNodes
+%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
+ Your CSV export of #{ pluralize(@written_count, 'issue') } from project
+ %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" }
+ = @project.full_name
+ has been added to this email as an attachment.
+ - if @truncated
+ %p
+ This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues.
diff --git a/app/views/notify/issues_csv_email.text.erb b/app/views/notify/issues_csv_email.text.erb
new file mode 100644
index 00000000000..5d4128e3ae9
--- /dev/null
+++ b/app/views/notify/issues_csv_email.text.erb
@@ -0,0 +1,5 @@
+Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment.
+
+<% if @truncated %>
+This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues.
+<% end %>
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index f9222387e97..8217608db4e 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -8,4 +8,6 @@
- unless project.empty_repo?
= render 'shared/auto_devops_implicitly_enabled_banner', project: project
= render_if_exists 'projects/above_size_limit_warning', project: project
+ - if Feature.enabled?(:subscribable_banner_subscription)
+ = render_if_exists "layouts/header/ee_subscribable_banner", subscription: true
= render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)]
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index d9887cb470a..be58ecb3572 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -14,6 +14,7 @@
= @project.name
%span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
+ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project
.home-panel-metadata.d-flex.flex-wrap.text-secondary
- if can?(current_user, :read_project, @project)
%span.text-secondary
diff --git a/app/views/projects/alert_management/index.html.haml b/app/views/projects/alert_management/index.html.haml
new file mode 100644
index 00000000000..dab6aec0446
--- /dev/null
+++ b/app/views/projects/alert_management/index.html.haml
@@ -0,0 +1,3 @@
+- page_title _('Alerts')
+
+#js-alert_management{ data: alert_management_data(@project) }
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index cae8bbf8c01..445752d0a15 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -12,14 +12,13 @@
%h5.m-0.dropdown-bold-header= _('Download source code')
.dropdown-menu-content
= render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
- - if Feature.enabled?(:git_archive_path, default_enabled: true)
- - if vue_file_list_enabled?
- #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
- - elsif directory?
- %section.border-top.pt-1.mt-1
- %h5.m-0.dropdown-bold-header= _('Download this directory')
- .dropdown-menu-content
- = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
+ - if vue_file_list_enabled?
+ #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } }
+ - elsif directory?
+ %section.border-top.pt-1.mt-1
+ %h5.m-0.dropdown-bold-header= _('Download this directory')
+ .dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
- if pipeline && pipeline.latest_builds_with_artifacts.any?
%section.border-top.pt-1.mt-1
%h5.m-0.dropdown-bold-header= _('Download artifacts')
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index b0d9dfb0d37..da20fee227a 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -10,27 +10,25 @@
.card
.card-header
{{ __('Recent Project Activity') }}
- .content-block
- .container-fluid
- .row
- .col-12.column{ "v-for" => "item in state.summary", ":class" => "summaryTableColumnClass" }
- %h3.header {{ item.value }}
- %p.text {{ item.title }}
- .col-12.column{ ":class" => "summaryTableColumnClass" }
- .dropdown.inline.js-ca-dropdown
- %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
- %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
- %i.fa.fa-chevron-down
- %ul.dropdown-menu.dropdown-menu-right
- %li
- %a{ "href" => "#", "data-value" => "7" }
- {{ n__('Last %d day', 'Last %d days', 7) }}
- %li
- %a{ "href" => "#", "data-value" => "30" }
- {{ n__('Last %d day', 'Last %d days', 30) }}
- %li
- %a{ "href" => "#", "data-value" => "90" }
- {{ n__('Last %d day', 'Last %d days', 90) }}
+ .d-flex.justify-content-between
+ .flex-grow.text-center{ "v-for" => "item in state.summary" }
+ %h3.header {{ item.value }}
+ %p.text {{ item.title }}
+ .flex-grow.align-self-center.text-center
+ .dropdown.inline.js-ca-dropdown
+ %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
+ %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
+ %i.fa.fa-chevron-down
+ %ul.dropdown-menu.dropdown-menu-right
+ %li
+ %a{ "href" => "#", "data-value" => "7" }
+ {{ n__('Last %d day', 'Last %d days', 7) }}
+ %li
+ %a{ "href" => "#", "data-value" => "30" }
+ {{ n__('Last %d day', 'Last %d days', 30) }}
+ %li
+ %a{ "href" => "#", "data-value" => "90" }
+ {{ n__('Last %d day', 'Last %d days', 90) }}
.stage-panel-container
.card.stage-panel
.card-header.border-bottom-0
diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml
index 6003f33f0ba..4106bcc2e5a 100644
--- a/app/views/projects/import/jira/show.html.haml
+++ b/app/views/projects/import/jira/show.html.haml
@@ -1,6 +1,9 @@
-- if Feature.enabled?(:jira_issue_import_vue, @project)
+- if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
.js-jira-import-root{ data: { project_path: @project.full_path,
- is_jira_configured: @is_jira_configured.to_s,
+ issues_path: project_issues_path(@project),
+ is_jira_configured: @project.jira_service.present?.to_s,
+ jira_projects: @jira_projects.to_json,
+ in_progress_illustration: image_path('illustrations/export-import.svg'),
setup_illustration: image_path('illustrations/manual_action.svg') } }
- else
- title = _('Jira Issue Import')
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index c347b8d2c9c..71c9bb36936 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -8,7 +8,7 @@
.btn-group
- if show_export_button
- = render_if_exists 'projects/issues/export_csv/button'
+ = render 'projects/issues/export_csv/button'
- if show_import_button
= render 'projects/issues/import_csv/button'
@@ -23,7 +23,7 @@
id: "new_issue_link"
- if show_export_button
- = render_if_exists 'projects/issues/export_csv/modal'
+ = render 'projects/issues/export_csv/modal'
- if show_import_button
= render 'projects/issues/import_csv/modal'
diff --git a/app/views/projects/issues/export_csv/_button.html.haml b/app/views/projects/issues/export_csv/_button.html.haml
new file mode 100644
index 00000000000..ef3fb438641
--- /dev/null
+++ b/app/views/projects/issues/export_csv/_button.html.haml
@@ -0,0 +1,4 @@
+- if current_user
+ %button.csv_download_link.btn.has-tooltip{ title: _('Export as CSV'),
+ data: { toggle: 'modal', target: '.issues-export-modal', qa_selector: 'export_as_csv_button' } }
+ = sprite_icon('export')
diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml
new file mode 100644
index 00000000000..af3a087ca59
--- /dev/null
+++ b/app/views/projects/issues/export_csv/_modal.html.haml
@@ -0,0 +1,22 @@
+-# haml-lint:disable NoPlainNodes
+- if current_user
+ .issues-export-modal.modal
+ .modal-dialog
+ .modal-content{ data: { qa_selector: 'export_issues_modal' } }
+ .modal-header
+ %h3
+ = _('Export issues')
+ .svg-content.import-export-svg-container
+ = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration'
+ %a.close{ href: '#', 'data-dismiss' => 'modal' }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .modal-body
+ .modal-subheader
+ = icon('check', { class: 'checkmark' })
+ %strong.prepend-left-10
+ - issues_count = issuables_count_for_state(:issues, params[:state])
+ = n_('%d issue selected', '%d issues selected', issues_count) % issues_count
+ .modal-text
+ = _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email }
+ .modal-footer
+ = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 4fc67884584..e8987265a93 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -10,6 +10,8 @@
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
- can_create_issue = show_new_issue_link?(@project)
+= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
+
.detail-page-header
.detail-page-header-body
.issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(@issue, status_box: :closed) }
@@ -50,7 +52,7 @@
%li.divider
%li= link_to 'New issue', new_project_issue_path(@project), id: 'new_issue_link'
- = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue
+ = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(@issue.blocked?) && @issue.blocked?
- if can_report_spam
= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam'
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index 2eb96a7bc9b..1366c67f84e 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -2,17 +2,20 @@
- display_issuable_type = issuable_display_type(issuable)
- button_method = issuable_close_reopen_button_method(issuable)
- are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false)
+- add_blocked_class = false
+- if defined? warn_before_close
+ - add_blocked_class = warn_before_close
- if is_current_user
- if can_update
= link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method,
- class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' }
+ class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' }
- if can_reopen
= link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method,
class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}", data: { qa_selector: 'reopen_issue_button' }
- else
- if can_update && !are_close_and_open_buttons_hidden
- = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
+ = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
- else
= link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: 'Report abuse'
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index 0d59c9304b4..e71387b50bf 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -5,10 +5,13 @@
- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
- button_method = issuable_close_reopen_button_method(issuable)
+- add_blocked_class = false
+- if defined? warn_before_close
+ - add_blocked_class = !issuable.closed? && warn_before_close
.float-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
= link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable),
- method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}"
+ method: button_method, class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "#{display_button_action} #{display_issuable_type}"
= button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color",
data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index d29ba3eedc6..3d61943193f 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -54,6 +54,10 @@
.metadata-info.prepend-top-8
%span.user-access-role.d-block= Gitlab::Access.human_access(access)
+ - if !explore_projects_tab?
+ .metadata-info.prepend-top-8
+ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project
+
- if show_last_commit_as_description
.description.d-none.d-sm-block.append-right-default
= link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml
index 24b4eae0c58..675a8f922c4 100644
--- a/app/views/shared/runners/_form.html.haml
+++ b/app/views/shared/runners/_form.html.haml
@@ -47,5 +47,16 @@
.col-sm-10
= f.text_field :tag_list, value: runner.tag_list.sort.join(', '), class: 'form-control'
.form-text.text-muted= _('You can set up jobs to only use Runners with specific tags. Separate tags with commas.')
+ - if local_assigns[:in_gitlab_com_admin_context]
+ .form-group.row
+ = label_tag :public_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do
+ = _('Public projects Minutes cost factor')
+ .col-sm-10
+ = f.text_field :public_projects_minutes_cost_factor, class: 'form-control'
+ .form-group.row
+ = label_tag :private_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do
+ = _('Private projects Minutes cost factor')
+ .col-sm-10
+ = f.text_field :private_projects_minutes_cost_factor, class: 'form-control'
.form-actions
= f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 5ba6d52fefe..396b6e56ea9 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,54 +1,58 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
-
-.snippet-form-holder
- = form_for @snippet, url: url,
- html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
- data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f|
- = form_errors(@snippet)
-
- .form-group
- = f.label :title, class: 'label-bold'
- = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
-
- .form-group.js-description-input
- - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
- - is_expanded = @snippet.description && !@snippet.description.empty?
- = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold'
- .js-collapsible-input
- .js-collapsed{ class: ('d-none' if is_expanded) }
- = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
- .js-expanded{ class: ('d-none' if !is_expanded) }
- = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
- = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field'
- = render 'shared/notes/hints'
-
- .form-group.file-editor
- = f.label :file_name, s_('Snippets|File')
- .file-holder.snippet
- .js-file-title.file-title-flex-parent
- = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name'
- .file-content.code
- %pre#editor{ data: { 'editor-loading': true } }= @snippet.content
- = f.hidden_field :content, class: 'snippet-file-content'
-
- .form-group
- .font-weight-bold
- = _('Visibility level')
- = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank'
- = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false
-
- - if params[:files]
- - params[:files].each_with_index do |file, index|
- = hidden_field_tag "files[]", file, id: "files_#{index}"
-
- .form-actions
- - if @snippet.new_record?
- = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button"
- - else
- = f.submit 'Save changes', class: "btn-success btn"
-
- - if @snippet.project_id
- = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
- - else
- = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
+- if Feature.disabled?(:monaco_snippets)
+ - content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+
+- if Feature.enabled?(:snippets_edit_vue)
+ #js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access") } }
+- else
+ .snippet-form-holder
+ = form_for @snippet, url: url,
+ html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
+ data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f|
+ = form_errors(@snippet)
+
+ .form-group
+ = f.label :title, class: 'label-bold'
+ = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
+
+ .form-group.js-description-input
+ - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
+ - is_expanded = @snippet.description && !@snippet.description.empty?
+ = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold'
+ .js-collapsible-input
+ .js-collapsed{ class: ('d-none' if is_expanded) }
+ = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
+ .js-expanded{ class: ('d-none' if !is_expanded) }
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
+ = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field'
+ = render 'shared/notes/hints'
+
+ .form-group.file-editor
+ = f.label :file_name, s_('Snippets|File')
+ .file-holder.snippet
+ .js-file-title.file-title-flex-parent
+ = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name'
+ .file-content.code
+ %pre#editor{ data: { 'editor-loading': true } }= @snippet.content
+ = f.hidden_field :content, class: 'snippet-file-content'
+
+ .form-group
+ .font-weight-bold
+ = _('Visibility level')
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank'
+ = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false
+
+ - if params[:files]
+ - params[:files].each_with_index do |file, index|
+ = hidden_field_tag "files[]", file, id: "files_#{index}"
+
+ .form-actions
+ - if @snippet.new_record?
+ = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button"
+ - else
+ = f.submit 'Save changes', class: "btn-success btn"
+
+ - if @snippet.project_id
+ = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
+ - else
+ = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 3fea2c1e3fc..128ddbb8e8b 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -1,6 +1,5 @@
- link_project = local_assigns.fetch(:link_project, false)
- notes_count = @noteable_meta_data[snippet.id].user_notes_count
-- file_name = snippet_file_name(snippet)
%li.snippet-row.py-3
= image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
@@ -8,10 +7,6 @@
.title
= link_to gitlab_snippet_path(snippet) do
= snippet.title
- - if file_name.present?
- %span.snippet-filename.d-none.d-sm-inline-block.ml-2
- = sprite_icon('doc-code', size: 16, css_class: 'file-icon align-text-bottom')
- = file_name
%ul.controls
%li
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 38f518458d6..694f2c23e1e 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -269,6 +269,13 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+- :name: cronjob:x509_issuer_crl_check
+ :feature_category: :source_code_management
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
- :name: deployment:deployments_finished
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -709,7 +716,7 @@
:urgency: :high
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent: true
- :name: pipeline_creation:create_pipeline
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -849,7 +856,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent: true
- :name: pipeline_processing:update_head_pipeline_for_merge_request
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1046,6 +1053,13 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+- :name: export_csv
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent:
- :name: file_hook
:feature_category: :integrations
:has_external_dependencies:
diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb
index 3da21c56eff..9cbc75f8944 100644
--- a/app/workers/create_commit_signature_worker.rb
+++ b/app/workers/create_commit_signature_worker.rb
@@ -21,14 +21,19 @@ class CreateCommitSignatureWorker # rubocop:disable Scalability/IdempotentWorker
return if commits.empty?
- # This calculates and caches the signature in the database
- commits.each do |commit|
+ # Instantiate commits first to lazily load the signatures
+ commits.map! do |commit|
case commit.signature_type
when :PGP
- Gitlab::Gpg::Commit.new(commit).signature
+ Gitlab::Gpg::Commit.new(commit)
when :X509
- Gitlab::X509::Commit.new(commit).signature
+ Gitlab::X509::Commit.new(commit)
end
+ end
+
+ # This calculates and caches the signature in the database
+ commits.each do |commit|
+ commit&.signature
rescue => e
Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") # rubocop:disable Gitlab/RailsLogger
end
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 1d2708cdb44..0710ef9298b 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker
+class ExpirePipelineCacheWorker
include ApplicationWorker
include PipelineQueue
@@ -8,6 +8,8 @@ class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker
urgency :high
worker_resource_boundary :cpu
+ idempotent!
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb
new file mode 100644
index 00000000000..9e2b3ad9bb4
--- /dev/null
+++ b/app/workers/export_csv_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ feature_category :issue_tracking
+ worker_resource_boundary :cpu
+
+ def perform(current_user_id, project_id, params)
+ @current_user = User.find(current_user_id)
+ @project = Project.find(project_id)
+
+ params.symbolize_keys!
+ params[:project_id] = project_id
+ params.delete(:sort)
+
+ issues = IssuesFinder.new(@current_user, params).execute
+
+ Issues::ExportCsvService.new(issues, @project).email(@current_user)
+ end
+end
diff --git a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb
index 1d57b77ac7e..3e2cfe56cea 100644
--- a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb
@@ -10,7 +10,7 @@ module Gitlab
def import(project)
JiraImport.cache_cleanup(project.id)
- project.latest_jira_import&.finish!
+ project.latest_jira_import.finish!
end
end
end
diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb
index c60bee0ffdc..2166655115d 100644
--- a/app/workers/project_daily_statistics_worker.rb
+++ b/app/workers/project_daily_statistics_worker.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/214585
class ProjectDailyStatisticsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb
index aface8288e3..20db19536c3 100644
--- a/app/workers/stage_update_worker.rb
+++ b/app/workers/stage_update_worker.rb
@@ -1,12 +1,14 @@
# frozen_string_literal: true
-class StageUpdateWorker # rubocop:disable Scalability/IdempotentWorker
+class StageUpdateWorker
include ApplicationWorker
include PipelineQueue
queue_namespace :pipeline_processing
urgency :high
+ idempotent!
+
def perform(stage_id)
Ci::Stage.find_by_id(stage_id)&.update_legacy_status
end
diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb
new file mode 100644
index 00000000000..5fc92da803c
--- /dev/null
+++ b/app/workers/x509_issuer_crl_check_worker.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+class X509IssuerCrlCheckWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ feature_category :source_code_management
+ urgency :low
+
+ idempotent!
+ worker_has_external_dependencies!
+
+ attr_accessor :logger
+
+ def perform
+ @logger = Gitlab::GitLogger.build
+
+ X509Issuer.all.find_each do |issuer|
+ with_context(related_class: X509IssuerCrlCheckWorker) do
+ update_certificates(issuer)
+ end
+ end
+ end
+
+ private
+
+ def update_certificates(issuer)
+ crl = download_crl(issuer)
+ return unless crl
+
+ serials = X509Certificate.serial_numbers(issuer)
+ return if serials.empty?
+
+ revoked_serials = serials & crl.revoked.map(&:serial).map(&:to_i)
+
+ revoked_serials.each_slice(1000) do |batch|
+ certs = issuer.x509_certificates.where(serial_number: batch, certificate_status: :good) # rubocop: disable CodeReuse/ActiveRecord
+
+ certs.find_each do |cert|
+ logger.info(message: "Certificate revoked",
+ id: cert.id,
+ email: cert.email,
+ subject: cert.subject,
+ serial_number: cert.serial_number,
+ issuer: cert.x509_issuer.id,
+ issuer_subject: cert.x509_issuer.subject,
+ issuer_crl_url: cert.x509_issuer.crl_url)
+ end
+
+ certs.update_all(certificate_status: :revoked)
+ end
+ end
+
+ def download_crl(issuer)
+ response = Gitlab::HTTP.try_get(issuer.crl_url)
+
+ if response&.code == 200
+ OpenSSL::X509::CRL.new(response.body)
+ else
+ logger.warn(message: "Failed to download certificate revocation list",
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url)
+
+ nil
+ end
+
+ rescue OpenSSL::X509::CRLError
+ logger.warn(message: "Failed to parse certificate revocation list",
+ issuer: issuer.id,
+ issuer_subject: issuer.subject,
+ issuer_crl_url: issuer.crl_url)
+
+ nil
+ end
+end