summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/reports
diff options
context:
space:
mode:
authorShinya Maeda <shinya@gitlab.com>2018-07-23 14:23:29 +0900
committerShinya Maeda <shinya@gitlab.com>2018-07-23 14:23:29 +0900
commit41a439c75983a8bd200e0728b231487ff6e1699c (patch)
treeb25df6509d4bb5ac2496eb132379109b38ddb43d /app/assets/javascripts/reports
parentbc959fffb1a1e8937078a1399dad2f698534ac84 (diff)
parent7105b37a558acf22a23125cddfefc517c040a0fb (diff)
downloadgitlab-ce-41a439c75983a8bd200e0728b231487ff6e1699c.tar.gz
Merge branch 'artifact-format-v2-with-parser' of gitlab.com:gitlab-org/gitlab-ce into artifact-format-v2-with-parser
Diffstat (limited to 'app/assets/javascripts/reports')
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue115
-rw-r--r--app/assets/javascripts/reports/components/modal.vue98
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue42
-rw-r--r--app/assets/javascripts/reports/constants.js10
-rw-r--r--app/assets/javascripts/reports/store/actions.js92
-rw-r--r--app/assets/javascripts/reports/store/getters.js21
-rw-r--r--app/assets/javascripts/reports/store/index.js16
-rw-r--r--app/assets/javascripts/reports/store/mutation_types.js9
-rw-r--r--app/assets/javascripts/reports/store/mutations.js47
-rw-r--r--app/assets/javascripts/reports/store/state.js65
-rw-r--r--app/assets/javascripts/reports/store/utils.js44
11 files changed, 559 insertions, 0 deletions
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
new file mode 100644
index 00000000000..175424e9d73
--- /dev/null
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -0,0 +1,115 @@
+<script>
+ import { mapActions, mapGetters } from 'vuex';
+ import { s__ } from '~/locale';
+ import ReportSection from '~/vue_shared/components/reports/report_section.vue';
+ import SummaryRow from '~/vue_shared/components/reports/summary_row.vue';
+ import IssuesList from '~/vue_shared/components/reports/issues_list.vue';
+ import Modal from './modal.vue';
+ import createStore from '../store';
+ import { textBuilder, statusIcon } from '../store/utils';
+
+ export default {
+ name: 'GroupedTestReportsApp',
+ store: createStore(),
+ components: {
+ ReportSection,
+ SummaryRow,
+ IssuesList,
+ Modal,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters([
+ 'reports',
+ 'summaryStatus',
+ 'isLoading',
+ 'hasError',
+ 'summaryCounts',
+ 'modalTitle',
+ 'modalData',
+ 'isCreatingNewIssue',
+ ]),
+
+ groupedSummaryText() {
+ if (this.isLoading) {
+ return s__('Reports|Test summary results are being parsed');
+ }
+
+ if (this) {
+ return s__('Reports|Test summary failed loading results');
+ }
+
+ const { failed, total, resolved } = this.summaryCounts;
+
+ return textBuilder(s__('Reports|Test summary'), failed, resolved, total);
+ },
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+
+ this.fetchReports();
+ },
+ methods: {
+ ...mapActions(['setEndpoint', 'fetchReports']),
+ reportText(report) {
+ // TODO_SAM: Check with backend if summary is always present.
+ // if it is we may be able to remove this check.
+ const summary = report.summary || {};
+ return textBuilder(report.name, summary.failed, summary.resolved, summary.total);
+ },
+ getReportIcon(report) {
+ return statusIcon(report.status);
+ },
+ createIssue() {
+ // TODO_SAM
+ },
+ },
+ };
+</script>
+<template>
+ <report-section
+ :status="summaryStatus"
+ :success-text="groupedSummaryText"
+ :loading-text="groupedSummaryText"
+ :error-text="groupedSummaryText"
+ :has-issues="reports.length > 0"
+ class="mr-widget-border-top grouped-security-reports"
+ >
+ <div
+ slot="body"
+ class="mr-widget-grouped-section report-block"
+ >
+ <template
+ v-for="(report, i) in reports"
+ >
+ <summary-row
+ :summary="reportText(report)"
+ :status-icon="getReportIcon(report)"
+ :key="`summary-row-${i}`"
+
+ />
+ <issues-list
+ :unresolved-issues="report.new_failures"
+ :resolved-issues="report.resolved_failures"
+ :all-issues="report.existing_failures"
+ :key="`issues-list-${i}`"
+ type="test"
+ class="report-block-group-list"
+ />
+ </template>
+
+ <modal
+ :title="modalTitle"
+ :modal-data="modalData"
+ :is-creating-new-issue="isCreatingNewIssue"
+ @createIssue="createIssue"
+ />
+ </div>
+ </report-section>
+</template>
+
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue
new file mode 100644
index 00000000000..83edb6455a9
--- /dev/null
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -0,0 +1,98 @@
+<script>
+ import Modal from '~/vue_shared/components/gl_modal.vue';
+ import LoadingButton from '~/vue_shared/components/loading_button.vue';
+ import { fieldTypes } from '../constants';
+
+ export default {
+ components: {
+ Modal,
+ LoadingButton,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ modalData: {
+ type: Object,
+ required: true,
+ },
+ isCreatingNewIssue: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ fieldTypes,
+ methods: {
+ handleCreateIssueClick() {
+ // TODO_SAM: Check with Shynia how to handle this.
+ // I believe we've agreed we'd do this in a new iteration
+ // we may need to hide the footer always. - We probably need to provide some params
+ this.$emit('createIssue');
+ },
+ },
+ };
+</script>
+<template>
+ <modal
+ id="modal-mrwidget-reports"
+ :header-title-text="title"
+ :class="{ 'modal-hide-footer': false }"
+ class="modal-security-report-dast"
+ >
+ <slot>
+ <div
+ v-for="(field, key, index) in modalData"
+ v-if="field.value"
+ :key="index"
+ class="row prepend-top-10 append-bottom-10"
+ >
+ <label class="col-sm-2 text-right font-weight-bold">
+ {{ field.text }}:
+ </label>
+
+ <div class="col-sm-10 text-secondary">
+ <template v-if="field.type === $options.fieldTypes.codeBock">
+ <pre> {{ field.value }} </pre>
+ </template>
+
+ <template v-else-if="field.type === $options.fieldTypes.link">
+ <a
+ :href="field.value"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ field.value }}
+ </a>
+ </template>
+
+ <template v-else-if="field.type === $options.fieldTypes.miliseconds">
+ {{ field.value }} ms
+ </template>
+
+ <template v-else-if="field.type === $options.fieldTypes.text">
+ {{ field.value }}
+ </template>
+ </div>
+ </div>
+ </slot>
+ <div slot="footer">
+ <button
+ type="button"
+ class="btn btn-default"
+ data-dismiss="modal"
+ >
+ {{ __('Cancel' ) }}
+ </button>
+
+ <loading-button
+ v-if="canCreateIssuePermission"
+ :loading="isCreatingNewIssue"
+ :disabled="isCreatingNewIssue"
+ :label="__('Create issue')"
+ container-class="js-create-issue-btn btn btn-success btn-inverted"
+ @click="handleCreateIssueClick"
+ />
+ </div>
+ </modal>
+</template>
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
new file mode 100644
index 00000000000..0b01bee4557
--- /dev/null
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -0,0 +1,42 @@
+<script>
+ import { mapActions } from 'vuex';
+
+ export default {
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ // failed || success
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['openModal']),
+ handleIssueClick() {
+ const { issue, status, openModal } = this;
+ openModal({ issue, status });
+ },
+ },
+ };
+</script>
+<template>
+ <div class="report-block-list-issue-description prepend-top-5 append-bottom-5">
+ <div class="report-block-list-issue-description-text">
+ <!-- TODO_SAM: I've duplicated this from modal_open_name,
+ because it's expecting a title and we have name
+ Not sure if it's worth refactring now, or if it's ok
+ to leave it duplciated and fix it when we move the components around
+ -->
+ <button
+ type="button"
+ class="btn-link btn-blank text-left break-link vulnerability-name-button"
+ @click="handleIssueClick()"
+ >
+ {{ issue.name }}
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
new file mode 100644
index 00000000000..882617c20f1
--- /dev/null
+++ b/app/assets/javascripts/reports/constants.js
@@ -0,0 +1,10 @@
+export const fieldTypes = {
+ codeBock: 'codeBlock',
+ link: 'link',
+ miliseconds: 'miliseconds',
+ text: 'text',
+};
+
+export const LOADING = 'LOADING';
+export const ERROR = 'ERROR';
+export const SUCCESS = 'SUCCESS';
diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/store/actions.js
new file mode 100644
index 00000000000..c18885f23a4
--- /dev/null
+++ b/app/assets/javascripts/reports/store/actions.js
@@ -0,0 +1,92 @@
+import Visibility from 'visibilityjs';
+import $ from 'jquery';
+import axios from '../../lib/utils/axios_utils';
+import Poll from '../../lib/utils/poll';
+import flash from '../../flash';
+import { s__ } from '../../locale';
+import * as types from './mutation_types';
+
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+export const requestReports = ({ commit }) => commit(types.REQUEST_REPORTS);
+
+let eTagPoll;
+
+export const clearEtagPoll = () => {
+ eTagPoll = null;
+};
+
+export const stopPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+
+export const restartPolling = () => {
+ if (eTagPoll) eTagPoll.restart();
+};
+
+/**
+ * We need to poll the reports endpoint while they are being parsed in the Backend.
+ * This can take up to one minute.
+ *
+ * Poll.js will handle etag response.
+ * While http status code is 204, it means it's parsing, and we'll keep polling
+ * When http status code is 200, it means parsing is done, we can show the results & stop polling
+ * When http status code is 500, it means parsins went wrong and we stop polling
+ */
+export const fetchReports = ({ state, dispatch }) => {
+ dispatch('requestReports');
+
+ eTagPoll = new Poll({
+ resource: {
+ getReports(endpoint) {
+ return axios.get(endpoint);
+ },
+ },
+ data: state.endpoint,
+ method: 'getReports',
+ successCallback: ({ data }) => dispatch('receiveReportsSuccess', data),
+ errorCallback: () => dispatch('receiveReportsError'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ dispatch('restartPolling');
+ } else {
+ dispatch('stopPolling');
+ }
+ });
+};
+
+export const receiveReportsSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_REPORTS_SUCCESS, response);
+
+export const receiveReportsError = ({ commit }) => commit(types.RECEIVE_REPORTS_ERROR);
+
+export const openModal = ({ dispatch }, payload) => {
+ dispatch('setModalData', payload);
+
+ $('#modal-mrwidget-reports').modal('show');
+};
+
+export const setModalData = ({ commit }, payload) => commit(types.SET_ISSUE_MODAL_DATA, payload);
+
+export const createNewIssue = ({ state, dispatch }) => {
+ dispatch('requestCreateIssue');
+ return axios.post(state.modal.endpoint)
+ .then(() => dispatch('receiveCreateIssueSuccess'))
+ .catch(() => dispatch('receiveCreateIssueError'));
+};
+
+export const requestCreateIssue = ({ commit }) => commit(types.REQUEST_CREATE_ISSUE);
+export const receiveCreateIssueSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_ISSUE_SUCCESS);
+export const receiveCreateIssueError = ({ commit }) => {
+ flash(s__('Report|There was an error creating the issue. Please try again.'));
+ commit(types.RECEIVE_CREATE_ISSUE_ERROR);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/reports/store/getters.js b/app/assets/javascripts/reports/store/getters.js
new file mode 100644
index 00000000000..ddc4b3d3e76
--- /dev/null
+++ b/app/assets/javascripts/reports/store/getters.js
@@ -0,0 +1,21 @@
+import { LOADING, ERROR, SUCCESS } from '../constants';
+
+export const reports = state => state.reports;
+export const summaryCounts = state => state.summary;
+export const isLoading = state => state.isLoading;
+export const hasError = state => state.hasError;
+export const modalTitle = state => state.modal.title || '';
+export const modalData = state => state.modal.data || {};
+export const isCreatingNewIssue = state => state.modal.isLoading;
+
+export const summaryStatus = state => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+
+ if (state.hasError) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
diff --git a/app/assets/javascripts/reports/store/index.js b/app/assets/javascripts/reports/store/index.js
new file mode 100644
index 00000000000..1eb2960910b
--- /dev/null
+++ b/app/assets/javascripts/reports/store/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default () => new Vuex.Store({
+ actions,
+ mutations,
+ getters,
+ state: state(),
+});
diff --git a/app/assets/javascripts/reports/store/mutation_types.js b/app/assets/javascripts/reports/store/mutation_types.js
new file mode 100644
index 00000000000..6242c31b4e2
--- /dev/null
+++ b/app/assets/javascripts/reports/store/mutation_types.js
@@ -0,0 +1,9 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+
+export const REQUEST_REPORTS = 'REQUEST_REPORTS';
+export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
+export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
+export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA';
+export const REQUEST_CREATE_ISSUE = 'REQUEST_CREATE_ISSUE';
+export const RECEIVE_CREATE_ISSUE_SUCCESS = 'RECEIVE_CREATE_ISSUE_SUCCESS';
+export const RECEIVE_CREATE_ISSUE_ERROR = 'RECEIVE_CREATE_ISSUE_ERROR';
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
new file mode 100644
index 00000000000..120ec177666
--- /dev/null
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -0,0 +1,47 @@
+/* eslint-disable no-param-reassign */
+import Vue from 'vue';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.REQUEST_REPORTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_REPORTS_SUCCESS](state, response) {
+
+ state.isLoading = false;
+
+ Vue.set(state.summary, 'total', response.summary.total);
+ Vue.set(state.summary, 'resolved', response.summary.resolved);
+ Vue.set(state.summary, 'failed', response.summary.failed);
+
+ state.status = response.status;
+ state.reports = response.suites;
+
+ },
+ [types.RECEIVE_REPORTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.SET_ISSUE_MODAL_DATA](state, payload) {
+ Vue.set(state.modal, 'title', payload.issue.name);
+ Vue.set(state.modal, 'status', payload.status);
+
+ Object.keys(payload.issue).forEach((key) => {
+ if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) {
+ Vue.set(state.modal.data[key], 'value', payload.issue[key]);
+ }
+ });
+ },
+ [types.REQUEST_CREATE_ISSUE](state) {
+ Vue.set(state.modal, 'isLoading', true);
+ },
+ [types.RECEIVE_CREATE_ISSUE_SUCCESS](state) {
+ Vue.set(state.modal, 'isLoading', false);
+ },
+ [types.RECEIVE_CREATE_ISSUE_ERROR](state) {
+ Vue.set(state.modal, 'isLoading', false);
+ },
+};
diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js
new file mode 100644
index 00000000000..4254f839fd7
--- /dev/null
+++ b/app/assets/javascripts/reports/store/state.js
@@ -0,0 +1,65 @@
+import { s__ } from '~/locale';
+import { fieldTypes } from '../constants';
+
+export default () => ({
+ endpoint: null,
+
+ isLoading: false,
+ hasError: false,
+
+ status: null,
+
+ summary: {
+ total: 0,
+ resolved: 0,
+ failed: 0,
+ },
+
+ /**
+ * Each report will have the following format:
+ * {
+ * name: {String},
+ * summary: {
+ * total: {Number},
+ * resolved: {Number},
+ * failed: {Number},
+ * },
+ * new_failures: {Array.<Object>},
+ * resolved_failures: {Array.<Object>},
+ * existing_failures: {Array.<Object>},
+ * }
+ */
+ reports: [],
+
+ modal: {
+ title: null,
+
+ status: null,
+
+ isCreatingNewIssue: false,
+ hasError: false,
+
+ data: {
+ class: {
+ value: null,
+ text: s__('Reports|Class'),
+ type: fieldTypes.link,
+ },
+ execution_time: {
+ value: null,
+ text: s__('Reports|Execution time'),
+ type: fieldTypes.miliseconds,
+ },
+ failure: {
+ value: null,
+ text: s__('Reports|Failure'),
+ type: fieldTypes.codeBock,
+ },
+ system_output: {
+ value: null,
+ text: s__('Reports|System output'),
+ type: fieldTypes.codeBock,
+ },
+ },
+ },
+});
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
new file mode 100644
index 00000000000..23162c595b7
--- /dev/null
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -0,0 +1,44 @@
+import { sprintf, n__, s__ } from '~/locale';
+
+export const textBuilder = (name = '', failed = 0, resolved = 0, total = 0) => {
+ if (failed === 0 && resolved === 0) {
+ // TODO_SAM: Check with dimitrie
+ return sprintf(s__('Reports|%{name} found no tests'), { name });
+ }
+
+ const text = [name];
+
+ if (failed > 0 && resolved === 0) {
+ text.push(n__('found %d failed test', 'found %d failed tests', failed));
+ }
+
+ if (failed > 0 && resolved > 0) {
+ text.push(
+ `${n__('found %d failed test', 'found %d failed tests', failed)} ${n__(
+ 'and %d fixed test',
+ 'and %d fixed tests',
+ resolved,
+ )}`,
+ );
+ }
+
+ if (failed === 0 && resolved > 0) {
+ text.push(n__('found %d fixed test', 'found %d fixed tests', resolved));
+ }
+
+ if (failed === 0 && resolved === 0) {
+ text.push(s__('found no tests'));
+ }
+
+ text.push(sprintf('out of %{total} total', { total }));
+
+ return text.join(' ');
+};
+
+export const statusIcon = (status) => {
+ if (status === 'failed') {
+ return 'warning';
+ }
+
+ return 'success';
+};