summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue6
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js9
-rw-r--r--app/assets/javascripts/commit/image_file.js44
-rw-r--r--app/assets/javascripts/issue.js14
-rw-r--r--app/assets/javascripts/issue_show/index.js7
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js15
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue50
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue100
-rw-r--r--app/assets/javascripts/monitoring/constants.js13
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js1
-rw-r--r--app/assets/javascripts/monitoring/services/monitoring_service.js6
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js39
-rw-r--r--app/assets/javascripts/monitoring/utils.js34
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue5
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue121
-rw-r--r--app/assets/javascripts/related_merge_requests/index.js24
-rw-r--r--app/assets/javascripts/related_merge_requests/store/actions.js37
-rw-r--r--app/assets/javascripts/related_merge_requests/store/index.js14
-rw-r--r--app/assets/javascripts/related_merge_requests/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/related_merge_requests/store/mutations.js19
-rw-r--r--app/assets/javascripts/related_merge_requests/store/state.js7
-rw-r--r--app/assets/javascripts/serverless/components/area.vue146
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue54
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue5
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue44
-rw-r--r--app/assets/javascripts/serverless/components/missing_prometheus.vue63
-rw-r--r--app/assets/javascripts/serverless/constants.js3
-rw-r--r--app/assets/javascripts/serverless/serverless_bundle.js125
-rw-r--r--app/assets/javascripts/serverless/services/get_functions_service.js11
-rw-r--r--app/assets/javascripts/serverless/store/actions.js113
-rw-r--r--app/assets/javascripts/serverless/store/getters.js10
-rw-r--r--app/assets/javascripts/serverless/store/index.js18
-rw-r--r--app/assets/javascripts/serverless/store/mutation_types.js9
-rw-r--r--app/assets/javascripts/serverless/store/mutations.js38
-rw-r--r--app/assets/javascripts/serverless/store/state.js13
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_details_store.js11
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js29
-rw-r--r--app/assets/javascripts/serverless/utils.js23
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue21
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js62
-rw-r--r--app/assets/stylesheets/framework/buttons.scss1
-rw-r--r--app/assets/stylesheets/framework/common.scss1
-rw-r--r--app/assets/stylesheets/framework/variables.scss10
-rw-r--r--app/assets/stylesheets/pages/environments.scss2
-rw-r--r--app/assets/stylesheets/pages/monitor.scss5
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss30
-rw-r--r--app/assets/stylesheets/pages/settings.scss2
-rw-r--r--app/controllers/projects/blob_controller.rb2
-rw-r--r--app/controllers/projects/commits_controller.rb3
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb23
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb4
-rw-r--r--app/finders/projects/serverless/functions_finder.rb28
-rw-r--r--app/helpers/issuables_helper.rb2
-rw-r--r--app/models/serverless/function.rb26
-rw-r--r--app/serializers/projects/serverless/service_entity.rb7
-rw-r--r--app/services/error_tracking/list_projects_service.rb4
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml4
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml47
-rw-r--r--app/views/projects/_merge_request_merge_settings.html.haml16
-rw-r--r--app/views/projects/issues/_closed_by_box.html.haml4
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml36
-rw-r--r--app/views/projects/issues/show.html.haml5
-rw-r--r--app/views/projects/pipelines/new.html.haml4
-rw-r--r--app/views/projects/serverless/functions/index.html.haml5
-rw-r--r--app/views/projects/serverless/functions/show.html.haml11
-rw-r--r--changelogs/unreleased/52258-labels-with-long-names-overflow-on-metrics-dashboard.yml5
-rw-r--r--changelogs/unreleased/58835-button-run-pipeline.yml5
-rw-r--r--changelogs/unreleased/58981-migrate-clusters-tests-to-jest.yml5
-rw-r--r--changelogs/unreleased/59621-order-labels-alphabetically-in-issue-boards.yml5
-rw-r--r--changelogs/unreleased/60006-add-touch-events-to-image-diff-26971.yml5
-rw-r--r--changelogs/unreleased/60068-avoid-null-domain-help-text.yml5
-rw-r--r--changelogs/unreleased/60116-fix-button-wrapping.yml5
-rw-r--r--changelogs/unreleased/60149-nameerror-uninitialized-constant-sentry-client-sentryerror.yml5
-rw-r--r--changelogs/unreleased/_acet-related-mrs-widget-rewrite.yml5
-rw-r--r--changelogs/unreleased/bump_kubernetes_1_11_9.yml5
-rw-r--r--changelogs/unreleased/ce-proj-settings-ok-mr-settings-only.yml5
-rw-r--r--changelogs/unreleased/knative-prometheus.yml5
-rw-r--r--changelogs/unreleased/minimized-multiple-queries-ce.yml5
-rw-r--r--changelogs/unreleased/sh-add-gitaly-ref-name-caching-tree-controller.yml5
-rw-r--r--changelogs/unreleased/sh-fix-realtime-changes-with-reserved-words.yml5
-rw-r--r--changelogs/unreleased/sh-git-gc-after-initial-fetch.yml5
-rw-r--r--changelogs/unreleased/sh-improve-find-commit-caching.yml5
-rw-r--r--config/prometheus/common_metrics.yml10
-rw-r--r--config/routes/project.rb6
-rw-r--r--db/migrate/20190326164045_import_common_metrics_knative.rb17
-rw-r--r--db/post_migrate/20190313092516_clean_up_noteable_id_for_notes_on_commits.rb33
-rw-r--r--db/schema.rb2
-rw-r--r--doc/administration/job_traces.md8
-rw-r--r--doc/api/commits.md2
-rw-r--r--doc/api/project_clusters.md4
-rw-r--r--doc/ci/environments.md2
-rw-r--r--doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md2
-rw-r--r--doc/ci/introduction/index.md2
-rw-r--r--doc/ci/pipelines.md4
-rw-r--r--doc/ci/review_apps/index.md6
-rw-r--r--doc/ci/yaml/README.md2
-rw-r--r--doc/topics/autodevops/index.md2
-rw-r--r--doc/user/group/index.md2
-rw-r--r--doc/user/project/clusters/serverless/img/function-details-loaded.pngbin0 -> 93515 bytes
-rw-r--r--doc/user/project/clusters/serverless/index.md20
-rw-r--r--lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml8
-rw-r--r--lib/gitlab/ci/templates/Jobs/DAST.gitlab-ci.yml54
-rw-r--r--lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml51
-rw-r--r--lib/gitlab/database/migration_helpers.rb19
-rw-r--r--lib/gitlab/etag_caching/router.rb23
-rw-r--r--lib/gitlab/github_import/importer/repository_importer.rb5
-rw-r--r--lib/gitlab/kubernetes/helm.rb4
-rw-r--r--lib/gitlab/object_hierarchy.rb62
-rw-r--r--lib/gitlab/prometheus/queries/knative_invocation_query.rb39
-rw-r--r--lib/sentry/client.rb30
-rw-r--r--locale/gitlab.pot104
-rw-r--r--locale/uk/gitlab.po7
-rw-r--r--package.json2
-rw-r--r--spec/controllers/projects/blob_controller_spec.rb2
-rw-r--r--spec/controllers/projects/refs_controller_spec.rb4
-rw-r--r--spec/controllers/projects/serverless/functions_controller_spec.rb9
-rw-r--r--spec/controllers/projects/tree_controller_spec.rb2
-rw-r--r--spec/features/issuables/markdown_references/internal_references_spec.rb2
-rw-r--r--spec/features/issues/user_uses_quick_actions_spec.rb37
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb12
-rw-r--r--spec/features/projects/serverless/functions_spec.rb2
-rw-r--r--spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb4
-rw-r--r--spec/finders/projects/serverless/functions_finder_spec.rb32
-rw-r--r--spec/frontend/clusters/clusters_bundle_spec.js (renamed from spec/javascripts/clusters/clusters_bundle_spec.js)100
-rw-r--r--spec/frontend/clusters/components/application_row_spec.js (renamed from spec/javascripts/clusters/components/application_row_spec.js)12
-rw-r--r--spec/frontend/clusters/components/applications_spec.js (renamed from spec/javascripts/clusters/components/applications_spec.js)4
-rw-r--r--spec/frontend/clusters/services/mock_data.js (renamed from spec/javascripts/clusters/services/mock_data.js)0
-rw-r--r--spec/frontend/clusters/stores/clusters_store_spec.js (renamed from spec/javascripts/clusters/stores/clusters_store_spec.js)0
-rw-r--r--spec/frontend/serverless/components/area_spec.js122
-rw-r--r--spec/frontend/serverless/components/environment_row_spec.js (renamed from spec/javascripts/serverless/components/environment_row_spec.js)41
-rw-r--r--spec/frontend/serverless/components/function_details_spec.js117
-rw-r--r--spec/frontend/serverless/components/function_row_spec.js (renamed from spec/javascripts/serverless/components/function_row_spec.js)14
-rw-r--r--spec/frontend/serverless/components/functions_spec.js106
-rw-r--r--spec/frontend/serverless/components/missing_prometheus_spec.js38
-rw-r--r--spec/frontend/serverless/components/pod_box_spec.js23
-rw-r--r--spec/frontend/serverless/components/url_spec.js (renamed from spec/javascripts/serverless/components/url_spec.js)21
-rw-r--r--spec/frontend/serverless/mock_data.js (renamed from spec/javascripts/serverless/mock_data.js)57
-rw-r--r--spec/frontend/serverless/store/actions_spec.js90
-rw-r--r--spec/frontend/serverless/store/getters_spec.js43
-rw-r--r--spec/frontend/serverless/store/mutations_spec.js86
-rw-r--r--spec/frontend/serverless/utils.js20
-rw-r--r--spec/javascripts/fixtures/issues.rb58
-rw-r--r--spec/javascripts/monitoring/charts/area_spec.js23
-rw-r--r--spec/javascripts/monitoring/dashboard_spec.js93
-rw-r--r--spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js89
-rw-r--r--spec/javascripts/related_merge_requests/store/actions_spec.js110
-rw-r--r--spec/javascripts/related_merge_requests/store/mutations_spec.js49
-rw-r--r--spec/javascripts/serverless/components/functions_spec.js68
-rw-r--r--spec/javascripts/serverless/stores/serverless_store_spec.js36
-rw-r--r--spec/lib/gitlab/etag_caching/router_spec.rb18
-rw-r--r--spec/lib/gitlab/github_import/importer/repository_importer_spec.rb5
-rw-r--r--spec/lib/gitlab/kubernetes/helm/pod_spec.rb2
-rw-r--r--spec/lib/gitlab/object_hierarchy_spec.rb40
-rw-r--r--spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb26
-rw-r--r--spec/lib/sentry/client_spec.rb48
-rw-r--r--spec/migrations/clean_up_noteable_id_for_notes_on_commits_spec.rb34
-rw-r--r--spec/models/serverless/function_spec.rb21
-rw-r--r--spec/services/error_tracking/list_projects_service_spec.rb26
-rw-r--r--spec/support/helpers/prometheus_helpers.rb4
-rw-r--r--spec/support/shared_examples/quick_actions/issue/duplicate_quick_action_shared_examples.rb34
-rw-r--r--yarn.lock8
167 files changed, 3279 insertions, 821 deletions
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 90ab3a76342..206573dd444 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -1,4 +1,5 @@
<script>
+import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
@@ -92,6 +93,9 @@ export default {
const { referencePath, groupId } = this.issue;
return !groupId ? referencePath.split('#')[0] : null;
},
+ orderedLabels() {
+ return _.sortBy(this.issue.labels, 'title');
+ },
},
methods: {
isIndexLessThanlimit(index) {
@@ -176,7 +180,7 @@ export default {
</div>
<div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
<button
- v-for="label in issue.labels"
+ v-for="label in orderedLabels"
v-if="showLabel(label)"
:key="label.id"
v-gl-tooltip
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index c95d7608e37..df855261b3c 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -262,7 +262,7 @@ export default class Clusters {
this.store.updateAppProperty(appId, 'requestReason', null);
this.store.updateAppProperty(appId, 'statusReason', null);
- this.service.installApplication(appId, data.params).catch(() => {
+ return this.service.installApplication(appId, data.params).catch(() => {
this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
this.store.updateAppProperty(
appId,
@@ -288,10 +288,11 @@ export default class Clusters {
}
toggleIngressDomainHelpText(ingressPreviousState, ingressNewState) {
- const helpTextHidden = ingressNewState.status !== APPLICATION_STATUS.INSTALLED;
- const domainSnippetText = `${ingressNewState.externalIp}${INGRESS_DOMAIN_SUFFIX}`;
+ const { externalIp, status } = ingressNewState;
+ const helpTextHidden = status !== APPLICATION_STATUS.INSTALLED || !externalIp;
+ const domainSnippetText = `${externalIp}${INGRESS_DOMAIN_SUFFIX}`;
- if (ingressPreviousState.status !== ingressNewState.status) {
+ if (ingressPreviousState.status !== status) {
this.ingressDomainHelpText.classList.toggle('hide', helpTextHidden);
this.ingressDomainSnippet.textContent = domainSnippetText;
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index d4ecfa4aa93..bc666aef54b 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -71,29 +71,39 @@ export default class ImageFile {
// eslint-disable-next-line class-methods-use-this
initDraggable($el, padding, callback) {
var dragging = false;
- var $body = $('body');
- var $offsetEl = $el.parent();
-
- $el.off('mousedown').on('mousedown', function() {
+ const $body = $('body');
+ const $offsetEl = $el.parent();
+ const dragStart = function() {
dragging = true;
$body.css('user-select', 'none');
- });
+ };
+ const dragStop = function() {
+ dragging = false;
+ $body.css('user-select', '');
+ };
+ const dragMove = function(e) {
+ const moveX = e.pageX || e.touches[0].pageX;
+ const left = moveX - ($offsetEl.offset().left + padding);
+ if (!dragging) return;
+
+ callback(e, left);
+ };
+
+ $el
+ .off('mousedown')
+ .off('touchstart')
+ .on('mousedown', dragStart)
+ .on('touchstart', dragStart);
$body
.off('mouseup')
.off('mousemove')
- .on('mouseup', function() {
- dragging = false;
- $body.css('user-select', '');
- })
- .on('mousemove', function(e) {
- var left;
- if (!dragging) return;
-
- left = e.pageX - ($offsetEl.offset().left + padding);
-
- callback(e, left);
- });
+ .off('touchend')
+ .off('touchmove')
+ .on('mouseup', dragStop)
+ .on('touchend', dragStop)
+ .on('mousemove', dragMove)
+ .on('touchmove', dragMove);
}
prepareFrames(view) {
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index b3508f36cf9..cd1afb6ba83 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -15,7 +15,6 @@ export default class Issue {
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
- Issue.initMergeRequests();
if (document.querySelector('#related-branches')) {
Issue.initRelatedBranches();
}
@@ -143,19 +142,6 @@ export default class Issue {
}
}
- static initMergeRequests() {
- var $container;
- $container = $('#merge-requests');
- return axios
- .get($container.data('url'))
- .then(({ data }) => {
- if ('html' in data) {
- $container.html(data.html);
- }
- })
- .catch(() => flash('Failed to load referenced merge requests'));
- }
-
static initRelatedBranches() {
var $container;
$container = $('#related-branches');
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index d08e8ba0c4b..529b6386221 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,12 +1,9 @@
import Vue from 'vue';
-import sanitize from 'sanitize-html';
import issuableApp from './components/app.vue';
+import { parseIssuableData } from './utils/parse_data';
import '../vue_shared/vue_resource_interceptor';
export default function initIssueableApp() {
- const initialDataEl = document.getElementById('js-issuable-app-initial-data');
- const props = JSON.parse(sanitize(initialDataEl.textContent).replace(/&quot;/g, '"'));
-
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
@@ -14,7 +11,7 @@ export default function initIssueableApp() {
},
render(createElement) {
return createElement('issuable-app', {
- props,
+ props: parseIssuableData(),
});
},
});
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
new file mode 100644
index 00000000000..05e384adad3
--- /dev/null
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -0,0 +1,15 @@
+import sanitize from 'sanitize-html';
+
+export const parseIssuableData = () => {
+ try {
+ const initialDataEl = document.getElementById('js-issuable-app-initial-data');
+
+ return JSON.parse(sanitize(initialDataEl.textContent).replace(/&quot;/g, '"'));
+ } catch (e) {
+ console.error(e); // eslint-disable-line no-console
+
+ return {};
+ }
+};
+
+export default {};
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index b0bbe272d1f..afe8d87a8d6 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
@@ -12,6 +12,7 @@ let debouncedResize;
export default {
components: {
GlAreaChart,
+ GlChartSeriesLabel,
Icon,
},
inheritAttrs: false,
@@ -42,10 +43,10 @@ export default {
required: false,
default: () => [],
},
- alertData: {
- type: Object,
+ thresholds: {
+ type: Array,
required: false,
- default: () => ({}),
+ default: () => [],
},
},
data() {
@@ -64,6 +65,9 @@ export default {
},
computed: {
chartData() {
+ // Transforms & supplements query data to render appropriate labels & styles
+ // Input: [{ queryAttributes1 }, { queryAttributes2 }]
+ // Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
return this.graphData.queries.reduce((acc, query) => {
const { appearance } = query;
const lineType =
@@ -121,6 +125,9 @@ export default {
},
earliestDatapoint() {
return this.chartData.reduce((acc, series) => {
+ if (!series.data.length) {
+ return acc;
+ }
const [[timestamp]] = series.data.sort(([a], [b]) => {
if (a < b) {
return -1;
@@ -131,6 +138,9 @@ export default {
return timestamp < acc || acc === null ? timestamp : acc;
}, null);
},
+ isMultiSeries() {
+ return this.tooltip.content.length > 1;
+ },
recentDeployments() {
return this.deploymentData.reduce((acc, deployment) => {
if (deployment.created_at >= this.earliestDatapoint) {
@@ -191,7 +201,7 @@ export default {
);
this.tooltip.sha = deploy.sha.substring(0, 8);
} else {
- const { seriesName } = seriesData;
+ const { seriesName, color } = seriesData;
// seriesData.value contains the chart's [x, y] value pair
// seriesData.value[1] is threfore the chart y value
const value = seriesData.value[1].toFixed(3);
@@ -199,6 +209,7 @@ export default {
this.tooltip.content.push({
name: seriesName,
value,
+ color,
});
}
});
@@ -235,29 +246,38 @@ export default {
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
- :thresholds="alertData"
+ :thresholds="thresholds"
:width="width"
:height="height"
@updated="onChartUpdated"
>
- <template slot="tooltipTitle">
- <div v-if="tooltip.isDeployment">
+ <template v-if="tooltip.isDeployment">
+ <template slot="tooltipTitle">
{{ __('Deployed') }}
- </div>
- {{ tooltip.title }}
- </template>
- <template slot="tooltipContent">
- <div v-if="tooltip.isDeployment" class="d-flex align-items-center">
+ </template>
+ <div slot="tooltipContent" class="d-flex align-items-center">
<icon name="commit" class="mr-2" />
{{ tooltip.sha }}
</div>
- <template v-else>
+ </template>
+ <template v-else>
+ <template slot="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </template>
+ <template slot="tooltipContent">
<div
v-for="(content, key) in tooltip.content"
:key="key"
class="d-flex justify-content-between"
>
- {{ content.name }} {{ content.value }}
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
</div>
</template>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index ba6a17827f7..f2bd4150b6d 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import _ from 'underscore';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import '~/vue_shared/mixins/is_ee';
@@ -9,6 +10,8 @@ import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import MonitoringStore from '../stores/monitoring_store';
+import { timeWindows } from '../constants';
+import { getTimeDiff } from '../utils';
const sidebarAnimationDuration = 150;
let sidebarMutationObserver;
@@ -87,6 +90,10 @@ export default {
type: String,
required: true,
},
+ showTimeWindowDropdown: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -94,6 +101,7 @@ export default {
state: 'gettingStarted',
showEmptyState: true,
elWidth: 0,
+ selectedTimeWindow: '',
};
},
created() {
@@ -102,6 +110,9 @@ export default {
deploymentEndpoint: this.deploymentEndpoint,
environmentsEndpoint: this.environmentsEndpoint,
});
+
+ this.timeWindows = timeWindows;
+ this.selectedTimeWindow = this.timeWindows.eightHours;
},
beforeDestroy() {
if (sidebarMutationObserver) {
@@ -142,8 +153,13 @@ export default {
}
},
methods: {
- getGraphAlerts(graphId) {
- return this.alertData ? this.alertData[graphId] || {} : {};
+ getGraphAlerts(queries) {
+ if (!this.allAlerts) return {};
+ const metricIdsForChart = queries.map(q => q.metricId);
+ return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
+ },
+ getGraphAlertValues(queries) {
+ return Object.values(this.getGraphAlerts(queries));
},
getGraphsData() {
this.state = 'loading';
@@ -160,33 +176,73 @@ export default {
this.state = 'unableToConnect';
});
},
+ getGraphsDataWithTime(timeFrame) {
+ this.state = 'loading';
+ this.showEmptyState = true;
+ this.service
+ .getGraphsData(getTimeDiff(this.timeWindows[timeFrame]))
+ .then(data => {
+ this.store.storeMetrics(data);
+ this.selectedTimeWindow = this.timeWindows[timeFrame];
+ })
+ .catch(() => {
+ Flash(s__('Metrics|Not enough data to display'));
+ })
+ .finally(() => {
+ this.showEmptyState = false;
+ });
+ },
onSidebarMutation() {
setTimeout(() => {
this.elWidth = this.$el.clientWidth;
}, sidebarAnimationDuration);
},
+ activeTimeWindow(key) {
+ return this.timeWindows[key] === this.selectedTimeWindow;
+ },
},
};
</script>
<template>
<div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default">
- <div v-if="environmentsEndpoint" class="environments d-flex align-items-center">
- <strong>{{ s__('Metrics|Environment') }}</strong>
- <gl-dropdown
- class="prepend-left-10 js-environments-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="currentEnvironmentName"
- :disabled="store.environmentsData.length === 0"
- >
- <gl-dropdown-item
- v-for="environment in store.environmentsData"
- :key="environment.id"
- :active="environment.name === currentEnvironmentName"
- active-class="is-active"
- >{{ environment.name }}</gl-dropdown-item
+ <div
+ v-if="environmentsEndpoint"
+ class="dropdowns d-flex align-items-center justify-content-between"
+ >
+ <div class="d-flex align-items-center">
+ <strong>{{ s__('Metrics|Environment') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-environments-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="currentEnvironmentName"
+ :disabled="store.environmentsData.length === 0"
+ >
+ <gl-dropdown-item
+ v-for="environment in store.environmentsData"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ >{{ environment.name }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <div v-if="showTimeWindowDropdown" class="d-flex align-items-center">
+ <strong>{{ s__('Metrics|Show last') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-time-window-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="selectedTimeWindow"
>
- </gl-dropdown>
+ <gl-dropdown-item
+ v-for="(value, key) in timeWindows"
+ :key="key"
+ :active="activeTimeWindow(key)"
+ @click="getGraphsDataWithTime(key)"
+ >{{ value }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
</div>
<graph-group
v-for="(groupData, index) in store.groups"
@@ -199,17 +255,15 @@ export default {
:key="graphIndex"
:graph-data="graphData"
:deployment-data="store.deploymentData"
- :alert-data="getGraphAlerts(graphData.id)"
+ :thresholds="getGraphAlertValues(graphData.queries)"
:container-width="elWidth"
group-id="monitor-area-chart"
>
<alert-widget
- v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData.id"
+ v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData"
:alerts-endpoint="alertsEndpoint"
- :label="getGraphLabel(graphData)"
- :current-alerts="getQueryAlerts(graphData)"
- :custom-metric-id="graphData.id"
- :alert-data="alertData[graphData.id]"
+ :relevant-queries="graphData.queries"
+ :alerts-to-manage="getGraphAlerts(graphData.queries)"
@setAlerts="setAlerts"
/>
</monitor-area-chart>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 55ecf3b5334..9e5d0d0fd28 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const chartHeight = 300;
export const graphTypes = {
@@ -7,3 +9,14 @@ export const graphTypes = {
export const lineTypes = {
default: 'solid',
};
+
+export const timeWindows = {
+ thirtyMinutes: __('30 minutes'),
+ threeHours: __('3 hours'),
+ eightHours: __('8 hours'),
+ oneDay: __('1 day'),
+ threeDays: __('3 days'),
+ oneWeek: __('1 week'),
+};
+
+export const msPerMinute = 60000;
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 9d78b5ea110..2b4ddd7afbc 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -14,6 +14,7 @@ export default () => {
props: {
...el.dataset,
hasMetrics: parseBoolean(el.dataset.hasMetrics),
+ showTimeWindowDropdown: gon.features.metricsTimeWindow,
},
});
},
diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js
index 24b4acaf6da..5fcc2c8cfac 100644
--- a/app/assets/javascripts/monitoring/services/monitoring_service.js
+++ b/app/assets/javascripts/monitoring/services/monitoring_service.js
@@ -32,11 +32,11 @@ export default class MonitoringService {
this.environmentsEndpoint = environmentsEndpoint;
}
- getGraphsData() {
- return backOffRequest(() => axios.get(this.metricsEndpoint))
+ getGraphsData(params = {}) {
+ return backOffRequest(() => axios.get(this.metricsEndpoint, { params }))
.then(resp => resp.data)
.then(response => {
- if (!response || !response.data) {
+ if (!response || !response.data || !response.success) {
throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
}
return response.data;
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 70635059bd9..9761fe168be 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -27,10 +27,47 @@ function removeTimeSeriesNoData(queries) {
return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []);
}
+// Metrics and queries are currently stored 1:1, so `queries` is an array of length one.
+// We want to group queries onto a single chart by title & y-axis label.
+// This function will no longer be required when metrics:queries are 1:many,
+// though there is no consequence if the function stays in use.
+// @param metrics [Array<Object>]
+// Ex) [
+// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] },
+// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] },
+// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] }
+// ]
+// @return [Array<Object>]
+// Ex) [
+// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs },
+// { metricId: 2, ...query2Attrs }] },
+// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]}
+// ]
+function groupQueriesByChartInfo(metrics) {
+ const metricsByChart = metrics.reduce((accumulator, metric) => {
+ const { id, queries, ...chart } = metric;
+
+ const chartKey = `${chart.title}|${chart.y_label}`;
+ accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] };
+
+ queries.forEach(queryAttrs =>
+ accumulator[chartKey].queries.push({ metricId: id.toString(), ...queryAttrs }),
+ );
+
+ return accumulator;
+ }, {});
+
+ return Object.values(metricsByChart);
+}
+
function normalizeMetrics(metrics) {
- return metrics.map(metric => {
+ const groupedMetrics = groupQueriesByChartInfo(metrics);
+
+ return groupedMetrics.map(metric => {
const queries = metric.queries.map(query => ({
...query,
+ // custom metrics do not require a label, so we should ensure this attribute is defined
+ label: query.label || metric.y_label,
result: query.result.map(result => ({
...result,
values: result.values.map(([timestamp, value]) => [
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
new file mode 100644
index 00000000000..e379827b769
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -0,0 +1,34 @@
+import { timeWindows, msPerMinute } from './constants';
+
+/**
+ * method that converts a predetermined time window to minutes
+ * defaults to 8 hours as the default option
+ * @param {String} timeWindow - The time window to convert to minutes
+ * @returns {number} The time window in minutes
+ */
+const getTimeDifferenceMinutes = timeWindow => {
+ switch (timeWindow) {
+ case timeWindows.thirtyMinutes:
+ return 30;
+ case timeWindows.threeHours:
+ return 60 * 3;
+ case timeWindows.oneDay:
+ return 60 * 24 * 1;
+ case timeWindows.threeDays:
+ return 60 * 24 * 3;
+ case timeWindows.oneWeek:
+ return 60 * 24 * 7 * 1;
+ default:
+ return 60 * 8;
+ }
+};
+
+export const getTimeDiff = selectedTimeWindow => {
+ const end = Date.now();
+ const timeDifferenceMinutes = getTimeDifferenceMinutes(selectedTimeWindow);
+ const start = new Date(end - timeDifferenceMinutes * msPerMinute).getTime();
+
+ return { start, end };
+};
+
+export default {};
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 8987c8e3f47..0447d1f79fb 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import initIssueableApp from '~/issue_show';
+import initRelatedMergeRequestsApp from '~/related_merge_requests';
export default function() {
initIssueableApp();
+ initRelatedMergeRequestsApp();
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 2b32a6e4a98..0d5afe04e8e 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -57,6 +57,9 @@ export default {
},
},
computed: {
+ boundary() {
+ return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
+ },
status() {
return this.job && this.job.status ? this.job.status : {};
},
@@ -104,7 +107,7 @@ export default {
<div class="ci-job-component">
<gl-link
v-if="status.has_details"
- v-gl-tooltip
+ v-gl-tooltip="{ boundary, placement: 'bottom' }"
:href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
@@ -115,7 +118,7 @@ export default {
<div
v-else
- v-gl-tooltip
+ v-gl-tooltip="{ boundary, placement: 'bottom' }"
:title="tooltipText"
:class="cssClassJobName"
class="js-job-component-tooltip non-details-job-component"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 1bfab2a7fc0..02451839330 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -27,7 +27,8 @@ export default {
<template>
<span class="ci-job-name-component">
<ci-icon :status="status" />
-
- <span class="ci-status-text"> {{ name }} </span>
+ <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ {{ name }}
+ </span>
</span>
</template>
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
new file mode 100644
index 00000000000..52d4b75a3a1
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -0,0 +1,121 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { sprintf, n__, s__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import { parseIssuableData } from '../../issue_show/utils/parse_data';
+
+export default {
+ name: 'RelatedMergeRequests',
+ components: {
+ Icon,
+ GlLoadingIcon,
+ RelatedIssuableItem,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['isFetchingMergeRequests', 'mergeRequests', 'totalCount']),
+ closingMergeRequestsText() {
+ if (!this.hasClosingMergeRequest) {
+ return '';
+ }
+
+ const mrText = n__(
+ 'When this merge request is accepted',
+ 'When these merge requests are accepted',
+ this.totalCount,
+ );
+
+ return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText });
+ },
+ },
+ mounted() {
+ this.setInitialState({ apiEndpoint: this.endpoint });
+ this.fetchMergeRequests();
+ },
+ created() {
+ this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest;
+ },
+ methods: {
+ ...mapActions(['setInitialState', 'fetchMergeRequests']),
+ getAssignees(mr) {
+ if (mr.assignees) {
+ return mr.assignees;
+ }
+
+ return mr.assignee ? [mr.assignee] : [];
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
+ <div id="merge-requests" class="card-slim mt-3">
+ <div class="card-header">
+ <div class="card-title mt-0 mb-0 h5 merge-requests-title">
+ <span class="mr-1">
+ {{ __('Related merge requests') }}
+ </span>
+ <div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
+ <div class="mr-count-badge">
+ <div class="mr-count-badge-count">
+ <svg class="s16 mr-1 text-secondary">
+ <icon name="merge-request" class="mr-1 text-secondary" />
+ </svg>
+ <span class="js-items-count">{{ totalCount }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div
+ v-if="isFetchingMergeRequests"
+ class="related-related-merge-requests-icon qa-related-merge-requests-loading-icon"
+ >
+ <gl-loading-icon label="Fetching related merge requests" class="py-2" />
+ </div>
+ <ul v-else class="content-list related-items-list">
+ <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0">
+ <related-issuable-item
+ :id-key="mr.id"
+ :display-reference="mr.reference"
+ :title="mr.title"
+ :milestone="mr.milestone"
+ :assignees="getAssignees(mr)"
+ :created-at="mr.created_at"
+ :closed-at="mr.closed_at"
+ :merged-at="mr.merged_at"
+ :path="mr.web_url"
+ :state="mr.state"
+ :is-merge-request="true"
+ :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
+ path-id-separator="!"
+ />
+ </li>
+ </ul>
+ </div>
+ </div>
+ <div
+ v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
+ class="issue-closed-by-widget second-block"
+ >
+ {{ closingMergeRequestsText }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/related_merge_requests/index.js
new file mode 100644
index 00000000000..092ff1df00f
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import RelatedMergeRequests from './components/related_merge_requests.vue';
+import createStore from './store';
+
+export default function initRelatedMergeRequests() {
+ const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests');
+
+ if (relatedMergeRequestsElement) {
+ const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: relatedMergeRequestsElement,
+ components: {
+ RelatedMergeRequests,
+ },
+ store: createStore(),
+ render: createElement =>
+ createElement('related-merge-requests', {
+ props: { endpoint, projectNamespace, projectPath },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js
new file mode 100644
index 00000000000..69abeaaf7db
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/actions.js
@@ -0,0 +1,37 @@
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+
+const REQUEST_PAGE_COUNT = 100;
+
+export const setInitialState = ({ commit }, props) => {
+ commit(types.SET_INITIAL_STATE, props);
+};
+
+export const requestData = ({ commit }) => commit(types.REQUEST_DATA);
+
+export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data);
+
+export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR);
+
+export const fetchMergeRequests = ({ state, dispatch }) => {
+ dispatch('requestData');
+
+ return axios
+ .get(`${state.apiEndpoint}?per_page=${REQUEST_PAGE_COUNT}`)
+ .then(res => {
+ const { headers, data } = res;
+ const total = Number(normalizeHeaders(headers)['X-TOTAL']) || 0;
+
+ dispatch('receiveDataSuccess', { data, total });
+ })
+ .catch(() => {
+ dispatch('receiveDataError');
+ createFlash(s__('Something went wrong while fetching related merge requests.'));
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/related_merge_requests/store/index.js
new file mode 100644
index 00000000000..dcb70c22bcb
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/related_merge_requests/store/mutation_types.js
new file mode 100644
index 00000000000..31d4fe032e1
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/mutation_types.js
@@ -0,0 +1,4 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+export const REQUEST_DATA = 'REQUEST_DATA';
+export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS';
+export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR';
diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/related_merge_requests/store/mutations.js
new file mode 100644
index 00000000000..11ca28a5fb9
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/mutations.js
@@ -0,0 +1,19 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, { apiEndpoint }) {
+ state.apiEndpoint = apiEndpoint;
+ },
+ [types.REQUEST_DATA](state) {
+ state.isFetchingMergeRequests = true;
+ },
+ [types.RECEIVE_DATA_SUCCESS](state, { data, total }) {
+ state.isFetchingMergeRequests = false;
+ state.mergeRequests = data;
+ state.totalCount = total;
+ },
+ [types.RECEIVE_DATA_ERROR](state) {
+ state.isFetchingMergeRequests = false;
+ state.hasErrorFetchingMergeRequests = true;
+ },
+};
diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/related_merge_requests/store/state.js
new file mode 100644
index 00000000000..bc3468a025b
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/state.js
@@ -0,0 +1,7 @@
+export default () => ({
+ apiEndpoint: '',
+ isFetchingMergeRequests: false,
+ hasErrorFetchingMergeRequests: false,
+ mergeRequests: [],
+ totalCount: 0,
+});
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
new file mode 100644
index 00000000000..32c9d6eccb8
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -0,0 +1,146 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import dateFormat from 'dateformat';
+import { X_INTERVAL } from '../constants';
+import { validateGraphData } from '../utils';
+
+let debouncedResize;
+
+export default {
+ components: {
+ GlAreaChart,
+ },
+ inheritAttrs: false,
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: validateGraphData,
+ },
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tooltipPopoverTitle: '',
+ tooltipPopoverContent: '',
+ width: this.containerWidth,
+ };
+ },
+ computed: {
+ chartData() {
+ return this.graphData.queries.reduce((accumulator, query) => {
+ accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []);
+ return accumulator;
+ }, {});
+ },
+ extractTimeData() {
+ return this.chartData.requests.map(data => data.time);
+ },
+ generateSeries() {
+ return {
+ name: 'Invocations',
+ type: 'line',
+ data: this.chartData.requests.map(data => [data.time, data.value]),
+ symbolSize: 0,
+ };
+ },
+ getInterval() {
+ const { result } = this.graphData.queries[0];
+
+ if (result.length === 0) {
+ return 1;
+ }
+
+ const split = result[0].values.reduce(
+ (acc, pair) => (pair.value > acc ? pair.value : acc),
+ 1,
+ );
+
+ return split < X_INTERVAL ? split : X_INTERVAL;
+ },
+ chartOptions() {
+ return {
+ xAxis: {
+ name: 'time',
+ type: 'time',
+ axisLabel: {
+ formatter: date => dateFormat(date, 'h:MM TT'),
+ },
+ data: this.extractTimeData,
+ nameTextStyle: {
+ padding: [18, 0, 0, 0],
+ },
+ },
+ yAxis: {
+ name: this.yAxisLabel,
+ nameTextStyle: {
+ padding: [0, 0, 36, 0],
+ },
+ splitNumber: this.getInterval,
+ },
+ legend: {
+ formatter: this.xAxisLabel,
+ },
+ series: this.generateSeries,
+ };
+ },
+ xAxisLabel() {
+ return this.graphData.queries.map(query => query.label).join(', ');
+ },
+ yAxisLabel() {
+ const [query] = this.graphData.queries;
+ return `${this.graphData.y_label} (${query.unit})`;
+ },
+ },
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', debouncedResize);
+ },
+ created() {
+ debouncedResize = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', debouncedResize);
+ },
+ methods: {
+ formatTooltipText(params) {
+ const [seriesData] = params.seriesData;
+ this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+ this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`;
+ },
+ onResize() {
+ const { width } = this.$refs.areaChart.$el.getBoundingClientRect();
+ this.width = width;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-graph">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ </div>
+ <gl-area-chart
+ ref="areaChart"
+ v-bind="$attrs"
+ :data="[]"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :width="width"
+ :include-legend-avg-max="false"
+ >
+ <template slot="tooltipTitle">
+ {{ tooltipPopoverTitle }}
+ </template>
+ <template slot="tooltipContent">
+ {{ tooltipPopoverContent }}
+ </template>
+ </gl-area-chart>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index 4f89ad69129..b8906cfca4e 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -1,39 +1,77 @@
<script>
+import _ from 'underscore';
+import { mapState, mapActions, mapGetters } from 'vuex';
import PodBox from './pod_box.vue';
import Url from './url.vue';
+import AreaChart from './area.vue';
+import MissingPrometheus from './missing_prometheus.vue';
export default {
components: {
PodBox,
Url,
+ AreaChart,
+ MissingPrometheus,
},
props: {
func: {
type: Object,
required: true,
},
+ hasPrometheus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ elWidth: 0,
+ };
},
computed: {
name() {
return this.func.name;
},
description() {
- return this.func.description;
+ return _.isString(this.func.description) ? this.func.description : '';
},
funcUrl() {
return this.func.url;
},
podCount() {
- return this.func.podcount || 0;
+ return Number(this.func.podcount) || 0;
},
+ ...mapState(['graphData', 'hasPrometheusData']),
+ ...mapGetters(['hasPrometheusMissingData']),
+ },
+ created() {
+ this.fetchMetrics({
+ metricsPath: this.func.metricsUrl,
+ hasPrometheus: this.hasPrometheus,
+ });
+ },
+ mounted() {
+ this.elWidth = this.$el.clientWidth;
+ },
+ methods: {
+ ...mapActions(['fetchMetrics']),
},
};
</script>
<template>
<section id="serverless-function-details">
- <h3>{{ name }}</h3>
- <div class="append-bottom-default">
+ <h3 class="serverless-function-name">{{ name }}</h3>
+ <div class="append-bottom-default serverless-function-description">
<div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
<url :uri="funcUrl" />
@@ -52,5 +90,13 @@ export default {
</p>
</div>
<div v-else><p>No pods loaded at this time.</p></div>
+
+ <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" />
+ <missing-prometheus
+ v-if="!hasPrometheus || hasPrometheusMissingData"
+ :help-path="helpPath"
+ :clusters-path="clustersPath"
+ :missing-data="hasPrometheusMissingData"
+ />
</section>
</template>
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 773d18781fd..4b3bb078eae 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,4 +1,5 @@
<script>
+import _ from 'underscore';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import Url from './url.vue';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -19,6 +20,10 @@ export default {
return this.func.name;
},
description() {
+ if (!_.isString(this.func.description)) {
+ return '';
+ }
+
const desc = this.func.description.split('\n');
if (desc.length > 1) {
return desc[1];
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 4bde409f906..f9b4e789563 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,5 +1,6 @@
<script>
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
@@ -9,14 +10,9 @@ export default {
EnvironmentRow,
FunctionRow,
EmptyState,
- GlSkeletonLoading,
+ GlLoadingIcon,
},
props: {
- functions: {
- type: Object,
- required: true,
- default: () => ({}),
- },
installed: {
type: Boolean,
required: true,
@@ -29,17 +25,23 @@ export default {
type: String,
required: true,
},
- loadingData: {
- type: Boolean,
- required: false,
- default: true,
- },
- hasFunctionData: {
- type: Boolean,
- required: false,
- default: true,
+ statusPath: {
+ type: String,
+ required: true,
},
},
+ computed: {
+ ...mapState(['isLoading', 'hasFunctionData']),
+ ...mapGetters(['getFunctions']),
+ },
+ created() {
+ this.fetchFunctions({
+ functionsPath: this.statusPath,
+ });
+ },
+ methods: {
+ ...mapActions(['fetchFunctions']),
+ },
};
</script>
@@ -47,14 +49,16 @@ export default {
<section id="serverless-functions">
<div v-if="installed">
<div v-if="hasFunctionData">
- <template v-if="loadingData">
- <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
- </template>
+ <gl-loading-icon
+ v-if="isLoading"
+ :size="2"
+ class="prepend-top-default append-bottom-default"
+ />
<template v-else>
<div class="groups-list-tree-container">
<ul class="content-list group-list-tree">
<environment-row
- v-for="(env, index) in functions"
+ v-for="(env, index) in getFunctions"
:key="index"
:env="env"
:env-name="index"
diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue
new file mode 100644
index 00000000000..6c19434f202
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlButton, GlLink } from '@gitlab/ui';
+import { s__ } from '../../locale';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ },
+ props: {
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ missingData: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ missingStateClass() {
+ return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state';
+ },
+ prometheusHelpPath() {
+ return `${this.helpPath}#prometheus-support`;
+ },
+ description() {
+ return this.missingData
+ ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`)
+ : s__(
+ `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row" :class="missingStateClass">
+ <div class="col-12">
+ <div class="text-content">
+ <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4>
+ <p class="state-description">
+ {{ description }}
+ <gl-link :href="prometheusHelpPath">{{
+ s__(`ServerlessDetails|More information`)
+ }}</gl-link
+ >.
+ </p>
+
+ <div v-if="!missingData" class="text-left">
+ <gl-button :href="clustersPath" variant="success">
+ {{ s__('ServerlessDetails|Install Prometheus') }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js
new file mode 100644
index 00000000000..35f77205f2c
--- /dev/null
+++ b/app/assets/javascripts/serverless/constants.js
@@ -0,0 +1,3 @@
+export const MAX_REQUESTS = 3; // max number of times to retry
+
+export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis
diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js
index 47a510d5fb5..2d3f086ffee 100644
--- a/app/assets/javascripts/serverless/serverless_bundle.js
+++ b/app/assets/javascripts/serverless/serverless_bundle.js
@@ -1,13 +1,7 @@
-import Visibility from 'visibilityjs';
import Vue from 'vue';
-import { s__ } from '../locale';
-import Flash from '../flash';
-import Poll from '../lib/utils/poll';
-import ServerlessStore from './stores/serverless_store';
-import ServerlessDetailsStore from './stores/serverless_details_store';
-import GetFunctionsService from './services/get_functions_service';
import Functions from './components/functions.vue';
import FunctionDetails from './components/function_details.vue';
+import { createStore } from './store';
export default class Serverless {
constructor() {
@@ -19,10 +13,12 @@ export default class Serverless {
serviceUrl,
serviceNamespace,
servicePodcount,
+ serviceMetricsUrl,
+ prometheus,
+ clustersPath,
+ helpPath,
} = document.querySelector('.js-serverless-function-details-page').dataset;
const el = document.querySelector('#js-serverless-function-details');
- this.store = new ServerlessDetailsStore();
- const { store } = this;
const service = {
name: serviceName,
@@ -31,20 +27,19 @@ export default class Serverless {
url: serviceUrl,
namespace: serviceNamespace,
podcount: servicePodcount,
+ metricsUrl: serviceMetricsUrl,
};
- this.store.updateDetailedFunction(service);
this.functionDetails = new Vue({
el,
- data() {
- return {
- state: store.state,
- };
- },
+ store: createStore(),
render(createElement) {
return createElement(FunctionDetails, {
props: {
- func: this.state.functionDetail,
+ func: service,
+ hasPrometheus: prometheus !== undefined,
+ clustersPath,
+ helpPath,
},
});
},
@@ -54,95 +49,27 @@ export default class Serverless {
'.js-serverless-functions-page',
).dataset;
- this.service = new GetFunctionsService(statusPath);
- this.knativeInstalled = installed !== undefined;
- this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
- this.initServerless();
- this.functionLoadCount = 0;
-
- if (statusPath && this.knativeInstalled) {
- this.initPolling();
- }
- }
- }
-
- initServerless() {
- const { store } = this;
- const el = document.querySelector('#js-serverless-functions');
-
- this.functions = new Vue({
- el,
- data() {
- return {
- state: store.state,
- };
- },
- render(createElement) {
- return createElement(Functions, {
- props: {
- functions: this.state.functions,
- installed: this.state.installed,
- clustersPath: this.state.clustersPath,
- helpPath: this.state.helpPath,
- loadingData: this.state.loadingData,
- hasFunctionData: this.state.hasFunctionData,
- },
- });
- },
- });
- }
-
- initPolling() {
- this.poll = new Poll({
- resource: this.service,
- method: 'fetchData',
- successCallback: data => this.handleSuccess(data),
- errorCallback: () => Serverless.handleError(),
- });
-
- if (!Visibility.hidden()) {
- this.poll.makeRequest();
- } else {
- this.service
- .fetchData()
- .then(data => this.handleSuccess(data))
- .catch(() => Serverless.handleError());
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden() && !this.destroyed) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
- }
-
- handleSuccess(data) {
- if (data.status === 200) {
- this.store.updateFunctionsFromServer(data.data);
- this.store.updateLoadingState(false);
- } else if (data.status === 204) {
- /* Time out after 3 attempts to retrieve data */
- this.functionLoadCount += 1;
- if (this.functionLoadCount === 3) {
- this.poll.stop();
- this.store.toggleNoFunctionData();
- }
+ const el = document.querySelector('#js-serverless-functions');
+ this.functions = new Vue({
+ el,
+ store: createStore(),
+ render(createElement) {
+ return createElement(Functions, {
+ props: {
+ installed: installed !== undefined,
+ clustersPath,
+ helpPath,
+ statusPath,
+ },
+ });
+ },
+ });
}
}
- static handleError() {
- Flash(s__('Serverless|An error occurred while retrieving serverless components'));
- }
-
destroy() {
this.destroyed = true;
- if (this.poll) {
- this.poll.stop();
- }
-
this.functions.$destroy();
this.functionDetails.$destroy();
}
diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js
deleted file mode 100644
index 303b42dc66c..00000000000
--- a/app/assets/javascripts/serverless/services/get_functions_service.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default class GetFunctionsService {
- constructor(endpoint) {
- this.endpoint = endpoint;
- }
-
- fetchData() {
- return axios.get(this.endpoint);
- }
-}
diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js
new file mode 100644
index 00000000000..826501c9022
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/actions.js
@@ -0,0 +1,113 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import statusCodes from '~/lib/utils/http_status';
+import { backOff } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import { MAX_REQUESTS } from '../constants';
+
+export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING);
+export const receiveFunctionsSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_FUNCTIONS_SUCCESS, data);
+export const receiveFunctionsNoDataSuccess = ({ commit }) =>
+ commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS);
+export const receiveFunctionsError = ({ commit }, error) =>
+ commit(types.RECEIVE_FUNCTIONS_ERROR, error);
+
+export const receiveMetricsSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_METRICS_SUCCESS, data);
+export const receiveMetricsNoPrometheus = ({ commit }) =>
+ commit(types.RECEIVE_METRICS_NO_PROMETHEUS);
+export const receiveMetricsNoDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data);
+export const receiveMetricsError = ({ commit }, error) =>
+ commit(types.RECEIVE_METRICS_ERROR, error);
+
+export const fetchFunctions = ({ dispatch }, { functionsPath }) => {
+ let retryCount = 0;
+
+ dispatch('requestFunctionsLoading');
+
+ backOff((next, stop) => {
+ axios
+ .get(functionsPath)
+ .then(response => {
+ if (response.status === statusCodes.NO_CONTENT) {
+ retryCount += 1;
+ if (retryCount < MAX_REQUESTS) {
+ next();
+ } else {
+ stop(null);
+ }
+ } else {
+ stop(response.data);
+ }
+ })
+ .catch(stop);
+ })
+ .then(data => {
+ if (data !== null) {
+ dispatch('receiveFunctionsSuccess', data);
+ } else {
+ dispatch('receiveFunctionsNoDataSuccess');
+ }
+ })
+ .catch(error => {
+ dispatch('receiveFunctionsError', error);
+ createFlash(error);
+ });
+};
+
+export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => {
+ let retryCount = 0;
+
+ if (!hasPrometheus) {
+ dispatch('receiveMetricsNoPrometheus');
+ return;
+ }
+
+ backOff((next, stop) => {
+ axios
+ .get(metricsPath)
+ .then(response => {
+ if (response.status === statusCodes.NO_CONTENT) {
+ retryCount += 1;
+ if (retryCount < MAX_REQUESTS) {
+ next();
+ } else {
+ dispatch('receiveMetricsNoDataSuccess');
+ stop(null);
+ }
+ } else {
+ stop(response.data);
+ }
+ })
+ .catch(stop);
+ })
+ .then(data => {
+ if (data === null) {
+ return;
+ }
+
+ const updatedMetric = data.metrics;
+ const queries = data.metrics.queries.map(query => ({
+ ...query,
+ result: query.result.map(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => ({
+ time: new Date(timestamp * 1000).toISOString(),
+ value: Number(value),
+ })),
+ })),
+ }));
+
+ updatedMetric.queries = queries;
+ dispatch('receiveMetricsSuccess', updatedMetric);
+ })
+ .catch(error => {
+ dispatch('receiveMetricsError', error);
+ createFlash(error);
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js
new file mode 100644
index 00000000000..071f663d9d2
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/getters.js
@@ -0,0 +1,10 @@
+import { translate } from '../utils';
+
+export const hasPrometheusMissingData = state => state.hasPrometheus && !state.hasPrometheusData;
+
+// Convert the function list into a k/v grouping based on the environment scope
+
+export const getFunctions = state => translate(state.functions);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js
new file mode 100644
index 00000000000..5f72060633e
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state: createState(),
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js
new file mode 100644
index 00000000000..25b2f7ac38a
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutation_types.js
@@ -0,0 +1,9 @@
+export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING';
+export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS';
+export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS';
+export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR';
+
+export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS';
+export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS';
+export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS';
+export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR';
diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js
new file mode 100644
index 00000000000..991f32a275d
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutations.js
@@ -0,0 +1,38 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_FUNCTIONS_LOADING](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) {
+ state.functions = data;
+ state.isLoading = false;
+ state.hasFunctionData = true;
+ },
+ [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) {
+ state.isLoading = false;
+ state.hasFunctionData = false;
+ },
+ [types.RECEIVE_FUNCTIONS_ERROR](state, error) {
+ state.error = error;
+ state.hasFunctionData = false;
+ state.isLoading = false;
+ },
+ [types.RECEIVE_METRICS_SUCCESS](state, data) {
+ state.isLoading = false;
+ state.hasPrometheusData = true;
+ state.graphData = data;
+ },
+ [types.RECEIVE_METRICS_NODATA_SUCCESS](state) {
+ state.isLoading = false;
+ state.hasPrometheusData = false;
+ },
+ [types.RECEIVE_METRICS_ERROR](state, error) {
+ state.hasPrometheusData = false;
+ state.error = error;
+ },
+ [types.RECEIVE_METRICS_NO_PROMETHEUS](state) {
+ state.hasPrometheusData = false;
+ state.hasPrometheus = false;
+ },
+};
diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js
new file mode 100644
index 00000000000..afc3f37d7ba
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/state.js
@@ -0,0 +1,13 @@
+export default () => ({
+ error: null,
+ isLoading: true,
+
+ // functions
+ functions: [],
+ hasFunctionData: true,
+
+ // function_details
+ hasPrometheus: true,
+ hasPrometheusData: false,
+ graphData: {},
+});
diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js
deleted file mode 100644
index 5394d2cded1..00000000000
--- a/app/assets/javascripts/serverless/stores/serverless_details_store.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default class ServerlessDetailsStore {
- constructor() {
- this.state = {
- functionDetail: {},
- };
- }
-
- updateDetailedFunction(func) {
- this.state.functionDetail = func;
- }
-}
diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
deleted file mode 100644
index 816d55a03f9..00000000000
--- a/app/assets/javascripts/serverless/stores/serverless_store.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export default class ServerlessStore {
- constructor(knativeInstalled = false, clustersPath, helpPath) {
- this.state = {
- functions: {},
- hasFunctionData: true,
- loadingData: true,
- installed: knativeInstalled,
- clustersPath,
- helpPath,
- };
- }
-
- updateFunctionsFromServer(upstreamFunctions = []) {
- this.state.functions = upstreamFunctions.reduce((rv, func) => {
- const envs = rv;
- envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
-
- return envs;
- }, {});
- }
-
- updateLoadingState(loadingData) {
- this.state.loadingData = loadingData;
- }
-
- toggleNoFunctionData() {
- this.state.hasFunctionData = false;
- }
-}
diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js
new file mode 100644
index 00000000000..8b9e96ce9aa
--- /dev/null
+++ b/app/assets/javascripts/serverless/utils.js
@@ -0,0 +1,23 @@
+// Validate that the object coming in has valid query details and results
+export const validateGraphData = data =>
+ data.queries &&
+ Array.isArray(data.queries) &&
+ data.queries.filter(query => {
+ if (Array.isArray(query.result)) {
+ return query.result.filter(res => Array.isArray(res.values)).length === query.result.length;
+ }
+
+ return false;
+ }).length === data.queries.length;
+
+export const translate = functions =>
+ functions.reduce(
+ (acc, func) =>
+ Object.assign(acc, {
+ [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]),
+ }),
+ {},
+ );
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
index b25aebd7c98..2b5b2269ec8 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
@@ -40,12 +40,15 @@ export default {
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.stopDrag);
- this.$refs.dragger.removeEventListener('mousedown', this.startDrag);
+ document.body.removeEventListener('touchend', this.stopDrag);
+ document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
- const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left;
+ const moveX = e.pageX || e.touches[0].pageX;
+ const left = moveX - this.$refs.dragTrack.getBoundingClientRect().left;
const dragTrackWidth =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
@@ -60,11 +63,13 @@ export default {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
+ document.body.addEventListener('touchmove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
prepareOnionSkin() {
if (this.onionOldImgInfo && this.onionNewImgInfo) {
@@ -82,6 +87,7 @@ export default {
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
document.body.addEventListener('mouseup', this.stopDrag);
+ document.body.addEventListener('touchend', this.stopDrag);
}
},
onionNewImgLoaded(imgInfo) {
@@ -140,7 +146,14 @@ export default {
</div>
<div class="controls">
<div class="transparent"></div>
- <div ref="dragTrack" class="drag-track" @mousedown="startDrag" @mouseup="stopDrag">
+ <div
+ ref="dragTrack"
+ class="drag-track"
+ @mousedown="startDrag"
+ @mouseup="stopDrag"
+ @touchstart="startDrag"
+ @touchend="stopDrag"
+ >
<div ref="dragger" :style="{ left: onionDraggerPixelPos }" class="dragger"></div>
</div>
<div class="opaque"></div>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
index eddafc759a2..ad3b3b81ac5 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
@@ -46,6 +46,8 @@ export default {
window.removeEventListener('resize', this.resizeThrottled, false);
document.body.removeEventListener('mouseup', this.stopDrag);
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchend', this.stopDrag);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
mounted() {
window.addEventListener('resize', this.resize, false);
@@ -54,7 +56,8 @@ export default {
dragMove(e) {
if (!this.dragging) return;
- let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left;
+ const moveX = e.pageX || e.touches[0].pageX;
+ let leftValue = moveX - this.$refs.swipeFrame.getBoundingClientRect().left;
const spaceLeft = 20;
const { clientWidth } = this.$refs.swipeFrame;
if (leftValue <= 0) {
@@ -69,10 +72,12 @@ export default {
startDrag() {
this.dragging = true;
document.body.addEventListener('mousemove', this.dragMove);
+ document.body.addEventListener('touchmove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
prepareSwipe() {
if (this.swipeOldImgInfo && this.swipeNewImgInfo) {
@@ -83,6 +88,7 @@ export default {
Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2;
document.body.addEventListener('mouseup', this.stopDrag);
+ document.body.addEventListener('touchend', this.stopDrag);
}
},
swipeNewImgLoaded(imgInfo) {
@@ -143,6 +149,8 @@ export default {
class="swipe-bar"
@mousedown="startDrag"
@mouseup="stopDrag"
+ @touchstart="startDrag"
+ @touchend="stopDrag"
>
<span class="top-handle"></span> <span class="bottom-handle"></span>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index 27cfa8abb24..d4d18614f93 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -1,15 +1,17 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
-import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
-import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
-import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin';
+import { sprintf } from '~/locale';
+import IssueMilestone from '../../components/issue/issue_milestone.vue';
+import IssueAssignees from '../../components/issue/issue_assignees.vue';
+import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
+import CiIcon from '../ci_icon.vue';
export default {
name: 'IssueItem',
components: {
IssueMilestone,
IssueAssignees,
+ CiIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -27,9 +29,9 @@ export default {
return sprintf(
'<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
{
- state: this.isOpen ? __('Opened') : __('Closed'),
- timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords,
- timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp,
+ state: this.stateText,
+ timeInWords: this.stateTimeInWords,
+ timestamp: this.stateTimestamp,
},
);
},
@@ -84,6 +86,11 @@ export default {
{{ pathIdSeparator }}{{ itemId }}
</div>
<div class="item-meta-child d-flex align-items-center">
+ <span v-if="hasPipeline" class="mr-ci-status pr-2">
+ <a :href="pipelineStatus.details_path">
+ <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
+ </a>
+ </span>
<issue-milestone
v-if="hasMilestone"
:milestone="milestone"
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index 455ae832234..8e0e4baa75a 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import { sprintf, __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
@@ -58,6 +59,11 @@ const mixins = {
required: false,
default: '',
},
+ mergedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
milestone: {
type: Object,
required: false,
@@ -83,6 +89,16 @@ const mixins = {
required: false,
default: false,
},
+ isMergeRequest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pipelineStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
components: {
icon,
@@ -95,12 +111,18 @@ const mixins = {
hasState() {
return this.state && this.state.length > 0;
},
+ hasPipeline() {
+ return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
+ },
isOpen() {
return this.state === 'opened';
},
isClosed() {
return this.state === 'closed';
},
+ isMerged() {
+ return this.state === 'merged';
+ },
hasTitle() {
return this.title.length > 0;
},
@@ -108,9 +130,17 @@ const mixins = {
return !_.isEmpty(this.milestone);
},
iconName() {
+ if (this.isMergeRequest && this.isMerged) {
+ return 'merge';
+ }
+
return this.isOpen ? 'issue-open-m' : 'issue-close';
},
iconClass() {
+ if (this.isMergeRequest && this.isClosed) {
+ return 'merge-request-status closed issue-token-state-icon-closed';
+ }
+
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
},
computedLinkElementType() {
@@ -131,12 +161,44 @@ const mixins = {
createdAtTimestamp() {
return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
},
+ mergedAtTimestamp() {
+ return this.mergedAt ? formatDate(new Date(this.mergedAt)) : '';
+ },
+ mergedAtInWords() {
+ return this.mergedAt ? this.timeFormated(this.mergedAt) : '';
+ },
closedAtInWords() {
return this.closedAt ? this.timeFormated(this.closedAt) : '';
},
closedAtTimestamp() {
return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
},
+ stateText() {
+ if (this.isMerged) {
+ return __('Merged');
+ }
+
+ return this.isOpen ? __('Opened') : __('Closed');
+ },
+ stateTimeInWords() {
+ if (this.isMerged) {
+ return this.mergedAtInWords;
+ }
+
+ return this.isOpen ? this.createdAtInWords : this.closedAtInWords;
+ },
+ stateTimestamp() {
+ if (this.isMerged) {
+ return this.mergedAtTimestamp;
+ }
+
+ return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp;
+ },
+ pipelineStatusTooltip() {
+ return this.hasPipeline
+ ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label })
+ : '';
+ },
},
methods: {
onRemoveRequest() {
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 695ce014659..ab8f397f3a0 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -139,6 +139,7 @@
@include btn-white;
color: $gl-text-color;
+ white-space: nowrap;
&:focus:active {
outline: 0;
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index d72597a6147..db6c107210e 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -428,6 +428,7 @@ img.emoji {
.mw-460 { max-width: 460px; }
.mw-6em { max-width: 6em; }
+.mw-70p { max-width: 70%; }
.min-height-0 { min-height: 0; }
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index e2946e79f9d..da1f196afdb 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -662,6 +662,11 @@ $feature-toggle-text-color: #fff;
$feature-toggle-color-enabled: #4a8bee;
/*
+ * Monitor Charts
+ */
+$chart-tooltip-max-width: 512px;
+
+/*
Stat Graph
*/
$stat-graph-common-bg: #f3f3f3;
@@ -805,3 +810,8 @@ $compare-branches-sticky-header-height: 68px;
- Issue: https://gitlab.com/gitlab-org/design.gitlab.com/issues/242
*/
$enable-validation-icons: false;
+
+/*
+Licenses
+*/
+$license-header-cell-width: 150px;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 0eb854ecf98..8e1ee51628d 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -204,7 +204,7 @@
}
.prometheus-graphs {
- .environments {
+ .dropdowns {
.dropdown-menu-toggle {
svg {
position: absolute;
diff --git a/app/assets/stylesheets/pages/monitor.scss b/app/assets/stylesheets/pages/monitor.scss
new file mode 100644
index 00000000000..25ff5abd774
--- /dev/null
+++ b/app/assets/stylesheets/pages/monitor.scss
@@ -0,0 +1,5 @@
+.chart-tooltip > .popover {
+ min-width: 0;
+ width: max-content;
+ max-width: $chart-tooltip-max-width;
+}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index bb08440fda8..3e6aa43175e 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -712,15 +712,9 @@
top: 8px;
}
+.ci-build-text,
.ci-status-text {
- max-width: 110px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: bottom;
- display: inline-block;
- position: relative;
- font-weight: $gl-font-weight-normal;
+ font-weight: 200;
}
@mixin mini-pipeline-graph-color(
@@ -912,26 +906,6 @@ button.mini-pipeline-graph-dropdown-toggle {
flex: 1;
}
- // build name
- .ci-build-text,
- .ci-status-text {
- font-weight: 200;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 70%;
- margin-left: 2px;
- display: inline-block;
-
- &::after {
- content: '';
- display: block;
- }
-
- @include media-breakpoint-down(xs) {
- max-width: 60%;
- }
- }
.ci-status-icon {
@extend .append-right-8;
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index e4ed685bd1b..7b0538dca20 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -232,7 +232,7 @@
}
}
-.settings-flex-row {
+.content-list > .settings-flex-row {
display: flex;
align-items: center;
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 7e072788fc9..b04ffe80db4 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -9,6 +9,8 @@ class Projects::BlobController < Projects::ApplicationController
include ActionView::Helpers::SanitizeHelper
prepend_before_action :authenticate_user!, only: [:edit]
+ around_action :allow_gitaly_ref_name_caching, only: [:show]
+
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index a49ede04de7..f540ccee386 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -7,6 +7,7 @@ class Projects::CommitsController < Projects::ApplicationController
include RendersCommits
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
+ around_action :allow_gitaly_ref_name_caching
before_action :whitelist_query_limiting, except: :commits_root
before_action :require_non_empty_project
before_action :assign_ref_vars, except: :commits_root
@@ -14,8 +15,6 @@ class Projects::CommitsController < Projects::ApplicationController
before_action :validate_ref!, except: :commits_root
before_action :set_commits, except: :commits_root
- around_action :allow_gitaly_ref_name_caching
-
def commits_root
redirect_to project_commits_path(@project, @project.default_branch)
end
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index b97fbe19bbf..b3447812ef2 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -4,6 +4,8 @@ class Projects::RefsController < Projects::ApplicationController
include ExtractsPath
include TreeHelper
+ around_action :allow_gitaly_ref_name_caching, only: [:logs_tree]
+
before_action :require_non_empty_project
before_action :validate_ref_id
before_action :assign_ref_vars
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 39eca10134f..8c3d141c888 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -7,19 +7,14 @@ module Projects
before_action :authorize_read_cluster!
- INDEX_PRIMING_INTERVAL = 15_000
- INDEX_POLLING_INTERVAL = 60_000
-
def index
respond_to do |format|
format.json do
functions = finder.execute
if functions.any?
- Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
render json: serialize_function(functions)
else
- Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
head :no_content
end
end
@@ -33,6 +28,8 @@ module Projects
def show
@service = serialize_function(finder.service(params[:environment_id], params[:id]))
+ @prometheus = finder.has_prometheus?(params[:environment_id])
+
return not_found if @service.nil?
respond_to do |format|
@@ -44,10 +41,24 @@ module Projects
end
end
+ def metrics
+ respond_to do |format|
+ format.json do
+ metrics = finder.invocation_metrics(params[:environment_id], params[:id])
+
+ if metrics.nil?
+ head :no_content
+ else
+ render json: metrics
+ end
+ end
+ end
+ end
+
private
def finder
- Projects::Serverless::FunctionsFinder.new(project.clusters)
+ Projects::Serverless::FunctionsFinder.new(project)
end
def serialize_function(function)
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 90d53aa08ea..7509cc29a76 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -6,6 +6,8 @@ class Projects::TreeController < Projects::ApplicationController
include CreatesCommit
include ActionView::Helpers::SanitizeHelper
+ around_action :allow_gitaly_ref_name_caching, only: [:show]
+
before_action :require_non_empty_project, except: [:new, :create]
before_action :assign_ref_vars
before_action :assign_dir_vars, only: [:create_dir]
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 94258e0e90a..89dc43a48a1 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -10,6 +10,8 @@ class ProjectsController < Projects::ApplicationController
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
before_action :whitelist_query_limiting, only: [:create]
before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve]
before_action :redirect_git_extension, only: [:show]
@@ -26,8 +28,6 @@ class ProjectsController < Projects::ApplicationController
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
before_action :event_filter, only: [:show, :activity]
- around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
-
layout :determine_layout
def index
diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb
index 2f2816a4a08..d9802598c64 100644
--- a/app/finders/projects/serverless/functions_finder.rb
+++ b/app/finders/projects/serverless/functions_finder.rb
@@ -3,8 +3,9 @@
module Projects
module Serverless
class FunctionsFinder
- def initialize(clusters)
- @clusters = clusters
+ def initialize(project)
+ @clusters = project.clusters
+ @project = project
end
def execute
@@ -19,6 +20,23 @@ module Projects
knative_service(environment_scope, name)&.first
end
+ def invocation_metrics(environment_scope, name)
+ return unless prometheus_adapter&.can_query?
+
+ cluster = clusters_with_knative_installed.preload_knative.find do |c|
+ environment_scope == c.environment_scope
+ end
+
+ func = ::Serverless::Function.new(@project, name, cluster.platform_kubernetes&.actual_namespace)
+ prometheus_adapter.query(:knative_invocation, func)
+ end
+
+ def has_prometheus?(environment_scope)
+ clusters_with_knative_installed.preload_knative.to_a.any? do |cluster|
+ environment_scope == cluster.environment_scope && cluster.application_prometheus_available?
+ end
+ end
+
private
def knative_service(environment_scope, name)
@@ -55,6 +73,12 @@ module Projects
def clusters_with_knative_installed
@clusters.with_knative_installed
end
+
+ # rubocop: disable CodeReuse/ServiceClass
+ def prometheus_adapter
+ @prometheus_adapter ||= ::Prometheus::AdapterService.new(@project).prometheus_adapter
+ end
+ # rubocop: enable CodeReuse/ServiceClass
end
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 0622cdfc196..52c49498e9b 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -277,6 +277,8 @@ module IssuablesHelper
initialTaskStatus: issuable.task_status
}
+ data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue)
+
if parent.is_a?(Group)
data[:groupPath] = parent.path
else
diff --git a/app/models/serverless/function.rb b/app/models/serverless/function.rb
new file mode 100644
index 00000000000..5d4f8e0c9e2
--- /dev/null
+++ b/app/models/serverless/function.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Serverless
+ class Function
+ attr_accessor :name, :namespace
+
+ def initialize(project, name, namespace)
+ @project = project
+ @name = name
+ @namespace = namespace
+ end
+
+ def id
+ @project.id.to_s + "/" + @name + "/" + @namespace
+ end
+
+ def self.find_by_id(id)
+ array = id.split("/")
+ project = Project.find_by_id(array[0])
+ name = array[1]
+ namespace = array[2]
+
+ self.new(project, name, namespace)
+ end
+ end
+end
diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb
index c98dc1a1c4a..a46f8af1466 100644
--- a/app/serializers/projects/serverless/service_entity.rb
+++ b/app/serializers/projects/serverless/service_entity.rb
@@ -32,6 +32,13 @@ module Projects
service.dig('podcount')
end
+ expose :metrics_url do |service|
+ project_serverless_metrics_path(
+ request.project,
+ service.dig('environment_scope'),
+ service.dig('metadata', 'name')) + ".json"
+ end
+
expose :created_at do |service|
service.dig('metadata', 'creationTimestamp')
end
diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb
index 4e92353a13c..8d08f0cda94 100644
--- a/app/services/error_tracking/list_projects_service.rb
+++ b/app/services/error_tracking/list_projects_service.rb
@@ -15,8 +15,8 @@ module ErrorTracking
result = setting.list_sentry_projects
rescue Sentry::Client::Error => e
return error(e.message, :bad_request)
- rescue Sentry::Client::SentryError => e
- return error(e.message, :unprocessable_entity)
+ rescue Sentry::Client::MissingKeysError => e
+ return error(e.message, :internal_server_error)
end
success(projects: result[:projects])
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index c787d7420b7..3b6fc85e70e 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -8,12 +8,12 @@
- if status.has_details?
= link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
- else
.menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
- if status.has_action?
= link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml
index 935581643cd..9082bfc409d 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -2,34 +2,29 @@
.form-group
= label_tag :merge_method_merge, class: 'label-bold' do
- Merge method
+ = _('Merge method')
.form-check
= form.radio_button :merge_method, :merge, class: "js-merge-method-radio form-check-input"
= label_tag :project_merge_method_merge, class: 'form-check-label' do
- %strong Merge commit
- %br
- %span.descr
- A merge commit is created for every merge, and merging is allowed as long as there are no conflicts.
+ .mb-3
+ = _('Merge commit')
+ .text-secondary
+ = _('A merge commit is created for every merge, and merging is allowed as long as there are no conflicts.')
- .form-check
- = form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input"
- = label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do
- %strong Merge commit with semi-linear history
- %br
- %span.descr
- A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible.
- This way you could make sure that if this merge request would build, after merging to target branch it would also build.
- %br
- %span.descr
- When fast-forward merge is not possible, the user is given the option to rebase.
+.form-check
+ = form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input"
+ = label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do
+ .mb-3
+ = _('Merge commit with semi-linear history')
+ .text-secondary
+ = _('A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible. This way you could make sure that if this merge request would build, after merging to target branch it would also build.')
+ .text-secondary
+ = _('When fast-forward merge is not possible, the user is given the option to rebase.')
- .form-check
- = form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff form-check-input"
- = label_tag :project_merge_method_ff, class: 'form-check-label' do
- %strong Fast-forward merge
- %br
- %span.descr
- No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.
- %br
- %span.descr
- When fast-forward merge is not possible, the user is given the option to rebase.
+.form-check
+ = form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff form-check-input"
+ = label_tag :project_merge_method_ff, class: 'form-check-label' do
+ .mb-3
+ = _('Fast-forward merge')
+ .text-secondary
+ = _('No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded. When fast-forward merge is not possible, the user is given the option to rebase.')
diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml
index 6ac2e06afa5..3a9f7ca42db 100644
--- a/app/views/projects/_merge_request_merge_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_settings.html.haml
@@ -4,21 +4,21 @@
.form-check.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) }
= form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input'
= form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
- %strong Only allow merge requests to be merged if the pipeline succeeds
- %br
- %span.descr
- Pipelines need to be configured to enable this feature.
- = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank'
+ .mb-3
+ = _('Only allow merge requests to be merged if the pipeline succeeds')
+ .text-secondary
+ = _('Pipelines need to be configured to enable this feature.')
+ = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank'
= render_if_exists 'projects/merge_pipelines_settings', form: form
.form-check
= form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input'
= form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do
- %strong Only allow merge requests to be merged if all discussions are resolved
+ %p= _('Only allow merge requests to be merged if all discussions are resolved')
.form-check
= form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input'
= form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do
- %strong Automatically resolve merge request diff discussions when they become outdated
+ %p= _('Automatically resolve merge request diff discussions when they become outdated')
.form-check
= form.check_box :printing_merge_request_link_enabled, class: 'form-check-input'
= form.label :printing_merge_request_link_enabled, class: 'form-check-label' do
- %strong Show link to create/view merge request when pushing from the command line
+ %p= _('Show link to create/view merge request when pushing from the command line')
diff --git a/app/views/projects/issues/_closed_by_box.html.haml b/app/views/projects/issues/_closed_by_box.html.haml
deleted file mode 100644
index 38469ed4774..00000000000
--- a/app/views/projects/issues/_closed_by_box.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.issue-closed-by-widget.second-block
- - pluralized_mr_this = merge_request_count > 1 ? "these" : "this"
- - pluralized_mr_is = merge_request_count > 1 ? "are" : "is"
- When #{pluralized_mr_this} merge #{"request".pluralize(merge_request_count)} #{pluralized_mr_is} accepted, this issue will be closed automatically.
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
deleted file mode 100644
index 6a66c2e57cc..00000000000
--- a/app/views/projects/issues/_merge_requests.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- if @merge_requests.any?
- .card-slim.mt-3
- .card-header
- %h2.card-title.mt-0.mb-0.h5.merge-requests-title
- %span.mr-1.bold
- = _('Related merge requests')
- .d-inline-flex.lh-100.align-middle
- .mr-count-badge
- .mr-count-badge-count
- = sprite_icon('merge-request', size: 16, css_class: 'mr-1 text-secondary')
- = @merge_requests.count
- %ul.content-list.related-items-list
- - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id)
- - @merge_requests.each do |merge_request|
- - merge_request = merge_request.present(current_user: current_user)
- %li.list-item.py-0.px-0
- .item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3
- .item-contents
- .item-title.d-flex.align-items-center.mr-title
- = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-none d-xl-block append-right-8' }
- = link_to merge_request.title, merge_request_path(merge_request), { class: 'mr-title-link'}
- .item-meta
- = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-xl-none d-lg-block append-right-5' }
- %span.d-flex.align-items-center.append-right-8.mr-item-path.item-path-id.mt-0
- %span.path-id-text.bold.text-truncate{ data: { toggle: 'tooltip'}, title: merge_request.target_project.full_path }
- = merge_request.target_project.full_path
- = merge_request.to_reference
- %span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2
- - if merge_request.can_read_pipeline?
- = render 'ci/status/icon', status: merge_request.head_pipeline.detailed_status(current_user), tooltip_placement: 'bottom'
- - elsif has_any_head_pipeline
- = icon('blank fw')
-
- - if @closed_by_merge_requests.present?
- %p
- = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count}
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index f8969fbb5a2..2c95ac6dbb3 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -77,12 +77,11 @@
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
- #merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } }
- // This element is filled in using JavaScript.
+ #js-related-merge-requests{ data: { endpoint: api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid), project_namespace: @project.namespace.path, project_path: @project.path } }
- if can?(current_user, :download_code, @project)
#related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
- // This element is filled in using JavaScript.
+ -# This element is filled in using JavaScript.
.content-block.emoji-block.emoji-block-sticky
.row
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 41fe704601e..bfcaa09ae8c 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -10,7 +10,7 @@
= form_errors(@pipeline)
.form-group.row
.col-sm-12
- = f.label :ref, s_('Pipeline|Create for'), class: 'col-form-label'
+ = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label'
= hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch
= dropdown_tag(params[:ref] || @project.default_branch,
options: { toggle_class: 'js-branch-select wide monospace',
@@ -28,7 +28,7 @@
= (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe
.form-actions
- = f.submit s_('Pipeline|Create pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
+ = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
= link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right'
-# haml-lint:disable InlineJavaScript
diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml
index 635580eac5c..9c69aedfbfc 100644
--- a/app/views/projects/serverless/functions/index.html.haml
+++ b/app/views/projects/serverless/functions/index.html.haml
@@ -5,7 +5,10 @@
- status_path = project_serverless_functions_path(@project, format: :json)
- clusters_path = project_clusters_path(@project)
-.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } }
+.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path,
+ installed: @installed,
+ clusters_path: clusters_path,
+ help_path: help_page_path('user/project/clusters/serverless/index') } }
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
.js-serverless-functions-notice
diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml
index 29737b7014a..d1fe208ce60 100644
--- a/app/views/projects/serverless/functions/show.html.haml
+++ b/app/views/projects/serverless/functions/show.html.haml
@@ -1,14 +1,19 @@
- @no_container = true
- @content_class = "limit-container-width" unless fluid_layout
+- clusters_path = project_clusters_path(@project)
+- help_path = help_page_path('user/project/clusters/serverless/index')
- add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project))
- page_title @service[:name]
-.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } }
+.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json,
+ prometheus: @prometheus,
+ clusters_path: clusters_path,
+ help_path: help_path } }
+
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
- .top-area.adjust
- .serverless-function-details#js-serverless-function-details
+ .serverless-function-details#js-serverless-function-details
.js-serverless-function-notice
.flash-container
diff --git a/changelogs/unreleased/52258-labels-with-long-names-overflow-on-metrics-dashboard.yml b/changelogs/unreleased/52258-labels-with-long-names-overflow-on-metrics-dashboard.yml
new file mode 100644
index 00000000000..5dd25d0ffc1
--- /dev/null
+++ b/changelogs/unreleased/52258-labels-with-long-names-overflow-on-metrics-dashboard.yml
@@ -0,0 +1,5 @@
+---
+title: Fix long label overflow on metrics dashboard
+merge_request: 26775
+author:
+type: fixed
diff --git a/changelogs/unreleased/58835-button-run-pipeline.yml b/changelogs/unreleased/58835-button-run-pipeline.yml
new file mode 100644
index 00000000000..39407a60780
--- /dev/null
+++ b/changelogs/unreleased/58835-button-run-pipeline.yml
@@ -0,0 +1,5 @@
+---
+title: Changed button label at /pipelines/new
+merge_request: 26893
+author: antfobe,leonardofl
+type: other
diff --git a/changelogs/unreleased/58981-migrate-clusters-tests-to-jest.yml b/changelogs/unreleased/58981-migrate-clusters-tests-to-jest.yml
new file mode 100644
index 00000000000..3df13dbb960
--- /dev/null
+++ b/changelogs/unreleased/58981-migrate-clusters-tests-to-jest.yml
@@ -0,0 +1,5 @@
+---
+title: Migrate clusters tests to jest
+merge_request: 27013
+author:
+type: other
diff --git a/changelogs/unreleased/59621-order-labels-alphabetically-in-issue-boards.yml b/changelogs/unreleased/59621-order-labels-alphabetically-in-issue-boards.yml
new file mode 100644
index 00000000000..8b82d757303
--- /dev/null
+++ b/changelogs/unreleased/59621-order-labels-alphabetically-in-issue-boards.yml
@@ -0,0 +1,5 @@
+---
+title: Order labels alphabetically in issue boards
+merge_request: 26927
+author:
+type: changed
diff --git a/changelogs/unreleased/60006-add-touch-events-to-image-diff-26971.yml b/changelogs/unreleased/60006-add-touch-events-to-image-diff-26971.yml
new file mode 100644
index 00000000000..bfea3ac52af
--- /dev/null
+++ b/changelogs/unreleased/60006-add-touch-events-to-image-diff-26971.yml
@@ -0,0 +1,5 @@
+---
+title: "Make touch events work on image diff swipe view and onion skin"
+merge_request: 26971
+author: ftab
+type: added
diff --git a/changelogs/unreleased/60068-avoid-null-domain-help-text.yml b/changelogs/unreleased/60068-avoid-null-domain-help-text.yml
new file mode 100644
index 00000000000..5305b8584a8
--- /dev/null
+++ b/changelogs/unreleased/60068-avoid-null-domain-help-text.yml
@@ -0,0 +1,5 @@
+---
+title: Do not display Ingress IP help text when there isn’t an Ingress IP assigned
+merge_request: 27057
+author:
+type: fixed
diff --git a/changelogs/unreleased/60116-fix-button-wrapping.yml b/changelogs/unreleased/60116-fix-button-wrapping.yml
new file mode 100644
index 00000000000..d6df920b51d
--- /dev/null
+++ b/changelogs/unreleased/60116-fix-button-wrapping.yml
@@ -0,0 +1,5 @@
+---
+title: Add to white-space nowrap to all buttons
+merge_request: 27069
+author:
+type: fixed
diff --git a/changelogs/unreleased/60149-nameerror-uninitialized-constant-sentry-client-sentryerror.yml b/changelogs/unreleased/60149-nameerror-uninitialized-constant-sentry-client-sentryerror.yml
new file mode 100644
index 00000000000..8c3a47cf62c
--- /dev/null
+++ b/changelogs/unreleased/60149-nameerror-uninitialized-constant-sentry-client-sentryerror.yml
@@ -0,0 +1,5 @@
+---
+title: Handle possible HTTP exception for Sentry client
+merge_request: 27080
+author:
+type: fixed
diff --git a/changelogs/unreleased/_acet-related-mrs-widget-rewrite.yml b/changelogs/unreleased/_acet-related-mrs-widget-rewrite.yml
new file mode 100644
index 00000000000..b773eb2720c
--- /dev/null
+++ b/changelogs/unreleased/_acet-related-mrs-widget-rewrite.yml
@@ -0,0 +1,5 @@
+---
+title: Rewrite related MRs widget with Vue
+merge_request: 27027
+author:
+type: other
diff --git a/changelogs/unreleased/bump_kubernetes_1_11_9.yml b/changelogs/unreleased/bump_kubernetes_1_11_9.yml
deleted file mode 100644
index 4bc7ff6d8e9..00000000000
--- a/changelogs/unreleased/bump_kubernetes_1_11_9.yml
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Bump Helm and kubectl used in Kubernetes integration to 2.13.1 and 1.11.9 respectively
-merge_request: 26991
-author:
-type: other
diff --git a/changelogs/unreleased/ce-proj-settings-ok-mr-settings-only.yml b/changelogs/unreleased/ce-proj-settings-ok-mr-settings-only.yml
new file mode 100644
index 00000000000..4bbbc706e62
--- /dev/null
+++ b/changelogs/unreleased/ce-proj-settings-ok-mr-settings-only.yml
@@ -0,0 +1,5 @@
+---
+title: Improve project merge request settings
+merge_request: 26495
+author:
+type: other
diff --git a/changelogs/unreleased/knative-prometheus.yml b/changelogs/unreleased/knative-prometheus.yml
new file mode 100644
index 00000000000..e24f53b7225
--- /dev/null
+++ b/changelogs/unreleased/knative-prometheus.yml
@@ -0,0 +1,5 @@
+---
+title: Add Knative metrics to Prometheus
+merge_request: 24663
+author: Chris Baumbauer <cab@cabnetworks.net>
+type: added
diff --git a/changelogs/unreleased/minimized-multiple-queries-ce.yml b/changelogs/unreleased/minimized-multiple-queries-ce.yml
new file mode 100644
index 00000000000..d8c20d492d6
--- /dev/null
+++ b/changelogs/unreleased/minimized-multiple-queries-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Support multiple queries per chart on metrics dash
+merge_request: 25758
+author:
+type: added
diff --git a/changelogs/unreleased/sh-add-gitaly-ref-name-caching-tree-controller.yml b/changelogs/unreleased/sh-add-gitaly-ref-name-caching-tree-controller.yml
new file mode 100644
index 00000000000..a051c1f70a8
--- /dev/null
+++ b/changelogs/unreleased/sh-add-gitaly-ref-name-caching-tree-controller.yml
@@ -0,0 +1,5 @@
+---
+title: Enable Gitaly FindCommit caching for TreeController
+merge_request: 27100
+author:
+type: performance
diff --git a/changelogs/unreleased/sh-fix-realtime-changes-with-reserved-words.yml b/changelogs/unreleased/sh-fix-realtime-changes-with-reserved-words.yml
new file mode 100644
index 00000000000..3d1501cd667
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-realtime-changes-with-reserved-words.yml
@@ -0,0 +1,5 @@
+---
+title: Fix real-time updates for projects that contain a reserved word
+merge_request: 27060
+author:
+type: fixed
diff --git a/changelogs/unreleased/sh-git-gc-after-initial-fetch.yml b/changelogs/unreleased/sh-git-gc-after-initial-fetch.yml
new file mode 100644
index 00000000000..867d7e6b9df
--- /dev/null
+++ b/changelogs/unreleased/sh-git-gc-after-initial-fetch.yml
@@ -0,0 +1,5 @@
+---
+title: 'GitHub import: Run housekeeping after initial import'
+merge_request: 26600
+author:
+type: performance
diff --git a/changelogs/unreleased/sh-improve-find-commit-caching.yml b/changelogs/unreleased/sh-improve-find-commit-caching.yml
new file mode 100644
index 00000000000..1b38684d018
--- /dev/null
+++ b/changelogs/unreleased/sh-improve-find-commit-caching.yml
@@ -0,0 +1,5 @@
+---
+title: Expand FindCommit caching to blob and refs
+merge_request: 27084
+author:
+type: performance
diff --git a/config/prometheus/common_metrics.yml b/config/prometheus/common_metrics.yml
index 9bdaf1575e9..884868c6336 100644
--- a/config/prometheus/common_metrics.yml
+++ b/config/prometheus/common_metrics.yml
@@ -259,3 +259,13 @@
label: Pod average
unit: "cores"
track: canary
+ - title: "Knative function invocations"
+ y_label: "Invocations"
+ required_metrics:
+ - istio_revision_request_count
+ weight: 1
+ queries:
+ - id: system_metrics_knative_function_invocation_count
+ query_range: 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))'
+ label: invocations / minute
+ unit: requests
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 1cb8f331f6f..93d168fc595 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -252,7 +252,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
namespace :serverless do
- get '/functions/:environment_id/:id', to: 'functions#show'
+ scope :functions do
+ get '/:environment_id/:id', to: 'functions#show'
+ get '/:environment_id/:id/metrics', to: 'functions#metrics', as: :metrics
+ end
+
resources :functions, only: [:index]
end
diff --git a/db/migrate/20190326164045_import_common_metrics_knative.rb b/db/migrate/20190326164045_import_common_metrics_knative.rb
new file mode 100644
index 00000000000..340ec1e1f75
--- /dev/null
+++ b/db/migrate/20190326164045_import_common_metrics_knative.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ImportCommonMetricsKnative < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ require Rails.root.join('db/importers/common_metrics_importer.rb')
+
+ DOWNTIME = false
+
+ def up
+ Importers::CommonMetricsImporter.new.execute
+ end
+
+ def down
+ # no-op
+ end
+end
diff --git a/db/post_migrate/20190313092516_clean_up_noteable_id_for_notes_on_commits.rb b/db/post_migrate/20190313092516_clean_up_noteable_id_for_notes_on_commits.rb
new file mode 100644
index 00000000000..fcd63f42b0e
--- /dev/null
+++ b/db/post_migrate/20190313092516_clean_up_noteable_id_for_notes_on_commits.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CleanUpNoteableIdForNotesOnCommits < ActiveRecord::Migration[5.0]
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ TEMP_INDEX_NAME = 'index_notes_on_commit_with_null_noteable_id'
+
+ disable_ddl_transaction!
+
+ def up
+ remove_concurrent_index_by_name(:notes, TEMP_INDEX_NAME)
+
+ add_concurrent_index(:notes, :id, where: "noteable_type = 'Commit' AND noteable_id IS NOT NULL", name: TEMP_INDEX_NAME)
+
+ # rubocop:disable Migration/UpdateLargeTable
+ update_column_in_batches(:notes, :noteable_id, nil, batch_size: 300) do |table, query|
+ query.where(
+ table[:noteable_type].eq('Commit').and(table[:noteable_id].not_eq(nil))
+ )
+ end
+
+ remove_concurrent_index_by_name(:notes, TEMP_INDEX_NAME)
+ end
+
+ def down
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1a50c6efbc7..ca5b04e810a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20190325165127) do
+ActiveRecord::Schema.define(version: 20190326164045) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
diff --git a/doc/administration/job_traces.md b/doc/administration/job_traces.md
index 63945506f3c..aa9d87562a3 100644
--- a/doc/administration/job_traces.md
+++ b/doc/administration/job_traces.md
@@ -10,13 +10,13 @@ In the following table you can see the phases a trace goes through.
| Phase | State | Condition | Data flow | Stored path |
| ----- | ----- | --------- | --------- | ----------- |
-| 1: patching | Live trace | When a job is running | GitLab Runner => Unicorn => file storage |`#{ROOT_PATH}/builds/#{YYYY_mm}/#{project_id}/#{job_id}.log`|
-| 2: overwriting | Live trace | When a job is finished | GitLab Runner => Unicorn => file storage |`#{ROOT_PATH}/builds/#{YYYY_mm}/#{project_id}/#{job_id}.log`|
-| 3: archiving | Archived trace | After a job is finished | Sidekiq moves live trace to artifacts folder |`#{ROOT_PATH}/shared/artifacts/#{disk_hash}/#{YYYY_mm_dd}/#{job_id}/#{job_artifact_id}/job.log`|
+| 1: patching | Live trace | When a job is running | GitLab Runner => Unicorn => file storage |`#{ROOT_PATH}/gitlab-ci/builds/#{YYYY_mm}/#{project_id}/#{job_id}.log`|
+| 2: overwriting | Live trace | When a job is finished | GitLab Runner => Unicorn => file storage |`#{ROOT_PATH}/gitlab-ci/builds/#{YYYY_mm}/#{project_id}/#{job_id}.log`|
+| 3: archiving | Archived trace | After a job is finished | Sidekiq moves live trace to artifacts folder |`#{ROOT_PATH}/gitlab-rails/shared/artifacts/#{disk_hash}/#{YYYY_mm_dd}/#{job_id}/#{job_artifact_id}/job.log`|
| 4: uploading | Archived trace | After a trace is archived | Sidekiq moves archived trace to [object storage](#uploading-traces-to-object-storage) (if configured) |`#{bucket_name}/#{disk_hash}/#{YYYY_mm_dd}/#{job_id}/#{job_artifact_id}/job.log`|
The `ROOT_PATH` varies per your environment. For Omnibus GitLab it
-would be `/var/opt/gitlab/gitlab-ci`, whereas for installations from source
+would be `/var/opt/gitlab`, whereas for installations from source
it would be `/home/git/gitlab`.
## Changing the job traces local location
diff --git a/doc/api/commits.md b/doc/api/commits.md
index c8c282a71d9..e205307eeca 100644
--- a/doc/api/commits.md
+++ b/doc/api/commits.md
@@ -155,7 +155,7 @@ Example response:
}
```
-GitLab supports [form encoding](../README.md#encoding-api-parameters-of-array-and-hash-types). The following is an example using Commit API with form encoding:
+GitLab supports [form encoding](README.md#encoding-api-parameters-of-array-and-hash-types). The following is an example using Commit API with form encoding:
```bash
curl --request POST \
diff --git a/doc/api/project_clusters.md b/doc/api/project_clusters.md
index 02334f0298e..f36e352da67 100644
--- a/doc/api/project_clusters.md
+++ b/doc/api/project_clusters.md
@@ -159,7 +159,7 @@ Parameters:
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the project owned by the authenticated user |
| `name` | String | yes | The name of the cluster |
-| `domain` | String | no | The [base domain](../user/project/clusters/index.md#base_domain) of the cluster |
+| `domain` | String | no | The [base domain](../user/project/clusters/index.md#base-domain) of the cluster |
| `enabled` | Boolean | no | Determines if cluster is active or not, defaults to true |
| `platform_kubernetes_attributes[api_url]` | String | yes | The URL to access the Kubernetes API |
| `platform_kubernetes_attributes[token]` | String | yes | The token to authenticate against Kubernetes |
@@ -250,7 +250,7 @@ Parameters:
| `id` | integer | yes | The ID of the project owned by the authenticated user |
| `cluster_id` | integer | yes | The ID of the cluster |
| `name` | String | no | The name of the cluster |
-| `domain` | String | no | The [base domain](../user/project/clusters/index.md#base_domain) of the cluster |
+| `domain` | String | no | The [base domain](../user/project/clusters/index.md#base-domain) of the cluster |
| `platform_kubernetes_attributes[api_url]` | String | no | The URL to access the Kubernetes API |
| `platform_kubernetes_attributes[token]` | String | no | The token to authenticate against Kubernetes |
| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate |
diff --git a/doc/ci/environments.md b/doc/ci/environments.md
index 54493bc2922..eaafc7bc1c0 100644
--- a/doc/ci/environments.md
+++ b/doc/ci/environments.md
@@ -664,7 +664,7 @@ fetch = +refs/environments/*:refs/remotes/origin/environments/*
### Scoping environments with specs **[PREMIUM]**
Some GitLab [Enterprise Edition](https://about.gitlab.com/pricing/) features can behave differently for each
-environment. For example, you can [create a secret variable to be injected only into a production environment](variables/README.md#limiting-environment-scopes-of-variables-premium).
+environment. For example, you can [create a secret variable to be injected only into a production environment](https://docs.gitlab.com/ee/ci/variables/#limiting-environment-scopes-of-environment-variables-premium).
In most cases, these features use the _environment specs_ mechanism, which offers
an efficient way to implement scoping within each environment group.
diff --git a/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md
index 908cf85980e..d6ad00a77da 100644
--- a/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md
+++ b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md
@@ -511,7 +511,7 @@ Errors can be easily debugged through GitLab's build logs, and within minutes of
you can see the changes live on your game.
Setting up Continuous Integration and Continuous Deployment from the start with Dark Nova enables
-rapid but stable development. We can easily test changes in a separate [environment](../../../ci/environments.md#introduction-to-environments-and-deployments),
+rapid but stable development. We can easily test changes in a separate [environment](../../environments.md),
or multiple environments if needed. Balancing and updating a multiplayer game can be ongoing
and tedious, but having faith in a stable deployment with GitLab CI/CD allows
a lot of breathing room in quickly getting changes to players.
diff --git a/doc/ci/introduction/index.md b/doc/ci/introduction/index.md
index d505f2ae4ce..6055d8c282a 100644
--- a/doc/ci/introduction/index.md
+++ b/doc/ci/introduction/index.md
@@ -127,7 +127,7 @@ displayed by GitLab:
![pipeline status](img/pipeline_status.png)
At the end, if anything goes wrong, you can easily
-[roll back](../environments.md#rolling-back-changes) all the changes:
+[roll back](../environments.md#retrying-and-rolling-back) all the changes:
![rollback button](img/rollback.png)
diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md
index 38cd58f11ac..2ffa3d4edc7 100644
--- a/doc/ci/pipelines.md
+++ b/doc/ci/pipelines.md
@@ -295,7 +295,7 @@ You can do this straight from the pipeline graph. Just click on the play button
to execute that particular job.
For example, your pipeline start automatically, but require manual action to
-[deploy to production](environments.md#manually-deploying-to-environments). In the example below, the `production`
+[deploy to production](environments.md#configuring-manual-deployments). In the example below, the `production`
stage has a job with a manual action.
![Pipelines example](img/pipelines.png)
@@ -313,7 +313,7 @@ For example, if you start rolling out new code and:
- Users do not experience trouble, GitLab can automatically complete the deployment from 0% to 100%.
- Users experience trouble with the new code, you can stop the timed incremental rollout by canceling the pipeline
- and [rolling](environments.md#rolling-back-changes) back to the last stable version.
+ and [rolling](environments.md#retrying-and-rolling-back) back to the last stable version.
![Pipelines example](img/pipeline_incremental_rollout.png)
diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md
index 53651a807c2..2fc1e14f02e 100644
--- a/doc/ci/review_apps/index.md
+++ b/doc/ci/review_apps/index.md
@@ -33,7 +33,7 @@ In this example, you can see a branch was:
## How do Review Apps work?
-The basis of Review Apps in GitLab is [dynamic environments](../environments.md#dynamic-environments), which allow you to dynamically create a new environment for each branch.
+The basis of Review Apps in GitLab is [dynamic environments](../environments.md#configuring-dynamic-environments), which allow you to dynamically create a new environment for each branch.
Access to the Review App is made available as a link on the [merge request](../../user/project/merge_requests.md) relevant to the branch. Review Apps enable you to review all changes proposed by the merge request in live environment.
@@ -60,14 +60,14 @@ To get a better understanding of Review Apps, review documentation on how enviro
1. Learn about [environments](../environments.md) and their role in the development workflow.
1. Learn about [CI variables](../variables/README.md) and how they can be used in your CI jobs.
1. Explore the [`environment` syntax](../yaml/README.md#environment) as defined in `.gitlab-ci.yml`. This will become a primary reference.
-1. Additionally, find out about [manual actions](../environments.md#manually-deploying-to-environments) and how you can use them to deploy to critical environments like production with the push of a button.
+1. Additionally, find out about [manual actions](../environments.md#configuring-manual-deployments) and how you can use them to deploy to critical environments like production with the push of a button.
1. Follow the [example tutorials](#examples). These will guide you through setting up infrastructure and using Review Apps.
### Configuring dynamic environments
Configuring Review Apps dynamic environments depends on your technology stack and infrastructure.
-For more information, see [dynamic environments](../environments.md#dynamic-environments) documentation to understand how to define and create them.
+For more information, see [dynamic environments](../environments.md#configuring-dynamic-environments) documentation to understand how to define and create them.
### Creating and destroying Review Apps
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index d52312371cd..cf2189cd00a 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -756,7 +756,7 @@ Manual actions are a special type of job that are not executed automatically,
they need to be explicitly started by a user. An example usage of manual actions
would be a deployment to a production environment. Manual actions can be started
from the pipeline, job, environment, and deployment views. Read more at the
-[environments documentation](../environments.md#manually-deploying-to-environments).
+[environments documentation](../environments.md#configuring-manual-deployments).
Manual actions can be either optional or blocking. Blocking manual actions will
block the execution of the pipeline at the stage this action is defined in. It's
diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md
index 7693109b3c4..9060360e6a2 100644
--- a/doc/topics/autodevops/index.md
+++ b/doc/topics/autodevops/index.md
@@ -955,7 +955,7 @@ required to go from `10%` to `100%`, you can jump to whatever job you want.
You can also scale down by running a lower percentage job, just before hitting
`100%`. Once you get to `100%`, you cannot scale down, and you'd have to roll
back by redeploying the old version using the
-[rollback button](../../ci/environments.md#rolling-back-changes) in the
+[rollback button](../../ci/environments.md#retrying-and-rolling-back) in the
environment page.
Below, you can see how the pipeline will look if the rollout or staging
diff --git a/doc/user/group/index.md b/doc/user/group/index.md
index 2f1cadb2bbc..9c3f6fcec9b 100644
--- a/doc/user/group/index.md
+++ b/doc/user/group/index.md
@@ -156,7 +156,7 @@ There are two different ways to add a new project to a group:
Group owners or administrators can allow users with the
Developer role to create projects under groups.
-By default, [Developers and Maintainers](../permissions.md##group-members-permissions) can create projects under agroup, but this can be changed either within the group settings for a group, or
+By default, [Developers and Maintainers](../permissions.md#group-members-permissions) can create projects under agroup, but this can be changed either within the group settings for a group, or
be set globally by a GitLab administrator in the Admin area
at **Settings > General > Visibility and access controls**.
diff --git a/doc/user/project/clusters/serverless/img/function-details-loaded.png b/doc/user/project/clusters/serverless/img/function-details-loaded.png
new file mode 100644
index 00000000000..34465c5c087
--- /dev/null
+++ b/doc/user/project/clusters/serverless/img/function-details-loaded.png
Binary files differ
diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md
index b72083e85df..5b7e9ef906f 100644
--- a/doc/user/project/clusters/serverless/index.md
+++ b/doc/user/project/clusters/serverless/index.md
@@ -301,3 +301,23 @@ The second to last line, labeled **Service domain** contains the URL for the dep
browser to see the app live.
![knative app](img/knative-app.png)
+
+## Function details
+
+Go to the **Operations > Serverless** page and click on one of the function
+rows to bring up the function details page.
+
+![function_details](img/function-details-loaded.png)
+
+The pod count will give you the number of pods running the serverless function instances on a given cluster.
+
+### Prometheus support
+
+For the Knative function invocations to appear,
+[Prometheus must be installed](../index.md#installing-applications).
+
+Once Prometheus is installed, a message may appear indicating that the metrics data _is
+loading or is not available at this time._ It will appear upon the first access of the
+page, but should go away after a few seconds. If the message does not disappear, then it
+is possible that GitLab is unable to connect to the Prometheus instance running on the
+cluster.
diff --git a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml
index f786022beb0..b3eec2b6210 100644
--- a/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml
@@ -78,8 +78,14 @@ include:
- template: Jobs/Code-Quality.gitlab-ci.yml
- template: Jobs/Deploy.gitlab-ci.yml
- template: Jobs/Browser-Performance-Testing.gitlab-ci.yml
- - template: Jobs/DAST.gitlab-ci.yml
+ - template: Security/DAST.gitlab-ci.yml
- template: Security/Container-Scanning.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
- template: Security/License-Management.gitlab-ci.yml
- template: Security/SAST.gitlab-ci.yml
+
+# Override DAST job to exclude master branch
+dast:
+ except:
+ refs:
+ - master \ No newline at end of file
diff --git a/lib/gitlab/ci/templates/Jobs/DAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/DAST.gitlab-ci.yml
deleted file mode 100644
index aedbbb21674..00000000000
--- a/lib/gitlab/ci/templates/Jobs/DAST.gitlab-ci.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-dast:
- stage: dast
- image: docker:stable
- variables:
- DOCKER_DRIVER: overlay2
- allow_failure: true
- services:
- - docker:stable-dind
- script:
- - export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)}
- - export DAST_VERSION=${SP_VERSION:-$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')}
- - |
- if ! docker info &>/dev/null; then
- if [ -z "$DOCKER_HOST" -a "$KUBERNETES_PORT" ]; then
- export DOCKER_HOST='tcp://localhost:2375'
- fi
- fi
- - |
- function dast_run() {
- docker run \
- --env DAST_TARGET_AVAILABILITY_TIMEOUT \
- --volume "$PWD:/output" \
- --volume /var/run/docker.sock:/var/run/docker.sock \
- -w /output \
- "registry.gitlab.com/gitlab-org/security-products/dast:$DAST_VERSION" \
- /analyze -t $DAST_WEBSITE \
- "$@"
- }
- - |
- if [ -n "$DAST_AUTH_URL" ]
- then
- dast_run \
- --auth-url $DAST_AUTH_URL \
- --auth-username $DAST_USERNAME \
- --auth-password $DAST_PASSWORD \
- --auth-username-field $DAST_USERNAME_FIELD \
- --auth-password-field $DAST_PASSWORD_FIELD
- else
- dast_run
- fi
- artifacts:
- reports:
- dast: gl-dast-report.json
- only:
- refs:
- - branches
- - tags
- variables:
- - $GITLAB_FEATURES =~ /\bdast\b/
- except:
- refs:
- - master
- variables:
- - $DAST_DISABLED
diff --git a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml
index 770340de16f..2a90cc9a06c 100644
--- a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml
@@ -4,12 +4,6 @@
# List of the variables: https://gitlab.com/gitlab-org/security-products/dast#settings
# How to set: https://docs.gitlab.com/ee/ci/yaml/#variables
-include:
- - template: Jobs/DAST.gitlab-ci.yml
-
-variables:
- DAST_WEBSITE: http://example.com # Please edit to be your website to scan for vulnerabilities
-
stages:
- build
- test
@@ -17,10 +11,53 @@ stages:
- dast
dast:
+ stage: dast
+ image: docker:stable
+ variables:
+ DOCKER_DRIVER: overlay2
+ allow_failure: true
+ services:
+ - docker:stable-dind
+ script:
+ - export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)}
+ - export DAST_VERSION=${SP_VERSION:-$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')}
+ - |
+ if ! docker info &>/dev/null; then
+ if [ -z "$DOCKER_HOST" -a "$KUBERNETES_PORT" ]; then
+ export DOCKER_HOST='tcp://localhost:2375'
+ fi
+ fi
+ - |
+ function dast_run() {
+ docker run \
+ --env DAST_TARGET_AVAILABILITY_TIMEOUT \
+ --volume "$PWD:/output" \
+ --volume /var/run/docker.sock:/var/run/docker.sock \
+ -w /output \
+ "registry.gitlab.com/gitlab-org/security-products/dast:$DAST_VERSION" \
+ /analyze -t $DAST_WEBSITE \
+ "$@"
+ }
+ - |
+ if [ -n "$DAST_AUTH_URL" ]
+ then
+ dast_run \
+ --auth-url $DAST_AUTH_URL \
+ --auth-username $DAST_USERNAME \
+ --auth-password $DAST_PASSWORD \
+ --auth-username-field $DAST_USERNAME_FIELD \
+ --auth-password-field $DAST_PASSWORD_FIELD
+ else
+ dast_run
+ fi
+ artifacts:
+ reports:
+ dast: gl-dast-report.json
only:
refs:
- branches
+ variables:
+ - $GITLAB_FEATURES =~ /\bdast\b/
except:
- refs: [] # Override default from template
variables:
- $DAST_DISABLED
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 3abd0600e9d..7f5eb1188fc 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -282,6 +282,7 @@ module Gitlab
# Updates the value of a column in batches.
#
# This method updates the table in batches of 5% of the total row count.
+ # A `batch_size` option can also be passed to set this to a fixed number.
# This method will continue updating rows until no rows remain.
#
# When given a block this method will yield two values to the block:
@@ -320,7 +321,7 @@ module Gitlab
# make things _more_ complex).
#
# rubocop: disable Metrics/AbcSize
- def update_column_in_batches(table, column, value)
+ def update_column_in_batches(table, column, value, batch_size: nil)
if transaction_open?
raise 'update_column_in_batches can not be run inside a transaction, ' \
'you can disable transactions by calling disable_ddl_transaction! ' \
@@ -336,14 +337,16 @@ module Gitlab
return if total == 0
- # Update in batches of 5% until we run out of any rows to update.
- batch_size = ((total / 100.0) * 5.0).ceil
- max_size = 1000
+ if batch_size.nil?
+ # Update in batches of 5% until we run out of any rows to update.
+ batch_size = ((total / 100.0) * 5.0).ceil
+ max_size = 1000
- # The upper limit is 1000 to ensure we don't lock too many rows. For
- # example, for "merge_requests" even 1% of the table is around 35 000
- # rows for GitLab.com.
- batch_size = max_size if batch_size > max_size
+ # The upper limit is 1000 to ensure we don't lock too many rows. For
+ # example, for "merge_requests" even 1% of the table is around 35 000
+ # rows for GitLab.com.
+ batch_size = max_size if batch_size > max_size
+ end
start_arel = table.project(table[:id]).order(table[:id].asc).take(1)
start_arel = yield table, start_arel if block_given?
diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb
index 0891f79198d..17fbecbd097 100644
--- a/lib/gitlab/etag_caching/router.rb
+++ b/lib/gitlab/etag_caching/router.rb
@@ -15,50 +15,51 @@ module Gitlab
new environments].freeze
RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES
RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape)))
+ RESERVED_WORDS_PREFIX = %Q(^(?!.*\/(#{RESERVED_WORDS_REGEX})\/).*)
ROUTES = [
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z),
+ %r(#{RESERVED_WORDS_PREFIX}/noteable/issue/\d+/notes\z),
'issue_notes'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/issues/\d+/realtime_changes\z),
+ %r(#{RESERVED_WORDS_PREFIX}/issues/\d+/realtime_changes\z),
'issue_title'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/commit/\S+/pipelines\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/commit/\S+/pipelines\.json\z),
'commit_pipelines'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/new\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/merge_requests/new\.json\z),
'new_merge_request_pipelines'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/merge_requests/\d+/pipelines\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/merge_requests/\d+/pipelines\.json\z),
'merge_request_pipelines'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/pipelines\.json\z),
'project_pipelines'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines/\d+\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/pipelines/\d+\.json\z),
'project_pipeline'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/builds/\d+\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/builds/\d+\.json\z),
'project_build'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/environments\.json\z),
'environments'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/github/realtime_changes\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/import/github/realtime_changes\.json\z),
'realtime_changes_import_github'
),
Gitlab::EtagCaching::Router::Route.new(
- %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/import/gitea/realtime_changes\.json\z),
+ %r(#{RESERVED_WORDS_PREFIX}/import/gitea/realtime_changes\.json\z),
'realtime_changes_import_gitea'
)
].freeze
diff --git a/lib/gitlab/github_import/importer/repository_importer.rb b/lib/gitlab/github_import/importer/repository_importer.rb
index 6d48c6a15b4..6aad7955415 100644
--- a/lib/gitlab/github_import/importer/repository_importer.rb
+++ b/lib/gitlab/github_import/importer/repository_importer.rb
@@ -54,6 +54,11 @@ module Gitlab
project.repository.fetch_as_mirror(project.import_url, refmap: refmap, forced: true, remote_name: 'github')
project.change_head(default_branch) if default_branch
+
+ # The initial fetch can bring in lots of loose refs and objects.
+ # Running a `git gc` will make importing pull requests faster.
+ Projects::HousekeepingService.new(project, :gc).execute
+
true
rescue Gitlab::Git::Repository::NoRepository, Gitlab::Shell::Error => e
fail_import("Failed to import the repository: #{e.message}")
diff --git a/lib/gitlab/kubernetes/helm.rb b/lib/gitlab/kubernetes/helm.rb
index 24713223513..42c4745ff98 100644
--- a/lib/gitlab/kubernetes/helm.rb
+++ b/lib/gitlab/kubernetes/helm.rb
@@ -3,8 +3,8 @@
module Gitlab
module Kubernetes
module Helm
- HELM_VERSION = '2.13.1'.freeze
- KUBECTL_VERSION = '1.11.9'.freeze
+ HELM_VERSION = '2.12.3'.freeze
+ KUBECTL_VERSION = '1.11.7'.freeze
NAMESPACE = 'gitlab-managed-apps'.freeze
SERVICE_ACCOUNT = 'tiller'.freeze
CLUSTER_ROLE_BINDING = 'tiller-admin'.freeze
diff --git a/lib/gitlab/object_hierarchy.rb b/lib/gitlab/object_hierarchy.rb
index f2772c733c7..38b32770e90 100644
--- a/lib/gitlab/object_hierarchy.rb
+++ b/lib/gitlab/object_hierarchy.rb
@@ -5,6 +5,8 @@ module Gitlab
#
# This class uses recursive CTEs and as a result will only work on PostgreSQL.
class ObjectHierarchy
+ DEPTH_COLUMN = :depth
+
attr_reader :ancestors_base, :descendants_base, :model
# ancestors_base - An instance of ActiveRecord::Relation for which to
@@ -27,6 +29,17 @@ module Gitlab
end
# rubocop: enable CodeReuse/ActiveRecord
+ # Returns the maximum depth starting from the base
+ # A base object with no children has a maximum depth of `1`
+ def max_descendants_depth
+ unless hierarchy_supported?
+ # This makes the return value consistent with the case where hierarchy is supported
+ return descendants_base.exists? ? 1 : nil
+ end
+
+ base_and_descendants(with_depth: true).maximum(DEPTH_COLUMN)
+ end
+
# Returns the set of ancestors of a given relation, but excluding the given
# relation
#
@@ -64,10 +77,15 @@ module Gitlab
# Returns a relation that includes the descendants_base set of objects
# and all their descendants (recursively).
- def base_and_descendants
- return descendants_base unless hierarchy_supported?
-
- read_only(base_and_descendants_cte.apply_to(model.all))
+ #
+ # When `with_depth` is `true`, a `depth` column is included where it starts with `1` for the base objects
+ # and incremented as we go down the descendant tree
+ def base_and_descendants(with_depth: false)
+ unless hierarchy_supported?
+ return with_depth ? descendants_base.select("1 as #{DEPTH_COLUMN}", objects_table[Arel.star]) : descendants_base
+ end
+
+ read_only(base_and_descendants_cte(with_depth: with_depth).apply_to(model.all))
end
# Returns a relation that includes the base objects, their ancestors,
@@ -124,10 +142,9 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def base_and_ancestors_cte(stop_id = nil, hierarchy_order = nil)
cte = SQL::RecursiveCTE.new(:base_and_ancestors)
- depth_column = :depth
base_query = ancestors_base.except(:order)
- base_query = base_query.select("1 as #{depth_column}", objects_table[Arel.star]) if hierarchy_order
+ base_query = base_query.select("1 as #{DEPTH_COLUMN}", "ARRAY[id] AS tree_path", "false AS tree_cycle", objects_table[Arel.star]) if hierarchy_order
cte << base_query
@@ -137,7 +154,17 @@ module Gitlab
.where(objects_table[:id].eq(cte.table[:parent_id]))
.except(:order)
- parent_query = parent_query.select(cte.table[depth_column] + 1, objects_table[Arel.star]) if hierarchy_order
+ if hierarchy_order
+ quoted_objects_table_name = model.connection.quote_table_name(objects_table.name)
+
+ parent_query = parent_query.select(
+ cte.table[DEPTH_COLUMN] + 1,
+ "tree_path || #{quoted_objects_table_name}.id",
+ "#{quoted_objects_table_name}.id = ANY(tree_path)",
+ objects_table[Arel.star]
+ ).where(cte.table[:tree_cycle].eq(false))
+ end
+
parent_query = parent_query.where(cte.table[:parent_id].not_eq(stop_id)) if stop_id
cte << parent_query
@@ -146,17 +173,32 @@ module Gitlab
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def base_and_descendants_cte
+ def base_and_descendants_cte(with_depth: false)
cte = SQL::RecursiveCTE.new(:base_and_descendants)
- cte << descendants_base.except(:order)
+ base_query = descendants_base.except(:order)
+ base_query = base_query.select("1 AS #{DEPTH_COLUMN}", "ARRAY[id] AS tree_path", "false AS tree_cycle", objects_table[Arel.star]) if with_depth
+
+ cte << base_query
# Recursively get all the descendants of the base set.
- cte << model
+ descendants_query = model
.from([objects_table, cte.table])
.where(objects_table[:parent_id].eq(cte.table[:id]))
.except(:order)
+ if with_depth
+ quoted_objects_table_name = model.connection.quote_table_name(objects_table.name)
+
+ descendants_query = descendants_query.select(
+ cte.table[DEPTH_COLUMN] + 1,
+ "tree_path || #{quoted_objects_table_name}.id",
+ "#{quoted_objects_table_name}.id = ANY(tree_path)",
+ objects_table[Arel.star]
+ ).where(cte.table[:tree_cycle].eq(false))
+ end
+
+ cte << descendants_query
cte
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/lib/gitlab/prometheus/queries/knative_invocation_query.rb b/lib/gitlab/prometheus/queries/knative_invocation_query.rb
new file mode 100644
index 00000000000..2691abe46d6
--- /dev/null
+++ b/lib/gitlab/prometheus/queries/knative_invocation_query.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Prometheus
+ module Queries
+ class KnativeInvocationQuery < BaseQuery
+ include QueryAdditionalMetrics
+
+ def query(serverless_function_id)
+ PrometheusMetric
+ .find_by_identifier(:system_metrics_knative_function_invocation_count)
+ .to_query_metric.tap do |q|
+ q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id))
+ end
+ end
+
+ protected
+
+ def context(function_id)
+ function = Serverless::Function.find_by_id(function_id)
+ {
+ function_name: function.name,
+ kube_namespace: function.namespace
+ }
+ end
+
+ def run_query(query, context)
+ query %= context
+ client_query_range(query, start: 8.hours.ago.to_f, stop: Time.now.to_f)
+ end
+
+ def self.transform_reactive_result(result)
+ result[:metrics] = result.delete :data
+ result
+ end
+ end
+ end
+ end
+end
diff --git a/lib/sentry/client.rb b/lib/sentry/client.rb
index bb1aa2a7a10..4022e8ff946 100644
--- a/lib/sentry/client.rb
+++ b/lib/sentry/client.rb
@@ -47,9 +47,11 @@ module Sentry
end
def http_get(url, params = {})
- resp = Gitlab::HTTP.get(url, **request_params.merge(params))
+ response = handle_request_exceptions do
+ Gitlab::HTTP.get(url, **request_params.merge(params))
+ end
- handle_response(resp)
+ handle_response(response)
end
def get_issues(issue_status:, limit:)
@@ -63,14 +65,36 @@ module Sentry
http_get(projects_api_url)
end
+ def handle_request_exceptions
+ yield
+ rescue HTTParty::Error => e
+ Gitlab::Sentry.track_acceptable_exception(e)
+ raise_error 'Error when connecting to Sentry'
+ rescue Net::OpenTimeout
+ raise_error 'Connection to Sentry timed out'
+ rescue SocketError
+ raise_error 'Received SocketError when trying to connect to Sentry'
+ rescue OpenSSL::SSL::SSLError
+ raise_error 'Sentry returned invalid SSL data'
+ rescue Errno::ECONNREFUSED
+ raise_error 'Connection refused'
+ rescue => e
+ Gitlab::Sentry.track_acceptable_exception(e)
+ raise_error "Sentry request failed due to #{e.class}"
+ end
+
def handle_response(response)
unless response.code == 200
- raise Client::Error, "Sentry response status code: #{response.code}"
+ raise_error "Sentry response status code: #{response.code}"
end
response
end
+ def raise_error(message)
+ raise Client::Error, message
+ end
+
def projects_api_url
projects_url = URI(@url)
projects_url.path = '/api/0/projects/'
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 1166134347c..f22b351ad78 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -141,6 +141,9 @@ msgstr ""
msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
msgstr ""
+msgid "%{mrText}, this issue will be closed automatically."
+msgstr ""
+
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr ""
@@ -238,6 +241,9 @@ msgid_plural "%d closed merge requests"
msgstr[0] ""
msgstr[1] ""
+msgid "1 day"
+msgstr ""
+
msgid "1 merged merge request"
msgid_plural "%d merged merge requests"
msgstr[0] ""
@@ -258,6 +264,9 @@ msgid_plural "%d pipelines"
msgstr[0] ""
msgstr[1] ""
+msgid "1 week"
+msgstr ""
+
msgid "1st contribution!"
msgstr ""
@@ -267,6 +276,15 @@ msgstr ""
msgid "2FA enabled"
msgstr ""
+msgid "3 days"
+msgstr ""
+
+msgid "3 hours"
+msgstr ""
+
+msgid "30 minutes"
+msgstr ""
+
msgid "403|Please contact your GitLab administrator to get permission."
msgstr ""
@@ -282,6 +300,9 @@ msgstr ""
msgid "404|Please contact your GitLab administrator if you think this is a mistake."
msgstr ""
+msgid "8 hours"
+msgstr ""
+
msgid "<code>\"johnsmith@example.com\": \"@johnsmith\"</code> will add \"By <a href=\"#\">@johnsmith</a>\" to all issues and comments originally created by johnsmith@example.com, and will set <a href=\"#\">@johnsmith</a> as the assignee on all issues originally assigned to johnsmith@example.com."
msgstr ""
@@ -330,6 +351,12 @@ msgstr ""
msgid "A member of GitLab's abuse team will review your report as soon as possible."
msgstr ""
+msgid "A merge commit is created for every merge, and merging is allowed as long as there are no conflicts."
+msgstr ""
+
+msgid "A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible. This way you could make sure that if this merge request would build, after merging to target branch it would also build."
+msgstr ""
+
msgid "A new branch will be created in your fork and a new merge request will be started."
msgstr ""
@@ -1047,6 +1074,9 @@ msgstr ""
msgid "Automatically marked as default internal user"
msgstr ""
+msgid "Automatically resolve merge request diff discussions when they become outdated"
+msgstr ""
+
msgid "Automatically resolved"
msgstr ""
@@ -3622,6 +3652,9 @@ msgstr ""
msgid "Failure"
msgstr ""
+msgid "Fast-forward merge"
+msgstr ""
+
msgid "Fast-forward merge without a merge commit"
msgstr ""
@@ -4995,9 +5028,15 @@ msgstr ""
msgid "Merge Requests"
msgstr ""
+msgid "Merge commit"
+msgstr ""
+
msgid "Merge commit message"
msgstr ""
+msgid "Merge commit with semi-linear history"
+msgstr ""
+
msgid "Merge events"
msgstr ""
@@ -5007,6 +5046,9 @@ msgstr ""
msgid "Merge in progress"
msgstr ""
+msgid "Merge method"
+msgstr ""
+
msgid "Merge request"
msgstr ""
@@ -5109,6 +5151,12 @@ msgstr ""
msgid "Metrics|No deployed environments"
msgstr ""
+msgid "Metrics|Not enough data to display"
+msgstr ""
+
+msgid "Metrics|Show last"
+msgstr ""
+
msgid "Metrics|There was an error fetching the environments data, please try again"
msgstr ""
@@ -5432,6 +5480,9 @@ msgstr ""
msgid "No license. All rights reserved"
msgstr ""
+msgid "No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded. When fast-forward merge is not possible, the user is given the option to rebase."
+msgstr ""
+
msgid "No merge requests found"
msgstr ""
@@ -5623,6 +5674,12 @@ msgstr ""
msgid "Only admins"
msgstr ""
+msgid "Only allow merge requests to be merged if all discussions are resolved"
+msgstr ""
+
+msgid "Only allow merge requests to be merged if the pipeline succeeds"
+msgstr ""
+
msgid "Only mirror protected branches"
msgstr ""
@@ -5806,6 +5863,9 @@ msgstr ""
msgid "Pipeline triggers"
msgstr ""
+msgid "Pipeline: %{status}"
+msgstr ""
+
msgid "PipelineCharts|Failed:"
msgstr ""
@@ -5875,6 +5935,9 @@ msgstr ""
msgid "Pipelines for last year"
msgstr ""
+msgid "Pipelines need to be configured to enable this feature."
+msgstr ""
+
msgid "Pipelines settings for '%{project_name}' were successfully updated."
msgstr ""
@@ -5923,12 +5986,6 @@ msgstr ""
msgid "Pipeline|Coverage"
msgstr ""
-msgid "Pipeline|Create for"
-msgstr ""
-
-msgid "Pipeline|Create pipeline"
-msgstr ""
-
msgid "Pipeline|Duration"
msgstr ""
@@ -5941,6 +5998,9 @@ msgstr ""
msgid "Pipeline|Run Pipeline"
msgstr ""
+msgid "Pipeline|Run for"
+msgstr ""
+
msgid "Pipeline|Search branches"
msgstr ""
@@ -7217,9 +7277,24 @@ msgstr ""
msgid "Serverless"
msgstr ""
+msgid "ServerlessDetails|Function invocation metrics require Prometheus to be installed first."
+msgstr ""
+
+msgid "ServerlessDetails|Install Prometheus"
+msgstr ""
+
+msgid "ServerlessDetails|Invocation metrics loading or not available at this time."
+msgstr ""
+
+msgid "ServerlessDetails|Invocations"
+msgstr ""
+
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
+msgid "ServerlessDetails|More information"
+msgstr ""
+
msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity."
msgstr ""
@@ -7235,9 +7310,6 @@ msgstr ""
msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr ""
-msgid "Serverless|An error occurred while retrieving serverless components"
-msgstr ""
-
msgid "Serverless|Getting started with serverless"
msgstr ""
@@ -7370,6 +7442,9 @@ msgstr ""
msgid "Show latest version"
msgstr ""
+msgid "Show link to create/view merge request when pushing from the command line"
+msgstr ""
+
msgid "Show parent pages"
msgstr ""
@@ -7477,6 +7552,9 @@ msgstr ""
msgid "Something went wrong while fetching comments. Please try again."
msgstr ""
+msgid "Something went wrong while fetching related merge requests."
+msgstr ""
+
msgid "Something went wrong while fetching the environments for this merge request. Please try again."
msgstr ""
@@ -9205,6 +9283,14 @@ msgstr ""
msgid "When enabled, users cannot use GitLab until the terms have been accepted."
msgstr ""
+msgid "When fast-forward merge is not possible, the user is given the option to rebase."
+msgstr ""
+
+msgid "When this merge request is accepted"
+msgid_plural "When these merge requests are accepted"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "When:"
msgstr ""
diff --git a/locale/uk/gitlab.po b/locale/uk/gitlab.po
index b10f37f732b..a45ab9df805 100644
--- a/locale/uk/gitlab.po
+++ b/locale/uk/gitlab.po
@@ -4645,9 +4645,6 @@ msgstr "Вузол не працює або зламаний."
msgid "GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage."
msgstr "Вузол працює повільно, перевантажений або тільки що відновивÑÑ Ð¿Ñ–ÑÐ»Ñ Ð·Ð±Ð¾ÑŽ."
-msgid "GeoNodes|Alternate URL"
-msgstr ""
-
msgid "GeoNodes|Checksummed"
msgstr "Із контрольною Ñумою"
@@ -4828,9 +4825,6 @@ msgstr "Ð’ÑÑ– проекти плануютьÑÑ Ð´Ð»Ñ Ð¿Ð¾Ð²Ñ‚Ð¾Ñ€Ð½Ð¾Ñ— пÐ
msgid "Geo|All projects are being scheduled for re-sync"
msgstr "Ð’ÑÑ– проекти плануютьÑÑ Ð´Ð»Ñ Ð¿Ð¾Ð²Ñ‚Ð¾Ñ€Ð½Ð¾Ñ— Ñинхронізації"
-msgid "Geo|Alternate URL"
-msgstr ""
-
msgid "Geo|Batch operations"
msgstr "Групові операції"
@@ -12607,4 +12601,3 @@ msgstr[3] "протÑгом %d хвилин "
msgid "yaml invalid"
msgstr ""
-
diff --git a/package.json b/package.json
index 74de09010d4..6d3e6764a46 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@babel/preset-env": "^7.3.1",
"@gitlab/csslab": "^1.9.0",
"@gitlab/svgs": "^1.58.0",
- "@gitlab/ui": "^3.0.0",
+ "@gitlab/ui": "^3.2.0",
"apollo-cache-inmemory": "^1.5.1",
"apollo-client": "^2.5.1",
"apollo-upload-client": "^10.0.0",
diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb
index 32949e0e7d6..485e3e21c4d 100644
--- a/spec/controllers/projects/blob_controller_spec.rb
+++ b/spec/controllers/projects/blob_controller_spec.rb
@@ -10,6 +10,8 @@ describe Projects::BlobController do
context 'with file path' do
before do
+ expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
+
get(:show,
params: {
namespace_id: project.namespace,
diff --git a/spec/controllers/projects/refs_controller_spec.rb b/spec/controllers/projects/refs_controller_spec.rb
index 62f2af947e4..0d0fa5d9f45 100644
--- a/spec/controllers/projects/refs_controller_spec.rb
+++ b/spec/controllers/projects/refs_controller_spec.rb
@@ -44,11 +44,15 @@ describe Projects::RefsController do
end
it 'renders JS' do
+ expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
+
xhr_get(:js)
expect(response).to be_success
end
it 'renders JSON' do
+ expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
+
xhr_get(:json)
expect(response).to be_success
diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb
index 276cf340962..782f5f272d9 100644
--- a/spec/controllers/projects/serverless/functions_controller_spec.rb
+++ b/spec/controllers/projects/serverless/functions_controller_spec.rb
@@ -76,6 +76,15 @@ describe Projects::Serverless::FunctionsController do
end
end
+ describe 'GET #metrics' do
+ context 'invalid data' do
+ it 'has a bad function name' do
+ get :metrics, params: params({ format: :json, environment_id: "*", id: "foo" })
+ expect(response).to have_gitlab_http_status(204)
+ end
+ end
+ end
+
describe 'GET #index with data', :use_clean_rails_memory_store_caching do
before do
stub_kubeclient_service_pods
diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb
index b15a2bc84a5..78201498eaa 100644
--- a/spec/controllers/projects/tree_controller_spec.rb
+++ b/spec/controllers/projects/tree_controller_spec.rb
@@ -16,6 +16,8 @@ describe Projects::TreeController do
render_views
before do
+ expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
+
get(:show,
params: {
namespace_id: project.namespace.to_param,
diff --git a/spec/features/issuables/markdown_references/internal_references_spec.rb b/spec/features/issuables/markdown_references/internal_references_spec.rb
index 23385ba65fc..870e92b8de8 100644
--- a/spec/features/issuables/markdown_references/internal_references_spec.rb
+++ b/spec/features/issuables/markdown_references/internal_references_spec.rb
@@ -70,7 +70,7 @@ describe "Internal references", :js do
page.within("#merge-requests ul") do
expect(page).to have_content(private_project_merge_request.title)
- expect(page).to have_css(".merge-request-status")
+ expect(page).to have_css(".ic-issue-open-m")
end
expect(page).to have_content("mentioned in merge request #{private_project_merge_request.to_reference(public_project)}")
diff --git a/spec/features/issues/user_uses_quick_actions_spec.rb b/spec/features/issues/user_uses_quick_actions_spec.rb
index 6a8b5e76cda..9938a4e781c 100644
--- a/spec/features/issues/user_uses_quick_actions_spec.rb
+++ b/spec/features/issues/user_uses_quick_actions_spec.rb
@@ -58,6 +58,7 @@ describe 'Issues > User uses quick actions', :js do
it_behaves_like 'confidential quick action'
it_behaves_like 'remove_due_date quick action'
+ it_behaves_like 'duplicate quick action'
describe 'adding a due date from note' do
let(:issue) { create(:issue, project: project) }
@@ -87,42 +88,6 @@ describe 'Issues > User uses quick actions', :js do
end
end
- describe 'mark issue as duplicate' do
- let(:issue) { create(:issue, project: project) }
- let(:original_issue) { create(:issue, project: project) }
-
- context 'when the current user can update issues' do
- it 'does not create a note, and marks the issue as a duplicate' do
- add_note("/duplicate ##{original_issue.to_reference}")
-
- expect(page).not_to have_content "/duplicate #{original_issue.to_reference}"
- expect(page).to have_content 'Commands applied'
- expect(page).to have_content "marked this issue as a duplicate of #{original_issue.to_reference}"
-
- expect(issue.reload).to be_closed
- end
- end
-
- context 'when the current user cannot update the issue' do
- let(:guest) { create(:user) }
- before do
- project.add_guest(guest)
- gitlab_sign_out
- sign_in(guest)
- visit project_issue_path(project, issue)
- end
-
- it 'does not create a note, and does not mark the issue as a duplicate' do
- add_note("/duplicate ##{original_issue.to_reference}")
-
- expect(page).not_to have_content 'Commands applied'
- expect(page).not_to have_content "marked this issue as a duplicate of #{original_issue.to_reference}"
-
- expect(issue.reload).to be_open
- end
- end
- end
-
describe 'move the issue to another project' do
let(:issue) { create(:issue, project: project) }
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index cb14db7665d..de780f13681 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -685,7 +685,7 @@ describe 'Pipelines', :js do
end
it 'creates a new pipeline' do
- expect { click_on 'Create pipeline' }
+ expect { click_on 'Run Pipeline' }
.to change { Ci::Pipeline.count }.by(1)
expect(Ci::Pipeline.last).to be_web
@@ -698,7 +698,7 @@ describe 'Pipelines', :js do
fill_in "Input variable value", with: "value"
end
- expect { click_on 'Create pipeline' }
+ expect { click_on 'Run Pipeline' }
.to change { Ci::Pipeline.count }.by(1)
expect(Ci::Pipeline.last.variables.map { |var| var.slice(:key, :secret_value) })
@@ -709,7 +709,7 @@ describe 'Pipelines', :js do
context 'without gitlab-ci.yml' do
before do
- click_on 'Create pipeline'
+ click_on 'Run Pipeline'
end
it { expect(page).to have_content('Missing .gitlab-ci.yml file') }
@@ -722,14 +722,14 @@ describe 'Pipelines', :js do
click_link 'master'
end
- expect { click_on 'Create pipeline' }
+ expect { click_on 'Run Pipeline' }
.to change { Ci::Pipeline.count }.by(1)
end
end
end
end
- describe 'Create pipelines' do
+ describe 'Run Pipelines' do
let(:project) { create(:project, :repository) }
before do
@@ -740,7 +740,7 @@ describe 'Pipelines', :js do
it 'has field to add a new pipeline' do
expect(page).to have_selector('.js-branch-select')
expect(find('.js-branch-select')).to have_content project.default_branch
- expect(page).to have_content('Create for')
+ expect(page).to have_content('Run for')
end
end
diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb
index aa71669de98..e14934b1672 100644
--- a/spec/features/projects/serverless/functions_spec.rb
+++ b/spec/features/projects/serverless/functions_spec.rb
@@ -50,7 +50,7 @@ describe 'Functions', :js do
end
it 'sees an empty listing of serverless functions' do
- expect(page).to have_selector('.gl-responsive-table-row')
+ expect(page).to have_selector('.empty-state')
end
end
end
diff --git a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
index 84de6858d5f..b1c2bab08c0 100644
--- a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
+++ b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
@@ -93,11 +93,13 @@ describe 'Projects > Settings > User manages merge request settings' do
it 'when unchecked sets :printing_merge_request_link_enabled to false' do
uncheck('project_printing_merge_request_link_enabled')
within('.merge-request-settings-form') do
+ find('.qa-save-merge-request-changes')
click_on('Save changes')
end
- # Wait for save to complete and page to reload
+ find('.flash-notice')
checkbox = find_field('project_printing_merge_request_link_enabled')
+
expect(checkbox).not_to be_checked
project.reload
diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb
index 35279906854..3ad38207da4 100644
--- a/spec/finders/projects/serverless/functions_finder_spec.rb
+++ b/spec/finders/projects/serverless/functions_finder_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
describe Projects::Serverless::FunctionsFinder do
include KubernetesHelpers
+ include PrometheusHelpers
include ReactiveCachingHelpers
let(:user) { create(:user) }
@@ -24,12 +25,12 @@ describe Projects::Serverless::FunctionsFinder do
describe 'retrieve data from knative' do
it 'does not have knative installed' do
- expect(described_class.new(project.clusters).execute).to be_empty
+ expect(described_class.new(project).execute).to be_empty
end
context 'has knative installed' do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
- let(:finder) { described_class.new(project.clusters) }
+ let(:finder) { described_class.new(project) }
it 'there are no functions' do
expect(finder.execute).to be_empty
@@ -58,13 +59,36 @@ describe Projects::Serverless::FunctionsFinder do
expect(result).not_to be_empty
expect(result["metadata"]["name"]).to be_eql(cluster.project.name)
end
+
+ it 'has metrics', :use_clean_rails_memory_store_caching do
+ end
+ end
+
+ context 'has prometheus' do
+ let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
+ let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
+ let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
+ let(:finder) { described_class.new(project) }
+
+ before do
+ allow(finder).to receive(:prometheus_adapter).and_return(prometheus_adapter)
+ allow(prometheus_adapter).to receive(:query).and_return(prometheus_empty_body('matrix'))
+ end
+
+ it 'is available' do
+ expect(finder.has_prometheus?("*")).to be true
+ end
+
+ it 'has query data' do
+ expect(finder.invocation_metrics("*", cluster.project.name)).not_to be_nil
+ end
end
end
describe 'verify if knative is installed' do
context 'knative is not installed' do
it 'does not have knative installed' do
- expect(described_class.new(project.clusters).installed?).to be false
+ expect(described_class.new(project).installed?).to be false
end
end
@@ -72,7 +96,7 @@ describe Projects::Serverless::FunctionsFinder do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
it 'does have knative installed' do
- expect(described_class.new(project.clusters).installed?).to be true
+ expect(described_class.new(project).installed?).to be true
end
end
end
diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js
index 0d3dcc29f22..eea7bd87257 100644
--- a/spec/javascripts/clusters/clusters_bundle_spec.js
+++ b/spec/frontend/clusters/clusters_bundle_spec.js
@@ -5,19 +5,41 @@ import {
APPLICATION_STATUS,
INGRESS_DOMAIN_SUFFIX,
} from '~/clusters/constants';
-import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { loadHTMLFixture } from 'helpers/fixtures';
+import { setTestTimeout } from 'helpers/timeout';
+import $ from 'jquery';
describe('Clusters', () => {
+ setTestTimeout(500);
+
let cluster;
- preloadFixtures('clusters/show_cluster.html');
+ let mock;
+
+ const mockGetClusterStatusRequest = () => {
+ const { statusPath } = document.querySelector('.js-edit-cluster-form').dataset;
+
+ mock = new MockAdapter(axios);
+
+ mock.onGet(statusPath).reply(200);
+ };
+
+ beforeEach(() => {
+ loadHTMLFixture('clusters/show_cluster.html');
+ });
+
+ beforeEach(() => {
+ mockGetClusterStatusRequest();
+ });
beforeEach(() => {
- loadFixtures('clusters/show_cluster.html');
cluster = new Clusters();
});
afterEach(() => {
cluster.destroy();
+ mock.restore();
});
describe('toggle', () => {
@@ -29,16 +51,13 @@ describe('Clusters', () => {
'.js-cluster-enable-toggle-area .js-project-feature-toggle-input',
);
- toggleButton.click();
-
- getSetTimeoutPromise()
- .then(() => {
- expect(toggleButton.classList).not.toContain('is-checked');
+ $(toggleInput).one('trigger-change', () => {
+ expect(toggleButton.classList).not.toContain('is-checked');
+ expect(toggleInput.getAttribute('value')).toEqual('false');
+ done();
+ });
- expect(toggleInput.getAttribute('value')).toEqual('false');
- })
- .then(done)
- .catch(done.fail);
+ toggleButton.click();
});
});
@@ -197,7 +216,7 @@ describe('Clusters', () => {
describe('installApplication', () => {
it('tries to install helm', () => {
- spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
+ jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
@@ -209,7 +228,7 @@ describe('Clusters', () => {
});
it('tries to install ingress', () => {
- spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
+ jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null);
@@ -221,7 +240,7 @@ describe('Clusters', () => {
});
it('tries to install runner', () => {
- spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
+ jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.runner.requestStatus).toEqual(null);
@@ -233,7 +252,7 @@ describe('Clusters', () => {
});
it('tries to install jupyter', () => {
- spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
+ jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null);
cluster.installApplication({
@@ -248,35 +267,32 @@ describe('Clusters', () => {
});
});
- it('sets error request status when the request fails', done => {
- spyOn(cluster.service, 'installApplication').and.returnValue(
- Promise.reject(new Error('STUBBED ERROR')),
- );
+ it('sets error request status when the request fails', () => {
+ jest
+ .spyOn(cluster.service, 'installApplication')
+ .mockRejectedValueOnce(new Error('STUBBED ERROR'));
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
- cluster.installApplication({ id: 'helm' });
+ const promise = cluster.installApplication({ id: 'helm' });
expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalled();
- getSetTimeoutPromise()
- .then(() => {
- expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_FAILURE);
- expect(cluster.store.state.applications.helm.requestReason).toBeDefined();
- })
- .then(done)
- .catch(done.fail);
+ return promise.then(() => {
+ expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_FAILURE);
+ expect(cluster.store.state.applications.helm.requestReason).toBeDefined();
+ });
});
});
describe('handleSuccess', () => {
beforeEach(() => {
- spyOn(cluster.store, 'updateStateFromServer');
- spyOn(cluster, 'toggleIngressDomainHelpText');
- spyOn(cluster, 'checkForNewInstalls');
- spyOn(cluster, 'updateContainer');
+ jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis();
+ jest.spyOn(cluster, 'toggleIngressDomainHelpText').mockReturnThis();
+ jest.spyOn(cluster, 'checkForNewInstalls').mockReturnThis();
+ jest.spyOn(cluster, 'updateContainer').mockReturnThis();
cluster.handleSuccess({ data: {} });
});
@@ -300,9 +316,13 @@ describe('Clusters', () => {
describe('toggleIngressDomainHelpText', () => {
const { INSTALLED, INSTALLABLE, NOT_INSTALLABLE } = APPLICATION_STATUS;
+ let ingressPreviousState;
+ let ingressNewState;
- const ingressPreviousState = { status: INSTALLABLE };
- const ingressNewState = { status: INSTALLED, externalIp: '127.0.0.1' };
+ beforeEach(() => {
+ ingressPreviousState = { status: INSTALLABLE };
+ ingressNewState = { status: INSTALLED, externalIp: '127.0.0.1' };
+ });
describe(`when ingress application new status is ${INSTALLED}`, () => {
beforeEach(() => {
@@ -333,7 +353,7 @@ describe('Clusters', () => {
});
describe('when ingress application new status and old status are the same', () => {
- it('does not modify custom domain help text', () => {
+ it('does not display custom domain help text', () => {
ingressPreviousState.status = INSTALLED;
ingressNewState.status = ingressPreviousState.status;
@@ -342,5 +362,15 @@ describe('Clusters', () => {
expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(true);
});
});
+
+ describe(`when ingress new status is ${INSTALLED} and there isn’t an ip assigned`, () => {
+ it('does not display custom domain help text', () => {
+ ingressNewState.externalIp = null;
+
+ cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState);
+
+ expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(true);
+ });
+ });
});
});
diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js
index a2dd4e93daf..b28d0075d06 100644
--- a/spec/javascripts/clusters/components/application_row_spec.js
+++ b/spec/frontend/clusters/components/application_row_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import eventHub from '~/clusters/event_hub';
import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import mountComponent from 'helpers/vue_mount_component_helper';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
describe('Application Row', () => {
@@ -160,7 +160,7 @@ describe('Application Row', () => {
});
it('clicking install button emits event', () => {
- spyOn(eventHub, '$emit');
+ jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
@@ -176,7 +176,7 @@ describe('Application Row', () => {
});
it('clicking install button when installApplicationRequestParams are provided emits event', () => {
- spyOn(eventHub, '$emit');
+ jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
@@ -193,7 +193,7 @@ describe('Application Row', () => {
});
it('clicking disabled install button emits nothing', () => {
- spyOn(eventHub, '$emit');
+ jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLING,
@@ -255,7 +255,7 @@ describe('Application Row', () => {
});
it('clicking upgrade button emits event', () => {
- spyOn(eventHub, '$emit');
+ jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATE_ERRORED,
@@ -271,7 +271,7 @@ describe('Application Row', () => {
});
it('clicking disabled upgrade button emits nothing', () => {
- spyOn(eventHub, '$emit');
+ jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATING,
diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js
index 0f8153ad493..7c54a27d950 100644
--- a/spec/javascripts/clusters/components/applications_spec.js
+++ b/spec/frontend/clusters/components/applications_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import applications from '~/clusters/components/applications.vue';
import { CLUSTER_TYPE } from '~/clusters/constants';
import eventHub from '~/clusters/event_hub';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import mountComponent from 'helpers/vue_mount_component_helper';
import { APPLICATIONS_MOCK_STATE } from '../services/mock_data';
describe('Applications', () => {
@@ -314,7 +314,7 @@ describe('Applications', () => {
});
it('emits event when clicking Save changes button', () => {
- spyOn(eventHub, '$emit');
+ jest.spyOn(eventHub, '$emit');
vm = mountComponent(Applications, props);
const saveButton = vm.$el.querySelector('.js-knative-save-domain-button');
diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js
index b4d1bb710e0..b4d1bb710e0 100644
--- a/spec/javascripts/clusters/services/mock_data.js
+++ b/spec/frontend/clusters/services/mock_data.js
diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js
index 161722ec571..161722ec571 100644
--- a/spec/javascripts/clusters/stores/clusters_store_spec.js
+++ b/spec/frontend/clusters/stores/clusters_store_spec.js
diff --git a/spec/frontend/serverless/components/area_spec.js b/spec/frontend/serverless/components/area_spec.js
new file mode 100644
index 00000000000..62005e1981a
--- /dev/null
+++ b/spec/frontend/serverless/components/area_spec.js
@@ -0,0 +1,122 @@
+import { shallowMount } from '@vue/test-utils';
+import Area from '~/serverless/components/area.vue';
+import { mockNormalizedMetrics } from '../mock_data';
+
+describe('Area component', () => {
+ const mockWidgets = 'mockWidgets';
+ const mockGraphData = mockNormalizedMetrics;
+ let areaChart;
+
+ beforeEach(() => {
+ areaChart = shallowMount(Area, {
+ propsData: {
+ graphData: mockGraphData,
+ containerWidth: 0,
+ },
+ slots: {
+ default: mockWidgets,
+ },
+ sync: false,
+ });
+ });
+
+ afterEach(() => {
+ areaChart.destroy();
+ });
+
+ it('renders chart title', () => {
+ expect(areaChart.find({ ref: 'graphTitle' }).text()).toBe(mockGraphData.title);
+ });
+
+ it('contains graph widgets from slot', () => {
+ expect(areaChart.find({ ref: 'graphWidgets' }).text()).toBe(mockWidgets);
+ });
+
+ describe('methods', () => {
+ describe('formatTooltipText', () => {
+ const mockDate = mockNormalizedMetrics.queries[0].result[0].values[0].time;
+ const generateSeriesData = type => ({
+ seriesData: [
+ {
+ componentSubType: type,
+ value: [mockDate, 4],
+ },
+ ],
+ value: mockDate,
+ });
+
+ describe('series is of line type', () => {
+ beforeEach(() => {
+ areaChart.vm.formatTooltipText(generateSeriesData('line'));
+ });
+
+ it('formats tooltip title', () => {
+ expect(areaChart.vm.tooltipPopoverTitle).toBe('28 Feb 2019, 11:11AM');
+ });
+
+ it('formats tooltip content', () => {
+ expect(areaChart.vm.tooltipPopoverContent).toBe('Invocations (requests): 4');
+ });
+ });
+
+ it('verify default interval value of 1', () => {
+ expect(areaChart.vm.getInterval).toBe(1);
+ });
+ });
+
+ describe('onResize', () => {
+ const mockWidth = 233;
+
+ beforeEach(() => {
+ jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({
+ width: mockWidth,
+ }));
+ areaChart.vm.onResize();
+ });
+
+ it('sets area chart width', () => {
+ expect(areaChart.vm.width).toBe(mockWidth);
+ });
+ });
+ });
+
+ describe('computed', () => {
+ describe('chartData', () => {
+ it('utilizes all data points', () => {
+ expect(Object.keys(areaChart.vm.chartData)).toEqual(['requests']);
+ expect(areaChart.vm.chartData.requests.length).toBe(2);
+ });
+
+ it('creates valid data', () => {
+ const data = areaChart.vm.chartData.requests;
+
+ expect(
+ data.filter(
+ datum => new Date(datum.time).getTime() > 0 && typeof datum.value === 'number',
+ ).length,
+ ).toBe(data.length);
+ });
+ });
+
+ describe('generateSeries', () => {
+ it('utilizes correct time data', () => {
+ expect(areaChart.vm.generateSeries.data).toEqual([
+ ['2019-02-28T11:11:38.756Z', 0],
+ ['2019-02-28T11:12:38.756Z', 0],
+ ]);
+ });
+ });
+
+ describe('xAxisLabel', () => {
+ it('constructs a label for the chart x-axis', () => {
+ expect(areaChart.vm.xAxisLabel).toBe('invocations / minute');
+ });
+ });
+
+ describe('yAxisLabel', () => {
+ it('constructs a label for the chart y-axis', () => {
+ expect(areaChart.vm.yAxisLabel).toBe('Invocations (requests)');
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/serverless/components/environment_row_spec.js b/spec/frontend/serverless/components/environment_row_spec.js
index bdf7a714910..161a637dd75 100644
--- a/spec/javascripts/serverless/components/environment_row_spec.js
+++ b/spec/frontend/serverless/components/environment_row_spec.js
@@ -1,81 +1,70 @@
-import Vue from 'vue';
-
import environmentRowComponent from '~/serverless/components/environment_row.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import ServerlessStore from '~/serverless/stores/serverless_store';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
+import { translate } from '~/serverless/utils';
-const createComponent = (env, envName) =>
- mountComponent(Vue.extend(environmentRowComponent), { env, envName });
+const createComponent = (localVue, env, envName) =>
+ shallowMount(environmentRowComponent, { localVue, propsData: { env, envName }, sync: false }).vm;
describe('environment row component', () => {
describe('default global cluster case', () => {
+ let localVue;
let vm;
beforeEach(() => {
- const store = new ServerlessStore(false, '/cluster_path', 'help_path');
- store.updateFunctionsFromServer(mockServerlessFunctions);
- vm = createComponent(store.state.functions['*'], '*');
+ localVue = createLocalVue();
+ vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*');
});
+ afterEach(() => vm.$destroy());
+
it('has the correct envId', () => {
expect(vm.envId).toEqual('env-global');
- vm.$destroy();
});
it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true });
- vm.$destroy();
});
it('generates correct output', () => {
- expect(vm.$el.querySelectorAll('li').length).toEqual(2);
expect(vm.$el.id).toEqual('env-global');
expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
-
- vm.$destroy();
});
it('opens and closes correctly', () => {
expect(vm.isOpen).toBe(true);
vm.toggleOpen();
- Vue.nextTick(() => {
- expect(vm.isOpen).toBe(false);
- });
- vm.$destroy();
+ expect(vm.isOpen).toBe(false);
});
});
describe('default named cluster case', () => {
let vm;
+ let localVue;
beforeEach(() => {
- const store = new ServerlessStore(false, '/cluster_path', 'help_path');
- store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv);
- vm = createComponent(store.state.functions.test, 'test');
+ localVue = createLocalVue();
+ vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test');
});
+ afterEach(() => vm.$destroy());
+
it('has the correct envId', () => {
expect(vm.envId).toEqual('env-test');
- vm.$destroy();
});
it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true });
- vm.$destroy();
});
it('generates correct output', () => {
- expect(vm.$el.querySelectorAll('li').length).toEqual(1);
expect(vm.$el.id).toEqual('env-test');
expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
-
- vm.$destroy();
});
});
});
diff --git a/spec/frontend/serverless/components/function_details_spec.js b/spec/frontend/serverless/components/function_details_spec.js
new file mode 100644
index 00000000000..31348ff1194
--- /dev/null
+++ b/spec/frontend/serverless/components/function_details_spec.js
@@ -0,0 +1,117 @@
+import Vuex from 'vuex';
+
+import functionDetailsComponent from '~/serverless/components/function_details.vue';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { createStore } from '~/serverless/store';
+
+describe('functionDetailsComponent', () => {
+ let localVue;
+ let component;
+ let store;
+
+ beforeEach(() => {
+ localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ store = createStore();
+ });
+
+ afterEach(() => {
+ component.vm.$destroy();
+ });
+
+ describe('Verify base functionality', () => {
+ const serviceStub = {
+ name: 'test',
+ description: 'a description',
+ environment: '*',
+ url: 'http://service.com/test',
+ namespace: 'test-ns',
+ podcount: 0,
+ metricsUrl: '/metrics',
+ };
+
+ it('has a name, description, URL, and no pods loaded', () => {
+ component = shallowMount(functionDetailsComponent, {
+ localVue,
+ store,
+ propsData: {
+ func: serviceStub,
+ hasPrometheus: false,
+ clustersPath: '/clusters',
+ helpPath: '/help',
+ },
+ sync: false,
+ });
+
+ expect(
+ component.vm.$el.querySelector('.serverless-function-name').innerHTML.trim(),
+ ).toContain('test');
+
+ expect(
+ component.vm.$el.querySelector('.serverless-function-description').innerHTML.trim(),
+ ).toContain('a description');
+
+ expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain(
+ 'No pods loaded at this time.',
+ );
+ });
+
+ it('has a pods loaded', () => {
+ serviceStub.podcount = 1;
+
+ component = shallowMount(functionDetailsComponent, {
+ localVue,
+ store,
+ propsData: {
+ func: serviceStub,
+ hasPrometheus: false,
+ clustersPath: '/clusters',
+ helpPath: '/help',
+ },
+ sync: false,
+ });
+
+ expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('1 pod in use');
+ });
+
+ it('has multiple pods loaded', () => {
+ serviceStub.podcount = 3;
+
+ component = shallowMount(functionDetailsComponent, {
+ localVue,
+ store,
+ propsData: {
+ func: serviceStub,
+ hasPrometheus: false,
+ clustersPath: '/clusters',
+ helpPath: '/help',
+ },
+ sync: false,
+ });
+
+ expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('3 pods in use');
+ });
+
+ it('can support a missing description', () => {
+ serviceStub.description = null;
+
+ component = shallowMount(functionDetailsComponent, {
+ localVue,
+ store,
+ propsData: {
+ func: serviceStub,
+ hasPrometheus: false,
+ clustersPath: '/clusters',
+ helpPath: '/help',
+ },
+ sync: false,
+ });
+
+ expect(
+ component.vm.$el.querySelector('.serverless-function-description').querySelector('div')
+ .innerHTML.length,
+ ).toEqual(0);
+ });
+ });
+});
diff --git a/spec/javascripts/serverless/components/function_row_spec.js b/spec/frontend/serverless/components/function_row_spec.js
index 6933a8f6c87..414fdc5cd82 100644
--- a/spec/javascripts/serverless/components/function_row_spec.js
+++ b/spec/frontend/serverless/components/function_row_spec.js
@@ -1,11 +1,10 @@
-import Vue from 'vue';
-
import functionRowComponent from '~/serverless/components/function_row.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { shallowMount } from '@vue/test-utils';
import { mockServerlessFunction } from '../mock_data';
-const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func });
+const createComponent = func =>
+ shallowMount(functionRowComponent, { propsData: { func }, sync: false }).vm;
describe('functionRowComponent', () => {
it('Parses the function details correctly', () => {
@@ -13,10 +12,7 @@ describe('functionRowComponent', () => {
expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image);
- expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null);
- expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
- mockServerlessFunction.url,
- );
+ expect(vm.$el.querySelector('timeago-stub').getAttribute('time')).not.toBe(null);
vm.$destroy();
});
@@ -25,8 +21,6 @@ describe('functionRowComponent', () => {
const vm = createComponent(mockServerlessFunction);
expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
- expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image
- expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar
vm.$destroy();
});
diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js
new file mode 100644
index 00000000000..5533de1a70a
--- /dev/null
+++ b/spec/frontend/serverless/components/functions_spec.js
@@ -0,0 +1,106 @@
+import Vuex from 'vuex';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import functionsComponent from '~/serverless/components/functions.vue';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { createStore } from '~/serverless/store';
+import { mockServerlessFunctions } from '../mock_data';
+
+describe('functionsComponent', () => {
+ let component;
+ let store;
+ let localVue;
+
+ beforeEach(() => {
+ localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ store = createStore();
+ });
+
+ afterEach(() => {
+ component.vm.$destroy();
+ });
+
+ it('should render empty state when Knative is not installed', () => {
+ component = shallowMount(functionsComponent, {
+ localVue,
+ store,
+ propsData: {
+ installed: false,
+ clustersPath: '',
+ helpPath: '',
+ statusPath: '',
+ },
+ sync: false,
+ });
+
+ expect(component.vm.$el.querySelector('emptystate-stub')).not.toBe(null);
+ });
+
+ it('should render a loading component', () => {
+ store.dispatch('requestFunctionsLoading');
+ component = shallowMount(functionsComponent, {
+ localVue,
+ store,
+ propsData: {
+ installed: true,
+ clustersPath: '',
+ helpPath: '',
+ statusPath: '',
+ },
+ sync: false,
+ });
+
+ expect(component.vm.$el.querySelector('glloadingicon-stub')).not.toBe(null);
+ });
+
+ it('should render empty state when there is no function data', () => {
+ store.dispatch('receiveFunctionsNoDataSuccess');
+ component = shallowMount(functionsComponent, {
+ localVue,
+ store,
+ propsData: {
+ installed: true,
+ clustersPath: '',
+ helpPath: '',
+ statusPath: '',
+ },
+ sync: false,
+ });
+
+ expect(
+ component.vm.$el
+ .querySelector('.empty-state, .js-empty-state')
+ .classList.contains('js-empty-state'),
+ ).toBe(true);
+
+ expect(component.vm.$el.querySelector('.state-title, .text-center').innerHTML.trim()).toEqual(
+ 'No functions available',
+ );
+ });
+
+ fit('should render the functions list', () => {
+ const statusPath = 'statusPath';
+ const axiosMock = new AxiosMockAdapter(axios);
+ axiosMock.onGet(statusPath).reply(200);
+
+ component = shallowMount(functionsComponent, {
+ localVue,
+ store,
+ propsData: {
+ installed: true,
+ clustersPath: 'clustersPath',
+ helpPath: 'helpPath',
+ statusPath,
+ },
+ sync: false,
+ });
+
+ component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions);
+
+ return component.vm.$nextTick().then(() => {
+ expect(component.vm.$el.querySelector('environmentrow-stub')).not.toBe(null);
+ });
+ });
+});
diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js
new file mode 100644
index 00000000000..d0df6125290
--- /dev/null
+++ b/spec/frontend/serverless/components/missing_prometheus_spec.js
@@ -0,0 +1,38 @@
+import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue';
+import { shallowMount } from '@vue/test-utils';
+
+const createComponent = missingData =>
+ shallowMount(missingPrometheusComponent, {
+ propsData: {
+ clustersPath: '/clusters',
+ helpPath: '/help',
+ missingData,
+ },
+ sync: false,
+ }).vm;
+
+describe('missingPrometheusComponent', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render missing prometheus message', () => {
+ vm = createComponent(false);
+
+ expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain(
+ 'Function invocation metrics require Prometheus to be installed first.',
+ );
+
+ expect(vm.$el.querySelector('glbutton-stub').getAttribute('variant')).toEqual('success');
+ });
+
+ it('should render no prometheus data message', () => {
+ vm = createComponent(true);
+
+ expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain(
+ 'Invocation metrics loading or not available at this time.',
+ );
+ });
+});
diff --git a/spec/frontend/serverless/components/pod_box_spec.js b/spec/frontend/serverless/components/pod_box_spec.js
new file mode 100644
index 00000000000..d82825d8f62
--- /dev/null
+++ b/spec/frontend/serverless/components/pod_box_spec.js
@@ -0,0 +1,23 @@
+import podBoxComponent from '~/serverless/components/pod_box.vue';
+import { shallowMount } from '@vue/test-utils';
+
+const createComponent = count =>
+ shallowMount(podBoxComponent, {
+ propsData: {
+ count,
+ },
+ sync: false,
+ }).vm;
+
+describe('podBoxComponent', () => {
+ it('should render three boxes', () => {
+ const count = 3;
+ const vm = createComponent(count);
+ const rects = vm.$el.querySelectorAll('rect');
+
+ expect(rects.length).toEqual(3);
+ expect(parseInt(rects[2].getAttribute('x'), 10)).toEqual(40);
+
+ vm.$destroy();
+ });
+});
diff --git a/spec/javascripts/serverless/components/url_spec.js b/spec/frontend/serverless/components/url_spec.js
index 21a879a49bb..d05a9bba103 100644
--- a/spec/javascripts/serverless/components/url_spec.js
+++ b/spec/frontend/serverless/components/url_spec.js
@@ -1,15 +1,14 @@
import Vue from 'vue';
-
import urlComponent from '~/serverless/components/url.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-
-const createComponent = uri => {
- const component = Vue.extend(urlComponent);
+import { shallowMount } from '@vue/test-utils';
- return mountComponent(component, {
- uri,
- });
-};
+const createComponent = uri =>
+ shallowMount(Vue.extend(urlComponent), {
+ propsData: {
+ uri,
+ },
+ sync: false,
+ }).vm;
describe('urlComponent', () => {
it('should render correctly', () => {
@@ -17,9 +16,7 @@ describe('urlComponent', () => {
const vm = createComponent(uri);
expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
- expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual(
- uri,
- );
+ expect(vm.$el.querySelector('clipboardbutton-stub').getAttribute('text')).toEqual(uri);
expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
diff --git a/spec/javascripts/serverless/mock_data.js b/spec/frontend/serverless/mock_data.js
index ecd393b174c..a2c18616324 100644
--- a/spec/javascripts/serverless/mock_data.js
+++ b/spec/frontend/serverless/mock_data.js
@@ -77,3 +77,60 @@ export const mockMultilineServerlessFunction = {
description: 'testfunc1\nA test service line\\nWith additional services',
image: 'knative-test-container-buildtemplate',
};
+
+export const mockMetrics = {
+ success: true,
+ last_update: '2019-02-28T19:11:38.926Z',
+ metrics: {
+ id: 22,
+ title: 'Knative function invocations',
+ required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
+ weight: 0,
+ y_label: 'Invocations',
+ queries: [
+ {
+ query_range:
+ 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
+ unit: 'requests',
+ label: 'invocations / minute',
+ result: [
+ {
+ metric: {},
+ values: [[1551352298.756, '0'], [1551352358.756, '0']],
+ },
+ ],
+ },
+ ],
+ },
+};
+
+export const mockNormalizedMetrics = {
+ id: 22,
+ title: 'Knative function invocations',
+ required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
+ weight: 0,
+ y_label: 'Invocations',
+ queries: [
+ {
+ query_range:
+ 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
+ unit: 'requests',
+ label: 'invocations / minute',
+ result: [
+ {
+ metric: {},
+ values: [
+ {
+ time: '2019-02-28T11:11:38.756Z',
+ value: 0,
+ },
+ {
+ time: '2019-02-28T11:12:38.756Z',
+ value: 0,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
diff --git a/spec/frontend/serverless/store/actions_spec.js b/spec/frontend/serverless/store/actions_spec.js
new file mode 100644
index 00000000000..aac57c75a4f
--- /dev/null
+++ b/spec/frontend/serverless/store/actions_spec.js
@@ -0,0 +1,90 @@
+import MockAdapter from 'axios-mock-adapter';
+import statusCodes from '~/lib/utils/http_status';
+import { fetchFunctions, fetchMetrics } from '~/serverless/store/actions';
+import { mockServerlessFunctions, mockMetrics } from '../mock_data';
+import axios from '~/lib/utils/axios_utils';
+import testAction from '../../helpers/vuex_action_helper';
+import { adjustMetricQuery } from '../utils';
+
+describe('ServerlessActions', () => {
+ describe('fetchFunctions', () => {
+ it('should successfully fetch functions', done => {
+ const endpoint = '/functions';
+ const mock = new MockAdapter(axios);
+ mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions));
+
+ testAction(
+ fetchFunctions,
+ { functionsPath: endpoint },
+ {},
+ [],
+ [
+ { type: 'requestFunctionsLoading' },
+ { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions },
+ ],
+ () => {
+ mock.restore();
+ done();
+ },
+ );
+ });
+
+ it('should successfully retry', done => {
+ const endpoint = '/functions';
+ const mock = new MockAdapter(axios);
+ mock
+ .onGet(endpoint)
+ .reply(() => new Promise(resolve => setTimeout(() => resolve(200), Infinity)));
+
+ testAction(
+ fetchFunctions,
+ { functionsPath: endpoint },
+ {},
+ [],
+ [{ type: 'requestFunctionsLoading' }],
+ () => {
+ mock.restore();
+ done();
+ },
+ );
+ });
+ });
+
+ describe('fetchMetrics', () => {
+ it('should return no prometheus', done => {
+ const endpoint = '/metrics';
+ const mock = new MockAdapter(axios);
+ mock.onGet(endpoint).reply(statusCodes.NO_CONTENT);
+
+ testAction(
+ fetchMetrics,
+ { metricsPath: endpoint, hasPrometheus: false },
+ {},
+ [],
+ [{ type: 'receiveMetricsNoPrometheus' }],
+ () => {
+ mock.restore();
+ done();
+ },
+ );
+ });
+
+ it('should successfully fetch metrics', done => {
+ const endpoint = '/metrics';
+ const mock = new MockAdapter(axios);
+ mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics));
+
+ testAction(
+ fetchMetrics,
+ { metricsPath: endpoint, hasPrometheus: true },
+ {},
+ [],
+ [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }],
+ () => {
+ mock.restore();
+ done();
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/serverless/store/getters_spec.js b/spec/frontend/serverless/store/getters_spec.js
new file mode 100644
index 00000000000..fb549c8f153
--- /dev/null
+++ b/spec/frontend/serverless/store/getters_spec.js
@@ -0,0 +1,43 @@
+import serverlessState from '~/serverless/store/state';
+import * as getters from '~/serverless/store/getters';
+import { mockServerlessFunctions } from '../mock_data';
+
+describe('Serverless Store Getters', () => {
+ let state;
+
+ beforeEach(() => {
+ state = serverlessState;
+ });
+
+ describe('hasPrometheusMissingData', () => {
+ it('should return false if Prometheus is not installed', () => {
+ state.hasPrometheus = false;
+
+ expect(getters.hasPrometheusMissingData(state)).toEqual(false);
+ });
+
+ it('should return false if Prometheus is installed and there is data', () => {
+ state.hasPrometheusData = true;
+
+ expect(getters.hasPrometheusMissingData(state)).toEqual(false);
+ });
+
+ it('should return true if Prometheus is installed and there is no data', () => {
+ state.hasPrometheus = true;
+ state.hasPrometheusData = false;
+
+ expect(getters.hasPrometheusMissingData(state)).toEqual(true);
+ });
+ });
+
+ describe('getFunctions', () => {
+ it('should translate the raw function array to group the functions per environment scope', () => {
+ state.functions = mockServerlessFunctions;
+
+ const funcs = getters.getFunctions(state);
+
+ expect(Object.keys(funcs)).toContain('*');
+ expect(funcs['*'].length).toEqual(2);
+ });
+ });
+});
diff --git a/spec/frontend/serverless/store/mutations_spec.js b/spec/frontend/serverless/store/mutations_spec.js
new file mode 100644
index 00000000000..ca3053e5c38
--- /dev/null
+++ b/spec/frontend/serverless/store/mutations_spec.js
@@ -0,0 +1,86 @@
+import mutations from '~/serverless/store/mutations';
+import * as types from '~/serverless/store/mutation_types';
+import { mockServerlessFunctions, mockMetrics } from '../mock_data';
+
+describe('ServerlessMutations', () => {
+ describe('Functions List Mutations', () => {
+ it('should ensure loading is true', () => {
+ const state = {};
+
+ mutations[types.REQUEST_FUNCTIONS_LOADING](state);
+
+ expect(state.isLoading).toEqual(true);
+ });
+
+ it('should set proper state once functions are loaded', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_FUNCTIONS_SUCCESS](state, mockServerlessFunctions);
+
+ expect(state.isLoading).toEqual(false);
+ expect(state.hasFunctionData).toEqual(true);
+ expect(state.functions).toEqual(mockServerlessFunctions);
+ });
+
+ it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state);
+
+ expect(state.isLoading).toEqual(false);
+ expect(state.hasFunctionData).toEqual(false);
+ expect(state.functions).toBe(undefined);
+ });
+
+ it('should ensure loading has stopped, and an error is raised', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_FUNCTIONS_ERROR](state, 'sample error');
+
+ expect(state.isLoading).toEqual(false);
+ expect(state.hasFunctionData).toEqual(false);
+ expect(state.functions).toBe(undefined);
+ expect(state.error).not.toBe(undefined);
+ });
+ });
+
+ describe('Function Details Metrics Mutations', () => {
+ it('should ensure isLoading and hasPrometheus data flags indicate data is loaded', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_METRICS_SUCCESS](state, mockMetrics);
+
+ expect(state.isLoading).toEqual(false);
+ expect(state.hasPrometheusData).toEqual(true);
+ expect(state.graphData).toEqual(mockMetrics);
+ });
+
+ it('should ensure isLoading and hasPrometheus data flags are cleared indicating no functions available', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_METRICS_NODATA_SUCCESS](state);
+
+ expect(state.isLoading).toEqual(false);
+ expect(state.hasPrometheusData).toEqual(false);
+ expect(state.graphData).toBe(undefined);
+ });
+
+ it('should properly indicate an error', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_METRICS_ERROR](state, 'sample error');
+
+ expect(state.hasPrometheusData).toEqual(false);
+ expect(state.error).not.toBe(undefined);
+ });
+
+ it('should properly indicate when prometheus is installed', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_METRICS_NO_PROMETHEUS](state);
+
+ expect(state.hasPrometheus).toEqual(false);
+ expect(state.hasPrometheusData).toEqual(false);
+ });
+ });
+});
diff --git a/spec/frontend/serverless/utils.js b/spec/frontend/serverless/utils.js
new file mode 100644
index 00000000000..5ce2e37d493
--- /dev/null
+++ b/spec/frontend/serverless/utils.js
@@ -0,0 +1,20 @@
+export const adjustMetricQuery = data => {
+ const updatedMetric = data.metrics;
+
+ const queries = data.metrics.queries.map(query => ({
+ ...query,
+ result: query.result.map(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => ({
+ time: new Date(timestamp * 1000).toISOString(),
+ value: Number(value),
+ })),
+ })),
+ }));
+
+ updatedMetric.queries = queries;
+ return updatedMetric;
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb
index 645b3aa788a..0f3f9a10f94 100644
--- a/spec/javascripts/fixtures/issues.rb
+++ b/spec/javascripts/fixtures/issues.rb
@@ -65,3 +65,61 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller
store_frontend_fixture(response, fixture_file_name)
end
end
+
+describe API::Issues, '(JavaScript fixtures)', type: :request do
+ include ApiHelpers
+ include JavaScriptFixturesHelpers
+
+ def get_related_merge_requests(project_id, issue_iid, user = nil)
+ get api("/projects/#{project_id}/issues/#{issue_iid}/related_merge_requests", user)
+ end
+
+ def create_referencing_mr(user, project, issue)
+ attributes = {
+ author: user,
+ source_project: project,
+ target_project: project,
+ source_branch: "master",
+ target_branch: "test",
+ assignee: user,
+ description: "See #{issue.to_reference}"
+ }
+ create(:merge_request, attributes).tap do |merge_request|
+ create(:note, :system, project: issue.project, noteable: issue, author: user, note: merge_request.to_reference(full: true))
+ end
+ end
+
+ it 'issues/related_merge_requests.json' do |example|
+ user = create(:user)
+ project = create(:project, :public, creator_id: user.id, namespace: user.namespace)
+ issue_title = 'foo'
+ issue_description = 'closed'
+ milestone = create(:milestone, title: '1.0.0', project: project)
+ issue = create :issue,
+ author: user,
+ assignees: [user],
+ project: project,
+ milestone: milestone,
+ created_at: generate(:past_time),
+ updated_at: 1.hour.ago,
+ title: issue_title,
+ description: issue_description
+
+ project.add_reporter(user)
+ create_referencing_mr(user, project, issue)
+
+ create(:merge_request,
+ :simple,
+ author: user,
+ source_project: project,
+ target_project: project,
+ description: "Some description")
+ project2 = create(:project, :public, creator_id: user.id, namespace: user.namespace)
+ create_referencing_mr(user, project2, issue).update!(head_pipeline: create(:ci_pipeline))
+
+ get_related_merge_requests(project.id, issue.iid, user)
+
+ expect(response).to be_success
+ store_frontend_fixture(response, example.description)
+ end
+end
diff --git a/spec/javascripts/monitoring/charts/area_spec.js b/spec/javascripts/monitoring/charts/area_spec.js
index 549a7935c0f..41a6c04efb9 100644
--- a/spec/javascripts/monitoring/charts/area_spec.js
+++ b/spec/javascripts/monitoring/charts/area_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { shallowWrapperContainsSlotText } from 'spec/helpers/vue_test_utils_helper';
import Area from '~/monitoring/components/charts/area.vue';
import MonitoringStore from '~/monitoring/stores/monitoring_store';
@@ -65,7 +65,7 @@ describe('Area component', () => {
expect(props.data).toBe(areaChart.vm.chartData);
expect(props.option).toBe(areaChart.vm.chartOptions);
expect(props.formatTooltipText).toBe(areaChart.vm.formatTooltipText);
- expect(props.thresholds).toBe(areaChart.props('alertData'));
+ expect(props.thresholds).toBe(areaChart.vm.thresholds);
});
it('recieves a tooltip title', () => {
@@ -105,12 +105,13 @@ describe('Area component', () => {
seriesName: areaChart.vm.chartData[0].name,
componentSubType: type,
value: [mockDate, 5.55555],
+ seriesIndex: 0,
},
],
value: mockDate,
});
- describe('series is of line type', () => {
+ describe('when series is of line type', () => {
beforeEach(() => {
areaChart.vm.formatTooltipText(generateSeriesData('line'));
});
@@ -120,18 +121,20 @@ describe('Area component', () => {
});
it('formats tooltip content', () => {
- expect(areaChart.vm.tooltip.content).toEqual([{ name: 'Core Usage', value: '5.556' }]);
+ const name = 'Core Usage';
+ const value = '5.556';
+ const seriesLabel = areaChart.find(GlChartSeriesLabel);
+
+ expect(seriesLabel.vm.color).toBe('');
+ expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true);
+ expect(areaChart.vm.tooltip.content).toEqual([{ name, value, color: undefined }]);
expect(
- shallowWrapperContainsSlotText(
- areaChart.find(GlAreaChart),
- 'tooltipContent',
- 'Core Usage 5.556',
- ),
+ shallowWrapperContainsSlotText(areaChart.find(GlAreaChart), 'tooltipContent', value),
).toBe(true);
});
});
- describe('series is of scatter type', () => {
+ describe('when series is of scatter type', () => {
beforeEach(() => {
areaChart.vm.formatTooltipText(generateSeriesData('scatter'));
});
diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js
index 454777fa912..ce2c6c43c0f 100644
--- a/spec/javascripts/monitoring/dashboard_spec.js
+++ b/spec/javascripts/monitoring/dashboard_spec.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue';
+import { timeWindows } from '~/monitoring/constants';
import axios from '~/lib/utils/axios_utils';
import { metricsGroupsAPIResponse, mockApiEndpoint, environmentData } from './mock_data';
@@ -50,7 +51,7 @@ describe('Dashboard', () => {
it('shows a getting started empty state when no metrics are present', () => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData,
+ propsData: { ...propsData, showTimeWindowDropdown: false },
});
expect(component.$el.querySelector('.prometheus-graphs')).toBe(null);
@@ -66,7 +67,7 @@ describe('Dashboard', () => {
it('shows up a loading state', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData: { ...propsData, hasMetrics: true },
+ propsData: { ...propsData, hasMetrics: true, showTimeWindowDropdown: false },
});
Vue.nextTick(() => {
@@ -78,7 +79,12 @@ describe('Dashboard', () => {
it('hides the legend when showLegend is false', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData: { ...propsData, hasMetrics: true, showLegend: false },
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showLegend: false,
+ showTimeWindowDropdown: false,
+ },
});
setTimeout(() => {
@@ -92,7 +98,12 @@ describe('Dashboard', () => {
it('hides the group panels when showPanels is false', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData: { ...propsData, hasMetrics: true, showPanels: false },
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ },
});
setTimeout(() => {
@@ -106,7 +117,12 @@ describe('Dashboard', () => {
it('renders the environments dropdown with a number of environments', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData: { ...propsData, hasMetrics: true, showPanels: false },
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ },
});
component.store.storeEnvironmentsData(environmentData);
@@ -124,7 +140,12 @@ describe('Dashboard', () => {
it('hides the environments dropdown list when there is no environments', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData: { ...propsData, hasMetrics: true, showPanels: false },
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ },
});
component.store.storeEnvironmentsData([]);
@@ -142,7 +163,12 @@ describe('Dashboard', () => {
it('renders the environments dropdown with a single is-active element', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData: { ...propsData, hasMetrics: true, showPanels: false },
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ },
});
component.store.storeEnvironmentsData(environmentData);
@@ -166,6 +192,7 @@ describe('Dashboard', () => {
hasMetrics: true,
showPanels: false,
environmentsEndpoint: '',
+ showTimeWindowDropdown: false,
},
});
@@ -176,6 +203,51 @@ describe('Dashboard', () => {
done();
});
});
+
+ it('does not show the time window dropdown when the feature flag is not set', done => {
+ const component = new DashboardComponent({
+ el: document.querySelector('.prometheus-graphs'),
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ },
+ });
+
+ setTimeout(() => {
+ const timeWindowDropdown = component.$el.querySelector('.js-time-window-dropdown');
+
+ expect(timeWindowDropdown).toBeNull();
+
+ done();
+ });
+ });
+
+ it('renders the time window dropdown with a set of options', done => {
+ const component = new DashboardComponent({
+ el: document.querySelector('.prometheus-graphs'),
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: true,
+ },
+ });
+ const numberOfTimeWindows = Object.keys(timeWindows).length;
+
+ setTimeout(() => {
+ const timeWindowDropdown = component.$el.querySelector('.js-time-window-dropdown');
+ const timeWindowDropdownEls = component.$el.querySelectorAll(
+ '.js-time-window-dropdown .dropdown-item',
+ );
+
+ expect(timeWindowDropdown).not.toBeNull();
+ expect(timeWindowDropdownEls.length).toEqual(numberOfTimeWindows);
+
+ done();
+ });
+ });
});
describe('when the window resizes', () => {
@@ -191,7 +263,12 @@ describe('Dashboard', () => {
it('sets elWidth to page width when the sidebar is resized', done => {
const component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
- propsData: { ...propsData, hasMetrics: true, showPanels: false },
+ propsData: {
+ ...propsData,
+ hasMetrics: true,
+ showPanels: false,
+ showTimeWindowDropdown: false,
+ },
});
expect(component.elWidth).toEqual(0);
diff --git a/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js b/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js
new file mode 100644
index 00000000000..29760f79c3c
--- /dev/null
+++ b/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js
@@ -0,0 +1,89 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue';
+import createStore from '~/related_merge_requests/store/index';
+
+const FIXTURE_PATH = 'issues/related_merge_requests.json';
+const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests';
+const localVue = createLocalVue();
+
+describe('RelatedMergeRequests', () => {
+ let wrapper;
+ let mock;
+ let mockData;
+
+ beforeEach(done => {
+ loadFixtures(FIXTURE_PATH);
+ mockData = getJSONFixture(FIXTURE_PATH);
+ mock = new MockAdapter(axios);
+ mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 });
+
+ wrapper = mount(RelatedMergeRequests, {
+ localVue,
+ sync: false,
+ store: createStore(),
+ propsData: {
+ endpoint: API_ENDPOINT,
+ projectNamespace: 'gitlab-org',
+ projectPath: 'gitlab-ce',
+ },
+ });
+
+ setTimeout(done);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('methods', () => {
+ describe('getAssignees', () => {
+ const assignees = [{ name: 'foo' }, { name: 'bar' }];
+
+ describe('when there is assignees array', () => {
+ it('should return assignees array', () => {
+ const mr = { assignees };
+
+ expect(wrapper.vm.getAssignees(mr)).toEqual(assignees);
+ });
+ });
+
+ it('should return an array with single assingee', () => {
+ const mr = { assignee: assignees[0] };
+
+ expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]);
+ });
+
+ it('should return empty array when assignee is not set', () => {
+ expect(wrapper.vm.getAssignees({})).toEqual([]);
+ expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render related merge request items', () => {
+ expect(wrapper.find('.js-items-count').text()).toEqual('2');
+ expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2);
+
+ const props = wrapper
+ .findAll(RelatedIssuableItem)
+ .at(1)
+ .props();
+ const data = mockData[1];
+
+ expect(props.idKey).toEqual(data.id);
+ expect(props.pathIdSeparator).toEqual('!');
+ expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status);
+ expect(props.assignees).toEqual([data.assignee]);
+ expect(props.isMergeRequest).toBe(true);
+ expect(props.confidential).toEqual(false);
+ expect(props.title).toEqual(data.title);
+ expect(props.state).toEqual(data.state);
+ expect(props.createdAt).toEqual(data.created_at);
+ });
+ });
+});
diff --git a/spec/javascripts/related_merge_requests/store/actions_spec.js b/spec/javascripts/related_merge_requests/store/actions_spec.js
new file mode 100644
index 00000000000..65e436fbb17
--- /dev/null
+++ b/spec/javascripts/related_merge_requests/store/actions_spec.js
@@ -0,0 +1,110 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import * as types from '~/related_merge_requests/store/mutation_types';
+import actionsModule, * as actions from '~/related_merge_requests/store/actions';
+import testAction from 'spec/helpers/vuex_action_helper';
+
+describe('RelatedMergeRequest store actions', () => {
+ let state;
+ let flashSpy;
+ let mock;
+
+ beforeEach(() => {
+ state = {
+ apiEndpoint: '/api/related_merge_requests',
+ };
+ flashSpy = spyOnDependency(actionsModule, 'createFlash');
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setInitialState', () => {
+ it('commits types.SET_INITIAL_STATE with given props', done => {
+ const props = { a: 1, b: 2 };
+
+ testAction(
+ actions.setInitialState,
+ props,
+ {},
+ [{ type: types.SET_INITIAL_STATE, payload: props }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestData', () => {
+ it('commits types.REQUEST_DATA', done => {
+ testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done);
+ });
+ });
+
+ describe('receiveDataSuccess', () => {
+ it('commits types.RECEIVE_DATA_SUCCESS with data', done => {
+ const data = { a: 1, b: 2 };
+
+ testAction(
+ actions.receiveDataSuccess,
+ data,
+ {},
+ [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveDataError', () => {
+ it('commits types.RECEIVE_DATA_ERROR', done => {
+ testAction(
+ actions.receiveDataError,
+ null,
+ {},
+ [{ type: types.RECEIVE_DATA_ERROR }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchMergeRequests', () => {
+ describe('for a successful request', () => {
+ it('should dispatch success action', done => {
+ const data = { a: 1 };
+ mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 });
+
+ testAction(
+ actions.fetchMergeRequests,
+ null,
+ state,
+ [],
+ [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }],
+ done,
+ );
+ });
+ });
+
+ describe('for a failing request', () => {
+ it('should dispatch error action', done => {
+ mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400);
+
+ testAction(
+ actions.fetchMergeRequests,
+ null,
+ state,
+ [],
+ [{ type: 'requestData' }, { type: 'receiveDataError' }],
+ () => {
+ expect(flashSpy).toHaveBeenCalledTimes(1);
+ expect(flashSpy).toHaveBeenCalledWith(jasmine.stringMatching('Something went wrong'));
+
+ done();
+ },
+ );
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/related_merge_requests/store/mutations_spec.js b/spec/javascripts/related_merge_requests/store/mutations_spec.js
new file mode 100644
index 00000000000..21b6e26376b
--- /dev/null
+++ b/spec/javascripts/related_merge_requests/store/mutations_spec.js
@@ -0,0 +1,49 @@
+import mutations from '~/related_merge_requests/store/mutations';
+import * as types from '~/related_merge_requests/store/mutation_types';
+
+describe('RelatedMergeRequests Store Mutations', () => {
+ describe('SET_INITIAL_STATE', () => {
+ it('should set initial state according to given data', () => {
+ const apiEndpoint = '/api';
+ const state = {};
+
+ mutations[types.SET_INITIAL_STATE](state, { apiEndpoint });
+
+ expect(state.apiEndpoint).toEqual(apiEndpoint);
+ });
+ });
+
+ describe('REQUEST_DATA', () => {
+ it('should set loading flag', () => {
+ const state = {};
+
+ mutations[types.REQUEST_DATA](state);
+
+ expect(state.isFetchingMergeRequests).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_DATA_SUCCESS', () => {
+ it('should set loading flag and data', () => {
+ const state = {};
+ const mrs = [1, 2, 3];
+
+ mutations[types.RECEIVE_DATA_SUCCESS](state, { data: mrs, total: mrs.length });
+
+ expect(state.isFetchingMergeRequests).toEqual(false);
+ expect(state.mergeRequests).toEqual(mrs);
+ expect(state.totalCount).toEqual(mrs.length);
+ });
+ });
+
+ describe('RECEIVE_DATA_ERROR', () => {
+ it('should set loading and error flags', () => {
+ const state = {};
+
+ mutations[types.RECEIVE_DATA_ERROR](state);
+
+ expect(state.isFetchingMergeRequests).toEqual(false);
+ expect(state.hasErrorFetchingMergeRequests).toEqual(true);
+ });
+ });
+});
diff --git a/spec/javascripts/serverless/components/functions_spec.js b/spec/javascripts/serverless/components/functions_spec.js
deleted file mode 100644
index 85cfe71281f..00000000000
--- a/spec/javascripts/serverless/components/functions_spec.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import Vue from 'vue';
-
-import functionsComponent from '~/serverless/components/functions.vue';
-import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import ServerlessStore from '~/serverless/stores/serverless_store';
-
-import { mockServerlessFunctions } from '../mock_data';
-
-const createComponent = (
- functions,
- installed = true,
- loadingData = true,
- hasFunctionData = true,
-) => {
- const component = Vue.extend(functionsComponent);
-
- return mountComponent(component, {
- functions,
- installed,
- clustersPath: '/testClusterPath',
- helpPath: '/helpPath',
- loadingData,
- hasFunctionData,
- });
-};
-
-describe('functionsComponent', () => {
- it('should render empty state when Knative is not installed', () => {
- const vm = createComponent({}, false);
-
- expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true);
- expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
- 'Getting started with serverless',
- );
-
- vm.$destroy();
- });
-
- it('should render a loading component', () => {
- const vm = createComponent({});
-
- expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null);
- expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
- });
-
- it('should render empty state when there is no function data', () => {
- const vm = createComponent({}, true, false, false);
-
- expect(
- vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'),
- ).toBe(true);
-
- expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
- 'No functions available',
- );
-
- vm.$destroy();
- });
-
- it('should render the functions list', () => {
- const store = new ServerlessStore(false, '/cluster_path', 'help_path');
- store.updateFunctionsFromServer(mockServerlessFunctions);
- const vm = createComponent(store.state.functions, true, false);
-
- expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null);
- expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true);
- });
-});
diff --git a/spec/javascripts/serverless/stores/serverless_store_spec.js b/spec/javascripts/serverless/stores/serverless_store_spec.js
deleted file mode 100644
index 72fd903d7d1..00000000000
--- a/spec/javascripts/serverless/stores/serverless_store_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import ServerlessStore from '~/serverless/stores/serverless_store';
-import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
-
-describe('Serverless Functions Store', () => {
- let store;
-
- beforeEach(() => {
- store = new ServerlessStore(false, '/cluster_path', 'help_path');
- });
-
- describe('#updateFunctionsFromServer', () => {
- it('should pass an empty hash object', () => {
- store.updateFunctionsFromServer();
-
- expect(store.state.functions).toEqual({});
- });
-
- it('should group functions to one global environment', () => {
- const mockServerlessData = mockServerlessFunctions;
- store.updateFunctionsFromServer(mockServerlessData);
-
- expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
- expect(store.state.functions['*'].length).toEqual(2);
- });
-
- it('should group functions to multiple environments', () => {
- const mockServerlessData = mockServerlessFunctionsDiffEnv;
- store.updateFunctionsFromServer(mockServerlessData);
-
- expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*']));
- expect(store.state.functions['*'].length).toEqual(1);
- expect(store.state.functions.test.length).toEqual(1);
- expect(store.state.functions.test[0].name).toEqual('testfunc2');
- });
- });
-});
diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb
index f69cb502ca6..a7cb0bb2a87 100644
--- a/spec/lib/gitlab/etag_caching/router_spec.rb
+++ b/spec/lib/gitlab/etag_caching/router_spec.rb
@@ -19,6 +19,24 @@ describe Gitlab::EtagCaching::Router do
expect(result.name).to eq 'issue_title'
end
+ it 'matches with a project name that includes a suffix of create' do
+ result = described_class.match(
+ '/group/test-create/issues/123/realtime_changes'
+ )
+
+ expect(result).to be_present
+ expect(result.name).to eq 'issue_title'
+ end
+
+ it 'matches with a project name that includes a prefix of create' do
+ result = described_class.match(
+ '/group/create-test/issues/123/realtime_changes'
+ )
+
+ expect(result).to be_present
+ expect(result.name).to eq 'issue_title'
+ end
+
it 'matches project pipelines endpoint' do
result = described_class.match(
'/my-group/my-project/pipelines.json'
diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
index 41810a8ec03..705df1f4fe7 100644
--- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb
@@ -197,6 +197,11 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do
.to receive(:fetch_as_mirror)
.with(project.import_url, refmap: Gitlab::GithubImport.refmap, forced: true, remote_name: 'github')
+ service = double
+ expect(Projects::HousekeepingService)
+ .to receive(:new).with(project, :gc).and_return(service)
+ expect(service).to receive(:execute)
+
expect(importer.import_repository).to eq(true)
end
diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
index 10876709f36..06c8d127951 100644
--- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
@@ -30,7 +30,7 @@ describe Gitlab::Kubernetes::Helm::Pod do
it 'generates the appropriate specifications for the container' do
container = subject.generate.spec.containers.first
expect(container.name).to eq('helm')
- expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.13.1-kube-1.11.9')
+ expect(container.image).to eq('registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/2.12.3-kube-1.11.7')
expect(container.env.count).to eq(3)
expect(container.env.map(&:name)).to match_array([:HELM_VERSION, :TILLER_NAMESPACE, :COMMAND_SCRIPT])
expect(container.command).to match_array(["/bin/sh"])
diff --git a/spec/lib/gitlab/object_hierarchy_spec.rb b/spec/lib/gitlab/object_hierarchy_spec.rb
index 4700a7ad2e1..e6e9ae3223e 100644
--- a/spec/lib/gitlab/object_hierarchy_spec.rb
+++ b/spec/lib/gitlab/object_hierarchy_spec.rb
@@ -81,6 +81,24 @@ describe Gitlab::ObjectHierarchy, :postgresql do
expect { relation.update_all(share_with_group_lock: false) }
.to raise_error(ActiveRecord::ReadOnlyRecord)
end
+
+ context 'when with_depth is true' do
+ let(:relation) do
+ described_class.new(Group.where(id: parent.id)).base_and_descendants(with_depth: true)
+ end
+
+ it 'includes depth in the results' do
+ object_depths = {
+ parent.id => 1,
+ child1.id => 2,
+ child2.id => 3
+ }
+
+ relation.each do |object|
+ expect(object.depth).to eq(object_depths[object.id])
+ end
+ end
+ end
end
describe '#descendants' do
@@ -91,6 +109,28 @@ describe Gitlab::ObjectHierarchy, :postgresql do
end
end
+ describe '#max_descendants_depth' do
+ subject { described_class.new(base_relation).max_descendants_depth }
+
+ context 'when base relation is empty' do
+ let(:base_relation) { Group.where(id: nil) }
+
+ it { expect(subject).to be_nil }
+ end
+
+ context 'when base has no children' do
+ let(:base_relation) { Group.where(id: child2) }
+
+ it { expect(subject).to eq(1) }
+ end
+
+ context 'when base has grandchildren' do
+ let(:base_relation) { Group.where(id: parent) }
+
+ it { expect(subject).to eq(3) }
+ end
+ end
+
describe '#ancestors' do
it 'includes only the ancestors' do
relation = described_class.new(Group.where(id: child2)).ancestors
diff --git a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb
new file mode 100644
index 00000000000..7f6283715f2
--- /dev/null
+++ b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do
+ include PrometheusHelpers
+
+ let(:project) { create(:project) }
+ let(:serverless_func) { Serverless::Function.new(project, 'test-name', 'test-ns') }
+
+ let(:client) { double('prometheus_client') }
+ subject { described_class.new(client) }
+
+ context 'verify queries' do
+ before do
+ allow(PrometheusMetric).to receive(:find_by_identifier).and_return(create(:prometheus_metric, query: prometheus_istio_query('test-name', 'test-ns')))
+ allow(client).to receive(:query_range)
+ end
+
+ it 'has the query, but no data' do
+ results = subject.query(serverless_func.id)
+
+ expect(results.queries[0][:query_range]).to eql('floor(sum(rate(istio_revision_request_count{destination_configuration="test-name", destination_namespace="test-ns"}[1m])*30))')
+ end
+ end
+end
diff --git a/spec/lib/sentry/client_spec.rb b/spec/lib/sentry/client_spec.rb
index 3333f8307ae..cb14204b99a 100644
--- a/spec/lib/sentry/client_spec.rb
+++ b/spec/lib/sentry/client_spec.rb
@@ -61,13 +61,37 @@ describe Sentry::Client do
end
end
+ shared_examples 'maps exceptions' do
+ exceptions = {
+ HTTParty::Error => 'Error when connecting to Sentry',
+ Net::OpenTimeout => 'Connection to Sentry timed out',
+ SocketError => 'Received SocketError when trying to connect to Sentry',
+ OpenSSL::SSL::SSLError => 'Sentry returned invalid SSL data',
+ Errno::ECONNREFUSED => 'Connection refused',
+ StandardError => 'Sentry request failed due to StandardError'
+ }
+
+ exceptions.each do |exception, message|
+ context "#{exception}" do
+ before do
+ stub_request(:get, sentry_request_url).to_raise(exception)
+ end
+
+ it do
+ expect { subject }
+ .to raise_exception(Sentry::Client::Error, message)
+ end
+ end
+ end
+ end
+
describe '#list_issues' do
let(:issue_status) { 'unresolved' }
let(:limit) { 20 }
-
let(:sentry_api_response) { issues_sample_response }
+ let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
- let!(:sentry_api_request) { stub_sentry_request(sentry_url + '/issues/?limit=20&query=is:unresolved', body: sentry_api_response) }
+ let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit) }
@@ -121,16 +145,14 @@ describe Sentry::Client do
# Sentry API returns 404 if there are extra slashes in the URL!
context 'extra slashes in URL' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects//sentry-org/sentry-project/' }
- let(:client) { described_class.new(sentry_url, token) }
- let!(:valid_req_stub) do
- stub_sentry_request(
- 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
+ let(:sentry_request_url) do
+ 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved'
- )
end
it 'removes extra slashes in api url' do
+ expect(client.url).to eq(sentry_url)
expect(Gitlab::HTTP).to receive(:get).with(
URI('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/'),
anything
@@ -138,7 +160,7 @@ describe Sentry::Client do
subject
- expect(valid_req_stub).to have_been_requested
+ expect(sentry_api_request).to have_been_requested
end
end
@@ -169,6 +191,8 @@ describe Sentry::Client do
expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
end
end
+
+ it_behaves_like 'maps exceptions'
end
describe '#list_projects' do
@@ -260,12 +284,18 @@ describe Sentry::Client do
expect(valid_req_stub).to have_been_requested
end
end
+
+ context 'when exception is raised' do
+ let(:sentry_request_url) { sentry_list_projects_url }
+
+ it_behaves_like 'maps exceptions'
+ end
end
private
def stub_sentry_request(url, body: {}, status: 200, headers: {})
- WebMock.stub_request(:get, url)
+ stub_request(:get, url)
.to_return(
status: status,
headers: { 'Content-Type' => 'application/json' }.merge(headers),
diff --git a/spec/migrations/clean_up_noteable_id_for_notes_on_commits_spec.rb b/spec/migrations/clean_up_noteable_id_for_notes_on_commits_spec.rb
new file mode 100644
index 00000000000..572b7dfd0c8
--- /dev/null
+++ b/spec/migrations/clean_up_noteable_id_for_notes_on_commits_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20190313092516_clean_up_noteable_id_for_notes_on_commits.rb')
+
+describe CleanUpNoteableIdForNotesOnCommits, :migration do
+ let(:notes) { table(:notes) }
+
+ before do
+ notes.create!(noteable_type: 'Commit', commit_id: '3d0a182204cece4857f81c6462720e0ad1af39c9', noteable_id: 3, note: 'Test')
+ notes.create!(noteable_type: 'Commit', commit_id: '3d0a182204cece4857f81c6462720e0ad1af39c9', noteable_id: 3, note: 'Test')
+ notes.create!(noteable_type: 'Commit', commit_id: '3d0a182204cece4857f81c6462720e0ad1af39c9', noteable_id: 3, note: 'Test')
+
+ notes.create!(noteable_type: 'Issue', noteable_id: 1, note: 'Test')
+ notes.create!(noteable_type: 'MergeRequest', noteable_id: 1, note: 'Test')
+ notes.create!(noteable_type: 'Snippet', noteable_id: 1, note: 'Test')
+ end
+
+ it 'clears noteable_id for notes on commits' do
+ expect { migrate! }.to change { dirty_notes_on_commits.count }.from(3).to(0)
+ end
+
+ it 'does not clear noteable_id for other notes' do
+ expect { migrate! }.not_to change { other_notes.count }
+ end
+
+ def dirty_notes_on_commits
+ notes.where(noteable_type: 'Commit').where('noteable_id IS NOT NULL')
+ end
+
+ def other_notes
+ notes.where("noteable_type != 'Commit' AND noteable_id IS NOT NULL")
+ end
+end
diff --git a/spec/models/serverless/function_spec.rb b/spec/models/serverless/function_spec.rb
new file mode 100644
index 00000000000..1854d5f9415
--- /dev/null
+++ b/spec/models/serverless/function_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Serverless::Function do
+ let(:project) { create(:project) }
+ let(:func) { described_class.new(project, 'test', 'test-ns') }
+
+ it 'has a proper id' do
+ expect(func.id).to eql("#{project.id}/test/test-ns")
+ expect(func.name).to eql("test")
+ expect(func.namespace).to eql("test-ns")
+ end
+
+ it 'can decode an identifier' do
+ f = described_class.find_by_id("#{project.id}/testfunc/dummy-ns")
+
+ expect(f.name).to eql("testfunc")
+ expect(f.namespace).to eql("dummy-ns")
+ end
+end
diff --git a/spec/services/error_tracking/list_projects_service_spec.rb b/spec/services/error_tracking/list_projects_service_spec.rb
index a92d3376f7b..730fccc599e 100644
--- a/spec/services/error_tracking/list_projects_service_spec.rb
+++ b/spec/services/error_tracking/list_projects_service_spec.rb
@@ -51,14 +51,28 @@ describe ErrorTracking::ListProjectsService do
end
context 'sentry client raises exception' do
- before do
- expect(error_tracking_setting).to receive(:list_sentry_projects)
- .and_raise(Sentry::Client::Error, 'Sentry response status code: 500')
+ context 'Sentry::Client::Error' do
+ before do
+ expect(error_tracking_setting).to receive(:list_sentry_projects)
+ .and_raise(Sentry::Client::Error, 'Sentry response status code: 500')
+ end
+
+ it 'returns error response' do
+ expect(result[:message]).to eq('Sentry response status code: 500')
+ expect(result[:http_status]).to eq(:bad_request)
+ end
end
- it 'returns error response' do
- expect(result[:message]).to eq('Sentry response status code: 500')
- expect(result[:http_status]).to eq(:bad_request)
+ context 'Sentry::Client::MissingKeysError' do
+ before do
+ expect(error_tracking_setting).to receive(:list_sentry_projects)
+ .and_raise(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
+ end
+
+ it 'returns error response' do
+ expect(result[:message]).to eq('Sentry API response is missing keys. key not found: "id"')
+ expect(result[:http_status]).to eq(:internal_server_error)
+ end
end
end
diff --git a/spec/support/helpers/prometheus_helpers.rb b/spec/support/helpers/prometheus_helpers.rb
index 08d1d7a6059..87f825152cf 100644
--- a/spec/support/helpers/prometheus_helpers.rb
+++ b/spec/support/helpers/prometheus_helpers.rb
@@ -7,6 +7,10 @@ module PrometheusHelpers
%{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100}
end
+ def prometheus_istio_query(function_name, kube_namespace)
+ %{floor(sum(rate(istio_revision_request_count{destination_configuration=\"#{function_name}\", destination_namespace=\"#{kube_namespace}\"}[1m])*30))}
+ end
+
def prometheus_ping_url(prometheus_query)
query = { query: prometheus_query }.to_query
diff --git a/spec/support/shared_examples/quick_actions/issue/duplicate_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/duplicate_quick_action_shared_examples.rb
index 24576fe0021..633c7135fbc 100644
--- a/spec/support/shared_examples/quick_actions/issue/duplicate_quick_action_shared_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issue/duplicate_quick_action_shared_examples.rb
@@ -1,4 +1,38 @@
# frozen_string_literal: true
shared_examples 'duplicate quick action' do
+ context 'mark issue as duplicate' do
+ let(:original_issue) { create(:issue, project: project) }
+
+ context 'when the current user can update issues' do
+ it 'does not create a note, and marks the issue as a duplicate' do
+ add_note("/duplicate ##{original_issue.to_reference}")
+
+ expect(page).not_to have_content "/duplicate #{original_issue.to_reference}"
+ expect(page).to have_content 'Commands applied'
+ expect(page).to have_content "marked this issue as a duplicate of #{original_issue.to_reference}"
+
+ expect(issue.reload).to be_closed
+ end
+ end
+
+ context 'when the current user cannot update the issue' do
+ let(:guest) { create(:user) }
+ before do
+ project.add_guest(guest)
+ gitlab_sign_out
+ sign_in(guest)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'does not create a note, and does not mark the issue as a duplicate' do
+ add_note("/duplicate ##{original_issue.to_reference}")
+
+ expect(page).not_to have_content 'Commands applied'
+ expect(page).not_to have_content "marked this issue as a duplicate of #{original_issue.to_reference}"
+
+ expect(issue.reload).to be_open
+ end
+ end
+ end
end
diff --git a/yarn.lock b/yarn.lock
index cd420b81ad6..571ac952e90 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -663,10 +663,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.58.0.tgz#bb05263ff2eb7ca09a25cd14d0b1a932d2ea9c2f"
integrity sha512-RlWSjjBT4lMIFuNC1ziCO1nws9zqZtxCjhrqK2DxDDTgp2W0At9M/BFkHp8RHyMCrO3g1fHTrLPUgzr5oR3Epg==
-"@gitlab/ui@^3.0.0":
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.0.0.tgz#33ca2808dbd4395e69a366a219d1edc1f3dbccd5"
- integrity sha512-pDEa2k6ln5GE/N2z0V7dNEeFtSTW0p9ipO2/N9q6QMxO7fhhOhpMC0QVbdIljKTbglspDWI5v6BcqUjzYri5Pg==
+"@gitlab/ui@^3.2.0":
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.2.0.tgz#3a44ac806a22b87fe45e6edfa410cb9355164f04"
+ integrity sha512-If2ngMIw0jWAdQ1q3PfB8sDhCXz1r3DsRm1X5Vy767kZ2TeFd7SGBp5KP5ceMGGpQ4TTYU/V8IqYnQbTXfPKRw==
dependencies:
"@babel/standalone" "^7.0.0"
bootstrap-vue "^2.0.0-rc.11"