Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
1b26aaa633
commit
5629d98f53
|
@ -62,7 +62,6 @@ After your merge request has been approved according to our [approval guidelines
|
|||
[security process for developers]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md
|
||||
[secpick documentation]: https://gitlab.com/gitlab-org/release/docs/-/blob/master/general/security/utilities/secpick_script.md
|
||||
[security Release merge request template]: https://gitlab.com/gitlab-org/security/gitlab/blob/master/.gitlab/merge_request_templates/Security%20Release.md
|
||||
[code review process]: https://docs.gitlab.com/ee/development/code_review.html
|
||||
[approval guidelines]: https://docs.gitlab.com/ee/development/code_review.html#approval-guidelines
|
||||
[issue as related]: https://docs.gitlab.com/ee/user/project/issues/related_issues.html#adding-a-related-issue
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
3876ecd3e4f6bf756621ad07de5e033f8a5b6129
|
||||
40b90823b0d55561059d27249e02db426b428786
|
||||
|
|
|
@ -106,7 +106,6 @@ export default {
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
this.toggleOnPasteListener(this.$route.name);
|
||||
if (this.$route.path === '/designs') {
|
||||
this.$el.scrollIntoView();
|
||||
}
|
||||
|
|
|
@ -213,7 +213,7 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
|
|||
* This technical debt is being tracked here
|
||||
* https://gitlab.com/gitlab-org/gitlab/-/issues/214671
|
||||
*/
|
||||
export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
|
||||
export const OVERVIEW_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
|
||||
|
||||
/**
|
||||
* GitLab provide metrics dashboards that are available to a user once
|
||||
|
|
|
@ -17,7 +17,7 @@ import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
|
|||
import { s__, sprintf } from '../../locale';
|
||||
import { getDashboard, getPrometheusQueryData, getPanelJson } from '../requests';
|
||||
|
||||
import { ENVIRONMENT_AVAILABLE_STATE, DEFAULT_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
|
||||
import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
|
||||
|
||||
function prometheusMetricQueryParams(timeRange) {
|
||||
const { start, end } = convertToFixedRange(timeRange);
|
||||
|
@ -298,7 +298,7 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => {
|
|||
|
||||
export const fetchAnnotations = ({ state, dispatch, getters }) => {
|
||||
const { start } = convertToFixedRange(state.timeRange);
|
||||
const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH;
|
||||
const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH;
|
||||
return gqClient
|
||||
.mutate({
|
||||
mutation: getAnnotations,
|
||||
|
@ -331,12 +331,12 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN
|
|||
|
||||
export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => {
|
||||
/**
|
||||
* Normally, the default dashboard won't throw any validation warnings.
|
||||
* Normally, the overview dashboard won't throw any validation warnings.
|
||||
*
|
||||
* However, if a bug sneaks into the default dashboard making it invalid,
|
||||
* However, if a bug sneaks into the overview dashboard making it invalid,
|
||||
* this might come handy for our clients
|
||||
*/
|
||||
const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH;
|
||||
const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH;
|
||||
return gqClient
|
||||
.mutate({
|
||||
mutation: getDashboardValidationWarnings,
|
||||
|
|
|
@ -465,9 +465,9 @@ export const addPrefixToCustomVariableParams = name => `variables[${name}]`;
|
|||
* 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 empty, it is the overview dashboard.
|
||||
* If dashboard is set, it usually is a custom dashboard unless
|
||||
* explicitly it is set to default dashboard path.
|
||||
* explicitly it is set to overview dashboard path.
|
||||
*
|
||||
* @param {String} dashboard dashboard path
|
||||
* @param {String} dashboardPrefix custom dashboard directory prefix
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script>
|
||||
import { mapState, mapActions, mapGetters } from 'vuex';
|
||||
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
|
||||
import { escape } from 'lodash';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
|
||||
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
|
||||
|
@ -10,6 +9,7 @@ import { getParameterByName } from '~/lib/utils/common_utils';
|
|||
import AssetLinksForm from './asset_links_form.vue';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
|
||||
import TagField from './tag_field.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReleaseEditNewApp',
|
||||
|
@ -20,6 +20,7 @@ export default {
|
|||
MarkdownField,
|
||||
AssetLinksForm,
|
||||
MilestoneCombobox,
|
||||
TagField,
|
||||
},
|
||||
directives: {
|
||||
autofocusonshow,
|
||||
|
@ -55,23 +56,6 @@ export default {
|
|||
false,
|
||||
);
|
||||
},
|
||||
tagName() {
|
||||
return this.$store.state.detail.release.tagName;
|
||||
},
|
||||
tagNameHintText() {
|
||||
return sprintf(
|
||||
__(
|
||||
'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
|
||||
),
|
||||
{
|
||||
linkStart: `<a href="${escape(
|
||||
this.updateReleaseApiDocsPath,
|
||||
)}" target="_blank" rel="noopener noreferrer">`,
|
||||
linkEnd: '</a>',
|
||||
},
|
||||
false,
|
||||
);
|
||||
},
|
||||
releaseTitle: {
|
||||
get() {
|
||||
return this.$store.state.detail.release.name;
|
||||
|
@ -136,22 +120,7 @@ export default {
|
|||
<div class="d-flex flex-column">
|
||||
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
|
||||
<form v-if="showForm" @submit.prevent="updateRelease()">
|
||||
<gl-form-group>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-lg-5 col-xl-4">
|
||||
<label for="git-ref">{{ __('Tag name') }}</label>
|
||||
<gl-form-input
|
||||
id="git-ref"
|
||||
v-model="tagName"
|
||||
type="text"
|
||||
class="form-control"
|
||||
aria-describedby="tag-name-help"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div>
|
||||
</gl-form-group>
|
||||
<tag-field />
|
||||
<gl-form-group>
|
||||
<label for="release-title">{{ __('Release title') }}</label>
|
||||
<gl-form-input
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import TagFieldExisting from './tag_field_existing.vue';
|
||||
import TagFieldNew from './tag_field_new.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TagFieldExisting,
|
||||
TagFieldNew,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('detail', ['isExistingRelease']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tag-field-existing v-if="isExistingRelease" />
|
||||
<tag-field-new v-else />
|
||||
</template>
|
|
@ -0,0 +1,52 @@
|
|||
<script>
|
||||
import { mapState } from 'vuex';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
|
||||
|
||||
export default {
|
||||
name: 'TagFieldExisting',
|
||||
components: { GlFormGroup, GlFormInput, GlSprintf, GlLink },
|
||||
computed: {
|
||||
...mapState('detail', ['release', 'updateReleaseApiDocsPath']),
|
||||
inputId() {
|
||||
return uniqueId('tag-name-input-');
|
||||
},
|
||||
helpId() {
|
||||
return uniqueId('tag-name-help-');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<gl-form-group :label="__('Tag name')" :label-for="inputId">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-lg-5 col-xl-4">
|
||||
<gl-form-input
|
||||
:id="inputId"
|
||||
:value="release.tagName"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:aria-describedby="helpId"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #description>
|
||||
<div :id="helpId" data-testid="tag-name-help">
|
||||
<gl-sprintf
|
||||
:message="
|
||||
__(
|
||||
'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #link="{ content }">
|
||||
<gl-link :href="updateReleaseApiDocsPath" target="_blank">
|
||||
{{ content }}
|
||||
</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</div>
|
||||
</template>
|
||||
</gl-form-group>
|
||||
</template>
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
export default {
|
||||
name: 'TagFieldNew',
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
|
@ -1,6 +1,14 @@
|
|||
import { isEmpty } from 'lodash';
|
||||
import { hasContent } from '~/lib/utils/text_utility';
|
||||
|
||||
/**
|
||||
* @returns {Boolean} `true` if the app is editing an existing release.
|
||||
* `false` if the app is creating a new release.
|
||||
*/
|
||||
export const isExistingRelease = state => {
|
||||
return Boolean(state.originalRelease);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} link The link to test
|
||||
* @returns {Boolean} `true` if the release link is empty, i.e. it has
|
||||
|
|
|
@ -19,7 +19,12 @@ export default ({
|
|||
manageMilestonesPath,
|
||||
newMilestonePath,
|
||||
|
||||
/**
|
||||
* The name of the tag associated with the release, provided by the backend.
|
||||
* When creating a new release, this value is null.
|
||||
*/
|
||||
tagName,
|
||||
|
||||
releasesPagePath,
|
||||
defaultBranch,
|
||||
|
||||
|
|
|
@ -19,14 +19,13 @@ module CommitStatusEnums
|
|||
scheduler_failure: 11,
|
||||
data_integrity_failure: 12,
|
||||
forward_deployment_failure: 13,
|
||||
protected_environment_failure: 1_000,
|
||||
insufficient_bridge_permissions: 1_001,
|
||||
downstream_bridge_project_not_found: 1_002,
|
||||
invalid_bridge_trigger: 1_003,
|
||||
upstream_bridge_project_not_found: 1_004,
|
||||
insufficient_upstream_permissions: 1_005,
|
||||
bridge_pipeline_is_child_pipeline: 1_006,
|
||||
downstream_pipeline_creation_failed: 1_007
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
CommitStatusEnums.prepend_if_ee('EE::CommitStatusEnums')
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Add capabilities to increment a numeric model attribute efficiently by
|
||||
# using Redis and flushing the increments asynchronously to the database
|
||||
# after a period of time (10 minutes).
|
||||
# When an attribute is incremented by a value, the increment is added
|
||||
# to a Redis key. Then, FlushCounterIncrementsWorker will execute
|
||||
# `flush_increments_to_database!` which removes increments from Redis for a
|
||||
# given model attribute and updates the values in the database.
|
||||
#
|
||||
# @example:
|
||||
#
|
||||
# class ProjectStatistics
|
||||
# include CounterAttribute
|
||||
#
|
||||
# counter_attribute :commit_count
|
||||
# counter_attribute :storage_size
|
||||
# end
|
||||
#
|
||||
# To increment the counter we can use the method:
|
||||
# delayed_increment_counter(:commit_count, 3)
|
||||
#
|
||||
module CounterAttribute
|
||||
extend ActiveSupport::Concern
|
||||
extend AfterCommitQueue
|
||||
include Gitlab::ExclusiveLeaseHelpers
|
||||
|
||||
LUA_STEAL_INCREMENT_SCRIPT = <<~EOS.freeze
|
||||
local increment_key, flushed_key = KEYS[1], KEYS[2]
|
||||
local increment_value = redis.call("get", increment_key) or 0
|
||||
local flushed_value = redis.call("incrby", flushed_key, increment_value)
|
||||
if flushed_value == 0 then
|
||||
redis.call("del", increment_key, flushed_key)
|
||||
else
|
||||
redis.call("del", increment_key)
|
||||
end
|
||||
return flushed_value
|
||||
EOS
|
||||
|
||||
WORKER_DELAY = 10.minutes
|
||||
WORKER_LOCK_TTL = 10.minutes
|
||||
|
||||
class_methods do
|
||||
def counter_attribute(attribute)
|
||||
counter_attributes << attribute
|
||||
end
|
||||
|
||||
def counter_attributes
|
||||
@counter_attributes ||= Set.new
|
||||
end
|
||||
end
|
||||
|
||||
# This method must only be called by FlushCounterIncrementsWorker
|
||||
# because it should run asynchronously and with exclusive lease.
|
||||
# This will
|
||||
# 1. temporarily move the pending increment for a given attribute
|
||||
# to a relative "flushed" Redis key, delete the increment key and return
|
||||
# the value. If new increments are performed at this point, the increment
|
||||
# key is recreated as part of `delayed_increment_counter`.
|
||||
# The "flushed" key is used to ensure that we can keep incrementing
|
||||
# counters in Redis while flushing existing values.
|
||||
# 2. then the value is used to update the counter in the database.
|
||||
# 3. finally the "flushed" key is deleted.
|
||||
def flush_increments_to_database!(attribute)
|
||||
lock_key = counter_lock_key(attribute)
|
||||
|
||||
with_exclusive_lease(lock_key) do
|
||||
increment_key = counter_key(attribute)
|
||||
flushed_key = counter_flushed_key(attribute)
|
||||
increment_value = steal_increments(increment_key, flushed_key)
|
||||
|
||||
next if increment_value == 0
|
||||
|
||||
transaction do
|
||||
unsafe_update_counters(id, attribute => increment_value)
|
||||
redis_state { |redis| redis.del(flushed_key) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delayed_increment_counter(attribute, increment)
|
||||
return if increment == 0
|
||||
|
||||
run_after_commit_or_now do
|
||||
if counter_attribute_enabled?(attribute)
|
||||
redis_state do |redis|
|
||||
redis.incrby(counter_key(attribute), increment)
|
||||
end
|
||||
|
||||
FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute)
|
||||
else
|
||||
legacy_increment!(attribute, increment)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def counter_key(attribute)
|
||||
"project:{#{project_id}}:counters:#{self.class}:#{id}:#{attribute}"
|
||||
end
|
||||
|
||||
def counter_flushed_key(attribute)
|
||||
counter_key(attribute) + ':flushed'
|
||||
end
|
||||
|
||||
def counter_lock_key(attribute)
|
||||
counter_key(attribute) + ':lock'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def counter_attribute_enabled?(attribute)
|
||||
Feature.enabled?(:efficient_counter_attribute, project) &&
|
||||
self.class.counter_attributes.include?(attribute)
|
||||
end
|
||||
|
||||
def steal_increments(increment_key, flushed_key)
|
||||
redis_state do |redis|
|
||||
redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key])
|
||||
end
|
||||
end
|
||||
|
||||
def legacy_increment!(attribute, increment)
|
||||
increment!(attribute, increment)
|
||||
end
|
||||
|
||||
def unsafe_update_counters(id, increments)
|
||||
self.class.update_counters(id, increments)
|
||||
end
|
||||
|
||||
def redis_state(&block)
|
||||
Gitlab::Redis::SharedState.with(&block)
|
||||
end
|
||||
|
||||
def with_exclusive_lease(lock_key)
|
||||
in_lock(lock_key, ttl: WORKER_LOCK_TTL) do
|
||||
yield
|
||||
end
|
||||
rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
|
||||
# a worker is already updating the counters
|
||||
end
|
||||
end
|
|
@ -6,10 +6,10 @@ module Metrics
|
|||
module Dashboard
|
||||
class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
|
||||
DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml'
|
||||
DASHBOARD_NAME = N_('Default dashboard')
|
||||
DASHBOARD_NAME = N_('Overview')
|
||||
|
||||
# SHA256 hash of dashboard content
|
||||
DASHBOARD_VERSION = '1dff3e3cb76e73c8e368823c98b34c61aec0d141978450dea195a3b3dc2415d6'
|
||||
DASHBOARD_VERSION = '0f7ade2022e09f1a1da8e883cc95d84b9557e1e0e9b015c51eb964296aa73098'
|
||||
|
||||
SEQUENCE = [
|
||||
STAGES::CustomMetricsInserter,
|
||||
|
|
|
@ -6,7 +6,7 @@ module Metrics
|
|||
module Dashboard
|
||||
class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
|
||||
DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
|
||||
DASHBOARD_NAME = N_('Default dashboard')
|
||||
DASHBOARD_NAME = N_('Overview')
|
||||
|
||||
# SHA256 hash of dashboard content
|
||||
DASHBOARD_VERSION = '4685fe386c25b1a786b3be18f79bb2ee9828019003e003816284cdb634fa3e13'
|
||||
|
|
|
@ -1340,6 +1340,14 @@
|
|||
:weight: 1
|
||||
:idempotent:
|
||||
:tags: []
|
||||
- :name: flush_counter_increments
|
||||
:feature_category: :not_owned
|
||||
:has_external_dependencies:
|
||||
:urgency: :low
|
||||
:resource_boundary: :unknown
|
||||
:weight: 1
|
||||
:idempotent: true
|
||||
:tags: []
|
||||
- :name: git_garbage_collect
|
||||
:feature_category: :gitaly
|
||||
:has_external_dependencies:
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Invoked by CounterAttribute concern when incrementing counter
|
||||
# attributes. The method `flush_increments_to_database!` that
|
||||
# this worker uses is itself idempotent as it runs with exclusive
|
||||
# lease to ensure that only one instance at the time can flush
|
||||
# increments from Redis to the database.
|
||||
class FlushCounterIncrementsWorker
|
||||
include ApplicationWorker
|
||||
|
||||
feature_category_not_owned!
|
||||
urgency :low
|
||||
deduplicate :until_executing, including_scheduled: true
|
||||
|
||||
idempotent!
|
||||
|
||||
def perform(model_name, model_id, attribute)
|
||||
return unless self.class.const_defined?(model_name)
|
||||
|
||||
model_class = model_name.constantize
|
||||
model = model_class.find_by_id(model_id)
|
||||
return unless model
|
||||
|
||||
model.flush_increments_to_database!(attribute)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Enforce namespace storage limit via app setting
|
||||
merge_request: 38094
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Resolve Pasting an image into a comment still uploades a design
|
||||
merge_request: 38280
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: In metrics view, change default dashboard name to Overview
|
||||
merge_request: 38292
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Making component diagram click-friendly
|
||||
merge_request: 37147
|
||||
author: Arjun Pravin @Sgt.Arjun
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add mechanism that efficiently increments ActiveRecord counters using Redis
|
||||
merge_request: 35878
|
||||
author:
|
||||
type: performance
|
|
@ -1,4 +1,4 @@
|
|||
dashboard: 'Default dashboard'
|
||||
dashboard: 'Overview'
|
||||
priority: 1
|
||||
|
||||
templating:
|
||||
|
|
|
@ -108,6 +108,8 @@
|
|||
- 1
|
||||
- - file_hook
|
||||
- 1
|
||||
- - flush_counter_increments
|
||||
- 1
|
||||
- - gcp_cluster
|
||||
- 1
|
||||
- - geo
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddEnforceNamespaceStorageLimitToApplicationSettings < ActiveRecord::Migration[6.0]
|
||||
DOWNTIME = false
|
||||
|
||||
def change
|
||||
add_column :application_settings, :enforce_namespace_storage_limit, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
a3a6d4e488c9979efd61890a15fdfe4ccea044a0b030b392ad39885cc807f22d
|
|
@ -9248,6 +9248,7 @@ CREATE TABLE public.application_settings (
|
|||
maintenance_mode_message text,
|
||||
wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL,
|
||||
elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL,
|
||||
enforce_namespace_storage_limit boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
|
||||
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
|
||||
CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)),
|
||||
|
|
|
@ -14,6 +14,11 @@ Find more about them [in Audit Events documentation](audit_events.md).
|
|||
System log files are typically plain text in a standard log file format.
|
||||
This guide talks about how to read and use these system log files.
|
||||
|
||||
[Read more about how to customise logging on Omnibus GitLab
|
||||
installations](https://docs.gitlab.com/omnibus/settings/logs.html)
|
||||
including adjusting log retention, log forwarding,
|
||||
switching logs from JSON to plain text logging, and more.
|
||||
|
||||
## `production_json.log`
|
||||
|
||||
This file lives in `/var/log/gitlab/gitlab-rails/production_json.log` for
|
||||
|
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
@ -55,7 +55,7 @@ panels, provide a regular expression in the **Instance label regex** field.
|
|||
The dashboard uses metrics available in
|
||||
[Omnibus GitLab](https://docs.gitlab.com/omnibus/) installations.
|
||||
|
||||
![GitLab self monitoring default dashboard](img/self_monitoring_default_dashboard.png)
|
||||
![GitLab self monitoring overview dashboard](img/self_monitoring_overview_dashboard.png)
|
||||
|
||||
You can also
|
||||
[create your own dashboards](../../../operations/metrics/dashboards/index.md).
|
||||
|
@ -83,7 +83,7 @@ Once the webhook is setup, you can
|
|||
|
||||
You can add custom metrics in the self monitoring project by:
|
||||
|
||||
1. [Duplicating](../../../operations/metrics/dashboards/index.md#duplicate-a-gitlab-defined-dashboard) the default dashboard.
|
||||
1. [Duplicating](../../../operations/metrics/dashboards/index.md#duplicate-a-gitlab-defined-dashboard) the overview dashboard.
|
||||
1. [Editing](../../../operations/metrics/index.md) the newly created dashboard file and configuring it with [dashboard YAML properties](../../../operations/metrics/dashboards/yaml.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
|
|
@ -251,6 +251,7 @@ are listed in the descriptions of the relevant settings.
|
|||
| `email_additional_text` | string | no | **(PREMIUM)** Additional text added to the bottom of every email for legal/auditing/compliance reasons |
|
||||
| `email_author_in_body` | boolean | no | Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead. |
|
||||
| `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. |
|
||||
| `enforce_namespace_storage_limit` | boolean | no | Enabling this permits enforcement of namespace storage limits. |
|
||||
| `enforce_terms` | boolean | no | (**If enabled, requires:** `terms`) Enforce application ToS to all users. |
|
||||
| `external_auth_client_cert` | string | no | (**If enabled, requires:** `external_auth_client_key`) The certificate to use to authenticate with the external authorization service |
|
||||
| `external_auth_client_key_pass` | string | no | Passphrase to use for the private key when authenticating with the external service this is encrypted when stored |
|
||||
|
|
|
@ -108,6 +108,39 @@ graph TB
|
|||
GitLabWorkhorse -- TCP 443 --> ObjectStorage
|
||||
Registry -- TCP 443 --> ObjectStorage
|
||||
Geo -- TCP 5432 --> PostgreSQL
|
||||
|
||||
click Alertmanager "./architecture.html#alertmanager"
|
||||
click Praefect "./architecture.html#praefect"
|
||||
click Geo "./architecture.html#gitlab-geo"
|
||||
click NGINX "./architecture.html#nginx"
|
||||
click Runner "./architecture.html#gitlab-runner"
|
||||
click Registry "./architecture.html#registry"
|
||||
click ObjectStorage "./architecture.html#minio"
|
||||
click Mattermost "./architecture.html#mattermost"
|
||||
click Gitaly "./architecture.html#gitaly"
|
||||
click Jaeger "./architecture.html#jaeger"
|
||||
click GitLabWorkhorse "./architecture.html#gitlab-workhorse"
|
||||
click LDAP "./architecture.html#ldap-authentication"
|
||||
click Unicorn "./architecture.html#unicorn"
|
||||
click GitLabShell "./architecture.html#gitlab-shell"
|
||||
click SSH "./architecture.html#ssh-request-22"
|
||||
click Sidekiq "./architecture.html#sidekiq"
|
||||
click Sentry "./architecture.html#sentry"
|
||||
click GitLabExporter "./architecture.html#gitlab-exporter"
|
||||
click Elasticsearch "./architecture.html#elasticsearch"
|
||||
click Migrations "./architecture.html#database-migrations"
|
||||
click PostgreSQL "./architecture.html#postgresql"
|
||||
click Consul "./architecture.html#consul"
|
||||
click PgBouncer "./architecture.html#pgbouncer"
|
||||
click PgBouncerExporter "./architecture.html#pgbouncer-exporter"
|
||||
click RedisExporter "./architecture.html#redis-exporter"
|
||||
click Redis "./architecture.html#redis"
|
||||
click Prometheus "./architecture.html#prometheus"
|
||||
click Grafana "./architecture.html#grafana"
|
||||
click GitLabPages "./architecture.html#gitlab-pages"
|
||||
click PostgreSQLExporter "./architecture.html#postgresql-exporter"
|
||||
click SMTP "./architecture.html#outbound-email"
|
||||
click NodeExporter "./architecture.html#node-exporter"
|
||||
```
|
||||
|
||||
### Component legend
|
||||
|
|
|
@ -57,7 +57,7 @@ You can open the link directly into your browser for a
|
|||
|
||||
## Embedding metrics in issue templates
|
||||
|
||||
You can also embed either the default dashboard metrics or individual metrics in
|
||||
You can also embed either the overview dashboard metrics or individual metrics in
|
||||
issue templates. For charts to render side-by-side, separate links to the entire metrics
|
||||
dashboard or individual metrics by either a comma or a space.
|
||||
|
||||
|
|
|
@ -1,118 +1,5 @@
|
|||
---
|
||||
comments: false
|
||||
type: index
|
||||
redirect_to: 'https://docs.gitlab.com'
|
||||
---
|
||||
|
||||
# Books
|
||||
|
||||
List of books and resources that may be worth reading.
|
||||
|
||||
## Papers
|
||||
|
||||
1. **The Humble Programmer**
|
||||
|
||||
Edsger W. Dijkstra, 1972 ([paper](https://dl.acm.org/citation.cfm?id=361591))
|
||||
|
||||
## Programming
|
||||
|
||||
1. **Design Patterns: Elements of Reusable Object-Oriented Software**
|
||||
|
||||
Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, 1994 ([amazon](https://www.amazon.com/Design-Patterns-Elements-Reusable-Object-Oriented/dp/0201633612))
|
||||
|
||||
1. **Clean Code: A Handbook of Agile Software Craftsmanship**
|
||||
|
||||
Robert C. "Uncle Bob" Martin, 2008 ([amazon](https://www.amazon.com/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882))
|
||||
|
||||
1. **Code Complete: A Practical Handbook of Software Construction**, 2nd Edition
|
||||
|
||||
Steve McConnell, 2004 ([amazon](https://www.amazon.com/Code-Complete-Practical-Handbook-Construction/dp/0735619670))
|
||||
|
||||
1. **The Pragmatic Programmer: From Journeyman to Master**
|
||||
|
||||
Andrew Hunt, David Thomas, 1999 ([amazon](https://www.amazon.com/Pragmatic-Programmer-Journeyman-Master/dp/020161622X))
|
||||
|
||||
1. **Working Effectively with Legacy Code**
|
||||
|
||||
Michael Feathers, 2004 ([amazon](https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052))
|
||||
|
||||
1. **Eloquent Ruby**
|
||||
|
||||
Russ Olsen, 2011 ([amazon](https://www.amazon.com/Eloquent-Ruby-Addison-Wesley-Professional/dp/0321584104))
|
||||
|
||||
1. **Domain-Driven Design: Tackling Complexity in the Heart of Software**
|
||||
|
||||
Eric Evans, 2003 ([amazon](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215))
|
||||
|
||||
1. **How to Solve It: A New Aspect of Mathematical Method**
|
||||
|
||||
Polya G. 1957 ([amazon](https://www.amazon.com/How-Solve-Mathematical-Princeton-Science/dp/069116407X))
|
||||
|
||||
1. **Software Creativity 2.0**
|
||||
|
||||
Robert L. Glass, 2006 ([amazon](https://www.amazon.com/Software-Creativity-2-0-Robert-Glass/dp/0977213315))
|
||||
|
||||
1. **Object-Oriented Software Construction**
|
||||
|
||||
Bertrand Meyer, 1997 ([amazon](https://www.amazon.com/Object-Oriented-Software-Construction-Book-CD-ROM/dp/0136291554))
|
||||
|
||||
1. **Refactoring: Improving the Design of Existing Code**
|
||||
|
||||
Martin Fowler, Kent Beck, 1999 ([amazon](https://www.amazon.com/Refactoring-Improving-Design-Existing-Code/dp/0201485672))
|
||||
|
||||
1. **Test Driven Development: By Example**
|
||||
|
||||
Kent Beck, 2002 ([amazon](https://www.amazon.com/Test-Driven-Development-Kent-Beck/dp/0321146530))
|
||||
|
||||
1. **Algorithms in C++: Fundamentals, Data Structure, Sorting, Searching**
|
||||
|
||||
Robert Sedgewick, 1990 ([amazon](https://www.amazon.com/Algorithms-Parts-1-4-Fundamentals-Structure/dp/0201350882))
|
||||
|
||||
1. **Effective C++**
|
||||
|
||||
Scott Mayers, 1996 ([amazon](https://www.amazon.com/Effective-Specific-Improve-Programs-Designs/dp/0321334876))
|
||||
|
||||
1. **Extreme Programming Explained: Embrace Change**
|
||||
|
||||
Kent Beck, 1999 ([amazon](https://www.amazon.com/Extreme-Programming-Explained-Embrace-Change/dp/0321278658))
|
||||
|
||||
1. **The Art of Computer Programming**
|
||||
|
||||
Donald E. Knuth, 1997 ([amazon](https://www.amazon.com/Computer-Programming-Volumes-1-4A-Boxed/dp/0321751043))
|
||||
|
||||
1. **Writing Efficient Programs**
|
||||
|
||||
Jon Louis Bentley, 1982 ([amazon](https://www.amazon.com/Writing-Efficient-Programs-Prentice-Hall-Software/dp/013970244X))
|
||||
|
||||
1. **The Mythical Man-Month: Essays on Software Engineering**
|
||||
|
||||
Frederick Phillips Brooks, 1975 ([amazon](https://www.amazon.com/Mythical-Man-Month-Essays-Software-Engineering/dp/0201006502))
|
||||
|
||||
1. **Peopleware: Productive Projects and Teams** 3rd Edition
|
||||
|
||||
Tom DeMarco, Tim Lister, 2013 ([amazon](https://www.amazon.com/Peopleware-Productive-Projects-Teams-3rd/dp/0321934113))
|
||||
|
||||
1. **Principles Of Software Engineering Management**
|
||||
|
||||
Tom Gilb, 1988 ([amazon](https://www.amazon.com/Principles-Software-Engineering-Management-Gilb/dp/0201192462))
|
||||
|
||||
## Other
|
||||
|
||||
1. **Thinking, Fast and Slow**
|
||||
|
||||
Daniel Kahneman, 2013 ([amazon](https://www.amazon.com/Thinking-Fast-Slow-Daniel-Kahneman/dp/0374533555))
|
||||
|
||||
1. **The Social Animal** 11th Edition
|
||||
|
||||
Elliot Aronson, 2011 ([amazon](https://www.amazon.com/Social-Animal-Elliot-Aronson/dp/1429233419))
|
||||
|
||||
1. **Influence: Science and Practice** 5th Edition
|
||||
|
||||
Robert B. Cialdini, 2008 ([amazon](https://www.amazon.com/Influence-Practice-Robert-B-Cialdini/dp/0205609996))
|
||||
|
||||
1. **Getting to Yes: Negotiating Agreement Without Giving In**
|
||||
|
||||
Roger Fisher, William L. Ury, Bruce Patton, 2011 ([amazon](https://www.amazon.com/Getting-Yes-Negotiating-Agreement-Without/dp/0143118757))
|
||||
|
||||
1. **How to Win Friends & Influence People**
|
||||
|
||||
Dale Carnegie, 1981 ([amazon](https://www.amazon.com/How-Win-Friends-Influence-People/dp/0671027034))
|
||||
Visit our [documentation page](https://docs.gitlab.com) for information about GitLab.
|
||||
|
|
|
@ -1,24 +1,5 @@
|
|||
---
|
||||
comments: false
|
||||
type: index
|
||||
redirect_to: 'https://docs.gitlab.com'
|
||||
---
|
||||
|
||||
# The GitLab Book Club
|
||||
|
||||
The Book Club is a casual meet-up to read and discuss books we like.
|
||||
We'll find a time that suits most, if not all.
|
||||
|
||||
See the [book list](booklist.md) for additional recommendations.
|
||||
|
||||
## Currently reading : Books about remote work
|
||||
|
||||
1. **Remote: Office not required**
|
||||
|
||||
David Heinemeier Hansson and Jason Fried, 2013
|
||||
([Amazon](https://www.amazon.co.uk/dp/0091954673/ref=cm_sw_r_tw_dp_x_0yy9EbZ2WXJ6Y))
|
||||
|
||||
1. **The Year Without Pants**
|
||||
|
||||
Scott Berkun, 2013 ([ScottBerkun.com](https://scottberkun.com/yearwithoutpants/))
|
||||
|
||||
Any other books you'd like to suggest? Edit this page and add them to the queue.
|
||||
Visit our [documentation page](https://docs.gitlab.com) for information about GitLab.
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
---
|
||||
comments: false
|
||||
redirect_to: 'https://docs.gitlab.com'
|
||||
---
|
||||
|
||||
# Glossary
|
||||
|
||||
This page has been removed after an effort to ensure that all applicable GitLab-specific
|
||||
terms are available in context on the relevant [GitLab Documentation](https://docs.gitlab.com/)
|
||||
or <https://about.gitlab.com/> pages.
|
||||
|
||||
If you are looking for a definition of a specific term, please search these sites.
|
||||
Visit our [documentation page](https://docs.gitlab.com) for information about GitLab.
|
||||
|
|
|
@ -217,6 +217,10 @@ As a workaround, try an alternate mapping:
|
|||
|
||||
#### How do I diagnose why a user is unable to sign in
|
||||
|
||||
Ensure that the user has been added to the SCIM app.
|
||||
|
||||
If you receive "User is not linked to a SAML account", then most likely the user already exists in GitLab. Have the user follow the [User access and linking setup](#user-access-and-linking-setup) instructions.
|
||||
|
||||
The **Identity** (`extern_uid`) value stored by GitLab is updated by SCIM whenever `id` or `externalId` changes. Users won't be able to sign in unless the GitLab Identity (`extern_uid`) value matches the `NameId` sent by SAML.
|
||||
|
||||
This value is also used by SCIM to match users on the `id`, and is updated by SCIM whenever the `id` or `externalId` values change.
|
||||
|
|
|
@ -22,7 +22,7 @@ module Gitlab
|
|||
return if payload[:name] == 'SCHEMA' || IGNORABLE_SQL.include?(payload[:sql])
|
||||
|
||||
current_transaction.observe(:gitlab_sql_duration_seconds, event.duration / 1000.0) do
|
||||
buckets [0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
|
||||
buckets [0.05, 0.1]
|
||||
end
|
||||
|
||||
increment_db_counters(payload)
|
||||
|
|
|
@ -12,8 +12,6 @@
|
|||
# redis_usage_data { ::Gitlab::UsageCounters::PodLogs.usage_totals[:total] }
|
||||
module Gitlab
|
||||
class UsageData
|
||||
BATCH_SIZE = 100
|
||||
|
||||
class << self
|
||||
include Gitlab::Utils::UsageData
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
@ -353,29 +351,25 @@ module Gitlab
|
|||
|
||||
results
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def services_usage
|
||||
# rubocop: disable UsageData/LargeTable:
|
||||
Service.available_services_names.without('jira').each_with_object({}) do |service_name, response|
|
||||
Service.available_services_names.each_with_object({}) do |service_name, response|
|
||||
response["projects_#{service_name}_active".to_sym] = count(Service.active.where(template: false, type: "#{service_name}_service".camelize))
|
||||
end.merge(jira_usage).merge(jira_import_usage)
|
||||
end.merge(jira_usage, jira_import_usage)
|
||||
# rubocop: enable UsageData/LargeTable:
|
||||
end
|
||||
|
||||
def jira_usage
|
||||
# Jira Cloud does not support custom domains as per https://jira.atlassian.com/browse/CLOUD-6999
|
||||
# so we can just check for subdomains of atlassian.net
|
||||
|
||||
results = {
|
||||
projects_jira_server_active: 0,
|
||||
projects_jira_cloud_active: 0,
|
||||
projects_jira_active: 0
|
||||
projects_jira_cloud_active: 0
|
||||
}
|
||||
|
||||
# rubocop: disable UsageData/LargeTable:
|
||||
JiraService.active.includes(:jira_tracker_data).find_in_batches(batch_size: BATCH_SIZE) do |services|
|
||||
JiraService.active.includes(:jira_tracker_data).find_in_batches(batch_size: 100) do |services|
|
||||
counts = services.group_by do |service|
|
||||
# TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
|
||||
service_url = service.data_fields&.url || (service.properties && service.properties['url'])
|
||||
|
@ -384,22 +378,12 @@ module Gitlab
|
|||
|
||||
results[:projects_jira_server_active] += counts[:server].size if counts[:server]
|
||||
results[:projects_jira_cloud_active] += counts[:cloud].size if counts[:cloud]
|
||||
results[:projects_jira_active] += services.size
|
||||
end
|
||||
# rubocop: enable UsageData/LargeTable:
|
||||
results
|
||||
rescue ActiveRecord::StatementInvalid
|
||||
{ projects_jira_server_active: FALLBACK, projects_jira_cloud_active: FALLBACK, projects_jira_active: FALLBACK }
|
||||
{ projects_jira_server_active: FALLBACK, projects_jira_cloud_active: FALLBACK }
|
||||
end
|
||||
|
||||
# rubocop: disable UsageData/LargeTable
|
||||
def successful_deployments_with_cluster(scope)
|
||||
scope
|
||||
.joins(cluster: :deployments)
|
||||
.merge(Clusters::Cluster.enabled)
|
||||
.merge(Deployment.success)
|
||||
end
|
||||
# rubocop: enable UsageData/LargeTable
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
|
||||
def jira_import_usage
|
||||
|
@ -414,6 +398,17 @@ module Gitlab
|
|||
# rubocop: enable UsageData/LargeTable
|
||||
end
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
# rubocop: disable UsageData/LargeTable
|
||||
def successful_deployments_with_cluster(scope)
|
||||
scope
|
||||
.joins(cluster: :deployments)
|
||||
.merge(Clusters::Cluster.enabled)
|
||||
.merge(Deployment.success)
|
||||
end
|
||||
# rubocop: enable UsageData/LargeTable
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
|
||||
def user_preferences_usage
|
||||
{} # augmented in EE
|
||||
end
|
||||
|
|
|
@ -7603,9 +7603,6 @@ msgstr ""
|
|||
msgid "Default classification label"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default dashboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default deletion adjourned period"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -28,7 +28,6 @@ Migration/UpdateLargeTable:
|
|||
- :resource_label_events
|
||||
- :routes
|
||||
- :sent_notifications
|
||||
- :services
|
||||
- :system_note_metadata
|
||||
- :taggings
|
||||
- :todos
|
||||
|
|
|
@ -165,7 +165,7 @@ RSpec.describe MetricsDashboard do
|
|||
it 'adds starred dashboard information and sorts the list' do
|
||||
all_dashboards = json_response['all_dashboards'].map { |dashboard| dashboard.slice('display_name', 'starred', 'user_starred_path') }
|
||||
expected_response = [
|
||||
{ "display_name" => "Default dashboard", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: 'config/prometheus/common_metrics.yml' }) },
|
||||
{ "display_name" => "Overview", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: 'config/prometheus/common_metrics.yml' }) },
|
||||
{ "display_name" => "anomaly.yml", "starred" => false, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/anomaly.yml' }) },
|
||||
{ "display_name" => "errors.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/errors.yml' }) },
|
||||
{ "display_name" => "test.yml", "starred" => true, 'user_starred_path' => api_v4_projects_metrics_user_starred_dashboards_path(id: project.id, params: { dashboard_path: '.gitlab/dashboards/test.yml' }) }
|
||||
|
|
|
@ -2,7 +2,17 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.shared_examples_for 'snippet editor' do
|
||||
RSpec.describe 'User creates snippet', :js do
|
||||
include DropzoneHelper
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
let(:title) { 'My Snippet Title' }
|
||||
let(:file_content) { 'Hello World!' }
|
||||
let(:md_description) { 'My Snippet **Description**' }
|
||||
let(:description) { 'My Snippet Description' }
|
||||
let(:created_snippet) { Snippet.last }
|
||||
|
||||
before do
|
||||
stub_feature_flags(snippets_vue: false)
|
||||
stub_feature_flags(snippets_edit_vue: false)
|
||||
|
@ -14,15 +24,15 @@ RSpec.shared_examples_for 'snippet editor' do
|
|||
end
|
||||
|
||||
def fill_form
|
||||
fill_in 'personal_snippet_title', with: 'My Snippet Title'
|
||||
fill_in 'personal_snippet_title', with: title
|
||||
|
||||
# Click placeholder first to expand full description field
|
||||
description_field.click
|
||||
fill_in 'personal_snippet_description', with: 'My Snippet **Description**'
|
||||
fill_in 'personal_snippet_description', with: md_description
|
||||
|
||||
page.within('.file-editor') do
|
||||
el = find('.inputarea')
|
||||
el.send_keys 'Hello World!'
|
||||
el.send_keys file_content
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -34,12 +44,12 @@ RSpec.shared_examples_for 'snippet editor' do
|
|||
click_button('Create snippet')
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content('My Snippet Title')
|
||||
expect(page).to have_content(title)
|
||||
page.within('.snippet-header .description') do
|
||||
expect(page).to have_content('My Snippet Description')
|
||||
expect(page).to have_content(description)
|
||||
expect(page).to have_selector('strong')
|
||||
end
|
||||
expect(page).to have_content('Hello World!')
|
||||
expect(page).to have_content(file_content)
|
||||
end
|
||||
|
||||
it 'previews a snippet with file' do
|
||||
|
@ -57,7 +67,7 @@ RSpec.shared_examples_for 'snippet editor' do
|
|||
link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
|
||||
expect(link).to match(%r{/uploads/-/system/user/#{user.id}/\h{32}/banana_sample\.gif\z})
|
||||
|
||||
# Adds a cache buster for checking if the image exists as Selenium is now handling the cached regquests
|
||||
# Adds a cache buster for checking if the image exists as Selenium is now handling the cached requests
|
||||
# not anymore as requests when they come straight from memory cache.
|
||||
reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
|
||||
expect(reqs.first.status_code).to eq(200)
|
||||
|
@ -99,15 +109,10 @@ RSpec.shared_examples_for 'snippet editor' do
|
|||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'displays the error' do
|
||||
it 'renders the new page and displays the error' do
|
||||
expect(page).to have_content(error)
|
||||
end
|
||||
|
||||
it 'renders new page' do
|
||||
expect(page).to have_content('New Snippet')
|
||||
end
|
||||
|
||||
it 'has the correct action path' do
|
||||
action = find('form.snippet-form')['action']
|
||||
expect(action).to match(%r{/snippets\z})
|
||||
end
|
||||
|
@ -116,46 +121,10 @@ RSpec.shared_examples_for 'snippet editor' do
|
|||
it 'validation fails for the first time' do
|
||||
visit new_snippet_path
|
||||
|
||||
fill_in 'personal_snippet_title', with: 'My Snippet Title'
|
||||
fill_in 'personal_snippet_title', with: title
|
||||
click_button('Create snippet')
|
||||
|
||||
expect(page).to have_selector('#error_explanation')
|
||||
|
||||
fill_form
|
||||
dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif')
|
||||
|
||||
click_button('Create snippet')
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content('My Snippet Title')
|
||||
page.within('.snippet-header .description') do
|
||||
expect(page).to have_content('My Snippet Description')
|
||||
expect(page).to have_selector('strong')
|
||||
end
|
||||
expect(page).to have_content('Hello World!')
|
||||
link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
|
||||
expect(link).to match(%r{/uploads/-/system/personal_snippet/#{Snippet.last.id}/\h{32}/banana_sample\.gif\z})
|
||||
|
||||
reqs = inspect_requests { visit("#{link}?ran=#{SecureRandom.base64(20)}") }
|
||||
expect(reqs.first.status_code).to eq(200)
|
||||
end
|
||||
|
||||
it 'Authenticated user creates a snippet with + in filename' do
|
||||
visit new_snippet_path
|
||||
|
||||
fill_in 'personal_snippet_title', with: 'My Snippet Title'
|
||||
page.within('.file-editor') do
|
||||
find(:xpath, "//input[@id='personal_snippet_file_name']").set 'snippet+file+name'
|
||||
el = find('.inputarea')
|
||||
el.send_keys 'Hello World!'
|
||||
end
|
||||
|
||||
click_button 'Create snippet'
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content('My Snippet Title')
|
||||
expect(page).to have_content('snippet+file+name')
|
||||
expect(page).to have_content('Hello World!')
|
||||
end
|
||||
|
||||
context 'when snippets default visibility level is restricted' do
|
||||
|
@ -172,20 +141,7 @@ RSpec.shared_examples_for 'snippet editor' do
|
|||
click_button('Create snippet')
|
||||
wait_for_requests
|
||||
|
||||
visit snippets_path
|
||||
click_link('Internal')
|
||||
|
||||
expect(page).to have_content('My Snippet Title')
|
||||
created_snippet = Snippet.last
|
||||
expect(created_snippet.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe 'User creates snippet', :js do
|
||||
include DropzoneHelper
|
||||
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
it_behaves_like "snippet editor"
|
||||
end
|
||||
|
|
|
@ -72,6 +72,7 @@ describe('Design management index page', () => {
|
|||
const dropzoneClasses = () => findDropzone().classes();
|
||||
const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]');
|
||||
const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
|
||||
const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]');
|
||||
|
||||
function createComponent({
|
||||
loading = false,
|
||||
|
@ -508,6 +509,10 @@ describe('Design management index page', () => {
|
|||
});
|
||||
|
||||
event = new Event('paste');
|
||||
event.clipboardData = {
|
||||
files: [{ name: 'image.png', type: 'image/png' }],
|
||||
getData: () => 'test.png',
|
||||
};
|
||||
|
||||
router.replace({
|
||||
name: DESIGNS_ROUTE_NAME,
|
||||
|
@ -517,12 +522,18 @@ describe('Design management index page', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('calls onUploadDesign with valid paste', () => {
|
||||
event.clipboardData = {
|
||||
files: [{ name: 'image.png', type: 'image/png' }],
|
||||
getData: () => 'test.png',
|
||||
};
|
||||
it('does not call paste event if designs wrapper is not hovered', () => {
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when designs wrapper is hovered', () => {
|
||||
beforeEach(() => {
|
||||
findDesignsWrapper().trigger('mouseenter');
|
||||
});
|
||||
|
||||
it('calls onUploadDesign with valid paste', () => {
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
|
||||
|
@ -532,11 +543,6 @@ describe('Design management index page', () => {
|
|||
});
|
||||
|
||||
it('renames a design if it has an image.png filename', () => {
|
||||
event.clipboardData = {
|
||||
files: [{ name: 'image.png', type: 'image/png' }],
|
||||
getData: () => 'image.png',
|
||||
};
|
||||
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1);
|
||||
|
@ -555,6 +561,14 @@ describe('Design management index page', () => {
|
|||
|
||||
expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes onPaste listener after mouseleave event', async () => {
|
||||
findDesignsWrapper().trigger('mouseleave');
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when navigating', () => {
|
||||
|
|
|
@ -257,7 +257,7 @@ describe('Dashboard header', () => {
|
|||
});
|
||||
|
||||
const duplicableCases = [
|
||||
null, // When no path is specified, it uses the default dashboard path.
|
||||
null, // When no path is specified, it uses the overview dashboard path.
|
||||
dashboardGitResponse[0].path,
|
||||
dashboardGitResponse[2].path,
|
||||
selfMonitoringDashboardGitResponse[0].path,
|
||||
|
|
|
@ -886,7 +886,7 @@ describe('Dashboard', () => {
|
|||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
it('is not present for the default dashboard', () => {
|
||||
it('is not present for the overview dashboard', () => {
|
||||
expect(findEditLink().exists()).toBe(false);
|
||||
});
|
||||
|
||||
|
@ -905,7 +905,7 @@ describe('Dashboard', () => {
|
|||
|
||||
describe('document title', () => {
|
||||
const originalTitle = 'Original Title';
|
||||
const defaultDashboardName = dashboardGitResponse[0].display_name;
|
||||
const overviewDashboardName = dashboardGitResponse[0].display_name;
|
||||
|
||||
beforeEach(() => {
|
||||
document.title = originalTitle;
|
||||
|
@ -916,11 +916,11 @@ describe('Dashboard', () => {
|
|||
document.title = '';
|
||||
});
|
||||
|
||||
it('is prepended with default dashboard name by default', () => {
|
||||
it('is prepended with the overview dashboard name by default', () => {
|
||||
setupAllDashboards(store);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true);
|
||||
expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -935,11 +935,11 @@ describe('Dashboard', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('is prepended with default dashboard name is path is not known', () => {
|
||||
it('is prepended with the overview dashboard name if path is not known', () => {
|
||||
setupAllDashboards(store, 'unknown/path');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true);
|
||||
expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -73,7 +73,7 @@ describe('DashboardsDropdown', () => {
|
|||
});
|
||||
|
||||
it('filters dropdown items when searched for item exists in the list', () => {
|
||||
const searchTerm = 'Default';
|
||||
const searchTerm = 'Overview';
|
||||
setSearchTerm(searchTerm);
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
|
|
|
@ -170,7 +170,7 @@ export const environmentData = [
|
|||
export const dashboardGitResponse = [
|
||||
{
|
||||
default: true,
|
||||
display_name: 'Default',
|
||||
display_name: 'Overview',
|
||||
can_edit: false,
|
||||
system_dashboard: true,
|
||||
out_of_the_box_dashboard: true,
|
||||
|
|
|
@ -380,7 +380,7 @@ describe('Monitoring store Getters', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns a non-default dashboard', () => {
|
||||
it('returns a dashboard different from the overview dashboard', () => {
|
||||
const localState = {
|
||||
allDashboards: dashboardGitResponse,
|
||||
currentDashboard: dashboardGitResponse[1].path,
|
||||
|
@ -391,7 +391,7 @@ describe('Monitoring store Getters', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns a default dashboard when no dashboard is selected', () => {
|
||||
it('returns the overview dashboard when no dashboard is selected', () => {
|
||||
const localState = {
|
||||
allDashboards: dashboardGitResponse,
|
||||
currentDashboard: null,
|
||||
|
@ -402,7 +402,7 @@ describe('Monitoring store Getters', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns a default dashboard when dashboard cannot be found', () => {
|
||||
it('returns the overview dashboard when dashboard cannot be found', () => {
|
||||
const localState = {
|
||||
allDashboards: dashboardGitResponse,
|
||||
currentDashboard: 'wrong_path',
|
||||
|
|
|
@ -34,6 +34,7 @@ describe('Release edit/new component', () => {
|
|||
|
||||
getters = {
|
||||
isValid: () => true,
|
||||
isExistingRelease: () => true,
|
||||
validationErrors: () => ({
|
||||
assets: {
|
||||
links: [],
|
||||
|
@ -96,28 +97,6 @@ describe('Release edit/new component', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('renders the correct tag name in the "Tag name" field', () => {
|
||||
expect(wrapper.find('#git-ref').element.value).toBe(release.tagName);
|
||||
});
|
||||
|
||||
it('renders the correct help text under the "Tag name" field', () => {
|
||||
const helperText = wrapper.find('#tag-name-help');
|
||||
const helperTextLink = helperText.find('a');
|
||||
const helperTextLinkAttrs = helperTextLink.attributes();
|
||||
|
||||
expect(helperText.text()).toBe(
|
||||
'Changing a Release tag is only supported via Releases API. More information',
|
||||
);
|
||||
expect(helperTextLink.text()).toBe('More information');
|
||||
expect(helperTextLinkAttrs).toEqual(
|
||||
expect.objectContaining({
|
||||
href: state.updateReleaseApiDocsPath,
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the correct release title in the "Release title" field', () => {
|
||||
expect(wrapper.find('#release-title').element.value).toBe(release.name);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import { GlFormInput } from '@gitlab/ui';
|
||||
import { shallowMount, mount } from '@vue/test-utils';
|
||||
import TagFieldExisting from '~/releases/components/tag_field_existing.vue';
|
||||
import createStore from '~/releases/stores';
|
||||
import createDetailModule from '~/releases/stores/modules/detail';
|
||||
|
||||
const TEST_TAG_NAME = 'test-tag-name';
|
||||
const TEST_DOCS_PATH = '/help/test/docs/path';
|
||||
|
||||
describe('releases/components/tag_field_existing', () => {
|
||||
let store;
|
||||
let wrapper;
|
||||
|
||||
const createComponent = (mountFn = shallowMount) => {
|
||||
wrapper = mountFn(TagFieldExisting, {
|
||||
store,
|
||||
});
|
||||
};
|
||||
|
||||
const findInput = () => wrapper.find(GlFormInput);
|
||||
const findHelp = () => wrapper.find('[data-testid="tag-name-help"]');
|
||||
const findHelpLink = () => {
|
||||
const link = findHelp().find('a');
|
||||
|
||||
return {
|
||||
text: link.text(),
|
||||
href: link.attributes('href'),
|
||||
target: link.attributes('target'),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore({
|
||||
modules: {
|
||||
detail: createDetailModule({
|
||||
updateReleaseApiDocsPath: TEST_DOCS_PATH,
|
||||
tagName: TEST_TAG_NAME,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
store.state.detail.release = {
|
||||
tagName: TEST_TAG_NAME,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
describe('default', () => {
|
||||
it('shows the tag name', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findInput().attributes()).toMatchObject({
|
||||
disabled: '',
|
||||
value: TEST_TAG_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows help', () => {
|
||||
createComponent(mount);
|
||||
|
||||
expect(findHelp().text()).toMatchInterpolatedText(
|
||||
'Changing a Release tag is only supported via Releases API. More information',
|
||||
);
|
||||
|
||||
const helpLink = findHelpLink();
|
||||
|
||||
expect(helpLink).toEqual({
|
||||
text: 'More information',
|
||||
href: TEST_DOCS_PATH,
|
||||
target: '_blank',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import TagFieldNew from '~/releases/components/tag_field_new.vue';
|
||||
import createStore from '~/releases/stores';
|
||||
import createDetailModule from '~/releases/stores/modules/detail';
|
||||
|
||||
describe('releases/components/tag_field_new', () => {
|
||||
let store;
|
||||
let wrapper;
|
||||
|
||||
const createComponent = (mountFn = shallowMount) => {
|
||||
wrapper = mountFn(TagFieldNew, {
|
||||
store,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore({
|
||||
modules: {
|
||||
detail: createDetailModule({}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
it('renders a placeholder component', () => {
|
||||
createComponent();
|
||||
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import TagField from '~/releases/components/tag_field.vue';
|
||||
import TagFieldNew from '~/releases/components/tag_field_new.vue';
|
||||
import TagFieldExisting from '~/releases/components/tag_field_existing.vue';
|
||||
import createStore from '~/releases/stores';
|
||||
import createDetailModule from '~/releases/stores/modules/detail';
|
||||
|
||||
describe('releases/components/tag_field', () => {
|
||||
let store;
|
||||
let wrapper;
|
||||
|
||||
const createComponent = ({ originalRelease }) => {
|
||||
store = createStore({
|
||||
modules: {
|
||||
detail: createDetailModule({}),
|
||||
},
|
||||
});
|
||||
|
||||
store.state.detail.originalRelease = originalRelease;
|
||||
|
||||
wrapper = shallowMount(TagField, { store });
|
||||
};
|
||||
|
||||
const findTagFieldNew = () => wrapper.find(TagFieldNew);
|
||||
const findTagFieldExisting = () => wrapper.find(TagFieldExisting);
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
describe('when an existing release is being edited', () => {
|
||||
beforeEach(() => {
|
||||
const originalRelease = { name: 'Version 1.0' };
|
||||
createComponent({ originalRelease });
|
||||
});
|
||||
|
||||
it('renders the TagFieldExisting component', () => {
|
||||
expect(findTagFieldExisting().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render the TagFieldNew component', () => {
|
||||
expect(findTagFieldNew().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a new release is being created', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ originalRelease: null });
|
||||
});
|
||||
|
||||
it('renders the TagFieldNew component', () => {
|
||||
expect(findTagFieldNew().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render the TagFieldExisting component', () => {
|
||||
expect(findTagFieldExisting().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,6 +1,20 @@
|
|||
import * as getters from '~/releases/stores/modules/detail/getters';
|
||||
|
||||
describe('Release detail getters', () => {
|
||||
describe('isExistingRelease', () => {
|
||||
it('returns true if the release is an existing release that already exists in the database', () => {
|
||||
const state = { originalRelease: { name: 'The first release' } };
|
||||
|
||||
expect(getters.isExistingRelease(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if the release is a new release that has not yet been saved to the database', () => {
|
||||
const state = { originalRelease: null };
|
||||
|
||||
expect(getters.isExistingRelease(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseLinksToCreate', () => {
|
||||
it("returns an empty array if state.release doesn't exist", () => {
|
||||
const state = {};
|
||||
|
|
|
@ -142,7 +142,7 @@ RSpec.describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store
|
|||
|
||||
describe '.find_all_paths' do
|
||||
let(:all_dashboard_paths) { described_class.find_all_paths(project) }
|
||||
let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Default dashboard', default: true, system_dashboard: true, out_of_the_box_dashboard: true } }
|
||||
let(:system_dashboard) { { path: system_dashboard_path, display_name: 'Overview', default: true, system_dashboard: true, out_of_the_box_dashboard: true } }
|
||||
|
||||
it 'includes only the system dashboard by default' do
|
||||
expect(all_dashboard_paths).to eq([system_dashboard])
|
||||
|
@ -163,7 +163,7 @@ RSpec.describe Gitlab::Metrics::Dashboard::Finder, :use_clean_rails_memory_store
|
|||
let(:self_monitoring_dashboard) do
|
||||
{
|
||||
path: self_monitoring_dashboard_path,
|
||||
display_name: 'Default dashboard',
|
||||
display_name: 'Overview',
|
||||
default: true,
|
||||
system_dashboard: true,
|
||||
out_of_the_box_dashboard: true
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe CounterAttribute, :counter_attribute, :clean_gitlab_redis_shared_state do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
let(:project_statistics) { create(:project_statistics) }
|
||||
let(:model) { CounterAttributeModel.find(project_statistics.id) }
|
||||
|
||||
it_behaves_like CounterAttribute, [:build_artifacts_size, :commit_count] do
|
||||
let(:model) { CounterAttributeModel.find(project_statistics.id) }
|
||||
end
|
||||
|
||||
describe '.steal_increments' do
|
||||
let(:increment_key) { 'counters:Model:123:attribute' }
|
||||
let(:flushed_key) { 'counter:Model:123:attribute:flushed' }
|
||||
|
||||
subject { model.send(:steal_increments, increment_key, flushed_key) }
|
||||
|
||||
where(:increment, :flushed, :result, :flushed_key_present) do
|
||||
nil | nil | 0 | false
|
||||
nil | 0 | 0 | false
|
||||
0 | 0 | 0 | false
|
||||
1 | 0 | 1 | true
|
||||
1 | nil | 1 | true
|
||||
1 | 1 | 2 | true
|
||||
1 | -2 | -1 | true
|
||||
-1 | 1 | 0 | false
|
||||
end
|
||||
|
||||
with_them do
|
||||
before do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.set(increment_key, increment) if increment
|
||||
redis.set(flushed_key, flushed) if flushed
|
||||
end
|
||||
end
|
||||
|
||||
it { is_expected.to eq(result) }
|
||||
|
||||
it 'drops the increment key and creates the flushed key if it does not exist' do
|
||||
subject
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
expect(redis.exists(increment_key)).to be_falsey
|
||||
expect(redis.exists(flushed_key)).to eq(flushed_key_present)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -132,7 +132,7 @@ RSpec.describe Metrics::Dashboard::DynamicEmbedService, :use_clean_rails_memory_
|
|||
end
|
||||
|
||||
shared_examples 'uses system dashboard' do
|
||||
it 'uses the default dashboard' do
|
||||
it 'uses the overview dashboard' do
|
||||
expect(Gitlab::Metrics::Dashboard::Finder)
|
||||
.to receive(:find_raw)
|
||||
.with(project, dashboard_path: system_dashboard_path)
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.before(:each, :counter_attribute) do
|
||||
stub_const('CounterAttributeModel', Class.new(ProjectStatistics))
|
||||
|
||||
CounterAttributeModel.class_eval do
|
||||
include CounterAttribute
|
||||
|
||||
counter_attribute :build_artifacts_size
|
||||
counter_attribute :commit_count
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,176 @@
|
|||
# frozen_string_literal: true
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.shared_examples_for CounterAttribute do |counter_attributes|
|
||||
it 'defines a Redis counter_key' do
|
||||
expect(model.counter_key(:counter_name))
|
||||
.to eq("project:{#{model.project_id}}:counters:CounterAttributeModel:#{model.id}:counter_name")
|
||||
end
|
||||
|
||||
it 'defines a method to store counters' do
|
||||
expect(model.class.counter_attributes.to_a).to eq(counter_attributes)
|
||||
end
|
||||
|
||||
counter_attributes.each do |attribute|
|
||||
describe attribute do
|
||||
describe '#delayed_increment_counter', :redis do
|
||||
let(:increment) { 10 }
|
||||
|
||||
subject { model.delayed_increment_counter(attribute, increment) }
|
||||
|
||||
context 'when attribute is a counter attribute' do
|
||||
where(:increment) { [10, -3] }
|
||||
|
||||
with_them do
|
||||
it 'increments the counter in Redis' do
|
||||
subject
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
counter = redis.get(model.counter_key(attribute))
|
||||
expect(counter).to eq(increment.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not increment the counter for the record' do
|
||||
expect { subject }.not_to change { model.reset.read_attribute(attribute) }
|
||||
end
|
||||
|
||||
it 'schedules a worker to flush counter increments asynchronously' do
|
||||
expect(FlushCounterIncrementsWorker).to receive(:perform_in)
|
||||
.with(CounterAttribute::WORKER_DELAY, model.class.name, model.id, attribute)
|
||||
.and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
context 'when increment is 0' do
|
||||
let(:increment) { 0 }
|
||||
|
||||
it 'does nothing' do
|
||||
expect(FlushCounterIncrementsWorker).not_to receive(:perform_in)
|
||||
expect(model).not_to receive(:update!)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attribute is not a counter attribute' do
|
||||
it 'delegates to ActiveRecord update!' do
|
||||
expect { model.delayed_increment_counter(:unknown_attribute, 10) }
|
||||
.to raise_error(ActiveModel::MissingAttributeError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(efficient_counter_attribute: false)
|
||||
end
|
||||
|
||||
it 'delegates to ActiveRecord update!' do
|
||||
expect { subject }
|
||||
.to change { model.reset.read_attribute(attribute) }.by(increment)
|
||||
end
|
||||
|
||||
it 'does not increment the counter in Redis' do
|
||||
subject
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
counter = redis.get(model.counter_key(attribute))
|
||||
expect(counter).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.flush_increments_to_database!', :redis do
|
||||
let(:incremented_attribute) { counter_attributes.first }
|
||||
|
||||
subject { model.flush_increments_to_database!(incremented_attribute) }
|
||||
|
||||
it 'obtains an exclusive lease during processing' do
|
||||
expect(model)
|
||||
.to receive(:in_lock)
|
||||
.with(model.counter_lock_key(incremented_attribute), ttl: described_class::WORKER_LOCK_TTL)
|
||||
.and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
|
||||
context 'when there is a counter to flush' do
|
||||
before do
|
||||
model.delayed_increment_counter(incremented_attribute, 10)
|
||||
model.delayed_increment_counter(incremented_attribute, -3)
|
||||
end
|
||||
|
||||
it 'updates the record' do
|
||||
expect { subject }.to change { model.reset.read_attribute(incremented_attribute) }.by(7)
|
||||
end
|
||||
|
||||
it 'removes the increment entry from Redis' do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
key_exists = redis.exists(model.counter_key(incremented_attribute))
|
||||
expect(key_exists).to be_truthy
|
||||
end
|
||||
|
||||
subject
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
key_exists = redis.exists(model.counter_key(incremented_attribute))
|
||||
expect(key_exists).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no counters to flush' do
|
||||
context 'when there are no counters in the relative :flushed key' do
|
||||
it 'does not change the record' do
|
||||
expect { subject }.not_to change { model.reset.attributes }
|
||||
end
|
||||
end
|
||||
|
||||
# This can be the case where updating counters in the database fails with error
|
||||
# and retrying the worker will retry flushing the counters but the main key has
|
||||
# disappeared and the increment has been moved to the "<...>:flushed" key.
|
||||
context 'when there are counters in the relative :flushed key' do
|
||||
before do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.incrby(model.counter_flushed_key(incremented_attribute), 10)
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates the record' do
|
||||
expect { subject }.to change { model.reset.read_attribute(incremented_attribute) }.by(10)
|
||||
end
|
||||
|
||||
it 'deletes the relative :flushed key' do
|
||||
subject
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
key_exists = redis.exists(model.counter_flushed_key(incremented_attribute))
|
||||
expect(key_exists).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deleting :flushed key fails' do
|
||||
before do
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
redis.incrby(model.counter_flushed_key(incremented_attribute), 10)
|
||||
|
||||
expect(redis).to receive(:del).and_raise('could not delete key')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does a rollback of the counter update' do
|
||||
expect { subject }.to raise_error('could not delete key')
|
||||
|
||||
expect(model.reset.read_attribute(incremented_attribute)).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe FlushCounterIncrementsWorker, :counter_attribute do
|
||||
let(:project_statistics) { create(:project_statistics) }
|
||||
let(:model) { CounterAttributeModel.find(project_statistics.id) }
|
||||
|
||||
describe '#perform', :redis do
|
||||
let(:attribute) { model.class.counter_attributes.first }
|
||||
let(:worker) { described_class.new }
|
||||
|
||||
subject { worker.perform(model.class.name, model.id, attribute) }
|
||||
|
||||
it 'flushes increments to database' do
|
||||
expect(model.class).to receive(:find_by_id).and_return(model)
|
||||
expect(model)
|
||||
.to receive(:flush_increments_to_database!)
|
||||
.with(attribute)
|
||||
.and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
|
||||
context 'when model class does not exist' do
|
||||
subject { worker.perform('non-existend-model') }
|
||||
|
||||
it 'does nothing' do
|
||||
expect(worker).not_to receive(:in_lock)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when record does not exist' do
|
||||
subject { worker.perform(model.class.name, model.id + 100, attribute) }
|
||||
|
||||
it 'does nothing' do
|
||||
expect(worker).not_to receive(:in_lock)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue