@@ -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> | |||
@@ -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> | |||
)} |
@@ -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} |
@@ -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 | |||
}; |
@@ -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 | |||
}); | |||
} | |||
} |
@@ -200,3 +200,7 @@ | |||
.measures-details-components-empty { | |||
padding: 10px; | |||
} | |||
.measure-details-history { | |||
padding: 10px; | |||
} |
@@ -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; | |||
} |
@@ -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'; | |||
@@ -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; |
@@ -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 | |||
*/ |
@@ -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)); | |||
} |
@@ -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; | |||
} |
@@ -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 { |
@@ -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 = [ |