From: Stas Vilchik Date: Fri, 23 Oct 2015 12:07:09 +0000 (+0200) Subject: SONAR-6360 add detailed "Code Coverage" panel for the "Overview" main page X-Git-Tag: 5.3-RC1~457 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=c87df1b090c334e5844d4c2f39e5c8e2db9af5cc;p=sonarqube.git SONAR-6360 add detailed "Code Coverage" panel for the "Overview" main page --- diff --git a/server/sonar-web/src/main/js/api/measures.js b/server/sonar-web/src/main/js/api/measures.js new file mode 100644 index 00000000000..b79b56a5ef2 --- /dev/null +++ b/server/sonar-web/src/main/js/api/measures.js @@ -0,0 +1,14 @@ +import { getJSON } from '../helpers/request.js'; + +export function getMeasures (componentKey, metrics) { + let url = baseUrl + '/api/resources/index'; + let data = { resource: componentKey, metrics: metrics.join(',') }; + return getJSON(url, data).then(r => { + let msr = r[0].msr || []; + let measures = {}; + msr.forEach(measure => { + measures[measure.key] = measure.val; + }); + return measures; + }); +} diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/bubble-chart.js b/server/sonar-web/src/main/js/apps/overview/coverage/bubble-chart.js new file mode 100644 index 00000000000..fde275f7b6c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/coverage/bubble-chart.js @@ -0,0 +1,105 @@ +import _ from 'underscore'; +import React from 'react'; +import { BubbleChart } from '../../../components/charts/bubble-chart'; +import { getProjectUrl } from '../../../helpers/Url'; +import { getFiles } from '../../../api/components'; +import { formatMeasure } from '../formatting'; + + +const X_METRIC = 'complexity'; +const Y_METRIC = 'coverage'; +const SIZE_METRIC = 'sqale_index'; +const COMPONENTS_METRICS = [X_METRIC, Y_METRIC, SIZE_METRIC]; +const HEIGHT = 360; + + +function formatInt (d) { + return window.formatMeasure(d, 'SHORT_INT'); +} + +function formatPercent (d) { + return window.formatMeasure(d, 'PERCENT'); +} + +function getMeasure (component, metric) { + return component.measures[metric] || 0; +} + + +export class CoverageBubbleChart extends React.Component { + constructor () { + super(); + this.state = { loading: true, files: [] }; + } + + componentDidMount () { + this.requestFiles(); + } + + requestFiles () { + return getFiles(this.props.component.key, COMPONENTS_METRICS).then(r => { + let files = r.map(file => { + let measures = {}; + (file.msr || []).forEach(measure => { + measures[measure.key] = measure.val; + }); + return _.extend(file, { measures }); + }); + this.setState({ loading: false, files }); + }); + } + + renderLoading () { + return
+ +
; + } + + renderBubbleChart () { + if (this.state.loading) { + return this.renderLoading(); + } + + let items = this.state.files.map(component => { + return { + x: getMeasure(component, X_METRIC), + y: getMeasure(component, Y_METRIC), + size: getMeasure(component, SIZE_METRIC), + link: getProjectUrl(component.key) + }; + }); + let xGrid = this.state.files.map(component => component.measures[X_METRIC]); + let tooltips = this.state.files.map(component => { + let inner = [ + component.name, + `Complexity: ${formatMeasure(getMeasure(component, X_METRIC), X_METRIC)}`, + `Coverage: ${formatMeasure(getMeasure(component, Y_METRIC), Y_METRIC)}`, + `Technical Debt: ${formatMeasure(getMeasure(component, SIZE_METRIC), SIZE_METRIC)}` + ].join('
'); + return `
${inner}
`; + }); + return ; + } + + render () { + return
+
+

Project Files

+
    +
  • X: Complexity
  • +
  • Y: Coverage
  • +
  • Size: Technical Debt
  • +
+
+
+ {this.renderBubbleChart()} +
+
; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/coverage-details.js b/server/sonar-web/src/main/js/apps/overview/coverage/coverage-details.js new file mode 100644 index 00000000000..403d8b69120 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/coverage/coverage-details.js @@ -0,0 +1,113 @@ +import React from 'react'; +import { getMeasures } from '../../../api/measures'; + + +const METRICS = [ + 'coverage', + 'line_coverage', + 'branch_coverage', + 'it_coverage', + 'it_line_coverage', + 'it_branch_coverage', + 'overall_coverage', + 'overall_line_coverage', + 'overall_branch_coverage' +]; + + +function formatCoverage (value) { + return value != null ? window.formatMeasure(value, 'PERCENT') : '—'; +} + + +export class CoverageDetails extends React.Component { + constructor () { + super(); + this.state = { measures: {} }; + } + + componentDidMount () { + this.requestDetails(); + } + + requestDetails () { + return getMeasures(this.props.component.key, METRICS).then(measures => { + this.setState({ measures }); + }); + } + + renderCoverage (coverage, lineCoverage, branchCoverage) { + return + + + + + + + + + + + + + + +
Coverage + {formatCoverage(coverage)} +
Line Coverage + {formatCoverage(lineCoverage)} +
Branch Coverage + {formatCoverage(branchCoverage)} +
; + } + + renderUTCoverage () { + if (this.state.measures['coverage'] == null) { + return null; + } + return
+

Unit Tests

+ {this.renderCoverage( + this.state.measures['coverage'], + this.state.measures['line_coverage'], + this.state.measures['branch_coverage'])} +
; + } + + renderITCoverage () { + if (this.state.measures['it_coverage'] == null) { + return null; + } + return
+

Integration Tests

+ {this.renderCoverage( + this.state.measures['it_coverage'], + this.state.measures['it_line_coverage'], + this.state.measures['it_branch_coverage'])} +
; + } + + renderOverallCoverage () { + if (this.state.measures['coverage'] == null || + this.state.measures['it_coverage'] == null || + this.state.measures['overall_coverage'] == null) { + return null; + } + return
+

Overall

+ {this.renderCoverage( + this.state.measures['overall_coverage'], + this.state.measures['overall_line_coverage'], + this.state.measures['overall_branch_coverage'])} +
; + } + + render () { + return
+

Coverage Details

+ {this.renderUTCoverage()} + {this.renderITCoverage()} + {this.renderOverallCoverage()} +
; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/main.js b/server/sonar-web/src/main/js/apps/overview/coverage/main.js new file mode 100644 index 00000000000..88533e138c7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/coverage/main.js @@ -0,0 +1,31 @@ +import React from 'react'; + +import { CoverageDetails } from './coverage-details'; +import { TestsDetails } from './tests-details'; +import { CoverageBubbleChart } from './bubble-chart'; +import { CoverageTimeline } from './timeline'; +import { CoverageTreemap } from './treemap'; + +import { getSeverities, getTags, getAssignees } from '../../../api/issues'; + + +export default class CoverageDomain extends React.Component { + render () { + return
+ + + +
+
+ +
+
+ +
+
+ + + +
; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/tests-details.js b/server/sonar-web/src/main/js/apps/overview/coverage/tests-details.js new file mode 100644 index 00000000000..65034729d79 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/coverage/tests-details.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { getMeasures } from '../../../api/measures'; +import { formatMeasure } from '../formatting'; + + +const METRICS = [ + 'tests', + 'skipped_tests', + 'test_errors', + 'test_failures', + 'test_execution_time', + 'test_success_density' +]; + + +function format (value, metric) { + return value != null ? formatMeasure(value, metric) : '—'; +} + + +export class TestsDetails extends React.Component { + constructor () { + super(); + this.state = { measures: {} }; + } + + componentDidMount () { + this.requestDetails(); + } + + requestDetails () { + return getMeasures(this.props.component.key, METRICS).then(measures => { + this.setState({ measures }); + }); + } + + render () { + return
+

Tests Details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tests + {format(this.state.measures['tests'], 'tests')} +
Skipped Tests + {format(this.state.measures['skipped_tests'], 'skipped_tests')} +
Test Errors + {format(this.state.measures['test_errors'], 'test_errors')} +
Test Failures + {format(this.state.measures['test_failures'], 'test_failures')} +
Tests Execution Time + {format(this.state.measures['test_execution_time'], 'test_execution_time')} +
Tests Success + {format(this.state.measures['test_success_density'], 'test_success_density')} +
+
; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/timeline.js b/server/sonar-web/src/main/js/apps/overview/coverage/timeline.js new file mode 100644 index 00000000000..58e9a9d98e9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/coverage/timeline.js @@ -0,0 +1,164 @@ +import _ from 'underscore'; +import moment from 'moment'; +import React from 'react'; + +import { LineChart } from '../../../components/charts/line-chart'; +import { formatMeasure } from '../formatting'; +import { getTimeMachineData } from '../../../api/time-machine'; +import { getEvents } from '../../../api/events'; + + +const COVERAGE_METRICS = [ + 'coverage', + 'line_coverage', + 'branch_coverage', + 'lines_to_cover', + 'conditions_to_cover', + 'uncovered_lines', + 'uncovered_conditions', + + 'it_coverage', + 'it_line_coverage', + 'it_branch_coverage', + 'it_lines_to_cover', + 'it_conditions_to_cover', + 'it_uncovered_lines', + 'it_uncovered_conditions', + + 'overall_coverage', + 'overall_line_coverage', + 'overall_branch_coverage', + 'overall_lines_to_cover', + 'overall_conditions_to_cover', + 'overall_uncovered_lines', + 'overall_uncovered_conditions' +]; + +const TESTS_METRICS = [ + 'tests', + 'skipped_tests', + 'test_errors', + 'test_failures', + 'test_execution_time', + 'test_success_density' +]; + +const HEIGHT = 280; + + +export class CoverageTimeline extends React.Component { + constructor () { + super(); + this.state = { loading: true, currentMetric: COVERAGE_METRICS[0] }; + } + + componentDidMount () { + Promise.all([ + this.requestTimeMachineData(), + this.requestEvents() + ]).then(() => this.setState({ loading: false })); + } + + requestTimeMachineData () { + return getTimeMachineData(this.props.component.key, this.state.currentMetric).then(r => { + let snapshots = r[0].cells.map(cell => { + return { date: moment(cell.d).toDate(), value: cell.v[0] }; + }); + this.setState({ snapshots }); + }); + } + + requestEvents () { + return getEvents(this.props.component.key, 'Version').then(r => { + let events = r.map(event => { + return { version: event.n, date: moment(event.dt).toDate() }; + }); + events = _.sortBy(events, 'date'); + this.setState({ events }); + }); + } + + prepareEvents () { + let events = this.state.events; + let snapshots = this.state.snapshots; + return events + .map(event => { + let snapshot = snapshots.find(s => s.date.getTime() === event.date.getTime()); + event.value = snapshot && snapshot.value; + return event; + }) + .filter(event => event.value != null); + } + + handleMetricChange () { + let metric = React.findDOMNode(this.refs.metricSelect).value; + this.setState({ currentMetric: metric }, this.requestTimeMachineData); + } + + renderLoading () { + return
+ +
; + } + + renderLineChart () { + if (this.state.loading) { + return this.renderLoading(); + } + + let events = this.prepareEvents(); + + let data = events.map((event, index) => { + return { x: index, y: event.value }; + }); + + let xTicks = events.map(event => event.version.substr(0, 6)); + + let xValues = events.map(event => formatMeasure(event.value, this.state.currentMetric)); + + // TODO use leak period + let backdropConstraints = [ + this.state.events.length - 2, + this.state.events.length - 1 + ]; + + return ; + } + + renderTimelineMetricSelect () { + if (this.state.loading) { + return null; + } + + let issueOptions = COVERAGE_METRICS + .map(metric => ); + let debtOptions = TESTS_METRICS + .map(metric => ); + + return ; + } + + render () { + return
+
+

Project History

+ {this.renderTimelineMetricSelect()} +
+
+ {this.renderLineChart()} +
+
; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/coverage/treemap.js b/server/sonar-web/src/main/js/apps/overview/coverage/treemap.js new file mode 100644 index 00000000000..ee4af18e832 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/coverage/treemap.js @@ -0,0 +1,89 @@ +import _ from 'underscore'; +import d3 from 'd3'; +import React from 'react'; + +import { Treemap } from '../../../components/charts/treemap'; +import { formatMeasure } from '../formatting'; +import { getChildren } from '../../../api/components'; + +const COMPONENTS_METRICS = [ + 'lines', + 'coverage' +]; + +const HEIGHT = 360; + +const COLORS_5 = ['#ee0000', '#f77700', '#ffee00', '#80cc00', '#00aa00']; + +export class CoverageTreemap extends React.Component { + constructor () { + super(); + this.state = { loading: true, components: [] }; + } + + componentDidMount () { + this.requestComponents(); + } + + requestComponents () { + return getChildren(this.props.component.key, COMPONENTS_METRICS).then(r => { + let components = r.map(component => { + let measures = {}; + (component.msr || []).forEach(measure => { + measures[measure.key] = measure.val; + }); + return _.extend(component, { measures }); + }); + this.setState({ loading: false, components }); + }); + } + + renderLoading () { + return
+ +
; + } + + renderTreemap () { + if (this.state.loading) { + return this.renderLoading(); + } + + let colorScale = d3.scale.linear().domain([0, 25, 50, 75, 100]); + colorScale.range(COLORS_5); + + let items = this.state.components.map(component => { + let coverage = component.measures['coverage']; + console.log(coverage); + return { + size: component.measures['lines'], + color: coverage != null ? colorScale(coverage) : '#777' + }; + }); + let labels = this.state.components.map(component => component.name); + let tooltips = this.state.components.map(component => { + let inner = [ + component.name, + `Lines: ${formatMeasure(component.measures['lines'], 'lines')}`, + `Coverage: ${formatMeasure(component.measures['coverage'], 'coverage')}` + ].join('
'); + return `
${inner}
`; + }); + return ; + } + + render () { + return
+
+

Project Components

+
    +
  • Size: Lines
  • +
  • Color: Coverage
  • +
+
+
+ {this.renderTreemap()} +
+
; + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/formatting.js b/server/sonar-web/src/main/js/apps/overview/formatting.js index 7a91cbcd5f1..5baa7d2f13f 100644 --- a/server/sonar-web/src/main/js/apps/overview/formatting.js +++ b/server/sonar-web/src/main/js/apps/overview/formatting.js @@ -12,7 +12,40 @@ const METRIC_TYPES = { 'sqale_index': 'SHORT_WORK_DUR', 'sqale_debt_ratio': 'PERCENT', 'sqale_rating': 'RATING', - 'lines': 'SHORT_INT' + 'lines': 'SHORT_INT', + + 'coverage': 'PERCENT', + 'line_coverage': 'PERCENT', + 'branch_coverage': 'PERCENT', + 'lines_to_cover': 'SHORT_INT', + 'conditions_to_cover': 'SHORT_INT', + 'uncovered_lines': 'SHORT_INT', + 'uncovered_conditions': 'SHORT_INT', + + 'it_coverage': 'PERCENT', + 'it_line_coverage': 'PERCENT', + 'it_branch_coverage': 'PERCENT', + 'it_lines_to_cover': 'SHORT_INT', + 'it_conditions_to_cover': 'SHORT_INT', + 'it_uncovered_lines': 'SHORT_INT', + 'it_uncovered_conditions': 'SHORT_INT', + + 'overall_coverage': 'PERCENT', + 'overall_line_coverage': 'PERCENT', + 'overall_branch_coverage': 'PERCENT', + 'overall_lines_to_cover': 'SHORT_INT', + 'overall_conditions_to_cover': 'SHORT_INT', + 'overall_uncovered_lines': 'SHORT_INT', + 'overall_uncovered_conditions': 'SHORT_INT', + + 'tests': 'SHORT_INT', + 'skipped_tests': 'SHORT_INT', + 'test_errors': 'SHORT_INT', + 'test_failures': 'SHORT_INT', + 'test_execution_time': 'MILLISEC', + 'test_success_density': 'PERCENT', + + 'complexity': 'INT' }; export function formatMeasure (value, metric) { diff --git a/server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js b/server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js index 9de1e53825d..d7cbec60553 100644 --- a/server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js +++ b/server/sonar-web/src/main/js/apps/overview/general/nutshell-coverage.js @@ -18,8 +18,10 @@ export default React.createClass({ return null; } + let active = this.props.section === 'coverage'; + return ( - +
diff --git a/server/sonar-web/src/main/js/apps/overview/main.js b/server/sonar-web/src/main/js/apps/overview/main.js index 81924a313cc..6f9ddcfe7de 100644 --- a/server/sonar-web/src/main/js/apps/overview/main.js +++ b/server/sonar-web/src/main/js/apps/overview/main.js @@ -2,6 +2,7 @@ import React from 'react'; import offset from 'document-offset'; import GeneralMain from './general/main'; import IssuesMain from './issues/main'; +import CoverageMain from './coverage/main'; import Meta from './meta'; export default class Overview extends React.Component { @@ -27,6 +28,9 @@ export default class Overview extends React.Component { case 'issues': child = ; break; + case 'coverage': + child = ; + break; default: child = null; } diff --git a/server/sonar-web/src/main/js/libs/application.js b/server/sonar-web/src/main/js/libs/application.js index 5338ae64886..8692a73f9cb 100644 --- a/server/sonar-web/src/main/js/libs/application.js +++ b/server/sonar-web/src/main/js/libs/application.js @@ -492,6 +492,15 @@ function closeModalWindow () { return l10nKey !== result ? result : value; }; + + /** + * Format a milliseconds measure + * @param {number} value + */ + var millisecondsFormatter = function (value) { + return value + ' ms'; + }; + /** * Format a measure according to its type * @param measure @@ -517,7 +526,8 @@ function closeModalWindow () { 'WORK_DUR': durationFormatter, 'SHORT_WORK_DUR': shortDurationFormatter, 'RATING': ratingFormatter, - 'LEVEL': levelFormatter + 'LEVEL': levelFormatter, + 'MILLISEC': millisecondsFormatter }; if (measure != null && type != null) { formatted = formatters[type] != null ? formatters[type](measure) : measure; diff --git a/server/sonar-web/src/main/less/components/columns.less b/server/sonar-web/src/main/less/components/columns.less index 1e59a6f13ad..8759b4cac1a 100644 --- a/server/sonar-web/src/main/less/components/columns.less +++ b/server/sonar-web/src/main/less/components/columns.less @@ -48,3 +48,7 @@ .flex-column-third { width: ~"calc(100% / 3)"; } + +.flex-column-two-thirds { + width: ~"calc(100% / 3 * 2)"; +}