diff options
Diffstat (limited to 'webapp/components/analytics')
-rw-r--r-- | webapp/components/analytics/doughnut_chart.jsx | 81 | ||||
-rw-r--r-- | webapp/components/analytics/line_chart.jsx | 94 | ||||
-rw-r--r-- | webapp/components/analytics/statistic_count.jsx | 35 | ||||
-rw-r--r-- | webapp/components/analytics/system_analytics.jsx | 348 | ||||
-rw-r--r-- | webapp/components/analytics/table_chart.jsx | 61 | ||||
-rw-r--r-- | webapp/components/analytics/team_analytics.jsx | 237 |
6 files changed, 856 insertions, 0 deletions
diff --git a/webapp/components/analytics/doughnut_chart.jsx b/webapp/components/analytics/doughnut_chart.jsx new file mode 100644 index 000000000..169ac3105 --- /dev/null +++ b/webapp/components/analytics/doughnut_chart.jsx @@ -0,0 +1,81 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ReactDOM from 'react-dom'; +import {FormattedMessage} from 'react-intl'; +import Chart from 'chart.js'; + +import React from 'react'; + +export default class DoughnutChart extends React.Component { + constructor(props) { + super(props); + + this.initChart = this.initChart.bind(this); + this.chart = null; + } + + componentDidMount() { + this.initChart(this.props); + } + + componentWillReceiveProps(nextProps) { + if (this.chart) { + this.chart.destroy(); + this.initChart(nextProps); + } + } + + componentWillUnmount() { + if (this.chart) { + this.chart.destroy(); + } + } + + initChart(props) { + var el = ReactDOM.findDOMNode(this.refs.canvas); + var ctx = el.getContext('2d'); + this.chart = new Chart(ctx).Doughnut(props.data, props.options || {}); //eslint-disable-line new-cap + } + + render() { + let content; + if (this.props.data == null) { + content = ( + <FormattedMessage + id='analytics.chart.loading' + defaultMessage='Loading...' + /> + ); + } else { + content = ( + <canvas + ref='canvas' + width={this.props.width} + height={this.props.height} + /> + ); + } + + return ( + <div className='col-sm-6'> + <div className='total-count'> + <div className='title'> + {this.props.title} + </div> + <div className='content'> + {content} + </div> + </div> + </div> + ); + } +} + +DoughnutChart.propTypes = { + title: React.PropTypes.node, + width: React.PropTypes.string, + height: React.PropTypes.string, + data: React.PropTypes.array, + options: React.PropTypes.object +}; diff --git a/webapp/components/analytics/line_chart.jsx b/webapp/components/analytics/line_chart.jsx new file mode 100644 index 000000000..6a3b8c0f0 --- /dev/null +++ b/webapp/components/analytics/line_chart.jsx @@ -0,0 +1,94 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ReactDOM from 'react-dom'; +import {FormattedMessage} from 'react-intl'; +import Chart from 'chart.js'; + +import React from 'react'; + +export default class LineChart extends React.Component { + constructor(props) { + super(props); + + this.initChart = this.initChart.bind(this); + this.chart = null; + } + + componentDidMount() { + this.initChart(); + } + + componentDidUpdate() { + if (this.chart) { + this.chart.destroy(); + } + this.initChart(); + } + + componentWillUnmount() { + if (this.chart) { + this.chart.destroy(); + } + } + + initChart() { + if (!this.refs.canvas) { + return; + } + var el = ReactDOM.findDOMNode(this.refs.canvas); + var ctx = el.getContext('2d'); + this.chart = new Chart(ctx).Line(this.props.data, this.props.options || {}); //eslint-disable-line new-cap + } + + render() { + let content; + if (this.props.data == null) { + content = ( + <FormattedMessage + id='analytics.chart.loading' + defaultMessage='Loading...' + /> + ); + } else if (this.props.data.labels.length === 0) { + content = ( + <h5> + <FormattedMessage + id='analytics.chart.meaningful' + defaultMessage='Not enough data for a meaningful representation.' + /> + </h5> + ); + } else { + content = ( + <canvas + ref='canvas' + width={this.props.width} + height={this.props.height} + /> + ); + } + + return ( + <div className='col-sm-12'> + <div className='total-count by-day'> + <div className='title'> + {this.props.title} + </div> + <div className='content'> + {content} + </div> + </div> + </div> + ); + } +} + +LineChart.propTypes = { + title: React.PropTypes.node.isRequired, + width: React.PropTypes.string.isRequired, + height: React.PropTypes.string.isRequired, + data: React.PropTypes.object, + options: React.PropTypes.object +}; + diff --git a/webapp/components/analytics/statistic_count.jsx b/webapp/components/analytics/statistic_count.jsx new file mode 100644 index 000000000..cbb8935dd --- /dev/null +++ b/webapp/components/analytics/statistic_count.jsx @@ -0,0 +1,35 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {FormattedMessage} from 'react-intl'; + +import React from 'react'; + +export default class StatisticCount extends React.Component { + render() { + let loading = ( + <FormattedMessage + id='analytics.chart.loading' + defaultMessage='Loading...' + /> + ); + + return ( + <div className='col-sm-3'> + <div className='total-count'> + <div className='title'> + {this.props.title} + <i className={'fa ' + this.props.icon}/> + </div> + <div className='content'>{this.props.count == null ? loading : this.props.count}</div> + </div> + </div> + ); + } +} + +StatisticCount.propTypes = { + title: React.PropTypes.node.isRequired, + icon: React.PropTypes.string.isRequired, + count: React.PropTypes.number +}; diff --git a/webapp/components/analytics/system_analytics.jsx b/webapp/components/analytics/system_analytics.jsx new file mode 100644 index 000000000..77f5efaa6 --- /dev/null +++ b/webapp/components/analytics/system_analytics.jsx @@ -0,0 +1,348 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LineChart from './line_chart.jsx'; +import DoughnutChart from './doughnut_chart.jsx'; +import StatisticCount from './statistic_count.jsx'; + +import AnalyticsStore from 'stores/analytics_store.jsx'; + +import * as Utils from 'utils/utils.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import Constants from 'utils/constants.jsx'; +const StatTypes = Constants.StatTypes; + +import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl'; + +const holders = defineMessages({ + analyticsPublicChannels: { + id: 'analytics.system.publicChannels', + defaultMessage: 'Public Channels' + }, + analyticsPrivateGroups: { + id: 'analytics.system.privateGroups', + defaultMessage: 'Private Groups' + }, + analyticsFilePosts: { + id: 'analytics.system.totalFilePosts', + defaultMessage: 'Posts with Files' + }, + analyticsHashtagPosts: { + id: 'analytics.system.totalHashtagPosts', + defaultMessage: 'Posts with Hashtags' + }, + analyticsTextPosts: { + id: 'analytics.system.textPosts', + defaultMessage: 'Posts with Text-only' + } +}); + +import React from 'react'; + +class SystemAnalytics extends React.Component { + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + + this.state = {stats: AnalyticsStore.getAllSystem()}; + } + + componentDidMount() { + AnalyticsStore.addChangeListener(this.onChange); + + AsyncClient.getStandardAnalytics(); + AsyncClient.getPostsPerDayAnalytics(); + AsyncClient.getUsersPerDayAnalytics(); + + if (global.window.mm_license.IsLicensed === 'true') { + AsyncClient.getAdvancedAnalytics(); + } + } + + componentWillUnmount() { + AnalyticsStore.removeChangeListener(this.onChange); + } + + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(nextState.stats, this.state.stats)) { + return true; + } + + return false; + } + + onChange() { + this.setState({stats: AnalyticsStore.getAllSystem()}); + } + + render() { + const stats = this.state.stats; + + let advancedCounts; + let advancedGraphs; + if (global.window.mm_license.IsLicensed === 'true') { + advancedCounts = ( + <div className='row'> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalSessions' + defaultMessage='Total Sessions' + /> + } + icon='fa-signal' + count={stats[StatTypes.TOTAL_SESSIONS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalCommands' + defaultMessage='Total Commands' + /> + } + icon='fa-terminal' + count={stats[StatTypes.TOTAL_COMMANDS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalIncomingWebhooks' + defaultMessage='Incoming Webhooks' + /> + } + icon='fa-arrow-down' + count={stats[StatTypes.TOTAL_IHOOKS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalOutgoingWebhooks' + defaultMessage='Outgoing Webhooks' + /> + } + icon='fa-arrow-up' + count={stats[StatTypes.TOTAL_OHOOKS]} + /> + </div> + ); + + const channelTypeData = formatChannelDoughtnutData(stats[StatTypes.TOTAL_PUBLIC_CHANNELS], stats[StatTypes.TOTAL_PRIVATE_GROUPS], this.props.intl); + const postTypeData = formatPostDoughtnutData(stats[StatTypes.TOTAL_FILE_POSTS], stats[StatTypes.TOTAL_HASHTAG_POSTS], stats[StatTypes.TOTAL_POSTS], this.props.intl); + + advancedGraphs = ( + <div className='row'> + <DoughnutChart + title={ + <FormattedMessage + id='analytics.system.channelTypes' + defaultMessage='Channel Types' + /> + } + data={channelTypeData} + width='300' + height='225' + /> + <DoughnutChart + title={ + <FormattedMessage + id='analytics.system.postTypes' + defaultMessage='Posts, Files and Hashtags' + /> + } + data={postTypeData} + width='300' + height='225' + /> + </div> + ); + } + + const postCountsDay = formatPostsPerDayData(stats[StatTypes.POST_PER_DAY]); + const userCountsWithPostsDay = formatUsersWithPostsPerDayData(stats[StatTypes.USERS_WITH_POSTS_PER_DAY]); + + return ( + <div className='wrapper--fixed team_statistics'> + <h3> + <FormattedMessage + id='analytics.system.title' + defaultMessage='System Statistics' + /> + </h3> + <div className='row'> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalUsers' + defaultMessage='Total Users' + /> + } + icon='fa-user' + count={stats[StatTypes.TOTAL_USERS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalTeams' + defaultMessage='Total Teams' + /> + } + icon='fa-users' + count={stats[StatTypes.TOTAL_TEAMS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalPosts' + defaultMessage='Total Posts' + /> + } + icon='fa-comment' + count={stats[StatTypes.TOTAL_POSTS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.system.totalChannels' + defaultMessage='Total Channels' + /> + } + icon='fa-globe' + count={stats[StatTypes.TOTAL_PUBLIC_CHANNELS] + stats[StatTypes.TOTAL_PRIVATE_GROUPS]} + /> + </div> + {advancedCounts} + {advancedGraphs} + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.system.totalPosts' + defaultMessage='Total Posts' + /> + } + data={postCountsDay} + width='740' + height='225' + /> + </div> + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.system.activeUsers' + defaultMessage='Active Users With Posts' + /> + } + data={userCountsWithPostsDay} + width='740' + height='225' + /> + </div> + </div> + ); + } +} + +SystemAnalytics.propTypes = { + intl: intlShape.isRequired, + team: React.PropTypes.object +}; + +export default injectIntl(SystemAnalytics); + +export function formatChannelDoughtnutData(totalPublic, totalPrivate, intl) { + const {formatMessage} = intl; + const channelTypeData = [ + { + value: totalPublic, + color: '#46BFBD', + highlight: '#5AD3D1', + label: formatMessage(holders.analyticsPublicChannels) + }, + { + value: totalPrivate, + color: '#FDB45C', + highlight: '#FFC870', + label: formatMessage(holders.analyticsPrivateGroups) + } + ]; + + return channelTypeData; +} + +export function formatPostDoughtnutData(filePosts, hashtagPosts, totalPosts, intl) { + const {formatMessage} = intl; + const postTypeData = [ + { + value: filePosts, + color: '#46BFBD', + highlight: '#5AD3D1', + label: formatMessage(holders.analyticsFilePosts) + }, + { + value: hashtagPosts, + color: '#F7464A', + highlight: '#FF5A5E', + label: formatMessage(holders.analyticsHashtagPosts) + }, + { + value: totalPosts - filePosts - hashtagPosts, + color: '#FDB45C', + highlight: '#FFC870', + label: formatMessage(holders.analyticsTextPosts) + } + ]; + + return postTypeData; +} + +export function formatPostsPerDayData(data) { + var chartData = { + labels: [], + datasets: [{ + fillColor: 'rgba(151,187,205,0.2)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', + pointHighlightFill: '#fff', + pointHighlightStroke: 'rgba(151,187,205,1)', + data: [] + }] + }; + + for (var index in data) { + if (data[index]) { + var row = data[index]; + chartData.labels.push(row.name); + chartData.datasets[0].data.push(row.value); + } + } + + return chartData; +} + +export function formatUsersWithPostsPerDayData(data) { + var chartData = { + labels: [], + datasets: [{ + fillColor: 'rgba(151,187,205,0.2)', + strokeColor: 'rgba(151,187,205,1)', + pointColor: 'rgba(151,187,205,1)', + pointStrokeColor: '#fff', + pointHighlightFill: '#fff', + pointHighlightStroke: 'rgba(151,187,205,1)', + data: [] + }] + }; + + for (var index in data) { + if (data[index]) { + var row = data[index]; + chartData.labels.push(row.name); + chartData.datasets[0].data.push(row.value); + } + } + + return chartData; +} diff --git a/webapp/components/analytics/table_chart.jsx b/webapp/components/analytics/table_chart.jsx new file mode 100644 index 000000000..18ed54f96 --- /dev/null +++ b/webapp/components/analytics/table_chart.jsx @@ -0,0 +1,61 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from 'utils/constants.jsx'; + +import {Tooltip, OverlayTrigger} from 'react-bootstrap'; + +import React from 'react'; + +export default class TableChart extends React.Component { + render() { + return ( + <div className='col-sm-6'> + <div className='total-count recent-active-users'> + <div className='title'> + {this.props.title} + </div> + <div className='content'> + <table> + <tbody> + { + this.props.data.map((item) => { + const tooltip = ( + <Tooltip id={'tip-table-entry-' + item.name}> + {item.tip} + </Tooltip> + ); + + return ( + <tr key={'table-entry-' + item.name}> + <td> + <OverlayTrigger + delayShow={Constants.OVERLAY_TIME_DELAY} + placement='top' + overlay={tooltip} + > + <time> + {item.name} + </time> + </OverlayTrigger> + </td> + <td> + {item.value} + </td> + </tr> + ); + }) + } + </tbody> + </table> + </div> + </div> + </div> + ); + } +} + +TableChart.propTypes = { + title: React.PropTypes.node, + data: React.PropTypes.array +}; diff --git a/webapp/components/analytics/team_analytics.jsx b/webapp/components/analytics/team_analytics.jsx new file mode 100644 index 000000000..efc965f24 --- /dev/null +++ b/webapp/components/analytics/team_analytics.jsx @@ -0,0 +1,237 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LineChart from './line_chart.jsx'; +import StatisticCount from './statistic_count.jsx'; +import TableChart from './table_chart.jsx'; + +import AnalyticsStore from 'stores/analytics_store.jsx'; + +import * as Utils from 'utils/utils.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import Constants from 'utils/constants.jsx'; +const StatTypes = Constants.StatTypes; + +import {formatPostsPerDayData, formatUsersWithPostsPerDayData} from './system_analytics.jsx'; +import {injectIntl, intlShape, FormattedMessage, FormattedDate} from 'react-intl'; + +import React from 'react'; + +class TeamAnalytics extends React.Component { + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + + this.state = {stats: AnalyticsStore.getAllTeam(this.props.team.id)}; + } + + componentDidMount() { + AnalyticsStore.addChangeListener(this.onChange); + + this.getData(this.props.team.id); + } + + getData(id) { + AsyncClient.getStandardAnalytics(id); + AsyncClient.getPostsPerDayAnalytics(id); + AsyncClient.getUsersPerDayAnalytics(id); + AsyncClient.getRecentAndNewUsersAnalytics(id); + } + + componentWillUnmount() { + AnalyticsStore.removeChangeListener(this.onChange); + } + + componentWillReceiveProps(nextProps) { + this.getData(nextProps.team.id); + this.setState({stats: AnalyticsStore.getAllTeam(nextProps.team.id)}); + } + + shouldComponentUpdate(nextProps, nextState) { + if (!Utils.areObjectsEqual(nextState.stats, this.state.stats)) { + return true; + } + + if (!Utils.areObjectsEqual(nextProps.team, this.props.team)) { + return true; + } + + return false; + } + + onChange() { + this.setState({stats: AnalyticsStore.getAllTeam(this.props.team.id)}); + } + + render() { + const stats = this.state.stats; + const postCountsDay = formatPostsPerDayData(stats[StatTypes.POST_PER_DAY]); + const userCountsWithPostsDay = formatUsersWithPostsPerDayData(stats[StatTypes.USERS_WITH_POSTS_PER_DAY]); + const recentActiveUsers = formatRecentUsersData(stats[StatTypes.RECENTLY_ACTIVE_USERS]); + const newlyCreatedUsers = formatNewUsersData(stats[StatTypes.NEWLY_CREATED_USERS]); + + return ( + <div className='wrapper--fixed team_statistics'> + <h3> + <FormattedMessage + id='analytics.team.title' + defaultMessage='Team Statistics for {team}' + values={{ + team: this.props.team.name + }} + /> + </h3> + <div className='row'> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.totalUsers' + defaultMessage='Total Users' + /> + } + icon='fa-user' + count={stats[StatTypes.TOTAL_USERS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.publicChannels' + defaultMessage='Public Channels' + /> + } + icon='fa-users' + count={stats[StatTypes.TOTAL_PUBLIC_CHANNELS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.privateGroups' + defaultMessage='Private Groups' + /> + } + icon='fa-globe' + count={stats[StatTypes.TOTAL_PRIVATE_GROUPS]} + /> + <StatisticCount + title={ + <FormattedMessage + id='analytics.team.totalPosts' + defaultMessage='Total Posts' + /> + } + icon='fa-comment' + count={stats[StatTypes.TOTAL_POSTS]} + /> + </div> + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.team.totalPosts' + defaultMessage='Total Posts' + /> + } + data={postCountsDay} + width='740' + height='225' + /> + </div> + <div className='row'> + <LineChart + title={ + <FormattedMessage + id='analytics.team.activeUsers' + defaultMessage='Active Users With Posts' + /> + } + data={userCountsWithPostsDay} + width='740' + height='225' + /> + </div> + <div className='row'> + <TableChart + title={ + <FormattedMessage + id='analytics.team.activeUsers' + defaultMessage='Recent Active Users' + /> + } + data={recentActiveUsers} + /> + <TableChart + title={ + <FormattedMessage + id='analytics.team.newlyCreated' + defaultMessage='Newly Created Users' + /> + } + data={newlyCreatedUsers} + /> + </div> + </div> + ); + } +} + +TeamAnalytics.propTypes = { + intl: intlShape.isRequired, + team: React.PropTypes.object.isRequired +}; + +export default injectIntl(TeamAnalytics); + +export function formatRecentUsersData(data) { + if (data == null) { + return []; + } + + const formattedData = data.map((user) => { + const item = {}; + item.name = user.username; + item.value = ( + <FormattedDate + value={user.last_activity_at} + day='numeric' + month='long' + year='numeric' + hour12={true} + hour='2-digit' + minute='2-digit' + /> + ); + item.tip = user.email; + + return item; + }); + + return formattedData; +} + +export function formatNewUsersData(data) { + if (data == null) { + return []; + } + + const formattedData = data.map((user) => { + const item = {}; + item.name = user.username; + item.value = ( + <FormattedDate + value={user.create_at} + day='numeric' + month='long' + year='numeric' + hour12={true} + hour='2-digit' + minute='2-digit' + /> + ); + item.tip = user.email; + + return item; + }); + + return formattedData; +} |