@@ -38,7 +38,7 @@ type Response = { | |||
export const getTimeMachineData = ( | |||
component: string, | |||
metrics: Array<string>, | |||
other?: {} | |||
other?: { p?: number, ps?: number, from?: string, to?: string } | |||
): Promise<Response> => | |||
getJSON('/api/measures/search_history', { | |||
component, | |||
@@ -46,3 +46,39 @@ export const getTimeMachineData = ( | |||
ps: 1000, | |||
...other | |||
}); | |||
export const getAllTimeMachineData = ( | |||
component: string, | |||
metrics: Array<string>, | |||
other?: { p?: number, ps?: number, from?: string, to?: string }, | |||
prev?: Response | |||
): Promise<Response> => | |||
getTimeMachineData(component, metrics, other).then((r: Response) => { | |||
const result = prev | |||
? { | |||
measures: prev.measures.map((measure, idx) => ({ | |||
...measure, | |||
history: measure.history.concat(r.measures[idx].history) | |||
})), | |||
paging: r.paging | |||
} | |||
: r; | |||
if ( | |||
// TODO Remove the sameAsPrevious condition when the webservice paging is working correctly ? | |||
// Or keep it to be sure to not have an infinite loop ? | |||
result.measures.every((measure, idx) => { | |||
const equalToTotal = measure.history.length >= result.paging.total; | |||
const sameAsPrevious = prev && measure.history.length === prev.measures[idx].history.length; | |||
return equalToTotal || sameAsPrevious; | |||
}) | |||
) { | |||
return result; | |||
} | |||
return getAllTimeMachineData( | |||
component, | |||
metrics, | |||
{ ...other, p: result.paging.pageIndex + 1 }, | |||
result | |||
); | |||
}); |
@@ -22,9 +22,10 @@ import React from 'react'; | |||
import { groupBy } from 'lodash'; | |||
import moment from 'moment'; | |||
import ProjectActivityAnalysis from './ProjectActivityAnalysis'; | |||
import ProjectActivityPageFooter from './ProjectActivityPageFooter'; | |||
import FormattedDate from '../../../components/ui/FormattedDate'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import type { Analysis } from '../types'; | |||
import type { Analysis, Paging } from '../types'; | |||
type Props = { | |||
addCustomEvent: (analysis: string, name: string, category?: string) => Promise<*>, | |||
@@ -33,49 +34,63 @@ type Props = { | |||
canAdmin: boolean, | |||
changeEvent: (event: string, name: string) => Promise<*>, | |||
deleteAnalysis: (analysis: string) => Promise<*>, | |||
deleteEvent: (analysis: string, event: string) => Promise<*> | |||
deleteEvent: (analysis: string, event: string) => Promise<*>, | |||
fetchMoreActivity: () => void, | |||
paging?: Paging | |||
}; | |||
export default function ProjectActivityAnalysesList(props: Props) { | |||
if (props.analyses.length === 0) { | |||
return <div className="note">{translate('no_results')}</div>; | |||
return ( | |||
<div className="layout-page-side-outer project-activity-page-side-outer"> | |||
<div className="boxed-group boxed-group-inner"> | |||
<div className="note">{translate('no_results')}</div> | |||
</div> | |||
</div> | |||
); | |||
} | |||
const firstAnalysis = props.analyses[0]; | |||
const byDay = groupBy(props.analyses, analysis => moment(analysis.date).startOf('day').valueOf()); | |||
return ( | |||
<div className="boxed-group boxed-group-inner"> | |||
<ul className="project-activity-days-list"> | |||
{Object.keys(byDay).map(day => ( | |||
<li | |||
key={day} | |||
className="project-activity-day" | |||
data-day={moment(Number(day)).format('YYYY-MM-DD')}> | |||
<div className="project-activity-date"> | |||
<FormattedDate date={Number(day)} format="LL" /> | |||
</div> | |||
<div className="layout-page-side-outer project-activity-page-side-outer"> | |||
<div className="boxed-group boxed-group-inner"> | |||
<ul className="project-activity-days-list"> | |||
{Object.keys(byDay).map(day => ( | |||
<li | |||
key={day} | |||
className="project-activity-day" | |||
data-day={moment(Number(day)).format('YYYY-MM-DD')}> | |||
<div className="project-activity-date"> | |||
<FormattedDate date={Number(day)} format="LL" /> | |||
</div> | |||
<ul className="project-activity-analyses-list"> | |||
{byDay[day] != null && | |||
byDay[day].map(analysis => ( | |||
<ProjectActivityAnalysis | |||
addCustomEvent={props.addCustomEvent} | |||
addVersion={props.addVersion} | |||
analysis={analysis} | |||
canAdmin={props.canAdmin} | |||
changeEvent={props.changeEvent} | |||
deleteAnalysis={props.deleteAnalysis} | |||
deleteEvent={props.deleteEvent} | |||
isFirst={analysis === firstAnalysis} | |||
key={analysis.key} | |||
/> | |||
))} | |||
</ul> | |||
</li> | |||
))} | |||
</ul> | |||
<ul className="project-activity-analyses-list"> | |||
{byDay[day] != null && | |||
byDay[day].map(analysis => ( | |||
<ProjectActivityAnalysis | |||
addCustomEvent={props.addCustomEvent} | |||
addVersion={props.addVersion} | |||
analysis={analysis} | |||
canAdmin={props.canAdmin} | |||
changeEvent={props.changeEvent} | |||
deleteAnalysis={props.deleteAnalysis} | |||
deleteEvent={props.deleteEvent} | |||
isFirst={analysis === firstAnalysis} | |||
key={analysis.key} | |||
/> | |||
))} | |||
</ul> | |||
</li> | |||
))} | |||
</ul> | |||
<ProjectActivityPageFooter | |||
analyses={props.analyses} | |||
fetchMoreActivity={props.fetchMoreActivity} | |||
paging={props.paging} | |||
/> | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -20,16 +20,19 @@ | |||
// @flow | |||
import React from 'react'; | |||
import Helmet from 'react-helmet'; | |||
import moment from 'moment'; | |||
import ProjectActivityPageHeader from './ProjectActivityPageHeader'; | |||
import ProjectActivityAnalysesList from './ProjectActivityAnalysesList'; | |||
import ProjectActivityPageFooter from './ProjectActivityPageFooter'; | |||
import ProjectActivityGraphs from './ProjectActivityGraphs'; | |||
import throwGlobalError from '../../../app/utils/throwGlobalError'; | |||
import * as api from '../../../api/projectActivity'; | |||
import * as actions from '../actions'; | |||
import { parseQuery, serializeQuery, serializeUrlQuery } from '../utils'; | |||
import { getAllTimeMachineData } from '../../../api/time-machine'; | |||
import { getMetrics } from '../../../api/metrics'; | |||
import { GRAPHS_METRICS, parseQuery, serializeQuery, serializeUrlQuery } from '../utils'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import './projectActivity.css'; | |||
import type { Analysis, Query, Paging } from '../types'; | |||
import type { Analysis, LeakPeriod, MeasureHistory, Metric, Query, Paging } from '../types'; | |||
import type { RawQuery } from '../../../helpers/query'; | |||
type Props = { | |||
@@ -40,7 +43,11 @@ type Props = { | |||
export type State = { | |||
analyses: Array<Analysis>, | |||
leakPeriod?: LeakPeriod, | |||
loading: boolean, | |||
measures: Array<*>, | |||
metrics: Array<Metric>, | |||
measuresHistory: Array<MeasureHistory>, | |||
paging?: Paging, | |||
query: Query | |||
}; | |||
@@ -52,7 +59,14 @@ export default class ProjectActivityApp extends React.PureComponent { | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { analyses: [], loading: true, query: parseQuery(props.location.query) }; | |||
this.state = { | |||
analyses: [], | |||
loading: true, | |||
measures: [], | |||
measuresHistory: [], | |||
metrics: [], | |||
query: parseQuery(props.location.query) | |||
}; | |||
} | |||
componentDidMount() { | |||
@@ -85,6 +99,21 @@ export default class ProjectActivityApp extends React.PureComponent { | |||
return api.getProjectActivity(parameters).catch(throwGlobalError); | |||
}; | |||
fetchMetrics = (): Promise<Array<Metric>> => getMetrics().catch(throwGlobalError); | |||
fetchMeasuresHistory = (metrics: Array<string>): Promise<Array<MeasureHistory>> => | |||
getAllTimeMachineData(this.props.project.key, metrics) | |||
.then(({ measures }) => | |||
measures.map(measure => ({ | |||
metric: measure.metric, | |||
history: measure.history.map(analysis => ({ | |||
date: moment(analysis.date).toDate(), | |||
value: analysis.value | |||
})) | |||
})) | |||
) | |||
.catch(throwGlobalError); | |||
fetchMoreActivity = () => { | |||
const { paging, query } = this.state; | |||
if (!paging) { | |||
@@ -136,15 +165,29 @@ export default class ProjectActivityApp extends React.PureComponent { | |||
.then(() => this.mounted && this.setState(actions.deleteAnalysis(analysis))) | |||
.catch(throwGlobalError); | |||
getMetricType = () => { | |||
const metricKey = GRAPHS_METRICS[this.state.query.graph][0]; | |||
const metric = this.state.metrics.find(metric => metric.key === metricKey); | |||
return metric ? metric.type : 'INT'; | |||
}; | |||
handleQueryChange() { | |||
const query = parseQuery(this.props.location.query); | |||
const graphMetrics = GRAPHS_METRICS[query.graph]; | |||
this.setState({ loading: true, query }); | |||
this.fetchActivity(query).then(({ analyses, paging }) => { | |||
Promise.all([ | |||
this.fetchActivity(query), | |||
this.fetchMetrics(), | |||
this.fetchMeasuresHistory(graphMetrics) | |||
]).then(response => { | |||
if (this.mounted) { | |||
this.setState({ | |||
analyses, | |||
analyses: response[0].analyses, | |||
loading: false, | |||
paging | |||
metrics: response[1], | |||
measuresHistory: response[2], | |||
paging: response[0].paging | |||
}); | |||
} | |||
}); | |||
@@ -174,21 +217,30 @@ export default class ProjectActivityApp extends React.PureComponent { | |||
<ProjectActivityPageHeader category={query.category} updateQuery={this.updateQuery} /> | |||
<ProjectActivityAnalysesList | |||
addCustomEvent={this.addCustomEvent} | |||
addVersion={this.addVersion} | |||
analyses={this.state.analyses} | |||
canAdmin={canAdmin} | |||
changeEvent={this.changeEvent} | |||
deleteAnalysis={this.deleteAnalysis} | |||
deleteEvent={this.deleteEvent} | |||
/> | |||
<ProjectActivityPageFooter | |||
analyses={this.state.analyses} | |||
fetchMoreActivity={this.fetchMoreActivity} | |||
paging={this.state.paging} | |||
/> | |||
<div className="layout-page project-activity-page"> | |||
<ProjectActivityAnalysesList | |||
addCustomEvent={this.addCustomEvent} | |||
addVersion={this.addVersion} | |||
analyses={this.state.analyses} | |||
canAdmin={canAdmin} | |||
changeEvent={this.changeEvent} | |||
deleteAnalysis={this.deleteAnalysis} | |||
deleteEvent={this.deleteEvent} | |||
fetchMoreActivity={this.fetchMoreActivity} | |||
paging={this.state.paging} | |||
/> | |||
<ProjectActivityGraphs | |||
analyses={this.state.analyses} | |||
leakPeriod={this.state.leakPeriod} | |||
loading={this.state.loading} | |||
measuresHistory={this.state.measuresHistory} | |||
metricsType={this.getMetricType()} | |||
project={this.props.project.key} | |||
query={query} | |||
updateQuery={this.updateQuery} | |||
/> | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,52 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import ProjectActivityGraphsHeader from './ProjectActivityGraphsHeader'; | |||
import StaticGraphs from './StaticGraphs'; | |||
import type { RawQuery } from '../../../helpers/query'; | |||
import type { Analysis, MeasureHistory, Query } from '../types'; | |||
type Props = { | |||
analyses: Array<Analysis>, | |||
loading: boolean, | |||
measuresHistory: Array<MeasureHistory>, | |||
metricsType: string, | |||
project: string, | |||
query: Query, | |||
updateQuery: RawQuery => void | |||
}; | |||
export default function ProjectActivityGraphs(props: Props) { | |||
return ( | |||
<div className="project-activity-layout-page-main"> | |||
<div className="project-activity-layout-page-main-inner boxed-group boxed-group-inner"> | |||
<ProjectActivityGraphsHeader graph={props.query.graph} updateQuery={props.updateQuery} /> | |||
<StaticGraphs | |||
analyses={props.analyses} | |||
loading={props.loading} | |||
measuresHistory={props.measuresHistory} | |||
metricsType={props.metricsType} | |||
project={props.project} | |||
/> | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,60 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import Select from 'react-select'; | |||
import { GRAPH_TYPES } from '../utils'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import type { RawQuery } from '../../../helpers/query'; | |||
type Props = { | |||
updateQuery: RawQuery => void, | |||
graph: string | |||
}; | |||
export default class ProjectActivityGraphsHeader extends React.PureComponent { | |||
props: Props; | |||
handleGraphChange = (option: { value: string }) => { | |||
if (option.value !== this.props.graph) { | |||
this.props.updateQuery({ graph: option.value }); | |||
} | |||
}; | |||
render() { | |||
const selectOptions = GRAPH_TYPES.map(graph => ({ | |||
label: translate('project_activity.graphs', graph), | |||
value: graph | |||
})); | |||
return ( | |||
<header className="page-header"> | |||
<Select | |||
className="input-medium" | |||
clearable={false} | |||
searchable={false} | |||
value={this.props.graph} | |||
options={selectOptions} | |||
onChange={this.handleGraphChange} | |||
/> | |||
</header> | |||
); | |||
} | |||
} |
@@ -43,21 +43,15 @@ export default class ProjectActivityPageHeader extends React.PureComponent { | |||
return ( | |||
<header className="page-header"> | |||
<div className="page-actions"> | |||
<Select | |||
className="input-medium" | |||
placeholder={translate('filter_verb') + '...'} | |||
clearable={true} | |||
searchable={false} | |||
value={this.props.category} | |||
options={selectOptions} | |||
onChange={this.handleCategoryChange} | |||
/> | |||
</div> | |||
<div className="page-description"> | |||
{translate('project_activity.page.description')} | |||
</div> | |||
<Select | |||
className="input-medium" | |||
placeholder={translate('project_activity.filter_events') + '...'} | |||
clearable={true} | |||
searchable={false} | |||
value={this.props.category} | |||
options={selectOptions} | |||
onChange={this.handleCategoryChange} | |||
/> | |||
</header> | |||
); | |||
} |
@@ -0,0 +1,113 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info 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 React from 'react'; | |||
import moment from 'moment'; | |||
import { some, sortBy } from 'lodash'; | |||
import AdvancedTimeline from '../../../components/charts/AdvancedTimeline'; | |||
import StaticGraphsLegend from './StaticGraphsLegend'; | |||
import ResizeHelper from '../../../components/common/ResizeHelper'; | |||
import { formatMeasure, getShortType } from '../../../helpers/measures'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import type { Analysis, MeasureHistory } from '../types'; | |||
type Props = { | |||
analyses: Array<Analysis>, | |||
loading: boolean, | |||
measuresHistory: Array<MeasureHistory>, | |||
metricsType: string | |||
}; | |||
export default class StaticGraphs extends React.PureComponent { | |||
props: Props; | |||
getEvents = () => { | |||
const events = this.props.analyses.reduce((acc, analysis) => { | |||
return acc.concat( | |||
analysis.events.map(event => ({ | |||
className: event.category, | |||
name: event.name, | |||
date: moment(analysis.date).toDate() | |||
})) | |||
); | |||
}, []); | |||
return sortBy(events, 'date'); | |||
}; | |||
getSeries = () => | |||
sortBy(this.props.measuresHistory, 'metric').map(measure => ({ | |||
name: measure.metric, | |||
data: measure.history.map(analysis => ({ | |||
x: analysis.date, | |||
y: this.props.metricsType === 'LEVEL' ? analysis.value : Number(analysis.value) | |||
})) | |||
})); | |||
hasHistoryData = () => | |||
some(this.props.measuresHistory, measure => measure.history && measure.history.length > 2); | |||
render() { | |||
const { loading } = this.props; | |||
if (loading) { | |||
return ( | |||
<div className="project-activity-graph-container"> | |||
<div className="text-center"> | |||
<i className="spinner" /> | |||
</div> | |||
</div> | |||
); | |||
} | |||
if (!this.hasHistoryData()) { | |||
return ( | |||
<div className="project-activity-graph-container"> | |||
<div className="note text-center"> | |||
{translate('component_measures.no_history')} | |||
</div> | |||
</div> | |||
); | |||
} | |||
const { metricsType } = this.props; | |||
const formatValue = value => formatMeasure(value, metricsType); | |||
const formatYTick = tick => formatMeasure(tick, getShortType(metricsType)); | |||
const series = this.getSeries(); | |||
return ( | |||
<div className="project-activity-graph-container"> | |||
<StaticGraphsLegend series={series} /> | |||
<div className="project-activity-graph"> | |||
<ResizeHelper> | |||
<AdvancedTimeline | |||
basisCurve={false} | |||
series={series} | |||
metricType={metricsType} | |||
events={this.getEvents()} | |||
interpolate="linear" | |||
formatValue={formatValue} | |||
formatYTick={formatYTick} | |||
leakPeriodDate={this.props.leakPeriodDate} | |||
padding={[25, 25, 30, 60]} | |||
/> | |||
</ResizeHelper> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,42 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info 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 React from 'react'; | |||
import classNames from 'classnames'; | |||
import ChartLegendIcon from '../../../components/icons-components/ChartLegendIcon'; | |||
import { translate } from '../../../helpers/l10n'; | |||
type Props = { | |||
series: Array<{ name: string }> | |||
}; | |||
export default function StaticGraphsLegend({ series }: Props) { | |||
return ( | |||
<div className="project-activity-graph-legends"> | |||
{series.map((serie, idx) => ( | |||
<span className="big-spacer-left big-spacer-right" key={serie.name}> | |||
<ChartLegendIcon | |||
className={classNames('spacer-right line-chart-legend', 'line-chart-legend-' + idx)} | |||
/> | |||
{translate('metric', serie.name, 'name')} | |||
</span> | |||
))} | |||
</div> | |||
); | |||
} |
@@ -1,3 +1,58 @@ | |||
.project-activity-page { | |||
min-height: 600px; | |||
height: calc(100vh - 250px); | |||
} | |||
.project-activity-page-side-outer { | |||
width: 400px; | |||
overflow: auto; | |||
} | |||
.project-activity-page-side-outer .boxed-group { | |||
margin-bottom: 0; | |||
} | |||
.project-activity-layout-page-main { | |||
flex-grow: 1; | |||
min-width: 640px; | |||
padding-left: 20px; | |||
display: flex; | |||
} | |||
.project-activity-layout-page-main-inner { | |||
min-width: 640px; | |||
max-width: 880px; | |||
margin-bottom: 0px; | |||
flex: 1; | |||
display: flex; | |||
flex-direction: column; | |||
align-items: stretch; | |||
} | |||
.project-activity-list { | |||
max-width: 400px; | |||
} | |||
.project-activity-graph-container { | |||
padding: 10px 0; | |||
flex-grow: 1; | |||
display: flex; | |||
flex-direction: column; | |||
align-items: stretch; | |||
justify-content: center; | |||
} | |||
.project-activity-graph { | |||
flex: 1; | |||
max-height: 500px; | |||
} | |||
.project-activity-graph-legends { | |||
flex-grow: 0; | |||
padding-bottom: 16px; | |||
text-align: center; | |||
} | |||
.project-activity-days-list {} | |||
.project-activity-day { |
@@ -32,6 +32,23 @@ export type Analysis = { | |||
events: Array<Event> | |||
}; | |||
export type LeakPeriod = { | |||
date: string, | |||
index: number, | |||
mode: string, | |||
parameter: string | |||
}; | |||
export type HistoryItem = { date: Date, value: string }; | |||
export type MeasureHistory = { metric: string, history: Array<HistoryItem> }; | |||
export type Metric = { | |||
key: string, | |||
name: string, | |||
type: string | |||
}; | |||
export type Paging = { | |||
pageIndex: number, | |||
pageSize: number, | |||
@@ -39,6 +56,7 @@ export type Paging = { | |||
}; | |||
export type Query = { | |||
project: string, | |||
category: string | |||
category: string, | |||
graph: string, | |||
project: string | |||
}; |
@@ -22,19 +22,26 @@ import { cleanQuery, parseAsString, serializeString } from '../../helpers/query' | |||
import type { Query } from './types'; | |||
import type { RawQuery } from '../../helpers/query'; | |||
export const GRAPH_TYPES = ['overview']; | |||
export const GRAPHS_METRICS = { overview: ['bugs', 'vulnerabilities', 'code_smells'] }; | |||
export const parseQuery = (urlQuery: RawQuery): Query => ({ | |||
project: parseAsString(urlQuery['id']), | |||
category: parseAsString(urlQuery['category']) | |||
category: parseAsString(urlQuery['category']), | |||
graph: parseAsString(urlQuery['graph']) || 'overview', | |||
project: parseAsString(urlQuery['id']) | |||
}); | |||
export const serializeQuery = (query: Query): Query => | |||
export const serializeQuery = (query: Query): RawQuery => | |||
cleanQuery({ | |||
project: serializeString(query.project), | |||
category: serializeString(query.category) | |||
category: serializeString(query.category), | |||
project: serializeString(query.project) | |||
}); | |||
export const serializeUrlQuery = (query: Query): RawQuery => | |||
cleanQuery({ | |||
id: serializeString(query.project), | |||
category: serializeString(query.category) | |||
export const serializeUrlQuery = (query: Query): RawQuery => { | |||
const graph = query.graph === 'overview' ? '' : query.graph; | |||
return cleanQuery({ | |||
category: serializeString(query.category), | |||
graph: serializeString(graph), | |||
id: serializeString(query.project) | |||
}); | |||
}; |
@@ -0,0 +1,217 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
import { flatten } from 'lodash'; | |||
import { extent, max } from 'd3-array'; | |||
import { scaleLinear, scalePoint, scaleTime } from 'd3-scale'; | |||
import { line as d3Line, curveBasis } from 'd3-shape'; | |||
type Point = { x: Date, y: number | string }; | |||
type Serie = { name: string, data: Array<Point> }; | |||
type Event = { className?: string, name: string, date: Date }; | |||
type Scale = Function; | |||
type Props = { | |||
basisCurve?: boolean, | |||
events?: Array<Event>, | |||
eventSize?: number, | |||
formatYTick: number => string, | |||
formatValue: number => string, | |||
height: number, | |||
width: number, | |||
leakPeriodDate: Date, | |||
padding: Array<number>, | |||
series: Array<Serie> | |||
}; | |||
export default class AdvancedTimeline extends React.PureComponent { | |||
props: Props; | |||
static defaultProps = { | |||
eventSize: 8, | |||
padding: [10, 10, 10, 10] | |||
}; | |||
getRatingScale = (availableHeight: number) => | |||
scalePoint().domain([5, 4, 3, 2, 1]).range([availableHeight, 0]); | |||
getLevelScale = (availableHeight: number) => | |||
scalePoint().domain(['ERROR', 'WARN', 'OK']).range([availableHeight, 0]); | |||
getYScale = (availableHeight: number, flatData: Array<Point>) => { | |||
if (this.props.metricType === 'RATING') { | |||
return this.getRatingScale(availableHeight); | |||
} else if (this.props.metricType === 'LEVEL') { | |||
return this.getLevelScale(availableHeight); | |||
} else { | |||
return scaleLinear().range([availableHeight, 0]).domain([0, max(flatData, d => d.y)]).nice(); | |||
} | |||
}; | |||
getXScale = (availableWidth: number, flatData: Array<Point>) => | |||
scaleTime().domain(extent(flatData, d => d.x)).range([0, availableWidth]).clamp(true); | |||
getScales = () => { | |||
const availableWidth = this.props.width - this.props.padding[1] - this.props.padding[3]; | |||
const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2]; | |||
const flatData = flatten(this.props.series.map((serie: Serie) => serie.data)); | |||
return { | |||
xScale: this.getXScale(availableWidth, flatData), | |||
yScale: this.getYScale(availableHeight, flatData) | |||
}; | |||
}; | |||
getEventMarker = (size: number) => { | |||
const half = size / 2; | |||
return `M${half} 0 L${size} ${half} L ${half} ${size} L0 ${half} L${half} 0 L${size} ${half}`; | |||
}; | |||
renderHorizontalGrid = (xScale: Scale, yScale: Scale) => { | |||
const hasTicks = typeof yScale.ticks === 'function'; | |||
const ticks = hasTicks ? yScale.ticks(4) : yScale.domain(); | |||
if (!ticks.length) { | |||
ticks.push(yScale.domain()[1]); | |||
} | |||
return ( | |||
<g> | |||
{ticks.map(tick => ( | |||
<g key={tick}> | |||
<text | |||
className="line-chart-tick line-chart-tick-x" | |||
dx="-1em" | |||
dy="0.3em" | |||
textAnchor="end" | |||
x={xScale.range()[0]} | |||
y={yScale(tick)}> | |||
{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> | |||
))} | |||
</g> | |||
); | |||
}; | |||
renderTicks = (xScale: Scale, yScale: Scale) => { | |||
const format = xScale.tickFormat(7); | |||
const ticks = xScale.ticks(7); | |||
const y = yScale.range()[0]; | |||
return ( | |||
<g> | |||
{ticks.slice(0, -1).map((tick, index) => { | |||
const nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1]; | |||
const x = (xScale(tick) + xScale(nextTick)) / 2; | |||
return ( | |||
<text key={index} className="line-chart-tick" x={x} y={y} dy="2em"> | |||
{format(tick)} | |||
</text> | |||
); | |||
})} | |||
</g> | |||
); | |||
}; | |||
renderLeak = (xScale: Scale, yScale: Scale) => { | |||
if (!this.props.leakPeriodDate) { | |||
return null; | |||
} | |||
const yScaleRange = yScale.range(); | |||
return ( | |||
<rect | |||
x={xScale(this.props.leakPeriodDate)} | |||
y={yScaleRange[yScaleRange.length - 1]} | |||
width={xScale.range()[1] - xScale(this.props.leakPeriodDate)} | |||
height={yScaleRange[0] - yScaleRange[yScaleRange.length - 1]} | |||
fill="#fbf3d5" | |||
/> | |||
); | |||
}; | |||
renderLines = (xScale: Scale, yScale: Scale) => { | |||
const line = d3Line().x(d => xScale(d.x)).y(d => yScale(d.y)); | |||
if (this.props.basisCurve) { | |||
line.curve(curveBasis); | |||
} | |||
return ( | |||
<g> | |||
{this.props.series.map((serie, idx) => ( | |||
<path | |||
key={`${idx}-${serie.name}`} | |||
className={classNames('line-chart-path', 'line-chart-path-' + idx)} | |||
d={line(serie.data)} | |||
/> | |||
))} | |||
</g> | |||
); | |||
}; | |||
renderEvents = (xScale: Scale, yScale: Scale) => { | |||
const { events, eventSize } = this.props; | |||
if (!events || !eventSize) { | |||
return null; | |||
} | |||
const offset = eventSize / 2; | |||
return ( | |||
<g> | |||
{events.map((event, idx) => ( | |||
<path | |||
d={this.getEventMarker(eventSize)} | |||
className={classNames('line-chart-event', event.className)} | |||
key={`${idx}-${event.date.getTime()}`} | |||
transform={`translate(${xScale(event.date) - offset}, ${yScale.range()[0] - offset})`} | |||
/> | |||
))} | |||
</g> | |||
); | |||
}; | |||
render() { | |||
if (!this.props.width || !this.props.height) { | |||
return <div />; | |||
} | |||
const { xScale, yScale } = this.getScales(); | |||
return ( | |||
<svg className="line-chart" width={this.props.width} height={this.props.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.renderLines(xScale, yScale)} | |||
{this.renderEvents(xScale, yScale)} | |||
</g> | |||
</svg> | |||
); | |||
} | |||
} |
@@ -0,0 +1,75 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import ReactDOM from 'react-dom'; | |||
type Props = { | |||
children: React.Element<*>, | |||
height?: number, | |||
width?: number | |||
}; | |||
type State = { | |||
height?: number, | |||
width?: number | |||
}; | |||
export default class ResizeHelper extends React.PureComponent { | |||
props: Props; | |||
state: State; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { height: props.height, width: props.width }; | |||
} | |||
componentDidMount() { | |||
if (this.isResizable()) { | |||
this.handleResize(); | |||
window.addEventListener('resize', this.handleResize); | |||
} | |||
} | |||
componentWillUnmount() { | |||
if (this.isResizable()) { | |||
window.removeEventListener('resize', this.handleResize); | |||
} | |||
} | |||
isResizable = () => { | |||
return !this.props.width || !this.props.height; | |||
}; | |||
handleResize = () => { | |||
const domNode = ReactDOM.findDOMNode(this); | |||
if (domNode && domNode.parentElement) { | |||
const boundingClientRect = domNode.parentElement.getBoundingClientRect(); | |||
this.setState({ width: boundingClientRect.width, height: boundingClientRect.height }); | |||
} | |||
}; | |||
render() { | |||
return React.cloneElement(this.props.children, { | |||
width: this.props.width || this.state.width, | |||
height: this.props.height || this.state.height | |||
}); | |||
} | |||
} |
@@ -0,0 +1,40 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info 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. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
type Props = { className?: string, size?: number }; | |||
export default function ChartLegendIcon({ className, size = 16 }: Props) { | |||
/* eslint-disable max-len */ | |||
return ( | |||
<svg | |||
className={className} | |||
xmlns="http://www.w3.org/2000/svg" | |||
viewBox="0 0 16 16" | |||
width={size} | |||
height={size}> | |||
<path | |||
style={{ fill: 'currentColor' }} | |||
d="M14.325 7.143v1.714q0 0.357-0.25 0.607t-0.607 0.25h-10.857q-0.357 0-0.607-0.25t-0.25-0.607v-1.714q0-0.357 0.25-0.607t0.607-0.25h10.857q0.357 0 0.607 0.25t0.25 0.607z" | |||
/> | |||
</svg> | |||
); | |||
} |
@@ -107,21 +107,67 @@ | |||
* Line Chart | |||
*/ | |||
@defaultSerieColor: @darkBlue; | |||
@serieColor1: @blue; | |||
@serieColor2: #26adff; | |||
.line-chart { | |||
} | |||
.line-chart-path { | |||
fill: none; | |||
stroke: @blue; | |||
stroke: @defaultSerieColor; | |||
stroke-width: 2px; | |||
&.line-chart-path-1 { | |||
stroke: @serieColor1 | |||
} | |||
&.line-chart-path-2 { | |||
stroke: @serieColor2; | |||
} | |||
} | |||
.line-chart-legend { | |||
color: @defaultSerieColor; | |||
&.line-chart-legend-1 { | |||
color: @serieColor1; | |||
} | |||
&.line-chart-legend-2 { | |||
color: @serieColor2; | |||
} | |||
} | |||
.line-chart-point { | |||
fill: #fff; | |||
stroke: @darkBlue; | |||
stroke: @defaultSerieColor; | |||
stroke-width: 2px; | |||
} | |||
.line-chart-event { | |||
fill: #fff; | |||
stroke: @defaultSerieColor; | |||
stroke-width: 2px; | |||
&.VERSION { | |||
stroke: @green; | |||
} | |||
&.QUALITY_GATE { | |||
stroke: @blue; | |||
} | |||
&.QUALITY_PROFILE { | |||
stroke: @orange; | |||
} | |||
&.OTHER { | |||
stroke: @purple; | |||
} | |||
} | |||
.line-chart-backdrop { | |||
} | |||
@@ -591,7 +591,6 @@ comparison.page=Compare | |||
view_projects.page=Projects | |||
portfolios.page=Portfolios | |||
project_activity.page=Activity | |||
project_activity.page.description=The page shows the history of project analyses. | |||
#------------------------------------------------------------------------------ | |||
@@ -1271,8 +1270,8 @@ manual_rules.add_manual_rule=Add Manual Rule | |||
# | |||
#------------------------------------------------------------------------------ | |||
project_activity.project_analyzed=Project Analyzed | |||
project_activity.add_version=Create Version | |||
project_activity.project_analyzed=Project Analyzed | |||
project_activity.remove_version=Remove Version | |||
project_activity.remove_version.question=Are you sure you want to delete this version? | |||
project_activity.change_version=Change Version | |||
@@ -1282,6 +1281,9 @@ project_activity.remove_custom_event=Delete Event | |||
project_activity.remove_custom_event.question=Are you sure you want to delete this event? | |||
project_activity.delete_analysis=Delete Analysis | |||
project_activity.delete_analysis.question=Are you sure you want to delete this analysis from the project history? | |||
project_activity.filter_events=Filter events | |||
project_activity.graphs.overview=Overview | |||
project_history.col.year=Year | |||
project_history.col.month=Month |