aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>2017-06-13 17:41:46 +0200
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>2017-07-04 14:15:34 +0200
commit5b03b1c707168fcd83a3eee3bae40ad0d2c7022f (patch)
tree06bf7daaa3f33083388a6c502380acc27bae963a
parent47b553e761f7e061cdce150003123f1f5de724be (diff)
downloadsonarqube-5b03b1c707168fcd83a3eee3bae40ad0d2c7022f.tar.gz
sonarqube-5b03b1c707168fcd83a3eee3bae40ad0d2c7022f.zip
SONAR-9401 Add the overview chart to the project activity page
-rw-r--r--server/sonar-web/src/main/js/api/time-machine.js38
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js83
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js96
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js52
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js60
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js24
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js113
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js42
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css55
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/types.js22
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/utils.js25
-rw-r--r--server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js217
-rw-r--r--server/sonar-web/src/main/js/components/common/ResizeHelper.js75
-rw-r--r--server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js40
-rw-r--r--server/sonar-web/src/main/less/components/graphics.less50
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties6
16 files changed, 911 insertions, 87 deletions
diff --git a/server/sonar-web/src/main/js/api/time-machine.js b/server/sonar-web/src/main/js/api/time-machine.js
index 9104ec1d391..2378a726302 100644
--- a/server/sonar-web/src/main/js/api/time-machine.js
+++ b/server/sonar-web/src/main/js/api/time-machine.js
@@ -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
+ );
+ });
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js
index 96ab8cb14ad..b74b7179090 100644
--- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js
@@ -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>
);
}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js
index 7289766ece9..e23b67da52e 100644
--- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js
@@ -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>
);
}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js
new file mode 100644
index 00000000000..167567a8918
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js
@@ -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>
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js
new file mode 100644
index 00000000000..33ee4ffab72
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js
@@ -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>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js
index 8dd16cb5045..29e3e16925f 100644
--- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageHeader.js
@@ -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>
);
}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js
new file mode 100644
index 00000000000..1207d20439b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js
@@ -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>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js
new file mode 100644
index 00000000000..9f76e8cde43
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphsLegend.js
@@ -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>
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css b/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css
index 2b69f15e803..a9dc2009ddb 100644
--- a/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css
+++ b/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css
@@ -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 {
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/types.js b/server/sonar-web/src/main/js/apps/projectActivity/types.js
index b3d8211dfc8..51cc48cbea4 100644
--- a/server/sonar-web/src/main/js/apps/projectActivity/types.js
+++ b/server/sonar-web/src/main/js/apps/projectActivity/types.js
@@ -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
};
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/utils.js b/server/sonar-web/src/main/js/apps/projectActivity/utils.js
index be1646db137..be198ce067d 100644
--- a/server/sonar-web/src/main/js/apps/projectActivity/utils.js
+++ b/server/sonar-web/src/main/js/apps/projectActivity/utils.js
@@ -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)
});
+};
diff --git a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js
new file mode 100644
index 00000000000..a7cfa6b848c
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js
@@ -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>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/common/ResizeHelper.js b/server/sonar-web/src/main/js/components/common/ResizeHelper.js
new file mode 100644
index 00000000000..ec51c1c9c5e
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/common/ResizeHelper.js
@@ -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
+ });
+ }
+}
diff --git a/server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js b/server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js
new file mode 100644
index 00000000000..76602133108
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/icons-components/ChartLegendIcon.js
@@ -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>
+ );
+}
diff --git a/server/sonar-web/src/main/less/components/graphics.less b/server/sonar-web/src/main/less/components/graphics.less
index 911c8d90738..3c411450c52 100644
--- a/server/sonar-web/src/main/less/components/graphics.less
+++ b/server/sonar-web/src/main/less/components/graphics.less
@@ -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 {
}
diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
index f2f47860d44..675ef106a6d 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -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