Browse Source

SONAR-7404 Display history of selected measure on the "Measures" page

tags/5.5-M10
Stas Vilchik 8 years ago
parent
commit
b91bac7f10

+ 4
- 0
server/sonar-web/src/main/js/apps/component-measures/app.js View File

@@ -27,6 +27,9 @@ import AllMeasuresList from './components/AllMeasures';
import MeasureDetails from './components/MeasureDetails';
import MeasureDrilldownTree from './components/MeasureDrilldownTree';
import MeasureDrilldownList from './components/MeasureDrilldownList';
import MeasureHistory from './components/MeasureHistory';

import { checkHistoryExistence } from './hooks';

import './styles.css';

@@ -55,6 +58,7 @@ window.sonarqube.appStarted.then(options => {
<IndexRedirect to="tree"/>
<Route path="tree" component={MeasureDrilldownTree}/>
<Route path="list" component={MeasureDrilldownList}/>
<Route path="history" component={MeasureHistory} onEnter={checkHistoryExistence}/>
</Route>
</Route>


+ 8
- 3
server/sonar-web/src/main/js/apps/component-measures/components/MeasureDetails.js View File

@@ -25,7 +25,7 @@ import MeasureDrilldown from './MeasureDrilldown';

import { enhanceWithLeak } from '../utils';
import { getMeasuresAndMeta } from '../../../api/measures';
import { getLeakPeriodLabel } from '../../../helpers/periods';
import { getLeakPeriod, getPeriodDate, getPeriodLabel } from '../../../helpers/periods';

export default class MeasureDetails extends React.Component {
state = {};
@@ -92,7 +92,9 @@ export default class MeasureDetails extends React.Component {
}

const { tab } = this.props.params;
const leakPeriodLabel = getLeakPeriodLabel(periods);
const leakPeriod = getLeakPeriod(periods);
const leakPeriodLabel = getPeriodLabel(leakPeriod);
const leakPeriodDate = getPeriodDate(leakPeriod);

return (
<div className="measure-details">
@@ -102,7 +104,10 @@ export default class MeasureDetails extends React.Component {
leakPeriodLabel={leakPeriodLabel}/>

{measure && (
<MeasureDrilldown metric={this.metric} tab={tab}>
<MeasureDrilldown
metric={this.metric}
tab={tab}
leakPeriodDate={leakPeriodDate}>
{this.props.children}
</MeasureDrilldown>
)}

+ 13
- 2
server/sonar-web/src/main/js/apps/component-measures/components/MeasureDrilldown.js View File

@@ -22,14 +22,16 @@ import { Link } from 'react-router';

import IconList from './IconList';
import IconTree from './IconTree';

import { hasHistory } from '../utils';
import { translate } from '../../../helpers/l10n';

export default class MeasureDrilldown extends React.Component {
render () {
const { metric, children } = this.props;
const { children, metric, ...other } = this.props;
const { component } = this.context;

const child = React.cloneElement(children, { component, metric });
const child = React.cloneElement(children, { component, metric, ...other });

return (
<div className="measure-details-drilldown">
@@ -50,6 +52,15 @@ export default class MeasureDrilldown extends React.Component {
{translate('component_measures.tab.list')}
</Link>
</li>
{hasHistory(metric.key) && (
<li>
<Link
activeClassName="active"
to={{ pathname: `${metric.key}/history`, query: { id: component.key } }}>
{translate('component_measures.tab.history')}
</Link>
</li>
)}
</ul>

{child}

+ 182
- 0
server/sonar-web/src/main/js/apps/component-measures/components/MeasureHistory.js View File

@@ -0,0 +1,182 @@
/*
* SonarQube
* Copyright (C) 2009-2016 SonarSource SA
* mailto:contact AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import _ from 'underscore';
import moment from 'moment';
import React from 'react';

import Spinner from './Spinner';
import Timeline from '../../../components/charts/Timeline';
import { getTimeMachineData } from '../../../api/time-machine';
import { getEvents } from '../../../api/events';
import { formatMeasure, getShortType } from '../../../helpers/measures';

const HEIGHT = 280;

function parseValue (value, type) {
return type === 'RATING' && typeof value === 'string' ? value.charCodeAt(0) - 'A'.charCodeAt(0) + 1 : value;
}

export default class MeasureHistory extends React.Component {
state = {
components: [],
selected: null,
fetching: true
};

componentDidMount () {
this.mounted = true;
this.fetchHistory();
}

componentDidUpdate (nextProps, nextState, nextContext) {
if ((nextProps.metric !== this.props.metric) ||
(nextContext.component !== this.context.component)) {
this.fetchHistory();
}
}

componentWillUnmount () {
this.mounted = false;
}

fetchHistory () {
const { metric } = this.props;

Promise.all([
this.fetchTimeMachineData(metric.key),
this.fetchEvents()
]).then(responses => {
if (this.mounted) {
this.setState({
snapshots: responses[0],
events: responses[1],
fetching: false
});
}
});
}

fetchTimeMachineData (currentMetric, comparisonMetric) {
const metricsToRequest = [currentMetric];

if (comparisonMetric) {
metricsToRequest.push(comparisonMetric);
}

return getTimeMachineData(this.props.component.key, metricsToRequest.join()).then(r => {
return r[0].cells.map(cell => {
return {
date: moment(cell.d).toDate(),
values: cell.v
};
});
});
}

fetchEvents () {
return getEvents(this.props.component.key, 'Version').then(r => {
const events = r.map(event => {
return { version: event.n, date: moment(event.dt).toDate() };
});

return _.sortBy(events, 'date');
});
}

renderLineChart (snapshots, metric, index) {
if (!metric) {
return null;
}

if (snapshots.length < 2) {
return this.renderWhenNoHistoricalData();
}

const data = snapshots.map(snapshot => {
return {
x: snapshot.date,
y: parseValue(snapshot.values[index], metric.type)
};
});

const formatValue = (value) => formatMeasure(value, metric.type);
const formatYTick = (tick) => formatMeasure(tick, getShortType(metric.type));

return (
<div style={{ height: HEIGHT }}>
<Timeline key={metric.key}
data={data}
metricType={metric.type}
events={this.state.events}
height={HEIGHT}
interpolate="linear"
formatValue={formatValue}
formatYTick={formatYTick}
leakPeriodDate={this.props.leakPeriodDate}
padding={[25, 25, 25, 60]}/>
</div>
);
}

renderLineCharts () {
const { metric } = this.props;

return (
<div>
{this.renderLineChart(this.state.snapshots, metric, 0)}
{this.renderLineChart(this.state.snapshots, this.state.comparisonMetric, 1)}
</div>
);
}

render () {
const { fetching, snapshots } = this.state;

if (fetching) {
return (
<div className="measure-details-history">
<div className="note text-center" style={{ lineHeight: `${HEIGHT}px` }}>
<Spinner/>
</div>
</div>
);
}

if (!snapshots || snapshots.length < 2) {
return (
<div className="measure-details-history">
<div className="note text-center" style={{ lineHeight: `${HEIGHT}px` }}>
There is no historical data.
</div>
</div>
);
}

return (
<div className="measure-details-history">
{this.renderLineCharts()}
</div>
);
}
}

MeasureHistory.contextTypes = {
component: React.PropTypes.object
};

+ 31
- 0
server/sonar-web/src/main/js/apps/component-measures/hooks.js View File

@@ -0,0 +1,31 @@
/*
* SonarQube
* Copyright (C) 2009-2016 SonarSource SA
* mailto:contact AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { hasHistory } from './utils';

export function checkHistoryExistence (nextState, replace) {
const { metricKey } = nextState.params;

if (!hasHistory(metricKey)) {
replace({
pathname: metricKey,
query: nextState.location.query
});
}
}

+ 4
- 0
server/sonar-web/src/main/js/apps/component-measures/styles.css View File

@@ -200,3 +200,7 @@
.measures-details-components-empty {
padding: 10px;
}

.measure-details-history {
padding: 10px;
}

+ 4
- 0
server/sonar-web/src/main/js/apps/component-measures/utils.js View File

@@ -82,3 +82,7 @@ export function enhanceWithSingleMeasure (components) {
})
.filter(component => component.value != null || component.leak != null);
}

export function hasHistory (metricKey) {
return metricKey.indexOf('new_') !== 0;
}

+ 1
- 1
server/sonar-web/src/main/js/apps/overview/components/domain-timeline.js View File

@@ -26,7 +26,7 @@ import { getTimeMachineData } from '../../../api/time-machine';
import { getEvents } from '../../../api/events';
import { formatMeasure, groupByDomain } from '../../../helpers/measures';
import { getShortType } from '../helpers/metrics';
import { Timeline } from './timeline-chart';
import Timeline from './../../../components/charts/Timeline';
import { translate } from '../../../helpers/l10n';



server/sonar-web/src/main/js/apps/overview/components/timeline-chart.js → server/sonar-web/src/main/js/components/charts/Timeline.js View File

@@ -23,11 +23,10 @@ import d3 from 'd3';
import moment from 'moment';
import React from 'react';

import { ResizeMixin } from '../../../components/mixins/resize-mixin';
import { TooltipsMixin } from '../../../components/mixins/tooltips-mixin';
import { ResizeMixin } from '../mixins/resize-mixin';
import { TooltipsMixin } from '../mixins/tooltips-mixin';


export const Timeline = React.createClass({
const Timeline = React.createClass({
propTypes: {
data: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
padding: React.PropTypes.arrayOf(React.PropTypes.number),
@@ -37,30 +36,33 @@ export const Timeline = React.createClass({

mixins: [ResizeMixin, TooltipsMixin],

getDefaultProps() {
getDefaultProps () {
return {
padding: [10, 10, 10, 10],
interpolate: 'basis'
};
},

getInitialState() {
return { width: this.props.width, height: this.props.height };
getInitialState () {
return {
width: this.props.width,
height: this.props.height
};
},

getRatingScale(availableHeight) {
getRatingScale (availableHeight) {
return d3.scale.ordinal()
.domain([5, 4, 3, 2, 1])
.rangePoints([availableHeight, 0]);
},

getLevelScale(availableHeight) {
getLevelScale (availableHeight) {
return d3.scale.ordinal()
.domain(['ERROR', 'WARN', 'OK'])
.rangePoints([availableHeight, 0]);
},

getYScale(availableHeight) {
getYScale (availableHeight) {
if (this.props.metricType === 'RATING') {
return this.getRatingScale(availableHeight);
} else if (this.props.metricType === 'LEVEL') {
@@ -73,51 +75,65 @@ export const Timeline = React.createClass({
}
},

handleEventMouseEnter(event) {
handleEventMouseEnter (event) {
$(`.js-event-circle-${event.date.getTime()}`).tooltip('show');
},

handleEventMouseLeave(event) {
handleEventMouseLeave (event) {
$(`.js-event-circle-${event.date.getTime()}`).tooltip('hide');
},

renderHorizontalGrid (xScale, yScale) {
let hasTicks = typeof yScale.ticks === 'function';
let ticks = hasTicks ? yScale.ticks(4) : yScale.domain();
const hasTicks = typeof yScale.ticks === 'function';
const ticks = hasTicks ? yScale.ticks(4) : yScale.domain();

if (!ticks.length) {
ticks.push(yScale.domain()[1]);
}
let grid = ticks.map(tick => {
let opts = {

const grid = ticks.map(tick => {
const opts = {
x: xScale.range()[0],
y: yScale(tick)
};
return <g key={tick}>
<text className="line-chart-tick line-chart-tick-x" dx="-1em" dy="0.3em"
textAnchor="end" {...opts}>{this.props.formatYTick(tick)}</text>
<line className="line-chart-grid"
x1={xScale.range()[0]}
x2={xScale.range()[1]}
y1={yScale(tick)}
y2={yScale(tick)}/>
</g>;

return (
<g key={tick}>
<text className="line-chart-tick line-chart-tick-x" dx="-1em" dy="0.3em"
textAnchor="end" {...opts}>{this.props.formatYTick(tick)}</text>
<line className="line-chart-grid"
x1={xScale.range()[0]}
x2={xScale.range()[1]}
y1={yScale(tick)}
y2={yScale(tick)}/>
</g>
);
});

return <g>{grid}</g>;
},

renderTicks (xScale, yScale) {
let format = xScale.tickFormat(7);
const format = xScale.tickFormat(7);
let ticks = xScale.ticks(7);

ticks = _.initial(ticks).map((tick, index) => {
let nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1];
let x = (xScale(tick) + xScale(nextTick)) / 2;
let y = yScale.range()[0];
return <text key={index}
className="line-chart-tick"
x={x}
y={y}
dy="1.5em">{format(tick)}</text>;
const nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1];
const x = (xScale(tick) + xScale(nextTick)) / 2;
const y = yScale.range()[0];

return (
<text
key={index}
className="line-chart-tick"
x={x}
y={y}
dy="1.5em">
{format(tick)}
</text>
);
});

return <g>{ticks}</g>;
},

@@ -125,49 +141,56 @@ export const Timeline = React.createClass({
if (!this.props.leakPeriodDate) {
return null;
}
let opts = {

const opts = {
x: xScale(this.props.leakPeriodDate),
y: _.last(yScale.range()),
width: xScale.range()[1] - xScale(this.props.leakPeriodDate),
height: _.first(yScale.range()) - _.last(yScale.range()),
fill: '#fbf3d5'
};

return <rect {...opts}/>;
},

renderLine (xScale, yScale) {
let p = d3.svg.line()
const p = d3.svg.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.interpolate(this.props.interpolate);

return <path className="line-chart-path" d={p(this.props.data)}/>;
},

renderEvents(xScale, yScale) {
let points = this.props.events
renderEvents (xScale, yScale) {
const points = this.props.events
.map(event => {
let snapshot = this.props.data.find(d => d.x.getTime() === event.date.getTime());
const snapshot = this.props.data.find(d => d.x.getTime() === event.date.getTime());
return _.extend(event, { snapshot });
})
.filter(event => event.snapshot)
.map(event => {
let key = `${event.date.getTime()}-${event.snapshot.y}`;
let className = `line-chart-point js-event-circle-${event.date.getTime()}`;
let tooltip = [
const key = `${event.date.getTime()}-${event.snapshot.y}`;
const className = `line-chart-point js-event-circle-${event.date.getTime()}`;
const tooltip = [
`<span class="nowrap">${event.version}</span>`,
`<span class="nowrap">${moment(event.date).format('LL')}</span>`,
`<span class="nowrap">${event.snapshot.y ? this.props.formatValue(event.snapshot.y) : '—'}</span>`
].join('<br>');
return <circle key={key}
className={className}
r="4"
cx={xScale(event.snapshot.x)}
cy={yScale(event.snapshot.y)}
onMouseEnter={this.handleEventMouseEnter.bind(this, event)}
onMouseLeave={this.handleEventMouseLeave.bind(this, event)}
data-toggle="tooltip"
data-title={tooltip}/>;

return (
<circle key={key}
className={className}
r="4"
cx={xScale(event.snapshot.x)}
cy={yScale(event.snapshot.y)}
onMouseEnter={this.handleEventMouseEnter.bind(this, event)}
onMouseLeave={this.handleEventMouseLeave.bind(this, event)}
data-toggle="tooltip"
data-title={tooltip}/>
);
});

return <g>{points}</g>;
},

@@ -176,23 +199,27 @@ export const Timeline = React.createClass({
return <div/>;
}

let availableWidth = this.state.width - this.props.padding[1] - this.props.padding[3];
let availableHeight = this.state.height - this.props.padding[0] - this.props.padding[2];
const availableWidth = this.state.width - this.props.padding[1] - this.props.padding[3];
const availableHeight = this.state.height - this.props.padding[0] - this.props.padding[2];

let xScale = d3.time.scale()
const xScale = d3.time.scale()
.domain(d3.extent(this.props.data, d => d.x || 0))
.range([0, availableWidth])
.clamp(true);
let yScale = this.getYScale(availableHeight);

return <svg className="line-chart" width={this.state.width} height={this.state.height}>
<g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
{this.renderLeak(xScale, yScale)}
{this.renderHorizontalGrid(xScale, yScale)}
{this.renderTicks(xScale, yScale)}
{this.renderLine(xScale, yScale)}
{this.renderEvents(xScale, yScale)}
</g>
</svg>;
const yScale = this.getYScale(availableHeight);

return (
<svg className="line-chart" width={this.state.width} height={this.state.height}>
<g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
{this.renderLeak(xScale, yScale)}
{this.renderHorizontalGrid(xScale, yScale)}
{this.renderTicks(xScale, yScale)}
{this.renderLine(xScale, yScale)}
{this.renderEvents(xScale, yScale)}
</g>
</svg>
);
}
});

export default Timeline;

+ 15
- 0
server/sonar-web/src/main/js/helpers/measures.js View File

@@ -71,6 +71,21 @@ export function groupByDomain (metrics) {
}


/**
* Return corresponding "short" for better display in UI
* @param {string} type
* @returns {string}
*/
export function getShortType (type) {
if (type === 'INT') {
return 'SHORT_INT';
} else if (type === 'WORK_DUR') {
return 'SHORT_WORK_DUR';
}
return type;
}


/*
* Helpers
*/

+ 9
- 0
server/sonar-web/src/main/js/helpers/periods.js View File

@@ -17,6 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import moment from 'moment';
import { translate, translateWithParameters } from './l10n';

export function getLeakPeriod (periods) {
@@ -41,6 +42,14 @@ export function getPeriodLabel (period) {
return translateWithParameters(`overview.period.${period.mode}`, parameter);
}

export function getPeriodDate (period) {
if (!period) {
return null;
}

return moment(period.date).toDate();
}

export function getLeakPeriodLabel (periods) {
return getPeriodLabel(getLeakPeriod(periods));
}

+ 39
- 0
server/sonar-web/src/main/less/components/graphics.less View File

@@ -266,3 +266,42 @@ text.max-results-reached-message {
.link-no-underline;
}
}

.line-chart {

}

.line-chart-path {
fill: none;
stroke: @blue;
stroke-width: 2px;
}

.line-chart-point {
fill: #fff;
stroke: @darkBlue;
stroke-width: 2px;
}

.line-chart-backdrop {

}

.line-chart-tick {
fill: @secondFontColor;
font-size: 12px;
text-anchor: middle;
}

.line-chart-tick-x {
text-anchor: end;
}

.line-chart-tick-x-right {
text-anchor: start;
}

.line-chart-grid {
shape-rendering: crispedges;
stroke: #eee;
}

+ 0
- 31
server/sonar-web/src/main/less/pages/overview.less View File

@@ -452,40 +452,9 @@
position: absolute;
}

.line-chart-path {
fill: none;
stroke: @blue;
stroke-width: 2px;
}

.line-chart-point {
fill: #fff;
stroke: @darkBlue;
stroke-width: 2px;
}

.line-chart-backdrop {
fill: #fbf3d5;
}

.line-chart-tick {
fill: @secondFontColor;
font-size: 12px;
text-anchor: middle;
}

.line-chart-tick-x {
text-anchor: end;
}

.line-chart-tick-x-right {
text-anchor: start;
}

.line-chart-grid {
shape-rendering: crispedges;
stroke: #eee;
}
}

.overview-timeline-1 {

+ 1
- 1
server/sonar-web/tests/apps/overview/components/timeline-chart-test.js View File

@@ -2,7 +2,7 @@ import { expect } from 'chai';
import React from 'react';
import TestUtils from 'react-addons-test-utils';

import { Timeline } from '../../../../src/main/js/apps/overview/components/timeline-chart';
import Timeline from '../../../../src/main/js/components/charts/Timeline';


const ZERO_DATA = [

Loading…
Cancel
Save