diff options
12 files changed, 654 insertions, 3 deletions
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 <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> + <i className="spinner"/> + </div>; + } + + 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('<br>'); + return `<div class="text-left">${inner}</div>`; + }); + return <BubbleChart items={items} + xGrid={xGrid} + tooltips={tooltips} + height={HEIGHT} + padding={[25, 30, 50, 60]} + formatXTick={formatInt} + formatYTick={formatPercent}/>; + } + + render () { + return <div className="overview-bubble-chart overview-domain-dark"> + <div className="overview-domain-header"> + <h2 className="overview-title">Project Files</h2> + <ul className="list-inline small"> + <li>X: Complexity</li> + <li>Y: Coverage</li> + <li>Size: Technical Debt</li> + </ul> + </div> + <div> + {this.renderBubbleChart()} + </div> + </div>; + } +} 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 <table className="data zebra"> + <tbody> + <tr> + <td>Coverage</td> + <td className="thin nowrap text-right"> + {formatCoverage(coverage)} + </td> + </tr> + <tr> + <td>Line Coverage</td> + <td className="thin nowrap text-right"> + {formatCoverage(lineCoverage)} + </td> + </tr> + <tr> + <td>Branch Coverage</td> + <td className="thin nowrap text-right"> + {formatCoverage(branchCoverage)} + </td> + </tr> + </tbody> + </table>; + } + + renderUTCoverage () { + if (this.state.measures['coverage'] == null) { + return null; + } + return <div className="big-spacer-top"> + <h4 className="spacer-bottom">Unit Tests</h4> + {this.renderCoverage( + this.state.measures['coverage'], + this.state.measures['line_coverage'], + this.state.measures['branch_coverage'])} + </div>; + } + + renderITCoverage () { + if (this.state.measures['it_coverage'] == null) { + return null; + } + return <div className="big-spacer-top"> + <h4 className="spacer-bottom">Integration Tests</h4> + {this.renderCoverage( + this.state.measures['it_coverage'], + this.state.measures['it_line_coverage'], + this.state.measures['it_branch_coverage'])} + </div>; + } + + renderOverallCoverage () { + if (this.state.measures['coverage'] == null || + this.state.measures['it_coverage'] == null || + this.state.measures['overall_coverage'] == null) { + return null; + } + return <div className="big-spacer-top"> + <h4 className="spacer-bottom">Overall</h4> + {this.renderCoverage( + this.state.measures['overall_coverage'], + this.state.measures['overall_line_coverage'], + this.state.measures['overall_branch_coverage'])} + </div>; + } + + render () { + return <div className="overview-domain-section"> + <h2 className="overview-title">Coverage Details</h2> + {this.renderUTCoverage()} + {this.renderITCoverage()} + {this.renderOverallCoverage()} + </div>; + } +} 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 <div className="overview-domain"> + + <CoverageTimeline {...this.props}/> + + <div className="flex-columns"> + <div className="flex-column flex-column-half"> + <CoverageDetails {...this.props}/> + </div> + <div className="flex-column flex-column-half"> + <TestsDetails {...this.props}/> + </div> + </div> + + <CoverageBubbleChart {...this.props}/> + <CoverageTreemap {...this.props}/> + </div>; + } +} 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 <div className="overview-domain-section"> + <h2 className="overview-title">Tests Details</h2> + <table className="data zebra"> + <tbody> + <tr> + <td>Tests</td> + <td className="thin nowrap text-right"> + {format(this.state.measures['tests'], 'tests')} + </td> + </tr> + <tr> + <td>Skipped Tests</td> + <td className="thin nowrap text-right"> + {format(this.state.measures['skipped_tests'], 'skipped_tests')} + </td> + </tr> + <tr> + <td>Test Errors</td> + <td className="thin nowrap text-right"> + {format(this.state.measures['test_errors'], 'test_errors')} + </td> + </tr> + <tr> + <td>Test Failures</td> + <td className="thin nowrap text-right"> + {format(this.state.measures['test_failures'], 'test_failures')} + </td> + </tr> + <tr> + <td>Tests Execution Time</td> + <td className="thin nowrap text-right"> + {format(this.state.measures['test_execution_time'], 'test_execution_time')} + </td> + </tr> + <tr> + <td>Tests Success</td> + <td className="thin nowrap text-right"> + {format(this.state.measures['test_success_density'], 'test_success_density')} + </td> + </tr> + </tbody> + </table> + </div>; + } +} 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 <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> + <i className="spinner"/> + </div>; + } + + 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 <LineChart data={data} + xTicks={xTicks} + xValues={xValues} + backdropConstraints={backdropConstraints} + height={HEIGHT} + interpolate="linear" + padding={[25, 30, 50, 30]}/>; + } + + renderTimelineMetricSelect () { + if (this.state.loading) { + return null; + } + + let issueOptions = COVERAGE_METRICS + .map(metric => <option key={metric} value={metric}>{window.t('metric', metric, 'name')}</option>); + let debtOptions = TESTS_METRICS + .map(metric => <option key={metric} value={metric}>{window.t('metric', metric, 'name')}</option>); + + return <select ref="metricSelect" + className="overview-timeline-select" + onChange={this.handleMetricChange.bind(this)} + value={this.state.currentMetric}> + <optgroup label="Coverage">{issueOptions}</optgroup> + <optgroup label="Tests">{debtOptions}</optgroup> + </select>; + } + + render () { + return <div className="overview-timeline overview-domain-dark"> + <div className="overview-domain-header"> + <h2 className="overview-title">Project History</h2> + {this.renderTimelineMetricSelect()} + </div> + <div> + {this.renderLineChart()} + </div> + </div>; + } +} 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 <div className="overview-chart-placeholder" style={{ height: HEIGHT }}> + <i className="spinner"/> + </div>; + } + + 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('<br>'); + return `<div class="text-left">${inner}</div>`; + }); + return <Treemap items={items} labels={labels} tooltips={tooltips} height={HEIGHT}/>; + } + + render () { + return <div className="overview-domain-section overview-treemap"> + <div className="overview-domain-header"> + <h2 className="overview-title">Project Components</h2> + <ul className="list-inline small"> + <li>Size: Lines</li> + <li>Color: Coverage</li> + </ul> + </div> + <div> + {this.renderTreemap()} + </div> + </div>; + } +} 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 ( - <Card> + <Card linkTo="coverage" active={active} onRoute={this.props.onRoute}> <div className="measures"> <div className="measures-chart"> <Donut data={donutData} size="47"/> 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 = <IssuesMain {...this.props}/>; break; + case 'coverage': + child = <CoverageMain {...this.props}/>; + 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)"; +} |