提交 16210ba9 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 1b16af5f
......@@ -8,10 +8,12 @@ import {
GlIcon,
GlDropdown,
GlDropdownItem,
GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSprintf,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { s__ } from '~/locale';
......@@ -47,7 +49,7 @@ const initialPaginationState = {
export default {
i18n: {
noAlertsMsg: s__(
"AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page.",
'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.',
),
errorMsg: s__(
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
......@@ -119,10 +121,12 @@ export default {
GlDropdown,
GlDropdownItem,
GlIcon,
GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSprintf,
},
props: {
projectPath: {
......@@ -137,6 +141,10 @@ export default {
type: String,
required: true,
},
populatingAlertsHelpUrl: {
type: String,
required: true,
},
userCanEnableAlertManagement: {
type: Boolean,
required: true,
......@@ -319,7 +327,17 @@ export default {
<div>
<div v-if="alertManagementEnabled" class="alert-management-list">
<gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true">
{{ $options.i18n.noAlertsMsg }}
<gl-sprintf :message="$options.i18n.noAlertsMsg">
<template #link="{ content }">
<gl-link
class="gl-display-inline-block"
:href="populatingAlertsHelpUrl"
target="_blank"
>
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true">
{{ $options.i18n.errorMsg }}
......
......@@ -11,7 +11,12 @@ export default () => {
const selector = '#js-alert_management';
const domEl = document.querySelector(selector);
const { projectPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset;
const {
projectPath,
enableAlertManagementPath,
emptyAlertSvgPath,
populatingAlertsHelpUrl,
} = domEl.dataset;
let { alertManagementEnabled, userCanEnableAlertManagement } = domEl.dataset;
alertManagementEnabled = parseBoolean(alertManagementEnabled);
......@@ -45,6 +50,7 @@ export default () => {
props: {
projectPath,
enableAlertManagementPath,
populatingAlertsHelpUrl,
emptyAlertSvgPath,
alertManagementEnabled,
userCanEnableAlertManagement,
......
......@@ -6,6 +6,7 @@ import {
GlLink,
GlLoadingIcon,
GlPagination,
GlSkeletonLoading,
GlSprintf,
GlTable,
} from '@gitlab/ui';
......@@ -21,6 +22,7 @@ export default {
GlLink,
GlLoadingIcon,
GlPagination,
GlSkeletonLoading,
GlSprintf,
GlTable,
},
......@@ -28,7 +30,18 @@ export default {
tooltip,
},
computed: {
...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'providers', 'totalCulsters']),
...mapState([
'clusters',
'clustersPerPage',
'loadingClusters',
'loadingNodes',
'page',
'providers',
'totalCulsters',
]),
contentAlignClasses() {
return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start';
},
currentPage: {
get() {
return this.page;
......@@ -180,14 +193,12 @@ export default {
</script>
<template>
<gl-loading-icon v-if="loading" size="md" class="mt-3" />
<gl-loading-icon v-if="loadingClusters" size="md" class="gl-mt-3" />
<section v-else>
<gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table">
<template #cell(name)="{ item }">
<div
class="gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start js-status"
>
<div :class="[contentAlignClasses, 'js-status']">
<img
:src="selectedProvider(item.provider_type).path"
:alt="selectedProvider(item.provider_type).text"
......@@ -214,6 +225,9 @@ export default {
<template #cell(node_size)="{ item }">
<span v-if="item.nodes">{{ item.nodes.length }}</span>
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{
__('Unknown')
}}</small>
......@@ -231,6 +245,8 @@ export default {
>
</gl-sprintf>
</span>
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
</template>
<template #cell(total_memory)="{ item }">
......@@ -245,6 +261,8 @@ export default {
>
</gl-sprintf>
</span>
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
</template>
<template #cell(cluster_type)="{value}">
......
......@@ -19,6 +19,8 @@ const allNodesPresent = (clusters, retryCount) => {
export const fetchClusters = ({ state, commit }) => {
let retryCount = 0;
commit(types.SET_LOADING_NODES, true);
const poll = new Poll({
resource: {
fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint),
......@@ -34,15 +36,19 @@ export const fetchClusters = ({ state, commit }) => {
const paginationInformation = parseIntPagination(normalizedHeaders);
commit(types.SET_CLUSTERS_DATA, { data, paginationInformation });
commit(types.SET_LOADING_STATE, false);
commit(types.SET_LOADING_CLUSTERS, false);
if (allNodesPresent(data.clusters, retryCount)) {
poll.stop();
commit(types.SET_LOADING_NODES, false);
}
}
} catch (error) {
poll.stop();
commit(types.SET_LOADING_CLUSTERS, false);
commit(types.SET_LOADING_NODES, false);
Sentry.withScope(scope => {
scope.setTag('javascript_clusters_list', 'fetchClustersSuccessCallback');
Sentry.captureException(error);
......@@ -52,7 +58,8 @@ export const fetchClusters = ({ state, commit }) => {
errorCallback: response => {
poll.stop();
commit(types.SET_LOADING_STATE, false);
commit(types.SET_LOADING_CLUSTERS, false);
commit(types.SET_LOADING_NODES, false);
flash(__('Clusters|An error occurred while loading clusters'));
Sentry.withScope(scope => {
......
export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA';
export const SET_LOADING_STATE = 'SET_LOADING_STATE';
export const SET_LOADING_CLUSTERS = 'SET_LOADING_CLUSTERS';
export const SET_LOADING_NODES = 'SET_LOADING_NODES';
export const SET_PAGE = 'SET_PAGE';
import * as types from './mutation_types';
export default {
[types.SET_LOADING_STATE](state, value) {
state.loading = value;
[types.SET_LOADING_CLUSTERS](state, value) {
state.loadingClusters = value;
},
[types.SET_LOADING_NODES](state, value) {
state.loadingNodes = value;
},
[types.SET_CLUSTERS_DATA](state, { data, paginationInformation }) {
Object.assign(state, {
......
export default (initialState = {}) => ({
endpoint: initialState.endpoint,
hasAncestorClusters: false,
loading: true,
clusters: [],
clustersPerPage: 0,
loadingClusters: true,
loadingNodes: true,
page: 1,
providers: {
aws: { path: initialState.imgTagsAwsPath, text: initialState.imgTagsAwsText },
......
......@@ -22,6 +22,7 @@ export default (props = {}) => {
currentEnvironmentName,
dashboardTimezone,
metricsDashboardBasePath,
customDashboardBasePath,
...dataProps
} = el.dataset;
......@@ -34,6 +35,7 @@ export default (props = {}) => {
projectPath,
logsPath,
currentEnvironmentName,
customDashboardBasePath,
});
// HTML attributes are always strings, parse other types.
......
......@@ -117,12 +117,12 @@ export const fetchData = ({ dispatch }) => {
// Metrics dashboard
export const fetchDashboard = ({ state, commit, dispatch }) => {
export const fetchDashboard = ({ state, commit, dispatch, getters }) => {
dispatch('requestMetricsDashboard');
const params = {};
if (state.currentDashboard) {
params.dashboard = state.currentDashboard;
if (getters.fullDashboardPath) {
params.dashboard = getters.fullDashboardPath;
}
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
......@@ -204,7 +204,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
return Promise.all(promises)
.then(() => {
const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom';
trackDashboardLoad({
label: `${dashboardType}_metrics_dashboard`,
value: getters.metricsWithData().length,
......@@ -322,9 +322,9 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
};
export const fetchAnnotations = ({ state, dispatch }) => {
export const fetchAnnotations = ({ state, dispatch, getters }) => {
const { start } = convertToFixedRange(state.timeRange);
const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH;
const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH;
return gqClient
.mutate({
mutation: getAnnotations,
......
import { NOT_IN_DB_PREFIX } from '../constants';
import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils';
import {
addPrefixToCustomVariableParams,
addDashboardMetaDataToLink,
normalizeCustomDashboardPath,
} from './utils';
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
......@@ -10,10 +14,10 @@ const metricsIdsInPanel = panel =>
*
* @param {Object} state
*/
export const selectedDashboard = state => {
export const selectedDashboard = (state, getters) => {
const { allDashboards } = state;
return (
allDashboards.find(d => d.path === state.currentDashboard) ||
allDashboards.find(d => d.path === getters.fullDashboardPath) ||
allDashboards.find(d => d.default) ||
null
);
......@@ -154,5 +158,15 @@ export const getCustomVariablesParams = state =>
return acc;
}, {});
/**
* For a given custom dashboard file name, this method
* returns the full file path.
*
* @param {Object} state
* @returns {String} full dashboard path
*/
export const fullDashboardPath = state =>
normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -9,6 +9,13 @@ export default () => ({
// Dashboard request parameters
timeRange: null,
/**
* Currently selected dashboard. For custom dashboards,
* this could be the filename or the file path.
*
* If this is the filename and full path is required,
* getters.fullDashboardPath should be used.
*/
currentDashboard: null,
// Dashboard data
......@@ -58,4 +65,7 @@ export default () => ({
// GitLab paths to other pages
projectPath: null,
logsPath: invalidUrl,
// static paths
customDashboardBasePath: '',
});
......@@ -3,10 +3,10 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { parseTemplatingVariables } from './variable_mapping';
import { NOT_IN_DB_PREFIX, linkTypes } from '../constants';
import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants';
import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range';
import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility';
import { NOT_IN_DB_PREFIX, linkTypes, DEFAULT_DASHBOARD_PATH } from '../constants';
export const gqClient = createGqClient(
{},
......@@ -440,3 +440,31 @@ export const normalizeQueryResponseData = data => {
* @returns {String}
*/
export const addPrefixToCustomVariableParams = key => `variables[${key}]`;
/**
* Normalize custom dashboard paths. This method helps support
* metrics dashboard to work with custom dashboard file names instead
* of the entire path.
*
* If dashboard is empty, it is the default dashboard.
* If dashboard is set, it usually is a custom dashboard unless
* explicitly it is set to default dashboard path.
*
* @param {String} dashboard dashboard path
* @param {String} dashboardPrefix custom dashboard directory prefix
* @returns {String} normalized dashboard path
*/
export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => {
const currDashboard = dashboard || '';
let dashboardPath = `${dashboardPrefix}/${currDashboard}`;
if (!currDashboard) {
dashboardPath = '';
} else if (
currDashboard.startsWith(dashboardPrefix) ||
currDashboard.startsWith(DEFAULT_DASHBOARD_PATH)
) {
dashboardPath = currDashboard;
}
return dashboardPath;
};
......@@ -2,10 +2,12 @@
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: { Icon, GlDeprecatedButton, GlLoadingIcon },
directives: { 'gl-tooltip': GlTooltipDirective },
mixins: [glFeatureFlagsMixin()],
props: {
batchSuggestionsCount: {
type: Number,
......@@ -43,6 +45,9 @@ export default {
};
},
computed: {
canBeBatched() {
return Boolean(this.glFeatures.batchSuggestions);
},
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
......@@ -51,6 +56,11 @@ export default {
? __('This also resolves the discussion')
: __("Can't apply as this line has changed or the suggestion already matches its content.");
},
tooltipMessageBatch() {
return !this.canBeBatched
? __("Suggestions that change line count can't be added to batches, yet.")
: this.tooltipMessage;
},
isDisableButton() {
return this.isApplying || !this.canApply;
},
......@@ -97,7 +107,7 @@ export default {
<gl-loading-icon class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
<div v-else-if="canApply && isBatched" class="d-flex align-items-center">
<div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center">
<gl-deprecated-button
class="btn-inverted js-remove-from-batch-btn btn-grouped"
:disabled="isApplying"
......@@ -119,13 +129,15 @@ export default {
</gl-deprecated-button>
</div>
<div v-else class="d-flex align-items-center">
<gl-deprecated-button
class="btn-inverted js-add-to-batch-btn btn-grouped"
:disabled="isDisableButton"
@click="addSuggestionToBatch"
>
{{ __('Add suggestion to batch') }}
</gl-deprecated-button>
<span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0">
<gl-deprecated-button
class="btn-inverted js-add-to-batch-btn btn-grouped"
:disabled="isDisableButton"
@click="addSuggestionToBatch"
>
{{ __('Add suggestion to batch') }}
</gl-deprecated-button>
</span>
<span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
<gl-deprecated-button
class="btn-inverted js-apply-btn btn-grouped"
......
......@@ -35,6 +35,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
push_frontend_feature_flag(:multiline_comments, @project)
push_frontend_feature_flag(:file_identifier_hash)
push_frontend_feature_flag(:batch_suggestions, @project)
end
before_action do
......
......@@ -5,9 +5,10 @@ module Projects::AlertManagementHelper
{
'project-path' => project.full_path,
'enable-alert-management-path' => edit_project_service_path(project, AlertsService),
'populating-alerts-help-url' => help_page_url('user/project/operations/alert_management.html', anchor: 'enable-alert-management'),
'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => can?(current_user, :admin_project, project).to_s,
'alert-management-enabled' => (!!project.alerts_service_activated?).to_s
'alert-management-enabled' => alert_management_enabled?(project).to_s
}
end
......@@ -19,4 +20,10 @@ module Projects::AlertManagementHelper
'project-issues-path' => project_issues_path(project)
}
end
private
def alert_management_enabled?(project)
!!(project.alerts_service_activated? || project.prometheus_service_active?)
end
end
......@@ -801,6 +801,11 @@ module Ci
has_expiring_artifacts? && job_artifacts_archive.present?
end
def self.keep_artifacts!
update_all(artifacts_expire_at: nil)
Ci::JobArtifact.where(job: self.select(:id)).update_all(expire_at: nil)
end
def keep_artifacts!
self.update(artifacts_expire_at: nil)
self.job_artifacts.update_all(expire_at: nil)
......
......@@ -377,6 +377,7 @@ class Project < ApplicationRecord
delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, to: :project_setting
delegate :active?, to: :prometheus_service, allow_nil: true, prefix: true
# Validations
validates :creator, presence: true, on: :create
......
......@@ -11,3 +11,5 @@ module Evidences
expose :milestones, using: Evidences::MilestoneEntity
end
end
Evidences::ReleaseEntity.prepend_if_ee('EE::Evidences::ReleaseEntity')
......@@ -10,7 +10,7 @@ module Releases
def execute
evidence = release.evidences.build
summary = Evidences::EvidenceSerializer.new.represent(evidence) # rubocop: disable CodeReuse/Serializer
summary = ::Evidences::EvidenceSerializer.new.represent(evidence, evidence_options) # rubocop: disable CodeReuse/Serializer
evidence.summary = summary
# TODO: fix the sha generating https://gitlab.com/gitlab-org/gitlab/-/issues/209000
evidence.summary_sha = Gitlab::CryptoHelper.sha256(summary)
......@@ -20,6 +20,12 @@ module Releases
private
attr_reader :release
attr_reader :release, :pipeline
def evidence_options
{}
end
end
end
Releases::CreateEvidenceService.prepend_if_ee('EE::Releases::CreateEvidenceService')
---
title: Add skeleton loader to cluster list
merge_request: 34090
author:
type: changed
---
title: Create time-space partitions in separate schema
merge_request: 34504
author:
type: other
---
title: Support metrics dashboard with file name
merge_request: 34115
author:
type: added
---
title: Ensure that alerts are shown when prometheus service is active
merge_request: 33928
author:
type: fixed
# Ignore table used temporarily in background migration
ActiveRecord::SchemaDumper.ignore_tables = ["untracked_files_for_uploads"]
# Ignore dynamically managed partitions in static application schema
ActiveRecord::SchemaDumper.ignore_tables += ["partitions_dynamic.*"]
# frozen_string_literal: true
class CreateDynamicPartitionsSchema < ActiveRecord::Migration[6.0]
include Gitlab::Database::SchemaHelpers
DOWNTIME = false
def up
execute 'CREATE SCHEMA partitions_dynamic'
create_comment(:schema, :partitions_dynamic, <<~EOS.strip)
Schema to hold partitions managed dynamically from the application, e.g. for time space partitioning.
EOS
end
def down
execute 'DROP SCHEMA partitions_dynamic'
end
end
SET search_path=public;
CREATE SCHEMA partitions_dynamic;
COMMENT ON SCHEMA partitions_dynamic IS 'Schema to hold partitions managed dynamically from the application, e.g. for time space partitioning.';
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
CREATE TABLE public.abuse_reports (
......@@ -14002,6 +14006,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200609212701
20200613104045
20200615083635
20200615101135
20200615121217
20200615123055
20200615232735
......
......@@ -100,9 +100,9 @@ NOTE: **Note:**
GitLab.com is using the IP range `34.74.90.64/28` for traffic from its Web/API
fleet. This whole range is solely allocated to GitLab. You can expect connections from webhooks or repository mirroring to come
from those IPs and whitelist them.
from those IPs and allow them.
GitLab.com is fronted by Cloudflare. For incoming connections to GitLab.com you might need to whitelist CIDR blocks of Cloudflare ([IPv4](https://www.cloudflare.com/ips-v4) and [IPv6](https://www.cloudflare.com/ips-v6))
GitLab.com is fronted by Cloudflare. For incoming connections to GitLab.com you might need to allow CIDR blocks of Cloudflare ([IPv4](https://www.cloudflare.com/ips-v4) and [IPv6](https://www.cloudflare.com/ips-v6)).
For outgoing connections from CI/CD runners we are not providing static IP addresses.
All our runners are deployed into Google Cloud Platform (GCP) - any IP based
......
......@@ -17,16 +17,40 @@ being developed, efficiency and awareness can be increased.
NOTE: **Note:**
You will need at least Maintainer [permissions](../../permissions.md) to enable the Alert Management feature.
1. Follow the [instructions for toggling generic alerts](../integrations/generic_alerts.md#setting-up-generic-alerts)
1. You can now visit **{cloud-gear}** **Operations > Alerts** in your project's sidebar to [view a list](#alert-management-list) of alerts.
There are several ways to accept alerts into your GitLab project, as outlined below.
Enabling any of these methods will allow the Alerts list to display. After configuring
alerts, visit **{cloud-gear}** **Operations > Alerts** in your project's sidebar
to [view the list](#alert-management-list) of alerts.
![Alert Management Toggle](img/alert_management_1_v13_1.png)
### Enable a Generic Alerts endpoint
## Populate Alert data
GitLab provides the Generic Alerts endpoint so you can accept alerts from a third-party
alerts service. See the
[instructions for toggling generic alerts](../integrations/generic_alerts.md#setting-up-generic-alerts)
to add this option. After configuring the endpoint, the
[Alerts list](#alert-management-list) is enabled.
To populate data, see the instructions for
[customizing the payload](../integrations/generic_alerts.md) and making a
request to the alerts endpoint.
To populate the alerts with data, see [Customizing the payload](../integrations/generic_alerts.md#customizing-the-payload) for requests to the alerts endpoint.
### Enable GitLab-managed Prometheus alerts
You can install the GitLab-managed Prometheus application on your Kubernetes
cluster. For more information, see
[Managed Prometheus on Kubernetes](../integrations/prometheus.md#managed-prometheus-on-kubernetes).
When GitLab-managed Prometheus is installed, the [Alerts list](#alert-management-list)
is also enabled.
To populate the alerts with data, see
[GitLab-Managed Prometheus instances](../integrations/prometheus.md#managed-prometheus-instances).
### Enable external Prometheus alerts
You can configure an externally-managed Prometheus instance to send alerts
to GitLab. To set up this configuration, see the [configuring Prometheus](../integrations/prometheus.md#external-prometheus-instances) documentation. Activating the external Prometheus
configuration also enables the [Alerts list](#alert-management-list).
To populate the alerts with data, see
[External Prometheus instances](../integrations/prometheus.md#external-prometheus-instances).
## Alert Management severity
......
......@@ -8,6 +8,7 @@ module Gitlab
WHITELISTED_TABLES = %w[audit_events].freeze
ERROR_SCOPE = 'table partitioning'
DYNAMIC_PARTITIONS_SCHEMA = 'partitions_dynamic'
# Creates a partitioned copy of an existing table, using a RANGE partitioning strategy on a timestamp column.
# One partition is created per month between the given `min_date` and `max_date`.
......@@ -125,7 +126,7 @@ module Gitlab
min_date = min_date.beginning_of_month.to_date
max_date = max_date.next_month.beginning_of_month.to_date
create_range_partition_safely("#{table_name}_000000", table_name, 'MINVALUE', to_sql_date_literal(min_date))
create_range_partition_safely("#{table_name}_000000", table_name, 'MINVALUE', to_sql_date_literal(min_date), schema: DYNAMIC_PARTITIONS_SCHEMA)
while min_date < max_date
partition_name = "#{table_name}_#{min_date.strftime('%Y%m')}"
......@@ -133,7 +134,7 @@ module Gitlab
lower_bound = to_sql_date_literal(min_date)
upper_bound = to_sql_date_literal(next_date)
create_range_partition_safely(partition_name, table_name, lower_bound, upper_bound)
create_range_partition_safely(partition_name, table_name, lower_bound, upper_bound, schema: DYNAMIC_PARTITIONS_SCHEMA)
min_date = next_date
end
end
......@@ -142,8 +143,8 @@ module Gitlab
connection.quote(date.strftime('%Y-%m-%d'))
end
def create_range_partition_safely(partition_name, table_name, lower_bound, upper_bound)
if table_exists?(partition_name)
def create_range_partition_safely(partition_name, table_name, lower_bound, upper_bound, schema:)
if table_exists?("#{schema}.#{partition_name}")
# rubocop:disable Gitlab/RailsLogger
Rails.logger.warn "Partition not created because it already exists" \
" (this may be due to an aborted migration or similar): partition_name: #{partition_name}"
......@@ -151,7 +152,7 @@ module Gitlab
return
end
create_range_partition(partition_name, table_name, lower_bound, upper_bound)
create_range_partition(partition_name, table_name, lower_bound, upper_bound, schema: schema)
end
def create_sync_trigger(source_table, target_table, unique_key)
......
......@@ -69,9 +69,11 @@ module Gitlab
private
def create_range_partition(partition_name, table_name, lower_bound, upper_bound)
def create_range_partition(partition_name, table_name, lower_bound, upper_bound, schema:)
raise ArgumentError, 'explicit schema is required but currently missing' unless schema
execute(<<~SQL)
CREATE TABLE #{partition_name} PARTITION OF #{table_name}
CREATE TABLE #{schema}.#{partition_name} PARTITION OF #{table_name}
FOR VALUES FROM (#{lower_bound}) TO (#{upper_bound})
SQL
end
......
......@@ -1942,7 +1942,7 @@ msgstr ""
msgid "AlertManagement|No alert data to display."
msgstr ""
msgid "AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page."
msgid "AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list."
msgstr ""
msgid "AlertManagement|No alerts to display."
......@@ -21877,6 +21877,9 @@ msgstr ""
msgid "Suggestions must all be on the same branch."
msgstr ""
msgid "Suggestions that change line count can't be added to batches, yet."
msgstr ""
msgid "Suggestions:"
msgstr ""
......
{
"type": "object",
"required": [
"url"
],
"properties": {
"url": { "type": "string" }
},
"additionalProperties": false
}
......@@ -19,6 +19,10 @@
"milestones": {
"type": "array",
"items": { "$ref": "milestone.json" }
},
"report_artifacts": {
"type": "array",
"items": { "$ref": "build_artifact.json" }
}
},
"additionalProperties": false
......
......@@ -70,6 +70,7 @@ describe('AlertManagementList', () => {
propsData: {
projectPath: 'gitlab-org/gitlab',
enableAlertManagementPath: '/link',
populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data',
emptyAlertSvgPath: 'illustration/path',
...props,
},
......
......@@ -4,7 +4,7 @@ import ClusterStore from '~/clusters_list/store';
import MockAdapter from 'axios-mock-adapter';
import { apiData } from '../mock_data';
import { mount } from '@vue/test-utils';
import { GlLoadingIcon, GlTable, GlPagination } from '@gitlab/ui';
import { GlLoadingIcon, GlPagination, GlSkeletonLoading, GlTable } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
describe('Clusters', () => {
......@@ -64,7 +64,7 @@ describe('Clusters', () => {
describe('clusters table', () => {
describe('when data is loading', () => {
beforeEach(() => {
wrapper.vm.$store.state.loading = true;
wrapper.vm.$store.state.loadingClusters = true;
return wrapper.vm.$nextTick();
});
......@@ -131,19 +131,48 @@ describe('Clusters', () => {
});
describe('nodes present', () => {
it.each`
nodeSize | lineNumber
${'Unknown'} | ${0}
${'1'} | ${1}
${'2'} | ${2}
${'1'} | ${3}
${'1'} | ${4}
${'Unknown'} | ${5}
`('renders node size for each cluster', ({ nodeSize, lineNumber }) => {
const sizes = findTable().findAll('td:nth-child(3)');
const size = sizes.at(lineNumber);
expect(size.text()).toBe(nodeSize);
describe('nodes while loading', () => {
it.each`
nodeSize | lineNumber
${null} | ${0}
${'1'} | ${1}
${'2'} | ${2}
${'1'} | ${3}
${'1'} | ${4}
${null} | ${5}
`('renders node size for each cluster', ({ nodeSize, lineNumber }) => {
const sizes = findTable().findAll('td:nth-child(3)');
const size = sizes.at(lineNumber);
if (nodeSize) {
expect(size.text()).toBe(nodeSize);
} else {
expect(size.find(GlSkeletonLoading).exists()).toBe(true);
}
});
});
describe('nodes finish loading', () => {
beforeEach(() => {
wrapper.vm.$store.state.loadingNodes = false;
return wrapper.vm.$nextTick();
});
it.each`
nodeSize | lineNumber
${'Unknown'} | ${0}
${'1'} | ${1}
${'2'} | ${2}
${'1'} | ${3}
${'1'} | ${4}
${'Unknown'} | ${5}
`('renders node size for each cluster', ({ nodeSize, lineNumber }) => {
const sizes = findTable().findAll('td:nth-child(3)');
const size = sizes.at(lineNumber);
expect(size.text()).toBe(nodeSize);
expect(size.find(GlSkeletonLoading).exists()).toBe(false);
});
});
describe('nodes with unknown quantity', () => {
......
......@@ -48,8 +48,9 @@ describe('Clusters store actions', () => {
{ endpoint: apiData.endpoint },
{},
[
{ type: types.SET_LOADING_NODES, payload: true },
{ type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } },
{ type: types.SET_LOADING_STATE, payload: false },
{ type: types.SET_LOADING_CLUSTERS, payload: false },
],
[],
() => done(),
......@@ -63,7 +64,11 @@ describe('Clusters store actions', () => {
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
[{ type: types.SET_LOADING_STATE, payload: false }],
[
{ type: types.SET_LOADING_NODES, payload: true },
{ type: types.SET_LOADING_CLUSTERS, payload: false },
{ type: types.SET_LOADING_NODES, payload: false },
],
[],
() => {
expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error'));
......@@ -100,8 +105,9 @@ describe('Clusters store actions', () => {
{ endpoint: apiData.endpoint },
{},
[
{ type: types.SET_LOADING_NODES, payload: true },
{ type: types.SET_CLUSTERS_DATA, payload: { data: apiData, paginationInformation } },
{ type: types.SET_LOADING_STATE, payload: false },
{ type: types.SET_LOADING_CLUSTERS, payload: false },
],
[],
() => {
......@@ -149,11 +155,14 @@ describe('Clusters store actions', () => {
{ endpoint: apiData.endpoint },
{},
[
{ type: types.SET_LOADING_NODES, payload: true },
{
type: types.SET_CLUSTERS_DATA,
payload: { data: badApiResponse, paginationInformation },
},
{ type: types.SET_LOADING_STATE, payload: false },
{ type: types.SET_LOADING_CLUSTERS, payload: false },
{ type: types.SET_LOADING_CLUSTERS, payload: false },
{ type: types.SET_LOADING_NODES, payload: false },
],
[],
() => {
......
import * as types from '~/clusters_list/store/mutation_types';
import { apiData } from '../mock_data';
import getInitialState from '~/clusters_list/store/state';
import mutations from '~/clusters_list/store/mutations';
describe('Admin statistics panel mutations', () => {
let state;
const paginationInformation = {
nextPage: 1,
page: 1,
perPage: 20,
previousPage: 1,
total: apiData.clusters.length,
totalPages: 1,
};
beforeEach(() => {
state = getInitialState();
});
describe(`${types.SET_CLUSTERS_DATA}`, () => {
it('sets clusters and pagination values', () => {
mutations[types.SET_CLUSTERS_DATA](state, { data: apiData, paginationInformation });
expect(state.clusters).toBe(apiData.clusters);
expect(state.clustersPerPage).toBe(paginationInformation.perPage);
expect(state.hasAncestorClusters).toBe(apiData.has_ancestor_clusters);
expect(state.totalCulsters).toBe(paginationInformation.total);
});
});
describe(`${types.SET_LOADING_CLUSTERS}`, () => {
it('sets value to false', () => {
expect(state.loadingClusters).toBe(true);
mutations[types.SET_LOADING_CLUSTERS](state, false);
expect(state.loadingClusters).toBe(false);
});
});
describe(`${types.SET_LOADING_NODES}`, () => {
it('sets value to false', () => {
expect(state.loadingNodes).toBe(true);
mutations[types.SET_LOADING_NODES](state, false);
expect(state.loadingNodes).toBe(false);
});
});
describe(`${types.SET_PAGE}`, () => {
it('changes page value', () => {
mutations[types.SET_PAGE](state, 123);
expect(state.page).toBe(123);
});
});
});
......@@ -22,6 +22,8 @@ export const propsData = {
validateQueryPath: '',
};
export const customDashboardBasePath = '.gitlab/dashboards';
const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({
default: false,
display_name: `Custom Dashboard ${idx}`,
......
......@@ -6,6 +6,7 @@ import statusCodes from '~/lib/utils/http_status';
import * as commonUtils from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { defaultTimeRange } from '~/vue_shared/constants';
import * as getters from '~/monitoring/stores/getters';
import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import { createStore } from '~/monitoring/stores';
......@@ -62,7 +63,7 @@ describe('Monitoring store actions', () => {
let state;
beforeEach(() => {
store = createStore();
store = createStore({ getters });
state = store.state.monitoringDashboard;
mock = new MockAdapter(axios);
......@@ -265,6 +266,11 @@ describe('Monitoring store actions', () => {
state.projectPath = 'gitlab-org/gitlab-test';
state.currentEnvironmentName = 'production';
state.currentDashboard = '.gitlab/dashboards/custom_dashboard.yml';
// testAction doesn't have access to getters. The state is passed in as getters
// instead of the actual getters inside the testAction method implementation.
// All methods downstream that needs access to getters will throw and error.
// For that reason, the result of the getter is set as a state variable.
state.fullDashboardPath = store.getters['monitoringDashboard/fullDashboardPath'];
});
it('fetches annotations data and dispatches receiveAnnotationsSuccess', () => {
......@@ -581,9 +587,12 @@ describe('Monitoring store actions', () => {
let result;
beforeEach(() => {
const params = {};
const localGetters = {
fullDashboardPath: store.getters['monitoringDashboard/fullDashboardPath'],
};
result = () => {
mock.onGet(state.dashboardEndpoint).replyOnce(500, mockDashboardsErrorResponse);
return fetchDashboard({ state, commit, dispatch }, params);
return fetchDashboard({ state, commit, dispatch, getters: localGetters }, params);
};
});
......@@ -712,10 +721,10 @@ describe('Monitoring store actions', () => {
});
it('commits empty state when state.groups is empty', done => {
const getters = {
const localGetters = {
metricsWithData: () => [],
};
fetchDashboardData({ state, commit, dispatch, getters })
fetchDashboardData({ state, commit, dispatch, getters: localGetters })
.then(() => {
expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
......@@ -740,11 +749,11 @@ describe('Monitoring store actions', () => {
);
const [metric] = state.dashboard.panelGroups[0].panels[0].metrics;
const getters = {
const localGetters = {
metricsWithData: () => [metric.id],
};
fetchDashboardData({ state, commit, dispatch, getters })
fetchDashboardData({ state, commit, dispatch, getters: localGetters })
.then(() => {
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
metric,
......
......@@ -4,6 +4,7 @@ import mutations from '~/monitoring/stores/mutations';
import * as types from '~/monitoring/stores/mutation_types';
import { metricStates } from '~/monitoring/constants';
import {
customDashboardBasePath,
environmentData,
metricsResult,
dashboardGitResponse,
......@@ -364,45 +365,53 @@ describe('Monitoring store Getters', () => {
describe('selectedDashboard', () => {
const { selectedDashboard } = getters;
const localGetters = state => ({
fullDashboardPath: getters.fullDashboardPath(state),
});
it('returns a dashboard', () => {
const state = {
allDashboards: dashboardGitResponse,
currentDashboard: dashboardGitResponse[0].path,
customDashboardBasePath,
};
expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]);
});
it('returns a non-default dashboard', () => {
const state = {
allDashboards: dashboardGitResponse,
currentDashboard: dashboardGitResponse[1].path,
customDashboardBasePath,
};
expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]);
expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[1]);
});
it('returns a default dashboard when no dashboard is selected', () => {
const state = {
allDashboards: dashboardGitResponse,
currentDashboard: null,
customDashboardBasePath,
};
expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]);
});
it('returns a default dashboard when dashboard cannot be found', () => {
const state = {
allDashboards: dashboardGitResponse,
currentDashboard: 'wrong_path',
customDashboardBasePath,
};
expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]);
expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]);
});
it('returns null when no dashboards are present', () => {
const state = {
allDashboards: [],
currentDashboard: dashboardGitResponse[0].path,
customDashboardBasePath,
};
expect(selectedDashboard(state)).toEqual(null);
expect(selectedDashboard(state, localGetters(state))).toEqual(null);
});
});
......
......@@ -8,6 +8,7 @@ import {
normalizeQueryResponseData,
convertToGrafanaTimeRange,
addDashboardMetaDataToLink,
normalizeCustomDashboardPath,
} from '~/monitoring/stores/utils';
import { annotationsData } from '../mock_data';
import { NOT_IN_DB_PREFIX } from '~/monitoring/constants';
......@@ -700,3 +701,24 @@ describe('normalizeQueryResponseData', () => {
]);
});
});
describe('normalizeCustomDashboardPath', () => {
it.each`
input | expected
${[undefined]} | ${''}
${[null]} | ${''}
${[]} | ${''}
${['links.yml']} | ${'links.yml'}
${['links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'}
${['config/prometheus/common_metrics.yml']} | ${'config/prometheus/common_metrics.yml'}
${['config/prometheus/common_metrics.yml', '.gitlab/dashboards']} | ${'config/prometheus/common_metrics.yml'}
${['dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'}
${['dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'}
${['.gitlab/dashboards/links.yml']} | ${'.gitlab/dashboards/links.yml'}
${['.gitlab/dashboards/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'}
${['.gitlab/dashboards/dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'}
${['.gitlab/dashboards/dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'}
`(`normalizeCustomDashboardPath returns $expected for $input`, ({ input, expected }) => {
expect(normalizeCustomDashboardPath(...input)).toEqual(expected);
});
});
......@@ -14,12 +14,18 @@ const DEFAULT_PROPS = {
describe('Suggestion Diff component', () => {
let wrapper;
const createComponent = props => {
const createComponent = (props, glFeatures = {}) => {
wrapper = shallowMount(SuggestionDiffHeader, {
propsData: {
...DEFAULT_PROPS,
...props,
},
provide: {
glFeatures: {
batchSuggestions: true,
...glFeatures,
},
},
});
};
......@@ -204,6 +210,18 @@ describe('Suggestion Diff component', () => {
});
});
describe('batchSuggestions feature flag is set to false', () => {
beforeEach(() => {
createComponent({}, { batchSuggestions: false });
});
it('disables add to batch buttons but keeps apply suggestion enabled', () => {
expect(findApplyButton().exists()).toBe(true);
expect(findAddToBatchButton().exists()).toBe(false);
expect(findApplyButton().attributes('disabled')).not.toBe('true');
});
});
describe('canApply is set to false', () => {
beforeEach(() => {
createComponent({ canApply: false });
......
......@@ -28,6 +28,7 @@ describe Projects::AlertManagementHelper do
expect(helper.alert_management_data(current_user, project)).to match(
'project-path' => project_path,
'enable-alert-management-path' => setting_path,
'populating-alerts-help-url' => 'http://test.host/help/user/project/operations/alert_management.html#enable-alert-management',
'empty-alert-svg-path' => match_asset_path('/assets/illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => 'true',
'alert-management-enabled' => 'false'
......@@ -57,6 +58,28 @@ describe Projects::AlertManagementHelper do
end
end
context 'with prometheus service' do
let_it_be(:prometheus_service) { create(:prometheus_service, project: project) }
context 'when prometheus service is active' do
it 'enables alert management' do
expect(data).to include(
'alert-management-enabled' => 'true'
)
end
end
context 'when prometheus service is inactive' do
it 'disables alert management' do
prometheus_service.update!(manual_configuration: false)
expect(data).to include(
'alert-management-enabled' => 'false'
)
end
end
end
context 'when user does not have requisite enablement permissions' do
let(:user_can_enable_alert_management) { false }
......
......@@ -241,7 +241,7 @@ describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers
describe '#drop_partitioned_table_for' do
let(:expected_tables) do
%w[000000 201912 202001 202002].map { |suffix| "#{partitioned_table}_#{suffix}" }.unshift(partitioned_table)
%w[000000 201912 202001 202002].map { |suffix| "partitions_dynamic.#{partitioned_table}_#{suffix}" }.unshift(partitioned_table)
end
context 'when the table is not whitelisted' do
......
......@@ -1811,6 +1811,50 @@ describe Ci::Build do
end
end
describe '.keep_artifacts!' do
let!(:build) { create(:ci_build, artifacts_expire_at: Time.current + 7.days) }
let!(:builds_for_update) do
Ci::Build.where(id: create_list(:ci_build, 3, artifacts_expire_at: Time.current + 7.days).map(&:id))
end
it 'resets expire_at' do
builds_for_update.keep_artifacts!
builds_for_update.each do |build|
expect(build.reload.artifacts_expire_at).to be_nil
end
end
it 'does not reset expire_at for other builds' do
builds_for_update.keep_artifacts!
expect(build.reload.artifacts_expire_at).to be_present
end
context 'when having artifacts files' do
let!(:artifact) { create(:ci_job_artifact, job: build, expire_in: '7 days') }
let!(:artifacts_for_update) do
builds_for_update.map do |build|
create(:ci_job_artifact, job: build, expire_in: '7 days')
end
end
it 'resets dependent objects' do
builds_for_update.keep_artifacts!
artifacts_for_update.each do |artifact|
expect(artifact.reload.expire_at).to be_nil
end
end
it 'does not reset dependent object for other builds' do
builds_for_update.keep_artifacts!
expect(artifact.reload.expire_at).to be_present
end
end
end
describe '#keep_artifacts!' do
let(:build) { create(:ci_build, artifacts_expire_at: Time.current + 7.days) }
......
......@@ -5886,6 +5886,30 @@ describe Project do
end
end
describe '#prometheus_service_active?' do
let(:project) { create(:project) }
subject { project.prometheus_service_active? }
before do
create(:prometheus_service, project: project, manual_configuration: manual_configuration)
end
context 'when project has an activated prometheus service' do
let(:manual_configuration) { true }
it { is_expected.to be_truthy }
end
context 'when project has an inactive prometheus service' do
let(:manual_configuration) { false }
it 'the service is marked as inactive' do
expect(subject).to be_falsey
end
end
end
describe '#self_monitoring?' do
let_it_be(:project) { create(:project) }
......
......@@ -153,11 +153,20 @@ RSpec.configure do |config|
# fixed. If we raised the `JSException` the fixed test would be marked as
# failed again.
if example.exception && !example.exception.is_a?(RSpec::Core::Pending::PendingExampleFixedError)
console = page.driver.browser.manage.logs.get(:browser)&.reject { |log| log.message =~ JS_CONSOLE_FILTER }
if console.present?
message = "Unexpected browser console output:\n" + console.map(&:message).join("\n")
raise JSConsoleError, message
begin
console = page.driver.browser.manage.logs.get(:browser)&.reject { |log| log.message =~ JS_CONSOLE_FILTER }
if console.present?
message = "Unexpected browser console output:\n" + console.map(&:message).join("\n")
raise JSConsoleError, message
end
rescue Selenium::WebDriver::Error::WebDriverError => error
if error.message =~ /unknown command: session\/[0-9a-zA-Z]+(?:\/se)?\/log/
message = "Unable to access Chrome javascript console logs. You may be using an outdated version of ChromeDriver."
raise JSConsoleError, message
else
raise error
end
end
end
......
......@@ -8,8 +8,8 @@ module PartitioningHelpers
expect(columns_with_part_type).to match_array(actual_columns)
end
def expect_range_partition_of(partition_name, table_name, min_value, max_value)
definition = find_partition_definition(partition_name)
def expect_range_partition_of(partition_name, table_name, min_value, max_value, schema: 'partitions_dynamic')
definition = find_partition_definition(partition_name, schema: schema)
expect(definition).not_to be_nil
expect(definition['base_table']).to eq(table_name.to_s)
......@@ -40,7 +40,7 @@ module PartitioningHelpers
SQL
end
def find_partition_definition(partition)
def find_partition_definition(partition, schema: 'partitions_dynamic')
connection.select_one(<<~SQL)
select
parent_class.relname as base_table,
......@@ -48,7 +48,10 @@ module PartitioningHelpers
from pg_class
inner join pg_inherits i on pg_class.oid = inhrelid
inner join pg_class parent_class on parent_class.oid = inhparent
where pg_class.relname = '#{partition}' and pg_class.relispartition;
inner join pg_namespace ON pg_namespace.oid = pg_class.relnamespace
where pg_namespace.nspname = '#{schema}'
and pg_class.relname = '#{partition}'
and pg_class.relispartition
SQL
end
end
......@@ -840,10 +840,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.140.0.tgz#593f1f65b0df57c3399fcfb9f472f59aa64da074"
integrity sha512-6gANJGi2QkpvOgFTMcY3SIwEqhO69i6R3jU4BSskkVziwDdAWxGonln22a4Iu//Iv0NrsFDpAA0jIVfnJzw0iA==
"@gitlab/ui@16.12.2":
version "16.12.2"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-16.12.2.tgz#dc37bc6f827b55b86e29b10e42500913446818a3"
integrity sha512-pCl0dzVsQ94MLk0T0jCwgv9Dbf+FX+6vpR+E0FQH6SFAIaNEEpkBkSDiVp0Q1RJoRi1Q6nK1rVPoMWTwW6/7uA==
"@gitlab/ui@17.0.1":
version "17.0.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-17.0.1.tgz#daf036dfdc095f94123c80c3fb1ab5fe4dcbf95b"
integrity sha512-JSUGruV6oploADF0Sc0BBY43Des3utU9iWCnR8BAmttKFXFFNUKwTf908yZPGJtfnVyjJkVioOCOYkvUZ0jngg==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册