Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
7c862041c6
commit
f607152a08
|
@ -9,6 +9,7 @@ module Analytics
|
|||
belongs_to :project
|
||||
|
||||
alias_attribute :parent, :project
|
||||
alias_attribute :parent_id, :project_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -47,11 +47,17 @@ module Analytics
|
|||
!custom
|
||||
end
|
||||
|
||||
# The model that is going to be queried, Issue or MergeRequest
|
||||
def subject_model
|
||||
# The model class that is going to be queried, Issue or MergeRequest
|
||||
def subject_class
|
||||
start_event.object_type
|
||||
end
|
||||
|
||||
def matches_with_stage_params?(stage_params)
|
||||
default_stage? &&
|
||||
start_event_identifier.to_s.eql?(stage_params[:start_event_identifier].to_s) &&
|
||||
end_event_identifier.to_s.eql?(stage_params[:end_event_identifier].to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_stage_event_pairs
|
||||
|
|
|
@ -591,3 +591,5 @@ class MergeRequestDiff < ApplicationRecord
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
MergeRequestDiff.prepend_if_ee('EE::MergeRequestDiff')
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class IndexTimestampColumnsForIssueMetrics < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_index(*index_arguments)
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index(*index_arguments)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def index_arguments
|
||||
[
|
||||
:issue_metrics,
|
||||
[:issue_id, :first_mentioned_in_commit_at, :first_associated_with_milestone_at, :first_added_to_board_at],
|
||||
{
|
||||
name: 'index_issue_metrics_on_issue_id_and_timestamps'
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class IndexTimestampColumnsForMergeRequestsCreationDate < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_index(*index_arguments)
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index(*index_arguments)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def index_arguments
|
||||
[
|
||||
:merge_requests,
|
||||
[:target_project_id, :created_at],
|
||||
{
|
||||
name: 'index_merge_requests_target_project_id_created_at'
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
|
@ -1816,6 +1816,7 @@ ActiveRecord::Schema.define(version: 2019_10_04_134055) do
|
|||
t.datetime "first_added_to_board_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["issue_id", "first_mentioned_in_commit_at", "first_associated_with_milestone_at", "first_added_to_board_at"], name: "index_issue_metrics_on_issue_id_and_timestamps"
|
||||
t.index ["issue_id"], name: "index_issue_metrics"
|
||||
end
|
||||
|
||||
|
@ -2226,6 +2227,7 @@ ActiveRecord::Schema.define(version: 2019_10_04_134055) do
|
|||
t.index ["source_project_id", "source_branch"], name: "index_merge_requests_on_source_project_id_and_source_branch"
|
||||
t.index ["state", "merge_status"], name: "index_merge_requests_on_state_and_merge_status", where: "(((state)::text = 'opened'::text) AND ((merge_status)::text = 'can_be_merged'::text))"
|
||||
t.index ["target_branch"], name: "index_merge_requests_on_target_branch"
|
||||
t.index ["target_project_id", "created_at"], name: "index_merge_requests_target_project_id_created_at"
|
||||
t.index ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid", unique: true
|
||||
t.index ["target_project_id", "iid"], name: "index_merge_requests_on_target_project_id_and_iid_opened", where: "((state)::text = 'opened'::text)"
|
||||
t.index ["target_project_id", "merge_commit_sha", "id"], name: "index_merge_requests_on_tp_id_and_merge_commit_sha_and_id"
|
||||
|
|
|
@ -44,7 +44,7 @@ For instance, consider the following workflow:
|
|||
First of all, you need to define a job in your `.gitlab-ci.yml` file that generates the
|
||||
[Performance report artifact](../../../ci/yaml/README.md#artifactsreportsperformance-premium).
|
||||
For more information on how the Performance job should look like, check the
|
||||
example on [Testing Browser Performance](../../../ci/examples/browser_performance.md).
|
||||
example on [Configuring Browser Performance Testing](#configuring-browser-performance-testing).
|
||||
|
||||
GitLab then checks this report, compares key performance metrics for each page
|
||||
between the source and target branches, and shows the information right on the merge request.
|
||||
|
@ -60,11 +60,6 @@ report will be shown properly.
|
|||
|
||||
## Configuring Browser Performance Testing
|
||||
|
||||
NOTE: **Note:**
|
||||
The job definition shown below is supported in GitLab 11.5 and later versions.
|
||||
It also requires GitLab Runner 11.5 or later. For earlier versions, use the
|
||||
[previous job definitions](#previous-job-definitions).
|
||||
|
||||
This example shows how to run the [sitespeed.io container](https://hub.docker.com/r/sitespeedio/sitespeed.io/)
|
||||
on your code by using GitLab CI/CD and [sitespeed.io](https://www.sitespeed.io)
|
||||
using Docker-in-Docker.
|
||||
|
@ -73,29 +68,35 @@ First, you need GitLab Runner with
|
|||
[docker-in-docker build](../../../ci/docker/using_docker_build.md#use-docker-in-docker-workflow-with-docker-executor).
|
||||
|
||||
Once you set up the Runner, add a new job to `.gitlab-ci.yml` that generates the
|
||||
expected report:
|
||||
expected report.
|
||||
|
||||
For GitLab 12.4 and later, to define the `performance` job, you must
|
||||
[include](../../../ci/yaml/README.md#includetemplate) the
|
||||
[`Browser-Performance.gitlab-ci.yml` template](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml)
|
||||
that's provided as a part of your GitLab installation.
|
||||
For GitLab versions earlier than 12.4, you can copy and use the job as defined
|
||||
in that template.
|
||||
|
||||
CAUTION: **Caution:**
|
||||
The job definition provided by the template does not support Kubernetes yet. For a complete example of a more complex setup
|
||||
that works in Kubernetes, see [here](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml).
|
||||
|
||||
Add the following to your `.gitlab-ci.yml` file:
|
||||
|
||||
```yaml
|
||||
include:
|
||||
template: Verify/Browser-Performance.gitlab-ci.yml
|
||||
|
||||
performance:
|
||||
stage: performance
|
||||
image: docker:git
|
||||
variables:
|
||||
URL: https://example.com
|
||||
services:
|
||||
- docker:stable-dind
|
||||
script:
|
||||
- mkdir gitlab-exporter
|
||||
- wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js
|
||||
- mkdir sitespeed-results
|
||||
- docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results $URL
|
||||
- mv sitespeed-results/data/performance.json performance.json
|
||||
artifacts:
|
||||
paths:
|
||||
- sitespeed-results/
|
||||
reports:
|
||||
performance: performance.json
|
||||
```
|
||||
|
||||
CAUTION: **Caution:**
|
||||
The job definition provided by the template is supported in GitLab 11.5 and later versions.
|
||||
It also requires GitLab Runner 11.5 or later. For earlier versions, use the
|
||||
[previous job definitions](#previous-job-definitions).
|
||||
|
||||
The above example will create a `performance` job in your CI/CD pipeline and will run
|
||||
sitespeed.io against the webpage you defined in `URL` to gather key metrics.
|
||||
The [GitLab plugin for sitespeed.io](https://gitlab.com/gitlab-org/gl-performance)
|
||||
|
@ -106,6 +107,20 @@ take the latest Performance artifact available.
|
|||
The full HTML sitespeed.io report will also be saved as an artifact, and if you have
|
||||
[GitLab Pages](../pages/index.md) enabled, it can be viewed directly in your browser.
|
||||
|
||||
It is also possible to customize options by setting the `SITESPEED_OPTIONS` variable.
|
||||
For example, this is how to override the number of runs sitespeed.io
|
||||
will make on the given URL:
|
||||
|
||||
```yaml
|
||||
include:
|
||||
template: Verify/Browser-Performance.gitlab-ci.yml
|
||||
|
||||
performance:
|
||||
variables:
|
||||
URL: https://example.com
|
||||
SITESPEED_OPTIONS: -n 5
|
||||
```
|
||||
|
||||
For further customization options for sitespeed.io, including the ability to provide a
|
||||
list of URLs to test, please see the [Sitespeed.io Configuration](https://www.sitespeed.io/documentation/sitespeed.io/configuration/)
|
||||
documentation.
|
||||
|
@ -126,8 +141,9 @@ set this up:
|
|||
as an artifact is as simple as `echo $CI_ENVIRONMENT_URL > environment_url.txt`
|
||||
in your job's `script`.
|
||||
1. In the `performance` job, read the previous artifact into an environment
|
||||
variable, like `$CI_ENVIRONMENT_URL`, and use it to parameterize the test
|
||||
URLs.
|
||||
variable, in this case `$URL` because this is what our sitespeed.io command
|
||||
uses for the URL parameter. Because Review App URLs are dynamic, we define
|
||||
the `URL` variable through `before_script` instead of `variables`.
|
||||
1. You can now run the sitespeed.io container against the desired hostname and
|
||||
paths.
|
||||
|
||||
|
@ -138,6 +154,9 @@ stages:
|
|||
- deploy
|
||||
- performance
|
||||
|
||||
include:
|
||||
template: Verify/Browser-Performance.gitlab-ci.yml
|
||||
|
||||
review:
|
||||
stage: deploy
|
||||
environment:
|
||||
|
@ -155,28 +174,12 @@ review:
|
|||
- master
|
||||
|
||||
performance:
|
||||
stage: performance
|
||||
image: docker:git
|
||||
services:
|
||||
- docker:stable-dind
|
||||
dependencies:
|
||||
- review
|
||||
script:
|
||||
- export CI_ENVIRONMENT_URL=$(cat environment_url.txt)
|
||||
- mkdir gitlab-exporter
|
||||
- wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js
|
||||
- mkdir sitespeed-results
|
||||
- docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL"
|
||||
- mv sitespeed-results/data/performance.json performance.json
|
||||
artifacts:
|
||||
paths:
|
||||
- sitespeed-results/
|
||||
reports:
|
||||
performance: performance.json
|
||||
before_script:
|
||||
- export URL=$(cat environment_url.txt)
|
||||
```
|
||||
|
||||
A complete example can be found in our [Auto DevOps CI YML](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml).
|
||||
|
||||
### Previous job definitions
|
||||
|
||||
CAUTION: **Caution:**
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Analytics
|
||||
module CycleAnalytics
|
||||
class BaseQueryBuilder
|
||||
include Gitlab::CycleAnalytics::MetricsTables
|
||||
|
||||
delegate :subject_class, to: :stage
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
|
||||
def initialize(stage:, params: {})
|
||||
@stage = stage
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
query = subject_class
|
||||
query = filter_by_parent_model(query)
|
||||
query = filter_by_time_range(query)
|
||||
query = stage.start_event.apply_query_customization(query)
|
||||
query = stage.end_event.apply_query_customization(query)
|
||||
query.where(duration_condition)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :stage, :params
|
||||
|
||||
def duration_condition
|
||||
stage.end_event.timestamp_projection.gteq(stage.start_event.timestamp_projection)
|
||||
end
|
||||
|
||||
def filter_by_parent_model(query)
|
||||
if parent_class.eql?(Project)
|
||||
if subject_class.eql?(Issue)
|
||||
query.where(project_id: stage.parent_id)
|
||||
elsif subject_class.eql?(MergeRequest)
|
||||
query.where(target_project_id: stage.parent_id)
|
||||
else
|
||||
raise ArgumentError, "unknown subject_class: #{subject_class}"
|
||||
end
|
||||
else
|
||||
raise ArgumentError, "unknown parent_class: #{parent_class}"
|
||||
end
|
||||
end
|
||||
|
||||
def filter_by_time_range(query)
|
||||
from = params.fetch(:from, 30.days.ago)
|
||||
to = params[:to]
|
||||
|
||||
query = query.where(subject_table[:created_at].gteq(from))
|
||||
query = query.where(subject_table[:created_at].lteq(to)) if to
|
||||
query
|
||||
end
|
||||
|
||||
def subject_table
|
||||
subject_class.arel_table
|
||||
end
|
||||
|
||||
def parent_class
|
||||
stage.parent.class
|
||||
end
|
||||
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Analytics
|
||||
module CycleAnalytics
|
||||
# Arguments:
|
||||
# stage - an instance of CycleAnalytics::ProjectStage or CycleAnalytics::GroupStage
|
||||
# params:
|
||||
# current_user: an instance of User
|
||||
# from: DateTime
|
||||
# to: DateTime
|
||||
class DataCollector
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
def initialize(stage:, params: {})
|
||||
@stage = stage
|
||||
@params = params
|
||||
end
|
||||
|
||||
def records_fetcher
|
||||
strong_memoize(:records_fetcher) do
|
||||
RecordsFetcher.new(stage: stage, query: query, params: params)
|
||||
end
|
||||
end
|
||||
|
||||
def median
|
||||
strong_memoize(:median) do
|
||||
Median.new(stage: stage, query: query)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :stage, :params
|
||||
|
||||
def query
|
||||
BaseQueryBuilder.new(stage: stage, params: params).build
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -92,8 +92,8 @@ module Gitlab
|
|||
name: 'production',
|
||||
custom: false,
|
||||
relative_position: 7,
|
||||
start_event_identifier: :merge_request_merged,
|
||||
end_event_identifier: :merge_request_first_deployed_to_production
|
||||
start_event_identifier: :issue_created,
|
||||
end_event_identifier: :production_stage_end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Analytics
|
||||
module CycleAnalytics
|
||||
class Median
|
||||
include StageQueryHelpers
|
||||
|
||||
def initialize(stage:, query:)
|
||||
@stage = stage
|
||||
@query = query
|
||||
end
|
||||
|
||||
def seconds
|
||||
@query = @query.select(median_duration_in_seconds.as('median'))
|
||||
result = execute_query(@query).first || {}
|
||||
|
||||
result['median'] ? result['median'].to_i : nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :stage
|
||||
|
||||
def percentile_cont
|
||||
percentile_cont_ordering = Arel::Nodes::UnaryOperation.new(Arel::Nodes::SqlLiteral.new('ORDER BY'), duration)
|
||||
Arel::Nodes::NamedFunction.new(
|
||||
'percentile_cont(0.5) WITHIN GROUP',
|
||||
[percentile_cont_ordering]
|
||||
)
|
||||
end
|
||||
|
||||
def median_duration_in_seconds
|
||||
Arel::Nodes::Extract.new(percentile_cont, :epoch)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,132 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Analytics
|
||||
module CycleAnalytics
|
||||
class RecordsFetcher
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
include StageQueryHelpers
|
||||
include Gitlab::CycleAnalytics::MetricsTables
|
||||
|
||||
MAX_RECORDS = 20
|
||||
|
||||
MAPPINGS = {
|
||||
Issue => {
|
||||
finder_class: IssuesFinder,
|
||||
serializer_class: AnalyticsIssueSerializer,
|
||||
includes_for_query: { project: [:namespace], author: [] },
|
||||
columns_for_select: %I[title iid id created_at author_id project_id]
|
||||
},
|
||||
MergeRequest => {
|
||||
finder_class: MergeRequestsFinder,
|
||||
serializer_class: AnalyticsMergeRequestSerializer,
|
||||
includes_for_query: { target_project: [:namespace], author: [] },
|
||||
columns_for_select: %I[title iid id created_at author_id state target_project_id]
|
||||
}
|
||||
}.freeze
|
||||
|
||||
delegate :subject_class, to: :stage
|
||||
|
||||
def initialize(stage:, query:, params: {})
|
||||
@stage = stage
|
||||
@query = query
|
||||
@params = params
|
||||
end
|
||||
|
||||
def serialized_records
|
||||
strong_memoize(:serialized_records) do
|
||||
# special case (legacy): 'Test' and 'Staging' stages should show Ci::Build records
|
||||
if default_test_stage? || default_staging_stage?
|
||||
AnalyticsBuildSerializer.new.represent(ci_build_records.map { |e| e['build'] })
|
||||
else
|
||||
records.map do |record|
|
||||
project = record.project
|
||||
attributes = record.attributes.merge({
|
||||
project_path: project.path,
|
||||
namespace_path: project.namespace.path,
|
||||
author: record.author
|
||||
})
|
||||
serializer.represent(attributes)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :stage, :query, :params
|
||||
|
||||
def finder_query
|
||||
MAPPINGS
|
||||
.fetch(subject_class)
|
||||
.fetch(:finder_class)
|
||||
.new(params.fetch(:current_user), finder_params.fetch(stage.parent.class))
|
||||
.execute
|
||||
end
|
||||
|
||||
def columns
|
||||
MAPPINGS.fetch(subject_class).fetch(:columns_for_select).map do |column_name|
|
||||
subject_class.arel_table[column_name]
|
||||
end
|
||||
end
|
||||
|
||||
# EE will override this to include Group rules
|
||||
def finder_params
|
||||
{
|
||||
Project => { project_id: stage.parent_id }
|
||||
}
|
||||
end
|
||||
|
||||
def default_test_stage?
|
||||
stage.matches_with_stage_params?(Gitlab::Analytics::CycleAnalytics::DefaultStages.params_for_test_stage)
|
||||
end
|
||||
|
||||
def default_staging_stage?
|
||||
stage.matches_with_stage_params?(Gitlab::Analytics::CycleAnalytics::DefaultStages.params_for_staging_stage)
|
||||
end
|
||||
|
||||
def serializer
|
||||
MAPPINGS.fetch(subject_class).fetch(:serializer_class).new
|
||||
end
|
||||
|
||||
# Loading Ci::Build records instead of MergeRequest records
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def ci_build_records
|
||||
ci_build_join = mr_metrics_table
|
||||
.join(build_table)
|
||||
.on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id]))
|
||||
.join_sources
|
||||
|
||||
q = ordered_and_limited_query
|
||||
.joins(ci_build_join)
|
||||
.select(build_table[:id], round_duration_to_seconds.as('total_time'))
|
||||
|
||||
results = execute_query(q).to_a
|
||||
|
||||
Gitlab::CycleAnalytics::Updater.update!(results, from: 'id', to: 'build', klass: ::Ci::Build.includes({ project: [:namespace], user: [], pipeline: [] }))
|
||||
end
|
||||
|
||||
def ordered_and_limited_query
|
||||
query
|
||||
.reorder(stage.end_event.timestamp_projection.desc)
|
||||
.limit(MAX_RECORDS)
|
||||
end
|
||||
|
||||
def records
|
||||
results = finder_query
|
||||
.merge(ordered_and_limited_query)
|
||||
.select(*columns, round_duration_to_seconds.as('total_time'))
|
||||
|
||||
# using preloader instead of includes to avoid AR generating a large column list
|
||||
ActiveRecord::Associations::Preloader.new.preload(
|
||||
results,
|
||||
MAPPINGS.fetch(subject_class).fetch(:includes_for_query)
|
||||
)
|
||||
|
||||
results
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Analytics
|
||||
module CycleAnalytics
|
||||
module StageQueryHelpers
|
||||
def execute_query(query)
|
||||
ActiveRecord::Base.connection.execute(query.to_sql)
|
||||
end
|
||||
|
||||
def zero_interval
|
||||
Arel::Nodes::NamedFunction.new("CAST", [Arel.sql("'0' AS INTERVAL")])
|
||||
end
|
||||
|
||||
def round_duration_to_seconds
|
||||
Arel::Nodes::Extract.new(duration, :epoch)
|
||||
end
|
||||
|
||||
def duration
|
||||
Arel::Nodes::Subtraction.new(
|
||||
stage.end_event.timestamp_projection,
|
||||
stage.start_event.timestamp_projection
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
# Read more about the feature here: https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html
|
||||
|
||||
stages:
|
||||
- build
|
||||
- test
|
||||
- deploy
|
||||
- performance
|
||||
|
||||
performance:
|
||||
stage: performance
|
||||
image: docker:git
|
||||
variables:
|
||||
URL: https://example.com
|
||||
SITESPEED_VERSION: 6.3.1
|
||||
SITESPEED_OPTIONS: ''
|
||||
services:
|
||||
- docker:stable-dind
|
||||
script:
|
||||
- mkdir gitlab-exporter
|
||||
- wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js
|
||||
- mkdir sitespeed-results
|
||||
- docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS
|
||||
- mv sitespeed-results/data/performance.json performance.json
|
||||
artifacts:
|
||||
paths:
|
||||
- performance.json
|
||||
- sitespeed-results/
|
||||
reports:
|
||||
performance: performance.json
|
|
@ -6030,6 +6030,15 @@ msgstr ""
|
|||
msgid "Environments|An error occurred while fetching the environments."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|An error occurred while fetching the logs"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|An error occurred while fetching the logs - Error: %{message}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|An error occurred while fetching the logs for this environment or pod. Please try again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|An error occurred while making the request."
|
||||
msgstr ""
|
||||
|
||||
|
@ -6075,9 +6084,6 @@ msgstr ""
|
|||
msgid "Environments|No deployments yet"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|No pod name has been specified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Note that this action will stop the environment, but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment due to no “stop environment action” being defined in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file."
|
||||
msgstr ""
|
||||
|
||||
|
@ -14986,9 +14992,6 @@ msgstr ""
|
|||
msgid "Something went wrong on our end."
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong on our end. %{message}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong on our end. Please try again!"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :cycle_analytics_project_stage, class: Analytics::CycleAnalytics::ProjectStage do
|
||||
project
|
||||
sequence(:name) { |n| "Stage ##{n}" }
|
||||
hidden { false }
|
||||
issue_stage
|
||||
|
||||
trait :issue_stage do
|
||||
start_event_identifier { Gitlab::Analytics::CycleAnalytics::StageEvents::IssueCreated.identifier }
|
||||
end_event_identifier { Gitlab::Analytics::CycleAnalytics::StageEvents::IssueStageEnd.identifier }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,29 +1,102 @@
|
|||
<div class="js-kubernetes-logs" data-logs-path="/root/kubernetes-app/environments/1/logs">
|
||||
<div class="build-page">
|
||||
<div
|
||||
class="js-kubernetes-logs"
|
||||
data-current-environment-name="production"
|
||||
data-environments-path="/root/my-project/environments.json"
|
||||
data-logs-page="/root/my-project/environments/1/logs"
|
||||
data-logs-path="/root/my-project/environments/1/logs.json"
|
||||
>
|
||||
<div class="build-page-pod-logs">
|
||||
<div class="build-trace-container prepend-top-default">
|
||||
<div class="top-bar js-top-bar">
|
||||
<div class="truncated-info hidden-xs pull-left"></div>
|
||||
<div class="dropdown prepend-left-10 js-pod-dropdown">
|
||||
<button aria-expanded="false" class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
<div class="top-bar js-top-bar d-flex">
|
||||
<div class="row">
|
||||
<div class="form-group col-6" role="group">
|
||||
<label class="d-block col-form-label-sm col-form-label">
|
||||
Environment
|
||||
</label>
|
||||
<div class="dropdown js-environment-dropdown d-flex">
|
||||
<button
|
||||
aria-expanded="false"
|
||||
class="dropdown-menu-toggle d-flex align-content-center align-self-center"
|
||||
data-toggle="dropdown"
|
||||
type="button"
|
||||
>
|
||||
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
|
||||
<div class="dropdown-toggle-text">
|
||||
|
||||
</div>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
|
||||
</div>
|
||||
<div class="controllers pull-right">
|
||||
<div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Scroll to top">
|
||||
<button class="js-scroll-up btn-scroll btn-transparent btn-blank" disabled type="button"></button>
|
||||
</div>
|
||||
<div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Scroll to bottom">
|
||||
<button class="js-scroll-down btn-scroll btn-transparent btn-blank" disabled type="button"></button>
|
||||
<div class="form-group col-6" role="group">
|
||||
<label class="d-block col-form-label-sm col-form-label">
|
||||
Pod logs from
|
||||
</label>
|
||||
<div class="dropdown js-pod-dropdown d-flex">
|
||||
<button
|
||||
aria-expanded="false"
|
||||
class="dropdown-menu-toggle d-flex align-content-center align-self-center"
|
||||
data-toggle="dropdown"
|
||||
type="button"
|
||||
>
|
||||
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
|
||||
<div class="dropdown-toggle-text">
|
||||
|
||||
</div>
|
||||
<div class="refresh-control pull-right">
|
||||
<div class="has-tooltip controllers-buttons" data-container="body" data-placement="top" title="Refresh">
|
||||
<button class="js-refresh-log btn-default btn-refresh" disabled type="button"></button>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controllers align-self-end">
|
||||
<div
|
||||
class="has-tooltip controllers-buttons"
|
||||
data-container="body"
|
||||
data-placement="top"
|
||||
title="Scroll to top"
|
||||
>
|
||||
<button
|
||||
class="js-scroll-up btn-scroll btn-transparent btn-blank"
|
||||
disabled
|
||||
type="button"
|
||||
></button>
|
||||
</div>
|
||||
<div
|
||||
class="has-tooltip controllers-buttons"
|
||||
data-container="body"
|
||||
data-placement="top"
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<button
|
||||
class="js-scroll-down btn-scroll btn-transparent btn-blank"
|
||||
disabled
|
||||
type="button"
|
||||
></button>
|
||||
</div>
|
||||
<div class="refresh-control">
|
||||
<div
|
||||
class="has-tooltip controllers-buttons"
|
||||
data-container="body"
|
||||
data-placement="top"
|
||||
title="Refresh"
|
||||
>
|
||||
<button
|
||||
class="js-refresh-log btn btn-default btn-refresh h-32-px"
|
||||
disabled
|
||||
type="button"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="build-trace" id="build-trace"><code class="bash js-build-output"><div class="build-loader-animation js-build-refresh"></div></code></pre>
|
||||
<pre class="build-trace" id="build-trace">
|
||||
<code class="bash js-build-output"></code>
|
||||
<div class="build-loader-animation js-build-refresh">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::Analytics::CycleAnalytics::BaseQueryBuilder do
|
||||
let_it_be(:project) { create(:project, :empty_repo) }
|
||||
let_it_be(:mr1) { create(:merge_request, target_project: project, source_project: project, allow_broken: true, created_at: 3.months.ago) }
|
||||
let_it_be(:mr2) { create(:merge_request, target_project: project, source_project: project, allow_broken: true, created_at: 1.month.ago) }
|
||||
let(:params) { {} }
|
||||
let(:records) do
|
||||
stage = build(:cycle_analytics_project_stage, {
|
||||
start_event_identifier: :merge_request_created,
|
||||
end_event_identifier: :merge_request_merged,
|
||||
project: project
|
||||
})
|
||||
described_class.new(stage: stage, params: params).build.to_a
|
||||
end
|
||||
|
||||
before do
|
||||
mr1.metrics.update!(merged_at: 1.month.ago)
|
||||
mr2.metrics.update!(merged_at: Time.now)
|
||||
end
|
||||
|
||||
around do |example|
|
||||
Timecop.freeze { example.run }
|
||||
end
|
||||
|
||||
describe 'date range parameters' do
|
||||
context 'when filters by only the `from` parameter' do
|
||||
before do
|
||||
params[:from] = 4.months.ago
|
||||
end
|
||||
|
||||
it { expect(records.size).to eq(2) }
|
||||
end
|
||||
|
||||
context 'when filters by both `from` and `to` parameters' do
|
||||
before do
|
||||
params.merge!(from: 4.months.ago, to: 2.months.ago)
|
||||
end
|
||||
|
||||
it { expect(records.size).to eq(1) }
|
||||
end
|
||||
|
||||
context 'invalid date range is provided' do
|
||||
before do
|
||||
params.merge!(from: 1.month.ago, to: 10.months.ago)
|
||||
end
|
||||
|
||||
it { expect(records.size).to eq(0) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'scopes query within the target project' do
|
||||
other_mr = create(:merge_request, source_project: create(:project), allow_broken: true, created_at: 2.months.ago)
|
||||
other_mr.metrics.update!(merged_at: 1.month.ago)
|
||||
|
||||
params[:from] = 1.year.ago
|
||||
|
||||
expect(records.size).to eq(2)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,131 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::Analytics::CycleAnalytics::RecordsFetcher do
|
||||
around do |example|
|
||||
Timecop.freeze { example.run }
|
||||
end
|
||||
|
||||
let_it_be(:project) { create(:project, :empty_repo) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
subject do
|
||||
Gitlab::Analytics::CycleAnalytics::DataCollector.new(
|
||||
stage: stage,
|
||||
params: {
|
||||
from: 1.year.ago,
|
||||
current_user: user
|
||||
}
|
||||
).records_fetcher.serialized_records
|
||||
end
|
||||
|
||||
describe '#serialized_records' do
|
||||
shared_context 'when records are loaded by maintainer' do
|
||||
before do
|
||||
project.add_user(user, Gitlab::Access::MAINTAINER)
|
||||
end
|
||||
|
||||
it 'returns all records' do
|
||||
expect(subject.size).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'for issue based stage' do
|
||||
let_it_be(:issue1) { create(:issue, project: project) }
|
||||
let_it_be(:issue2) { create(:issue, project: project, confidential: true) }
|
||||
let(:stage) do
|
||||
build(:cycle_analytics_project_stage, {
|
||||
start_event_identifier: :plan_stage_start,
|
||||
end_event_identifier: :issue_first_mentioned_in_commit,
|
||||
project: project
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
issue1.metrics.update(first_added_to_board_at: 3.days.ago, first_mentioned_in_commit_at: 2.days.ago)
|
||||
issue2.metrics.update(first_added_to_board_at: 3.days.ago, first_mentioned_in_commit_at: 2.days.ago)
|
||||
end
|
||||
|
||||
context 'when records are loaded by guest' do
|
||||
before do
|
||||
project.add_user(user, Gitlab::Access::GUEST)
|
||||
end
|
||||
|
||||
it 'filters out confidential issues' do
|
||||
expect(subject.size).to eq(1)
|
||||
expect(subject.first[:iid].to_s).to eq(issue1.iid.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
include_context 'when records are loaded by maintainer'
|
||||
end
|
||||
|
||||
describe 'for merge request based stage' do
|
||||
let(:mr1) { create(:merge_request, created_at: 5.days.ago, source_project: project, allow_broken: true) }
|
||||
let(:mr2) { create(:merge_request, created_at: 4.days.ago, source_project: project, allow_broken: true) }
|
||||
let(:stage) do
|
||||
build(:cycle_analytics_project_stage, {
|
||||
start_event_identifier: :merge_request_created,
|
||||
end_event_identifier: :merge_request_merged,
|
||||
project: project
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
mr1.metrics.update(merged_at: 3.days.ago)
|
||||
mr2.metrics.update(merged_at: 3.days.ago)
|
||||
end
|
||||
|
||||
include_context 'when records are loaded by maintainer'
|
||||
end
|
||||
|
||||
describe 'special case' do
|
||||
let(:mr1) { create(:merge_request, source_project: project, allow_broken: true, created_at: 20.days.ago) }
|
||||
let(:mr2) { create(:merge_request, source_project: project, allow_broken: true, created_at: 19.days.ago) }
|
||||
let(:ci_build1) { create(:ci_build) }
|
||||
let(:ci_build2) { create(:ci_build) }
|
||||
let(:default_stages) { Gitlab::Analytics::CycleAnalytics::DefaultStages }
|
||||
let(:stage) { build(:cycle_analytics_project_stage, default_stages.params_for_test_stage.merge(project: project)) }
|
||||
|
||||
before do
|
||||
mr1.metrics.update!({
|
||||
merged_at: 5.days.ago,
|
||||
first_deployed_to_production_at: 1.day.ago,
|
||||
latest_build_started_at: 5.days.ago,
|
||||
latest_build_finished_at: 1.day.ago,
|
||||
pipeline: ci_build1.pipeline
|
||||
})
|
||||
mr2.metrics.update!({
|
||||
merged_at: 10.days.ago,
|
||||
first_deployed_to_production_at: 5.days.ago,
|
||||
latest_build_started_at: 9.days.ago,
|
||||
latest_build_finished_at: 7.days.ago,
|
||||
pipeline: ci_build2.pipeline
|
||||
})
|
||||
end
|
||||
|
||||
context 'returns build records' do
|
||||
shared_examples 'orders build records by `latest_build_finished_at`' do
|
||||
it 'orders by `latest_build_finished_at`' do
|
||||
build_ids = subject.map { |item| item[:id] }
|
||||
|
||||
expect(build_ids).to eq([ci_build1.id, ci_build2.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting records for default test stage' do
|
||||
include_examples 'orders build records by `latest_build_finished_at`'
|
||||
end
|
||||
|
||||
context 'when requesting records for default staging stage' do
|
||||
before do
|
||||
stage.assign_attributes(default_stages.params_for_staging_stage)
|
||||
end
|
||||
|
||||
include_examples 'orders build records by `latest_build_finished_at`'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -26,6 +26,13 @@ describe Gitlab::CycleAnalytics::CodeStage do
|
|||
|
||||
it_behaves_like 'base stage'
|
||||
|
||||
context 'when using the new query backend' do
|
||||
include_examples 'Gitlab::Analytics::CycleAnalytics::DataCollector backend examples' do
|
||||
let(:expected_record_count) { 2 }
|
||||
let(:expected_ordered_attribute_values) { [mr_2.title, mr_1.title] }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#project_median' do
|
||||
around do |example|
|
||||
Timecop.freeze { example.run }
|
||||
|
|
|
@ -21,6 +21,13 @@ describe Gitlab::CycleAnalytics::IssueStage do
|
|||
|
||||
it_behaves_like 'base stage'
|
||||
|
||||
context 'when using the new query backend' do
|
||||
include_examples 'Gitlab::Analytics::CycleAnalytics::DataCollector backend examples' do
|
||||
let(:expected_record_count) { 3 }
|
||||
let(:expected_ordered_attribute_values) { [issue_3.title, issue_2.title, issue_1.title] }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#median' do
|
||||
around do |example|
|
||||
Timecop.freeze { example.run }
|
||||
|
|
|
@ -21,6 +21,13 @@ describe Gitlab::CycleAnalytics::PlanStage do
|
|||
|
||||
it_behaves_like 'base stage'
|
||||
|
||||
context 'when using the new query backend' do
|
||||
include_examples 'Gitlab::Analytics::CycleAnalytics::DataCollector backend examples' do
|
||||
let(:expected_record_count) { 2 }
|
||||
let(:expected_ordered_attribute_values) { [issue_1.title, issue_2.title] }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#project_median' do
|
||||
around do |example|
|
||||
Timecop.freeze { example.run }
|
||||
|
|
|
@ -52,3 +52,21 @@ shared_examples 'calculate #median with date range' do
|
|||
it { expect(stage.project_median).to eq(nil) }
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'Gitlab::Analytics::CycleAnalytics::DataCollector backend examples' do
|
||||
let(:stage_params) { Gitlab::Analytics::CycleAnalytics::DefaultStages.send("params_for_#{stage_name}_stage").merge(project: project) }
|
||||
let(:stage) { Analytics::CycleAnalytics::ProjectStage.new(stage_params) }
|
||||
let(:data_collector) { Gitlab::Analytics::CycleAnalytics::DataCollector.new(stage: stage, params: { from: stage_options[:from], current_user: project.creator }) }
|
||||
let(:attribute_to_verify) { :title }
|
||||
|
||||
context 'provides the same results as the old implementation' do
|
||||
it 'for the median' do
|
||||
expect(data_collector.median.seconds).to eq(ISSUES_MEDIAN)
|
||||
end
|
||||
|
||||
it 'for the list of event records' do
|
||||
records = data_collector.records_fetcher.serialized_records
|
||||
expect(records.map { |event| event[attribute_to_verify] }).to eq(expected_ordered_attribute_values)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,17 +12,20 @@ describe Gitlab::CycleAnalytics::TestStage do
|
|||
it_behaves_like 'base stage'
|
||||
|
||||
describe '#median' do
|
||||
let(:mr_1) { create(:merge_request, :closed, source_project: project, created_at: 60.minutes.ago) }
|
||||
let(:mr_2) { create(:merge_request, :closed, source_project: project, created_at: 40.minutes.ago, source_branch: 'A') }
|
||||
let(:mr_3) { create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'B') }
|
||||
let(:mr_4) { create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'C') }
|
||||
let(:mr_5) { create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'D') }
|
||||
let(:ci_build1) { create(:ci_build, project: project) }
|
||||
let(:ci_build2) { create(:ci_build, project: project) }
|
||||
|
||||
before do
|
||||
issue_1 = create(:issue, project: project, created_at: 90.minutes.ago)
|
||||
issue_2 = create(:issue, project: project, created_at: 60.minutes.ago)
|
||||
issue_3 = create(:issue, project: project, created_at: 60.minutes.ago)
|
||||
mr_1 = create(:merge_request, :closed, source_project: project, created_at: 60.minutes.ago)
|
||||
mr_2 = create(:merge_request, :closed, source_project: project, created_at: 40.minutes.ago, source_branch: 'A')
|
||||
mr_3 = create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'B')
|
||||
mr_4 = create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'C')
|
||||
mr_5 = create(:merge_request, source_project: project, created_at: 10.minutes.ago, source_branch: 'D')
|
||||
mr_1.metrics.update!(latest_build_started_at: 32.minutes.ago, latest_build_finished_at: 2.minutes.ago)
|
||||
mr_2.metrics.update!(latest_build_started_at: 62.minutes.ago, latest_build_finished_at: 32.minutes.ago)
|
||||
mr_1.metrics.update!(latest_build_started_at: 32.minutes.ago, latest_build_finished_at: 2.minutes.ago, pipeline_id: ci_build1.commit_id)
|
||||
mr_2.metrics.update!(latest_build_started_at: 62.minutes.ago, latest_build_finished_at: 32.minutes.ago, pipeline_id: ci_build2.commit_id)
|
||||
mr_3.metrics.update!(latest_build_started_at: nil, latest_build_finished_at: nil)
|
||||
mr_4.metrics.update!(latest_build_started_at: nil, latest_build_finished_at: nil)
|
||||
mr_5.metrics.update!(latest_build_started_at: nil, latest_build_finished_at: nil)
|
||||
|
@ -43,5 +46,13 @@ describe Gitlab::CycleAnalytics::TestStage do
|
|||
end
|
||||
|
||||
include_examples 'calculate #median with date range'
|
||||
|
||||
context 'when using the new query backend' do
|
||||
include_examples 'Gitlab::Analytics::CycleAnalytics::DataCollector backend examples' do
|
||||
let(:expected_record_count) { 2 }
|
||||
let(:attribute_to_verify) { :id }
|
||||
let(:expected_ordered_attribute_values) { [mr_1.metrics.pipeline.builds.first.id, mr_2.metrics.pipeline.builds.first.id] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -55,11 +55,11 @@ shared_examples_for 'cycle analytics stage' do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#subject_model' do
|
||||
describe '#subject_class' do
|
||||
it 'infers the model from the start event' do
|
||||
stage = described_class.new(valid_params)
|
||||
|
||||
expect(stage.subject_model).to eq(MergeRequest)
|
||||
expect(stage.subject_class).to eq(MergeRequest)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -78,4 +78,30 @@ shared_examples_for 'cycle analytics stage' do
|
|||
expect(stage.end_event).to be_a_kind_of(Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#matches_with_stage_params?' do
|
||||
let(:params) { Gitlab::Analytics::CycleAnalytics::DefaultStages.params_for_test_stage }
|
||||
|
||||
it 'matches with default stage params' do
|
||||
stage = described_class.new(params)
|
||||
|
||||
expect(stage).to be_default_stage
|
||||
expect(stage).to be_matches_with_stage_params(params)
|
||||
end
|
||||
|
||||
it "mismatches when the stage is custom" do
|
||||
stage = described_class.new(params.merge(custom: true))
|
||||
|
||||
expect(stage).not_to be_default_stage
|
||||
expect(stage).not_to be_matches_with_stage_params(params)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parent_id' do
|
||||
it "delegates to 'parent_name'_id attribute" do
|
||||
stage = described_class.new(parent: parent)
|
||||
|
||||
expect(stage.parent_id).to eq(parent.id)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue