diff options
Diffstat (limited to 'app')
18 files changed, 593 insertions, 27 deletions
diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue new file mode 100644 index 00000000000..e5c0d1e4970 --- /dev/null +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue @@ -0,0 +1,96 @@ +<script> +import { GlDeprecatedButton } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; +import CustomMetricsFormFields from './custom_metrics_form_fields.vue'; +import DeleteCustomMetricModal from './delete_custom_metric_modal.vue'; +import { formDataValidator } from '../constants'; + +export default { + components: { + CustomMetricsFormFields, + DeleteCustomMetricModal, + GlDeprecatedButton, + }, + props: { + customMetricsPath: { + type: String, + required: false, + default: '', + }, + metricPersisted: { + type: Boolean, + required: true, + }, + editProjectServicePath: { + type: String, + required: true, + }, + validateQueryPath: { + type: String, + required: true, + }, + formData: { + type: Object, + required: true, + validator: formDataValidator, + }, + }, + data() { + return { + formIsValid: null, + errorMessage: '', + }; + }, + computed: { + saveButtonText() { + return this.metricPersisted ? __('Save Changes') : s__('Metrics|Create metric'); + }, + titleText() { + return this.metricPersisted ? s__('Metrics|Edit metric') : s__('Metrics|New metric'); + }, + }, + created() { + this.csrf = csrf.token != null ? csrf.token : ''; + this.formOperation = this.metricPersisted ? 'patch' : 'post'; + }, + methods: { + formValidation(isValid) { + this.formIsValid = isValid; + }, + submit() { + this.$refs.form.submit(); + }, + }, +}; +</script> +<template> + <div class="row my-3"> + <h4 class="prepend-top-0 col-lg-8 offset-lg-2">{{ titleText }}</h4> + <form ref="form" class="col-lg-8 offset-lg-2" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :form-operation="formOperation" + :form-data="formData" + :metric-persisted="metricPersisted" + :validate-query-path="validateQueryPath" + @formValidation="formValidation" + /> + <div class="form-actions"> + <gl-deprecated-button variant="success" :disabled="!formIsValid" @click="submit"> + {{ saveButtonText }} + </gl-deprecated-button> + <gl-deprecated-button + variant="secondary" + class="float-right" + :href="editProjectServicePath" + >{{ __('Cancel') }}</gl-deprecated-button + > + <delete-custom-metric-modal + v-if="metricPersisted" + :delete-metric-url="customMetricsPath" + :csrf-token="csrf" + /> + </div> + </form> + </div> +</template> diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue new file mode 100644 index 00000000000..f5207b47f69 --- /dev/null +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue @@ -0,0 +1,294 @@ +<script> +import { GlFormInput, GlLink, GlFormGroup, GlFormRadioGroup, GlLoadingIcon } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { __, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import csrf from '~/lib/utils/csrf'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import { queryTypes, formDataValidator } from '../constants'; + +const VALIDATION_REQUEST_TIMEOUT = 10000; +const axiosCancelToken = axios.CancelToken; +let cancelTokenSource; + +function backOffRequest(makeRequestCallback) { + return backOff((next, stop) => { + makeRequestCallback() + .then(resp => { + if (resp.status === statusCodes.OK) { + stop(resp); + } else { + next(); + } + }) + // If the request is cancelled by axios + // then consider it as noop so that its not + // caught by subsequent catches + .catch(thrown => (axios.isCancel(thrown) ? undefined : stop(thrown))); + }, VALIDATION_REQUEST_TIMEOUT); +} + +export default { + components: { + GlFormInput, + GlLink, + GlFormGroup, + GlFormRadioGroup, + GlLoadingIcon, + Icon, + }, + props: { + formOperation: { + type: String, + required: true, + }, + formData: { + type: Object, + required: false, + default: () => ({ + title: '', + yLabel: '', + query: '', + unit: '', + group: '', + legend: '', + }), + validator: formDataValidator, + }, + metricPersisted: { + type: Boolean, + required: false, + default: false, + }, + validateQueryPath: { + type: String, + required: true, + }, + }, + data() { + const group = this.formData.group.length ? this.formData.group : queryTypes.business; + + return { + queryIsValid: null, + queryValidateInFlight: false, + ...this.formData, + group, + }; + }, + computed: { + formIsValid() { + return Boolean( + this.queryIsValid && + this.title.length && + this.yLabel.length && + this.unit.length && + this.group.length, + ); + }, + validQueryMsg() { + return this.queryIsValid ? s__('Metrics|PromQL query is valid') : ''; + }, + invalidQueryMsg() { + return !this.queryIsValid ? this.errorMessage : ''; + }, + }, + watch: { + formIsValid(value) { + this.$emit('formValidation', value); + }, + }, + beforeMount() { + if (this.metricPersisted) { + this.validateQuery(); + } + }, + methods: { + requestValidation(query, cancelToken) { + return backOffRequest(() => + axios.post( + this.validateQueryPath, + { + query, + }, + { + cancelToken, + }, + ), + ); + }, + setFormState(isValid, inFlight, message) { + this.queryIsValid = isValid; + this.queryValidateInFlight = inFlight; + this.errorMessage = message; + }, + validateQuery() { + if (!this.query) { + this.setFormState(null, false, ''); + return; + } + this.setFormState(null, true, ''); + // cancel previously dispatched backoff request + if (cancelTokenSource) { + cancelTokenSource.cancel(); + } + // Creating a new token for each request because + // if a single token is used it can cancel existing requests + // as well. + cancelTokenSource = axiosCancelToken.source(); + this.requestValidation(this.query, cancelTokenSource.token) + .then(res => { + const response = res.data; + const { valid, error } = response.query; + if (response.success) { + this.setFormState(valid, false, valid ? '' : error); + } else { + throw new Error(__('There was an error trying to validate your query')); + } + }) + .catch(() => { + this.setFormState( + false, + false, + s__('Metrics|There was an error trying to validate your query'), + ); + }); + }, + debouncedValidateQuery: debounce(function checkQuery() { + this.validateQuery(); + }, 500), + }, + csrfToken: csrf.token || '', + formGroupOptions: [ + { text: __('Business'), value: queryTypes.business }, + { text: __('Response'), value: queryTypes.response }, + { text: __('System'), value: queryTypes.system }, + ], +}; +</script> + +<template> + <div> + <input ref="method" type="hidden" name="_method" :value="formOperation" /> + <input :value="$options.csrfToken" type="hidden" name="authenticity_token" /> + <gl-form-group :label="__('Name')" label-for="prometheus_metric_title" label-class="label-bold"> + <gl-form-input + id="prometheus_metric_title" + v-model="title" + name="prometheus_metric[title]" + class="form-control" + :placeholder="s__('Metrics|e.g. Throughput')" + data-qa-selector="custom_metric_prometheus_title_field" + required + /> + <span class="form-text text-muted">{{ s__('Metrics|Used as a title for the chart') }}</span> + </gl-form-group> + <gl-form-group :label="__('Type')" label-for="prometheus_metric_group" label-class="label-bold"> + <gl-form-radio-group + id="metric-group" + v-model="group" + :options="$options.formGroupOptions" + :checked="group" + name="prometheus_metric[group]" + /> + <span class="form-text text-muted">{{ s__('Metrics|For grouping similar metrics') }}</span> + </gl-form-group> + <gl-form-group + :label="__('Query')" + label-for="prometheus_metric_query" + label-class="label-bold" + :state="queryIsValid" + > + <gl-form-input + id="prometheus_metric_query" + v-model.trim="query" + data-qa-selector="custom_metric_prometheus_query_field" + name="prometheus_metric[query]" + class="form-control" + :placeholder="s__('Metrics|e.g. rate(http_requests_total[5m])')" + required + :state="queryIsValid" + @input="debouncedValidateQuery" + /> + <span v-if="queryValidateInFlight" class="form-text text-muted"> + <gl-loading-icon :inline="true" class="mr-1 align-middle" /> + {{ s__('Metrics|Validating query') }} + </span> + <slot v-if="!queryValidateInFlight" name="valid-feedback"> + <span class="form-text cgreen"> + {{ validQueryMsg }} + </span> + </slot> + <slot v-if="!queryValidateInFlight" name="invalid-feedback"> + <span class="form-text cred"> + {{ invalidQueryMsg }} + </span> + </slot> + <span v-show="query.length === 0" class="form-text text-muted"> + {{ s__('Metrics|Must be a valid PromQL query.') }} + <gl-link href="https://prometheus.io/docs/prometheus/latest/querying/basics/" tabindex="-1"> + {{ s__('Metrics|Prometheus Query Documentation') }} + <icon name="external-link" :size="12" /> + </gl-link> + </span> + </gl-form-group> + <gl-form-group + :label="s__('Metrics|Y-axis label')" + label-for="prometheus_metric_y_label" + label-class="label-bold" + > + <gl-form-input + id="prometheus_metric_y_label" + v-model="yLabel" + data-qa-selector="custom_metric_prometheus_y_label_field" + name="prometheus_metric[y_label]" + class="form-control" + :placeholder="s__('Metrics|e.g. Requests/second')" + required + /> + <span class="form-text text-muted"> + {{ + s__('Metrics|Label of the y-axis (usually the unit). The x-axis always represents time.') + }} + </span> + </gl-form-group> + <gl-form-group + :label="s__('Metrics|Unit label')" + label-for="prometheus_metric_unit" + label-class="label-bold" + > + <gl-form-input + id="prometheus_metric_unit" + v-model="unit" + data-qa-selector="custom_metric_prometheus_unit_label_field" + name="prometheus_metric[unit]" + class="form-control" + :placeholder="s__('Metrics|e.g. req/sec')" + required + /> + </gl-form-group> + <gl-form-group + :label="s__('Metrics|Legend label (optional)')" + label-for="prometheus_metric_legend" + label-class="label-bold" + > + <gl-form-input + id="prometheus_metric_legend" + v-model="legend" + data-qa-selector="custom_metric_prometheus_legend_label_field" + name="prometheus_metric[legend]" + class="form-control" + :placeholder="s__('Metrics|e.g. HTTP requests')" + required + /> + <span class="form-text text-muted"> + {{ + s__( + 'Metrics|Used if the query returns a single series. If it returns multiple series, their legend labels will be picked up from the response.', + ) + }} + </span> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue new file mode 100644 index 00000000000..34e4aeb290f --- /dev/null +++ b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue @@ -0,0 +1,54 @@ +<script> +import { GlModal, GlModalDirective, GlDeprecatedButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlModal, + GlDeprecatedButton, + }, + directives: { + 'gl-modal': GlModalDirective, + }, + props: { + deleteMetricUrl: { + type: String, + required: true, + }, + csrfToken: { + type: String, + required: true, + }, + }, + methods: { + onSubmit() { + this.$refs.form.submit(); + }, + }, + descriptionText: s__( + `Metrics|You're about to permanently delete this metric. This cannot be undone.`, + ), + modalId: 'delete-custom-metric-modal', +}; +</script> +<template> + <div class="d-inline-block float-right mr-3"> + <gl-deprecated-button v-gl-modal="$options.modalId" variant="danger"> + {{ __('Delete') }} + </gl-deprecated-button> + <gl-modal + :title="s__('Metrics|Delete metric?')" + :ok-title="s__('Metrics|Delete metric')" + :modal-id="$options.modalId" + ok-variant="danger" + @ok="onSubmit" + > + {{ $options.descriptionText }} + + <form ref="form" :action="deleteMetricUrl" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + </form> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/custom_metrics/constants.js b/app/assets/javascripts/custom_metrics/constants.js new file mode 100644 index 00000000000..2526445fdf9 --- /dev/null +++ b/app/assets/javascripts/custom_metrics/constants.js @@ -0,0 +1,12 @@ +export const queryTypes = { + business: 'business', + response: 'response', + system: 'system', +}; + +export const formDataValidator = val => { + const fieldNames = Object.keys(val); + const requiredFields = ['title', 'query', 'yLabel', 'unit', 'group', 'legend']; + + return requiredFields.every(name => fieldNames.includes(name)); +}; diff --git a/app/assets/javascripts/custom_metrics/index.js b/app/assets/javascripts/custom_metrics/index.js new file mode 100644 index 00000000000..4c279daf5f0 --- /dev/null +++ b/app/assets/javascripts/custom_metrics/index.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import CustomMetricsForm from './components/custom_metrics_form.vue'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-custom-metrics', + components: { + CustomMetricsForm, + }, + render(createElement) { + const domEl = document.querySelector(this.$options.el); + const { + customMetricsPath, + editProjectServicePath, + validateQueryPath, + title, + query, + yLabel, + unit, + group, + legend, + } = domEl.dataset; + let { metricPersisted } = domEl.dataset; + + metricPersisted = parseBoolean(metricPersisted); + + return createElement('custom-metrics-form', { + props: { + customMetricsPath, + metricPersisted, + editProjectServicePath, + validateQueryPath, + formData: { + title, + query, + yLabel, + unit, + group, + legend, + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 6c158ad8990..f839e9acf04 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -23,7 +23,7 @@ function getErrorMessage(res) { return res.message; } -export default function dropzoneInput(form) { +export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const divHover = '<div class="div-dropzone-hover"></div>'; const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; const $attachButton = form.find('.button-attach-file'); @@ -69,6 +69,7 @@ export default function dropzoneInput(form) { uploadMultiple: false, headers: csrf.headers, previewContainer: false, + ...config, processing: () => $('.div-dropzone-alert').alert('close'), dragover: () => { $mdArea.addClass('is-dropzone-hover'); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index a66555838ba..1811a942beb 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -45,7 +45,7 @@ export default class GLForm { ); this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM); - dropzoneInput(this.form); + dropzoneInput(this.form, { parallelUploads: 1 }); autosize(this.textarea); } // form and textarea event listeners diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index b2911bcae8f..e092c0ccae0 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -109,3 +109,9 @@ export const initialStateKeys = [...endpointKeys, 'currentEnvironmentName']; * Constant to indicate if a metric exists in the database */ export const NOT_IN_DB_PREFIX = 'NO_DB'; + +/** + * graphQL environments API value for active environments. + * Used as a value for the 'states' query filter + */ +export const ENVIRONMENT_AVAILABLE_STATE = 'available'; diff --git a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql index fd3a4348509..17cd1b2c342 100644 --- a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql @@ -1,6 +1,6 @@ -query getEnvironments($projectPath: ID!, $search: String) { +query getEnvironments($projectPath: ID!, $search: String, $states: [String!]) { project(fullPath: $projectPath) { - data: environments(search: $search) { + data: environments(search: $search, states: $states) { environments: nodes { name id diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index acc09fa6305..8427a72a68e 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -10,7 +10,7 @@ import statusCodes from '../../lib/utils/http_status'; import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; -import { PROMETHEUS_TIMEOUT } from '../constants'; +import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants'; function prometheusMetricQueryParams(timeRange) { const { start, end } = convertToFixedRange(timeRange); @@ -238,6 +238,7 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { variables: { projectPath: removeLeadingSlash(state.projectPath), search: state.environmentsSearchTerm, + states: [ENVIRONMENT_AVAILABLE_STATE], }, }) .then(resp => diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue new file mode 100644 index 00000000000..6aae9195be1 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue @@ -0,0 +1,62 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + name: 'AccessibilityIssueBody', + components: { + GlLink, + }, + props: { + issue: { + type: Object, + required: true, + }, + isNew: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + parsedTECHSCode() { + /* + * In issue code looks like "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail" + * or "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent" + * + * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation. + * Here we simply split the string on `.` and get the code in the 5th position + */ + if (this.issue.code === undefined) { + return null; + } + + return this.issue.code.split('.')[4] || null; + }, + learnMoreUrl() { + if (this.parsedTECHSCode === null) { + return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html'; + } + + return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`; + }, + }, +}; +</script> +<template> + <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> + <div ref="accessibility-issue-description" class="report-block-list-issue-description-text"> + <div + v-if="isNew" + ref="accessibility-issue-is-new-badge" + class="badge badge-danger append-right-5" + > + {{ s__('AccessibilityReport|New') }} + </div> + {{ issue.name }} + <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{ + s__('AccessibilityReport|Learn More') + }}</gl-link> + {{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js index 8b5af263d50..e106e60951b 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/reports/components/issue_body.js @@ -1,9 +1,12 @@ import TestIssueBody from './test_issue_body.vue'; +import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue'; export const components = { + AccessibilityIssueBody, TestIssueBody, }; export const componentNames = { + AccessibilityIssueBody: AccessibilityIssueBody.name, TestIssueBody: TestIssueBody.name, }; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index 875de57cfcc..ed5c133950d 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -42,16 +42,6 @@ html [type="button"], } h1, -h2, -h3, -h4, -h5, -h6 { - color: $gl-text-color; - font-weight: 600; -} - -h1, .h1, h2, .h2, diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index b2bbc09664c..69aed2fc20a 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -567,16 +567,6 @@ body { font-weight: $gl-font-weight-bold; } -h1, -h2, -h3, -h4, -h5, -h6 { - color: $gl-text-color; - font-weight: $gl-font-weight-bold; -} - .light-header { font-weight: $gl-font-weight-bold; } diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 7538459c97b..ef75dabbda4 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -35,6 +35,8 @@ $h3-font-size: 14px * 1.75; $h4-font-size: 14px * 1.5; $h5-font-size: 14px * 1.25; $h6-font-size: 14px; +$headings-color: $gl-text-color; +$headings-font-weight: $gl-font-weight-bold; $spacer: $grid-size; $spacers: ( 0: 0, diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 0d7e2a7bd38..2cd685ddcd4 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -10,7 +10,7 @@ module GroupsHelper ] end - def group_nav_link_paths + def group_settings_nav_link_paths %w[ groups#projects groups#edit diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb index b0e6464f5b1..369afba2bae 100644 --- a/app/uploaders/import_export_uploader.rb +++ b/app/uploaders/import_export_uploader.rb @@ -14,4 +14,12 @@ class ImportExportUploader < AttachmentUploader def move_to_cache false end + + def work_dir + File.join(Settings.shared['path'], 'tmp', 'work') + end + + def cache_dir + File.join(Settings.shared['path'], 'tmp', 'cache') + end end diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 89bcccb6185..8115c713a4f 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -133,7 +133,7 @@ = _('Members') - if group_sidebar_link?(:settings) - = nav_link(path: group_nav_link_paths) do + = nav_link(path: group_settings_nav_link_paths) do = link_to edit_group_path(@group) do .nav-icon-container = sprite_icon('settings') |