Commit ebc95134 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents e94e2691 62e6bb3d
15c2f3921c4729e9c4d7ce8592300decfcfdb2e6 12dcff902c9a2178fa6f4992d9d562ad9b422dd2
...@@ -20,7 +20,6 @@ export default { ...@@ -20,7 +20,6 @@ export default {
components: { components: {
GlIcon, GlIcon,
GlIntersectionObserver, GlIntersectionObserver,
descriptionComponent,
titleComponent, titleComponent,
editedComponent, editedComponent,
formComponent, formComponent,
...@@ -152,6 +151,18 @@ export default { ...@@ -152,6 +151,18 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
descriptionComponent: {
type: Object,
required: false,
default: () => {
return descriptionComponent;
},
},
showTitleBorder: {
type: Boolean,
required: false,
default: true,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
...@@ -209,6 +220,11 @@ export default { ...@@ -209,6 +220,11 @@ export default {
isOpenStatus() { isOpenStatus() {
return this.issuableStatus === IssuableStatus.Open; return this.issuableStatus === IssuableStatus.Open;
}, },
pinnedLinkClasses() {
return this.showTitleBorder
? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'
: '';
},
statusIcon() { statusIcon() {
return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close'; return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
}, },
...@@ -447,9 +463,11 @@ export default { ...@@ -447,9 +463,11 @@ export default {
<pinned-links <pinned-links
:zoom-meeting-url="zoomMeetingUrl" :zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl" :published-incident-url="publishedIncidentUrl"
:class="pinnedLinkClasses"
/> />
<description-component <component
:is="descriptionComponent"
v-if="state.descriptionHtml" v-if="state.descriptionHtml"
:can-update="canUpdate" :can-update="canUpdate"
:description-html="state.descriptionHtml" :description-html="state.descriptionHtml"
......
<script>
import { GlTab, GlTabs } from '@gitlab/ui';
import DescriptionComponent from './description.vue';
export default {
components: {
GlTab,
GlTabs,
DescriptionComponent,
},
};
</script>
<template>
<div>
<gl-tabs
content-class="gl-reset-line-height gl-mt-3"
class="gl-mt-n3"
data-testid="incident-tabs"
>
<gl-tab :title="__('Summary')">
<description-component v-bind="$attrs" />
</gl-tab>
</gl-tabs>
</div>
</template>
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
</script> </script>
<template> <template>
<div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start"> <div class="gl-display-flex gl-justify-content-start">
<template v-for="(link, i) in pinnedLinks"> <template v-for="(link, i) in pinnedLinks">
<div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }"> <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
<gl-button <gl-button
......
import Vue from 'vue';
import issuableApp from './components/app.vue';
import incidentTabs from './components/incident_tabs.vue';
export default function initIssuableApp(issuableData = {}) {
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
issuableApp,
},
render(createElement) {
return createElement('issuable-app', {
props: {
...issuableData,
descriptionComponent: incidentTabs,
showTitleBorder: false,
},
});
},
});
}
import Vue from 'vue'; import Vue from 'vue';
import issuableApp from './components/app.vue'; import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data';
export default function initIssueableApp() { export default function initIssuableApp(issuableData) {
return new Vue({ return new Vue({
el: document.getElementById('js-issuable-app'), el: document.getElementById('js-issuable-app'),
components: { components: {
...@@ -10,7 +9,7 @@ export default function initIssueableApp() { ...@@ -10,7 +9,7 @@ export default function initIssueableApp() {
}, },
render(createElement) { render(createElement) {
return createElement('issuable-app', { return createElement('issuable-app', {
props: parseIssuableData(), props: issuableData,
}); });
}, },
}); });
......
...@@ -4,14 +4,23 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; ...@@ -4,14 +4,23 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
import '~/notes/index'; import '~/notes/index';
import { store } from '~/notes/stores'; import { store } from '~/notes/stores';
import initIssueableApp from '~/issue_show'; import initIssueApp from '~/issue_show/issue';
import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initRelatedMergeRequestsApp from '~/related_merge_requests';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
export default function() { export default function() {
initIssueableApp(); const { issueType, ...issuableData } = parseIssuableData();
if (issueType === 'incident') {
initIncidentApp(issuableData);
} else {
initIssueApp(issuableData);
}
initIssuableHeaderWarning(store); initIssuableHeaderWarning(store);
initSentryErrorStackTraceApp(); initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp(); initRelatedMergeRequestsApp();
......
...@@ -327,7 +327,8 @@ module ApplicationSettingsHelper ...@@ -327,7 +327,8 @@ module ApplicationSettingsHelper
:group_import_limit, :group_import_limit,
:group_export_limit, :group_export_limit,
:group_download_export_limit, :group_download_export_limit,
:wiki_page_max_content_bytes :wiki_page_max_content_bytes,
:container_registry_delete_tags_service_timeout
] ]
end end
......
# frozen_string_literal: true
module ContainerRegistryHelper
def limit_delete_tags_service?
Feature.enabled?(:container_registry_expiration_policies_throttling) &&
ContainerRegistry::Client.supports_tag_delete?
end
end
...@@ -292,6 +292,7 @@ module IssuablesHelper ...@@ -292,6 +292,7 @@ module IssuablesHelper
{ {
hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0, hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0,
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord
} }
......
...@@ -282,6 +282,9 @@ class ApplicationSetting < ApplicationRecord ...@@ -282,6 +282,9 @@ class ApplicationSetting < ApplicationRecord
validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") } validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") }
validates :container_registry_delete_tags_service_timeout,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
SUPPORTED_KEY_TYPES.each do |type| SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end end
......
...@@ -163,7 +163,8 @@ module ApplicationSettingImplementation ...@@ -163,7 +163,8 @@ module ApplicationSettingImplementation
user_default_external: false, user_default_external: false,
user_default_internal_regex: nil, user_default_internal_regex: nil,
user_show_add_ssh_key_message: true, user_show_add_ssh_key_message: true,
wiki_page_max_content_bytes: 50.megabytes wiki_page_max_content_bytes: 50.megabytes,
container_registry_delete_tags_service_timeout: 100
} }
end end
......
...@@ -351,10 +351,10 @@ class Service < ApplicationRecord ...@@ -351,10 +351,10 @@ class Service < ApplicationRecord
{ success: result.present?, result: result } { success: result.present?, result: result }
end end
# Disable test for instance-level services. # Disable test for instance-level and group-level services.
# https://gitlab.com/gitlab-org/gitlab/-/issues/213138 # https://gitlab.com/gitlab-org/gitlab/-/issues/213138
def can_test? def can_test?
!instance? !instance? && !group_id
end end
# Returns a hash of the properties that have been assigned a new value since last save, # Returns a hash of the properties that have been assigned a new value since last save,
......
...@@ -5,6 +5,11 @@ module Projects ...@@ -5,6 +5,11 @@ module Projects
module Gitlab module Gitlab
class DeleteTagsService class DeleteTagsService
include BaseServiceUtility include BaseServiceUtility
include ::Gitlab::Utils::StrongMemoize
DISABLED_TIMEOUTS = [nil, 0].freeze
TimeoutError = Class.new(StandardError)
def initialize(container_repository, tag_names) def initialize(container_repository, tag_names)
@container_repository = container_repository @container_repository = container_repository
...@@ -17,12 +22,42 @@ module Projects ...@@ -17,12 +22,42 @@ module Projects
def execute def execute
return success(deleted: []) if @tag_names.empty? return success(deleted: []) if @tag_names.empty?
delete_tags
rescue TimeoutError => e
::Gitlab::ErrorTracking.track_exception(e, tags_count: @tag_names&.size, container_repository_id: @container_repository&.id)
error('timeout while deleting tags')
end
private
def delete_tags
start_time = Time.zone.now
deleted_tags = @tag_names.select do |name| deleted_tags = @tag_names.select do |name|
raise TimeoutError if timeout?(start_time)
@container_repository.delete_tag_by_name(name) @container_repository.delete_tag_by_name(name)
end end
deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags') deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
end end
def timeout?(start_time)
return false unless throttling_enabled?
return false if service_timeout.in?(DISABLED_TIMEOUTS)
(Time.zone.now - start_time) > service_timeout
end
def throttling_enabled?
strong_memoize(:feature_flag) do
Feature.enabled?(:container_registry_expiration_policies_throttling)
end
end
def service_timeout
::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout
end
end end
end end
end end
......
...@@ -15,7 +15,7 @@ module Projects ...@@ -15,7 +15,7 @@ module Projects
# This is a hack as the registry doesn't support deleting individual # This is a hack as the registry doesn't support deleting individual
# tags. This code effectively pushes a dummy image and assigns the tag to it. # tags. This code effectively pushes a dummy image and assigns the tag to it.
# This way when the tag is deleted only the dummy image is affected. # This way when the tag is deleted only the dummy image is affected.
# This is used to preverse compatibility with third-party registries that # This is used to preserve compatibility with third-party registries that
# don't support fast delete. # don't support fast delete.
# See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
def execute def execute
......
...@@ -14,5 +14,11 @@ ...@@ -14,5 +14,11 @@
.form-text.text-muted .form-text.text-muted
= _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.") = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
= link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries') = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries')
- if limit_delete_tags_service?
.form-group
= f.label :container_registry_delete_tags_service_timeout, _('Cleanup policy maximum processing time (seconds)'), class: 'label-bold'
= f.number_field :container_registry_delete_tags_service_timeout, min: 0, class: 'form-control'
.form-text.text-muted
= _("Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0.")
= f.submit 'Save changes', class: "btn btn-success" = f.submit 'Save changes', class: "btn btn-success"
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
.col-sm-12 .col-sm-12
= f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title') = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title')
%span.d-inline-block.mw-100.gl-mt-2 %span.d-inline-block.mw-100.gl-mt-2
= icon('lightbulb-o') = sprite_icon('bulb', size: 12, css_class: 'gl-mr-n1')
- if @page.persisted? - if @page.persisted?
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.") = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
= link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'),
......
...@@ -17,13 +17,13 @@ ...@@ -17,13 +17,13 @@
= sprite_icon('pencil') = sprite_icon('pencil')
- elsif current_user - elsif current_user
- if @user.abuse_report - if @user.abuse_report
%button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'), %button{ class: link_classes + 'btn btn-danger', title: s_('UserProfile|Already reported for abuse'),
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } } data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }>
= icon('exclamation-circle') = sprite_icon('error')
- else - else
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn', = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn',
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('exclamation-circle') = sprite_icon('error')
- if can?(current_user, :read_user_profile, @user) - if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do = link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do
= sprite_icon('rss', css_class: 'qa-rss-icon') = sprite_icon('rss', css_class: 'qa-rss-icon')
......
---
title: Add timeout support in the delete tags service for the GitLab Registry
merge_request: 36319
author:
type: changed
---
title: Replace fa-exclamation-circle and fa-lightbulb-o with GitLab SVG icons
merge_request: 40857
author:
type: changed
---
title: Add Summary tab for incident issues
merge_request: 39822
author:
type: added
---
name: container_registry_expiration_policies_throttling
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36319
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238190
group: group::package
type: development
default_enabled: false
# frozen_string_literal: true
class AddContainerRegistryDeleteTagsServiceTimeoutToApplicationSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
add_column(
:application_settings,
:container_registry_delete_tags_service_timeout,
:integer,
default: 250,
null: false
)
end
def down
remove_column(:application_settings, :container_registry_delete_tags_service_timeout)
end
end
3d49c22b718c5b4af0a7372584fe12ab730e1ffca501c7f582f7d01200708eb1
\ No newline at end of file
...@@ -9266,6 +9266,7 @@ CREATE TABLE public.application_settings ( ...@@ -9266,6 +9266,7 @@ CREATE TABLE public.application_settings (
wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL, wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL,
elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL, elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL,
enforce_namespace_storage_limit boolean DEFAULT false NOT NULL, enforce_namespace_storage_limit boolean DEFAULT false NOT NULL,
container_registry_delete_tags_service_timeout integer DEFAULT 250 NOT NULL,
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)), CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)), CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)), CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)),
......
...@@ -533,6 +533,11 @@ The cleanup policy: ...@@ -533,6 +533,11 @@ The cleanup policy:
1. Excludes from the list any tags matching the `name_regex_keep` value (tags to preserve). 1. Excludes from the list any tags matching the `name_regex_keep` value (tags to preserve).
1. Finally, the remaining tags in the list are deleted from the Container Registry. 1. Finally, the remaining tags in the list are deleted from the Container Registry.
CAUTION: **Warning:**
On GitLab.com, the execution time for the cleanup policy is limited, and some of the tags may remain in
the Container Registry after the policy runs. The next time the policy runs, the remaining tags are included,
so it may take multiple runs for all tags to be deleted.
### Create a cleanup policy ### Create a cleanup policy
You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI. You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI.
......
...@@ -4,9 +4,9 @@ import VulnerabilitySeverities from 'ee/security_dashboard/components/first_clas ...@@ -4,9 +4,9 @@ import VulnerabilitySeverities from 'ee/security_dashboard/components/first_clas
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue'; import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql'; import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue'; import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import CsvExportButton from './csv_export_button.vue'; import CsvExportButton from './csv_export_button.vue';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql'; import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql'; import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql';
...@@ -35,7 +35,7 @@ export default { ...@@ -35,7 +35,7 @@ export default {
return data.instanceSecurityDashboard.projects.nodes; return data.instanceSecurityDashboard.projects.nodes;
}, },
error() { error() {
createFlash(__('Something went wrong, unable to get projects')); createFlash({ message: createProjectLoadingError() });
}, },
}, },
}, },
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql'; import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import { createProjectLoadingError } from '../helpers';
import ProjectManager from './first_class_project_manager/project_manager.vue'; import ProjectManager from './first_class_project_manager/project_manager.vue';
export default { export default {
...@@ -27,13 +28,18 @@ export default { ...@@ -27,13 +28,18 @@ export default {
hasError: false, hasError: false,
}; };
}, },
computed: {
errorMessage() {
return createProjectLoadingError();
},
},
}; };
</script> </script>
<template> <template>
<security-dashboard-layout> <security-dashboard-layout>
<gl-alert v-if="hasError" variant="danger"> <gl-alert v-if="hasError" variant="danger">
{{ __('Something went wrong, unable to get projects') }} {{ errorMessage }}
</gl-alert> </gl-alert>
<div v-else class="gl-display-flex gl-justify-content-center"> <div v-else class="gl-display-flex gl-justify-content-center">
<project-manager :projects="projects" /> <project-manager :projects="projects" />
......
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import DashboardNotConfigured from './empty_states/group_dashboard_not_configured.vue';
import SecurityChartsLayout from './security_charts_layout.vue';
import VulnerabilityChart from './first_class_vulnerability_chart.vue'; import VulnerabilityChart from './first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from './first_class_vulnerability_severities.vue'; import VulnerabilitySeverities from './first_class_vulnerability_severities.vue';
import vulnerabilityHistoryQuery from '../graphql/group_vulnerability_history.query.graphql'; import vulnerabilityHistoryQuery from '../graphql/group_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/group_vulnerability_grades.query.graphql'; import vulnerabilityGradesQuery from '../graphql/group_vulnerability_grades.query.graphql';
import SecurityChartsLayout from './security_charts_layout.vue'; import vulnerableProjectsQuery from '../graphql/vulnerable_projects.query.graphql';
export default { export default {
components: { components: {
GlLoadingIcon,
DashboardNotConfigured,
SecurityChartsLayout, SecurityChartsLayout,
VulnerabilitySeverities, VulnerabilitySeverities,
VulnerabilityChart, VulnerabilityChart,
...@@ -17,18 +24,55 @@ export default { ...@@ -17,18 +24,55 @@ export default {
required: true, required: true,
}, },
}, },
apollo: {
projects: {
query: vulnerableProjectsQuery,
variables() {
return { fullPath: this.groupFullPath };
},
update(data) {
return data?.group?.projects?.nodes ?? [];
},
error() {
createFlash({ message: createProjectLoadingError() });
},
},
},
data() { data() {
return { return {
projects: [],
vulnerabilityHistoryQuery, vulnerabilityHistoryQuery,
vulnerabilityGradesQuery, vulnerabilityGradesQuery,
}; };
}, },
computed: {
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
shouldShowCharts() {
return Boolean(!this.isLoadingProjects && this.projects.length);
},
shouldShowEmptyState() {
return !this.isLoadingProjects && !this.projects.length;
},
},
}; };
</script> </script>
<template> <template>
<security-charts-layout> <security-charts-layout>
<template v-if="shouldShowEmptyState" #empty-state>
<dashboard-not-configured />
</template>
<template v-else-if="shouldShowCharts" #default>
<vulnerability-chart :query="vulnerabilityHistoryQuery" :group-full-path="groupFullPath" /> <vulnerability-chart :query="vulnerabilityHistoryQuery" :group-full-path="groupFullPath" />
<vulnerability-severities :query="vulnerabilityGradesQuery" :group-full-path="groupFullPath" /> <vulnerability-severities
:query="vulnerabilityGradesQuery"
:group-full-path="groupFullPath"
/>
</template>
<template v-else #loading>
<gl-loading-icon size="lg" class="gl-mt-6" />
</template>
</security-charts-layout> </security-charts-layout>
</template> </template>
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import DashboardNotConfigured from './empty_states/instance_dashboard_not_configured.vue';
import SecurityChartsLayout from './security_charts_layout.vue'; import SecurityChartsLayout from './security_charts_layout.vue';
import VulnerabilityChart from './first_class_vulnerability_chart.vue'; import VulnerabilityChart from './first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from './first_class_vulnerability_severities.vue'; import VulnerabilitySeverities from './first_class_vulnerability_severities.vue';
import projectsQuery from '../graphql/get_instance_security_dashboard_projects.query.graphql';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql'; import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql'; import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql';
export default { export default {
components: { components: {
GlLoadingIcon,
DashboardNotConfigured,
SecurityChartsLayout, SecurityChartsLayout,
VulnerabilitySeverities, VulnerabilitySeverities,
VulnerabilityChart, VulnerabilityChart,
}, },
apollo: {
projects: {
query: projectsQuery,
update(data) {
return data?.instanceSecurityDashboard?.projects?.nodes ?? [];
},
error() {
createFlash({ message: createProjectLoadingError() });
},
},
},
data() { data() {
return { return {
projects: [],
vulnerabilityHistoryQuery, vulnerabilityHistoryQuery,
vulnerabilityGradesQuery, vulnerabilityGradesQuery,
}; };
}, },
computed: {
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
shouldShowCharts() {
return Boolean(!this.isLoadingProjects && this.projects.length);
},
shouldShowEmptyState() {
return !this.isLoadingProjects && !this.projects.length;
},
},
}; };
</script> </script>
<template> <template>
<security-charts-layout> <security-charts-layout>
<template v-if="shouldShowEmptyState" #empty-state>
<dashboard-not-configured />
</template>
<template v-else-if="shouldShowCharts" #default>
<vulnerability-chart :query="vulnerabilityHistoryQuery" /> <vulnerability-chart :query="vulnerabilityHistoryQuery" />
<vulnerability-severities :query="vulnerabilityGradesQuery" /> <vulnerability-severities :query="vulnerabilityGradesQuery" />
</template>
<template v-else #loading>
<gl-loading-icon size="lg" class="gl-mt-6" />
</template>
</security-charts-layout> </security-charts-layout>
</template> </template>
...@@ -8,11 +8,13 @@ export default { ...@@ -8,11 +8,13 @@ export default {
}; };
</script> </script>
<template functional> <template>
<div> <div data-testid="security-charts-layout">
<h2>{{ $options.i18n.title }}</h2> <h2>{{ $options.i18n.title }}</h2>
<slot name="loading"></slot>
<div class="security-charts gl-display-flex gl-flex-wrap"> <div class="security-charts gl-display-flex gl-flex-wrap">
<slot></slot> <slot></slot>
</div> </div>
<slot name="empty-state"></slot>
</div> </div>
</template> </template>
export const COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY = export const COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY =
'hide_pipelines_security_reports_summary_details'; 'hide_pipelines_security_reports_summary_details';
export default () => ({});
...@@ -3,7 +3,7 @@ import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/c ...@@ -3,7 +3,7 @@ import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/c
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants'; import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants'; import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
const parseOptions = obj => const parseOptions = obj =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name })); Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
...@@ -107,4 +107,6 @@ export const preparePageInfo = pageInfo => { ...@@ -107,4 +107,6 @@ export const preparePageInfo = pageInfo => {
return { ...pageInfo, hasNextPage: Boolean(pageInfo?.endCursor) }; return { ...pageInfo, hasNextPage: Boolean(pageInfo?.endCursor) };
}; };
export const createProjectLoadingError = () => __('An error occurred while retrieving projects.');
export default () => ({}); export default () => ({});
...@@ -28,6 +28,10 @@ export default (el, dashboardType) => { ...@@ -28,6 +28,10 @@ export default (el, dashboardType) => {
} }
const props = {}; const props = {};
const provide = {
dashboardDocumentation: el.dataset.dashboardDocumentation,
emptyStateSvgPath: el.dataset.emptyStateSvgPath,
};
let component; let component;
...@@ -36,6 +40,7 @@ export default (el, dashboardType) => { ...@@ -36,6 +40,7 @@ export default (el, dashboardType) => {
props.groupFullPath = el.dataset.groupFullPath; props.groupFullPath = el.dataset.groupFullPath;
} else if (dashboardType === DASHBOARD_TYPES.INSTANCE) { } else if (dashboardType === DASHBOARD_TYPES.INSTANCE) {
component = InstanceSecurityCharts; component = InstanceSecurityCharts;
provide.instanceDashboardSettingsPath = el.dataset.instanceDashboardSettingsPath;
} }
const router = createRouter(); const router = createRouter();
...@@ -46,6 +51,7 @@ export default (el, dashboardType) => { ...@@ -46,6 +51,7 @@ export default (el, dashboardType) => {
store, store,
router, router,
apolloProvider, apolloProvider,
provide: () => provide,
render(createElement) { render(createElement) {
return createElement(component, { props }); return createElement(component, { props });
}, },
......
---
title: Implement empty state on security dashboard
merge_request: 40413
author:
type: changed
...@@ -51,7 +51,7 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -51,7 +51,7 @@ describe('First Class Instance Dashboard Component', () => {
}); });
it('renders the alert component', () => { it('renders the alert component', () => {
expect(findAlert().text()).toBe('Something went wrong, unable to get projects'); expect(findAlert().text()).toBe('An error occurred while retrieving projects.');
}); });
}); });
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'jest/helpers/test_constants'; import { TEST_HOST } from 'jest/helpers/test_constants';
import { GlLoadingIcon } from '@gitlab/ui';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/group_dashboard_not_configured.vue';
import GroupSecurityCharts from 'ee/security_dashboard/components/group_security_charts.vue'; import GroupSecurityCharts from 'ee/security_dashboard/components/group_security_charts.vue';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue'; import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue'; import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue'; import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import vulnerabilityHistoryQuery from 'ee/security_dashboard/graphql/group_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from 'ee/security_dashboard/graphql/group_vulnerability_grades.query.graphql';
jest.mock('ee/security_dashboard/graphql/group_vulnerability_history.query.graphql', () => ({})); jest.mock('ee/security_dashboard/graphql/group_vulnerability_grades.query.graphql', () => ({
mockGrades: true,
}));
jest.mock('ee/security_dashboard/graphql/group_vulnerability_history.query.graphql', () => ({
mockHistory: true,
}));
describe('Group Security Charts component', () => { describe('Group Security Charts component', () => {
let wrapper; let wrapper;
...@@ -13,32 +22,87 @@ describe('Group Security Charts component', () => { ...@@ -13,32 +22,87 @@ describe('Group Security Charts component', () => {
const groupFullPath = `${TEST_HOST}/group/5`; const groupFullPath = `${TEST_HOST}/group/5`;
const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout); const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart); const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities); const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities);
const findDashboardNotConfigured = () => wrapper.find(DashboardNotConfigured);
const createWrapper = () => { const createWrapper = ({ loading = false } = {}) => {
wrapper = shallowMount(GroupSecurityCharts, { wrapper = shallowMount(GroupSecurityCharts, {
mocks: {
$apollo: {
queries: {
projects: {
loading,
},
},
},
},
propsData: { groupFullPath }, propsData: { groupFullPath },
stubs: {
SecurityChartsLayout,
},
}); });
}; };
beforeEach(() => {
createWrapper();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
it('renders the default page', () => { it('renders the loading page', () => {
createWrapper({ loading: true });
const securityChartsLayout = findSecurityChartsLayoutComponent(); const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart(); const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities(); const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true); expect(securityChartsLayout.exists()).toBe(true);
expect(vulnerabilityChart.props()).toEqual({ query: {}, groupFullPath }); expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(true);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the empty state', () => {
createWrapper();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(true);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the default page', async () => {
createWrapper();
wrapper.setData({ projects: [{ name: 'project1' }] });
await wrapper.vm.$nextTick();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(true);
expect(vulnerabilityChart.props()).toEqual({ query: vulnerabilityHistoryQuery, groupFullPath });
expect(vulnerabilitySeverities.exists()).toBe(true); expect(vulnerabilitySeverities.exists()).toBe(true);
expect(vulnerabilitySeverities.props().groupFullPath).toEqual(groupFullPath); expect(vulnerabilitySeverities.props()).toEqual({
query: vulnerabilityGradesQuery,
groupFullPath,
helpPagePath: '',
});
}); });
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import InstanceSecurityCharts from 'ee/security_dashboard/components/instance_security_charts.vue'; import InstanceSecurityCharts from 'ee/security_dashboard/components/instance_security_charts.vue';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/instance_dashboard_not_configured.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue'; import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue'; import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue'; import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
...@@ -17,28 +19,79 @@ describe('Instance Security Charts component', () => { ...@@ -17,28 +19,79 @@ describe('Instance Security Charts component', () => {
let wrapper; let wrapper;
const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout); const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart); const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities); const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities);
const findDashboardNotConfigured = () => wrapper.find(DashboardNotConfigured);
const createWrapper = () => { const createWrapper = ({ loading = false } = {}) => {
wrapper = shallowMount(InstanceSecurityCharts, {}); wrapper = shallowMount(InstanceSecurityCharts, {
}; mocks: {
$apollo: {
beforeEach(() => { queries: {
createWrapper(); projects: {
loading,
},
},
},
},
stubs: {
SecurityChartsLayout,
},
}); });
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
it('renders the default page', () => { it('renders the loading page', () => {
createWrapper({ loading: true });
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(true);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the empty state', () => {
createWrapper();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(true);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the default page', async () => {
createWrapper();
wrapper.setData({ projects: [{ name: 'project1' }] });
await wrapper.vm.$nextTick();
const securityChartsLayout = findSecurityChartsLayoutComponent(); const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart(); const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities(); const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true); expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.props()).toEqual({ query: vulnerabilityHistoryQuery }); expect(vulnerabilityChart.props()).toEqual({ query: vulnerabilityHistoryQuery });
expect(vulnerabilitySeverities.exists()).toBe(true); expect(vulnerabilitySeverities.exists()).toBe(true);
expect(vulnerabilitySeverities.props()).toEqual({ expect(vulnerabilitySeverities.props()).toEqual({
......
...@@ -4,19 +4,23 @@ import SecurityChartsLayout from 'ee/security_dashboard/components/security_char ...@@ -4,19 +4,23 @@ import SecurityChartsLayout from 'ee/security_dashboard/components/security_char
describe('Security Charts Layout component', () => { describe('Security Charts Layout component', () => {
let wrapper; let wrapper;
const DummyComponent = { const DummyComponent1 = {
name: 'dummy-component', name: 'dummy-component-1',
template: '<p>dummy component</p>', template: '<p>dummy component 1</p>',
};
const DummyComponent2 = {
name: 'dummy-component-2',
template: '<p>dummy component 2</p>',
}; };
const findSlot = () => wrapper.find('.security-charts'); const findSlot = () => wrapper.find(`[data-testid="security-charts-layout"]`);
const createWrapper = slots => { const createWrapper = slots => {
wrapper = shallowMount(SecurityChartsLayout, { slots }); wrapper = shallowMount(SecurityChartsLayout, { slots });
}; };
beforeEach(() => { beforeEach(() => {
createWrapper({ default: DummyComponent }); createWrapper({ default: DummyComponent1, 'empty-state': DummyComponent2 });
}); });
afterEach(() => { afterEach(() => {
...@@ -26,6 +30,11 @@ describe('Security Charts Layout component', () => { ...@@ -26,6 +30,11 @@ describe('Security Charts Layout component', () => {
it('should render the default slot', () => { it('should render the default slot', () => {
const slot = findSlot(); const slot = findSlot();
expect(slot.find(DummyComponent).exists()).toBe(true); expect(slot.find(DummyComponent1).exists()).toBe(true);
});
it('should render the empty-state slot', () => {
const slot = findSlot();
expect(slot.find(DummyComponent2).exists()).toBe(true);
}); });
}); });
...@@ -21,6 +21,17 @@ module ContainerRegistry ...@@ -21,6 +21,17 @@ module ContainerRegistry
# Taken from: FaradayMiddleware::FollowRedirects # Taken from: FaradayMiddleware::FollowRedirects
REDIRECT_CODES = Set.new [301, 302, 303, 307] REDIRECT_CODES = Set.new [301, 302, 303, 307]
def self.supports_tag_delete?
registry_config = Gitlab.config.registry
return false unless registry_config.enabled && registry_config.api_url.present?
return true if ::Gitlab.com?
token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
client = new(registry_config.api_url, token: token)
client.supports_tag_delete?
end
def initialize(base_uri, options = {}) def initialize(base_uri, options = {})
@base_uri = base_uri @base_uri = base_uri
@options = options @options = options
......
...@@ -2834,6 +2834,9 @@ msgstr "" ...@@ -2834,6 +2834,9 @@ msgstr ""
msgid "An error occurred while retrieving diff files" msgid "An error occurred while retrieving diff files"
msgstr "" msgstr ""
msgid "An error occurred while retrieving projects."
msgstr ""
msgid "An error occurred while saving LDAP override status. Please try again." msgid "An error occurred while saving LDAP override status. Please try again."
msgstr "" msgstr ""
...@@ -5009,6 +5012,9 @@ msgstr "" ...@@ -5009,6 +5012,9 @@ msgstr ""
msgid "Cleanup policy for tags" msgid "Cleanup policy for tags"
msgstr "" msgstr ""
msgid "Cleanup policy maximum processing time (seconds)"
msgstr ""
msgid "Clear" msgid "Clear"
msgstr "" msgstr ""
...@@ -24103,6 +24109,9 @@ msgstr "" ...@@ -24103,6 +24109,9 @@ msgstr ""
msgid "Tags" msgid "Tags"
msgstr "" msgstr ""
msgid "Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0."
msgstr ""
msgid "Tags feed" msgid "Tags feed"
msgstr "" msgstr ""
......
...@@ -285,6 +285,55 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n ...@@ -285,6 +285,55 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
expect(current_settings.auto_devops_domain).to eq('domain.com') expect(current_settings.auto_devops_domain).to eq('domain.com')
expect(page).to have_content "Application settings saved successfully" expect(page).to have_content "Application settings saved successfully"
end end
context 'Container Registry' do
context 'delete tags service execution timeout' do
let(:feature_flag_enabled) { true }
let(:client_support) { true }
before do
stub_container_registry_config(enabled: true)
stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
end
RSpec.shared_examples 'not having service timeout settings' do
it 'lacks the timeout settings' do
visit ci_cd_admin_application_settings_path
expect(page).not_to have_content "Container Registry delete tags service execution timeout"
end
end
context 'with feature flag enabled' do
context 'with client supporting tag delete' do
it 'changes the timeout' do
visit ci_cd_admin_application_settings_path
page.within('.as-registry') do
fill_in 'application_setting_container_registry_delete_tags_service_timeout', with: 400
click_button 'Save changes'
end
expect(current_settings.container_registry_delete_tags_service_timeout).to eq(400)
expect(page).to have_content "Application settings saved successfully"
end
end
context 'with client not supporting tag delete' do
let(:client_support) { false }
it_behaves_like 'not having service timeout settings'
end
end
context 'with feature flag disabled' do
let(:feature_flag_enabled) { false }
it_behaves_like 'not having service timeout settings'
end
end
end
end end
context 'Repository page' do context 'Repository page' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Incident Detail', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:incident) { create(:issue, project: project, author: user, issue_type: 'incident', description: 'hello') }
context 'when user displays the incident' do
before do
visit project_issue_path(project, incident)
wait_for_requests
end
it 'shows the incident tabs' do
page.within('.issuable-details') do
incident_tabs = find('[data-testid="incident-tabs"]')
expect(find('h2')).to have_content(incident.title)
expect(incident_tabs).to have_content('Summary')
expect(incident_tabs).to have_content(incident.description)
end
end
end
end
...@@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm'; ...@@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue'; import IssuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub'; import eventHub from '~/issue_show/event_hub';
import { initialRequest, secondRequest } from '../mock_data'; import { initialRequest, secondRequest } from '../mock_data';
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
import PinnedLinks from '~/issue_show/components/pinned_links.vue';
function formatText(text) { function formatText(text) {
return text.trim().replace(/\s\s+/g, ' '); return text.trim().replace(/\s\s+/g, ' ');
...@@ -22,6 +25,27 @@ const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; ...@@ -22,6 +25,27 @@ const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
const publishedIncidentUrl = 'https://status.com/'; const publishedIncidentUrl = 'https://status.com/';
const defaultProps = {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
};
describe('Issuable output', () => { describe('Issuable output', () => {
useMockIntersectionObserver(); useMockIntersectionObserver();
...@@ -31,6 +55,12 @@ describe('Issuable output', () => { ...@@ -31,6 +55,12 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const mountComponent = (props = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...defaultProps, ...props },
});
};
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div> <div>
...@@ -57,28 +87,7 @@ describe('Issuable output', () => { ...@@ -57,28 +87,7 @@ describe('Issuable output', () => {
return res; return res;
}); });
wrapper = mount(IssuableApp, { mountComponent();
propsData: {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
},
});
}); });
afterEach(() => { afterEach(() => {
...@@ -562,4 +571,46 @@ describe('Issuable output', () => { ...@@ -562,4 +571,46 @@ describe('Issuable output', () => {
}); });
}); });
}); });
describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.find(IncidentTabs);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findPinnedLinks = () => wrapper.find(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => {
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('does not render incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(false);
});
it('adds a border below the header', () => {
expect(findPinnedLinks().attributes('class')).toContain(borderClass);
});
});
describe('when using incident tabs description wrapper', () => {
beforeEach(() => {
mountComponent({
descriptionComponent: IncidentTabs,
showTitleBorder: false,
});
});
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('renders incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(true);
});
it('does not add a border below the header', () => {
expect(findPinnedLinks().attributes('class')).not.toContain(borderClass);
});
});
});
}); });
...@@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper'; ...@@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import Description from '~/issue_show/components/description.vue'; import Description from '~/issue_show/components/description.vue';
import TaskList from '~/task_list'; import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data';
jest.mock('~/task_list'); jest.mock('~/task_list');
describe('Description component', () => { describe('Description component', () => {
let vm; let vm;
let DescriptionComponent; let DescriptionComponent;
const props = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
updatedAt: new Date().toString(),
taskStatus: '',
updateUrl: TEST_HOST,
};
beforeEach(() => { beforeEach(() => {
DescriptionComponent = Vue.extend(Description); DescriptionComponent = Vue.extend(Description);
......
import { shallowMount } from '@vue/test-utils';
import { GlTab } from '@gitlab/ui';
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
import { descriptionProps } from '../mock_data';
import DescriptionComponent from '~/issue_show/components/description.vue';
describe('Incident Tabs component', () => {
let wrapper;
const mountComponent = () => {
wrapper = shallowMount(IncidentTabs, {
propsData: {
...descriptionProps,
},
stubs: {
DescriptionComponent: true,
},
});
};
beforeEach(() => {
mountComponent();
});
const findTabs = () => wrapper.findAll(GlTab);
const findSummaryTab = () => findTabs().at(0);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
describe('default state', () => {
it('renders the summary tab', async () => {
expect(findTabs()).toHaveLength(1);
expect(findSummaryTab().exists()).toBe(true);
expect(findSummaryTab().attributes('title')).toBe('Summary');
});
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('passes all props to the description component', () => {
expect(findDescriptionComponent().props()).toMatchObject(descriptionProps);
});
});
});
import initIssueableApp from '~/issue_show'; import initIssuableApp from '~/issue_show/issue';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
describe('Issue show index', () => { describe('Issue show index', () => {
describe('initIssueableApp', () => { describe('initIssueableApp', () => {
it('should initialize app with no potential XSS attack', () => { // Warning: this test is currently faulty.
// More details at https://gitlab.com/gitlab-org/gitlab/-/issues/241717
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should initialize app with no potential XSS attack', () => {
const d = document.createElement('div'); const d = document.createElement('div');
d.id = 'js-issuable-app-initial-data'; d.id = 'js-issuable-app-initial-data';
d.innerHTML = JSON.stringify({ d.innerHTML = JSON.stringify({
initialDescriptionHtml: '&lt;img src=x onerror=alert(1)&gt;', initialDescriptionHtml: '&lt;img src=x onerror=alert(1)&gt;',
}); });
document.body.appendChild(d); document.body.appendChild(d);
const alertSpy = jest.spyOn(window, 'alert'); const alertSpy = jest.spyOn(window, 'alert');
initIssueableApp(); const issuableData = parseIssuableData();
initIssuableApp(issuableData);
expect(alertSpy).not.toHaveBeenCalled(); expect(alertSpy).not.toHaveBeenCalled();
}); });
......
import { TEST_HOST } from 'helpers/test_constants';
export const initialRequest = { export const initialRequest = {
title: '<p>this is a title</p>', title: '<p>this is a title</p>',
title_text: 'this is a title', title_text: 'this is a title',
...@@ -21,3 +23,11 @@ export const secondRequest = { ...@@ -21,3 +23,11 @@ export const secondRequest = {
updated_by_path: '/other_user', updated_by_path: '/other_user',
lock_version: 2, lock_version: 2,
}; };
export const descriptionProps = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
taskStatus: '',
updateUrl: TEST_HOST,
};
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ContainerRegistryHelper do
using RSpec::Parameterized::TableSyntax
describe '#limit_delete_tags_service?' do
subject { helper.limit_delete_tags_service? }
where(:feature_flag_enabled, :client_support, :expected_result) do
true | true | true
true | false | false
false | true | false
false | false | false
end
with_them do
before do
stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
end
it { is_expected.to eq(expected_result) }
end
end
end
...@@ -197,7 +197,8 @@ RSpec.describe IssuablesHelper do ...@@ -197,7 +197,8 @@ RSpec.describe IssuablesHelper do
initialTitleText: issue.title, initialTitleText: issue.title,
initialDescriptionHtml: '<p dir="auto">issue text</p>', initialDescriptionHtml: '<p dir="auto">issue text</p>',
initialDescriptionText: 'issue text', initialDescriptionText: 'issue text',
initialTaskStatus: '0 of 0 tasks completed' initialTaskStatus: '0 of 0 tasks completed',
issueType: 'issue'
} }
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data)) expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end end
......
...@@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do ...@@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do
end end
end end
end end
describe '.supports_tag_delete?' do
let(:registry_enabled) { true }
let(:registry_api_url) { 'http://sandbox.local' }
let(:registry_tags_support_enabled) { true }
let(:is_on_dot_com) { false }
subject { described_class.supports_tag_delete? }
before do
allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
stub_registry_tags_support(registry_tags_support_enabled)
end
context 'with the registry enabled' do
it { is_expected.to be true }
context 'without an api url' do
let(:registry_api_url) { '' }
it { is_expected.to be false }
end
context 'on .com' do
let(:is_on_dot_com) { true }
it { is_expected.to be true }
end
context 'when registry server does not support tag deletion' do
let(:registry_tags_support_enabled) { false }
it { is_expected.to be false }
end
end
context 'with the registry disabled' do
let(:registry_enabled) { false }
it { is_expected.to be false }
end
def stub_registry_tags_support(supported = true)
status_code = supported ? 200 : 404
stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag")
.to_return(
status: status_code,
body: '',
headers: { 'Allow' => 'DELETE' }
)
end
end
end end
...@@ -71,6 +71,8 @@ RSpec.describe ApplicationSetting do ...@@ -71,6 +71,8 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value('three').for(:push_event_activities_limit) } it { is_expected.not_to allow_value('three').for(:push_event_activities_limit) }
it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) } it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) }
it { is_expected.to validate_numericality_of(:container_registry_delete_tags_service_timeout).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) } it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) }
it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) } it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) }
it { is_expected.to validate_presence_of(:max_artifacts_size) } it { is_expected.to validate_presence_of(:max_artifacts_size) }
......
...@@ -171,6 +171,16 @@ RSpec.describe Service do ...@@ -171,6 +171,16 @@ RSpec.describe Service do
it { is_expected.to be_falsey } it { is_expected.to be_falsey }
end end
end end
context 'when group-level service' do
Service.available_services_types.each do |service_type|
let(:service) do
service_type.constantize.new(group_id: group.id)
end
it { is_expected.to be_falsey }
end
end
end end
describe '#test' do describe '#test' do
......
...@@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do ...@@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
subject { service.execute(repository) } subject { service.execute(repository) }
before do
stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
context 'without permissions' do context 'without permissions' do
it { is_expected.to include(status: :error) } it { is_expected.to include(status: :error) }
end end
...@@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do ...@@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
it_behaves_like 'logging a success response' it_behaves_like 'logging a success response'
end end
context 'with a timeout error' do
before do
expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service|
expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError)
end
end
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
end
end end
context 'and the feature is disabled' do context 'and the feature is disabled' do
......
...@@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do ...@@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
subject { service.execute } subject { service.execute }
context 'with tags to delete' do before do
stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
RSpec.shared_examples 'deleting tags' do
it 'deletes the tags by name' do it 'deletes the tags by name' do
stub_delete_reference_requests(tags) stub_delete_reference_requests(tags)
expect_delete_tag_by_names(tags) expect_delete_tag_by_names(tags)
is_expected.to eq(status: :success, deleted: tags) is_expected.to eq(status: :success, deleted: tags)
end end
end
context 'with tags to delete' do
it_behaves_like 'deleting tags'
it 'succeeds when tag delete returns 404' do it 'succeeds when tag delete returns 404' do
stub_delete_reference_requests('A' => 200, 'Ba' => 404) stub_delete_reference_requests('A' => 200, 'Ba' => 404)
...@@ -41,6 +49,47 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do ...@@ -41,6 +49,47 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
it { is_expected.to eq(status: :error, message: 'could not delete tags') } it { is_expected.to eq(status: :error, message: 'could not delete tags') }
end end
end end
context 'with throttling enabled' do
let(:timeout) { 10 }
before do
stub_feature_flags(container_registry_expiration_policies_throttling: true)
stub_application_setting(container_registry_delete_tags_service_timeout: timeout)
end
it_behaves_like 'deleting tags'
context 'with timeout' do
context 'set to a valid value' do
before do
allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout
stub_delete_reference_requests('A' => 200)
end
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
it 'tracks the exception' do
expect(::Gitlab::ErrorTracking)
.to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
subject
end
end
context 'set to 0' do
let(:timeout) { 0 }
it_behaves_like 'deleting tags'
end
context 'set to nil' do
let(:timeout) { nil }
it_behaves_like 'deleting tags'
end
end
end
end end
context 'with empty tags' do context 'with empty tags' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment