From 2a26d857574f2160e3ee5538ad3a84ec47082f86 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Thu, 21 Jan 2016 12:14:17 -0500 Subject: [PATCH] Generalize analytics server functions and begin componentizing client analytics controls --- api/admin.go | 12 +- mattermost.go | 4 +- store/sql_channel_store.go | 16 +- store/sql_post_store.go | 52 +++- store/sql_user_store.go | 27 ++ store/store.go | 1 + .../components/admin_console/analytics.jsx | 277 ++++++++++++++++++ .../admin_console/team_analytics.jsx | 256 ++-------------- web/react/utils/client.jsx | 18 +- 9 files changed, 407 insertions(+), 256 deletions(-) create mode 100644 web/react/components/admin_console/analytics.jsx diff --git a/api/admin.go b/api/admin.go index 72a95ba6ae..e196169860 100644 --- a/api/admin.go +++ b/api/admin.go @@ -27,6 +27,7 @@ func InitAdmin(r *mux.Router) { sr.Handle("/client_props", ApiAppHandler(getClientConfig)).Methods("GET") sr.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST") sr.Handle("/analytics/{id:[A-Za-z0-9]+}/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET") + sr.Handle("/analytics/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET") } func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { @@ -153,13 +154,15 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { name := params["name"] if name == "standard" { - var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 3) + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 4) rows[0] = &model.AnalyticsRow{"channel_open_count", 0} rows[1] = &model.AnalyticsRow{"channel_private_count", 0} rows[2] = &model.AnalyticsRow{"post_count", 0} + rows[3] = &model.AnalyticsRow{"unique_user_count", 0} openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN) privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE) postChan := Srv.Store.Post().AnalyticsPostCount(teamId) + userChan := Srv.Store.User().AnalyticsUniqueUserCount(teamId) if r := <-openChan; r.Err != nil { c.Err = r.Err @@ -182,6 +185,13 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { rows[2].Value = float64(r.Data.(int64)) } + if r := <-userChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[3].Value = float64(r.Data.(int64)) + } + w.Write([]byte(rows.ToJson())) } else if name == "post_counts_day" { if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil { diff --git a/mattermost.go b/mattermost.go index 9786a6abd5..51a9591dbc 100644 --- a/mattermost.go +++ b/mattermost.go @@ -66,7 +66,9 @@ func main() { api.InitApi() web.InitWeb() - utils.LoadLicense() + if model.BuildEnterpriseReady == "true" { + utils.LoadLicense() + } if flagRunCmds { runCmds() diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go index 4585647de8..336398ae76 100644 --- a/store/sql_channel_store.go +++ b/store/sql_channel_store.go @@ -869,15 +869,13 @@ func (s SqlChannelStore) AnalyticsTypeCount(teamId string, channelType string) S go func() { result := StoreResult{} - v, err := s.GetReplica().SelectInt( - `SELECT - COUNT(Id) AS Value - FROM - Channels - WHERE - TeamId = :TeamId - AND Type = :ChannelType`, - map[string]interface{}{"TeamId": teamId, "ChannelType": channelType}) + query := "SELECT COUNT(Id) AS Value FROM Channels WHERE Type = :ChannelType" + + if len(teamId) > 0 { + query += " AND TeamId = :TeamId" + } + + v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId, "ChannelType": channelType}) if err != nil { result.Err = model.NewAppError("SqlChannelStore.AnalyticsTypeCount", "We couldn't get channel type counts", err.Error()) } else { diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 40dca99303..e332858e4a 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -805,9 +805,13 @@ func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChan FROM Posts, Channels WHERE - Posts.ChannelId = Channels.Id - AND Channels.TeamId = :TeamId - AND Posts.CreateAt <= :EndTime + Posts.ChannelId = Channels.Id` + + if len(teamId) > 0 { + query += " AND Channels.TeamId = :TeamId" + } + + query += ` AND Posts.CreateAt <= :EndTime ORDER BY Name DESC) AS t1 GROUP BY Name ORDER BY Name DESC @@ -824,9 +828,13 @@ func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChan FROM Posts, Channels WHERE - Posts.ChannelId = Channels.Id - AND Channels.TeamId = :TeamId - AND Posts.CreateAt <= :EndTime + Posts.ChannelId = Channels.Id` + + if len(teamId) > 0 { + query += " AND Channels.TeamId = :TeamId" + } + + query += ` AND Posts.CreateAt <= :EndTime ORDER BY Name DESC) AS t1 GROUP BY Name ORDER BY Name DESC @@ -869,9 +877,13 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel { FROM Posts, Channels WHERE - Posts.ChannelId = Channels.Id - AND Channels.TeamId = :TeamId - AND Posts.CreateAt <= :EndTime + Posts.ChannelId = Channels.Id` + + if len(teamId) > 0 { + query += " AND Channels.TeamId = :TeamId" + } + + query += ` AND Posts.CreateAt <= :EndTime AND Posts.CreateAt >= :StartTime) AS t1 GROUP BY Name ORDER BY Name DESC @@ -888,9 +900,13 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel { FROM Posts, Channels WHERE - Posts.ChannelId = Channels.Id - AND Channels.TeamId = :TeamId - AND Posts.CreateAt <= :EndTime + Posts.ChannelId = Channels.Id` + + if len(teamId) > 0 { + query += " AND Channels.TeamId = :TeamId" + } + + query += ` AND Posts.CreateAt <= :EndTime AND Posts.CreateAt >= :StartTime) AS t1 GROUP BY Name ORDER BY Name DESC @@ -924,16 +940,20 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string) StoreChannel { go func() { result := StoreResult{} - v, err := s.GetReplica().SelectInt( + query := `SELECT COUNT(Posts.Id) AS Value FROM Posts, Channels WHERE - Posts.ChannelId = Channels.Id - AND Channels.TeamId = :TeamId`, - map[string]interface{}{"TeamId": teamId}) + Posts.ChannelId = Channels.Id` + + if len(teamId) > 0 { + query += " AND Channels.TeamId = :TeamId" + } + + v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}) if err != nil { result.Err = model.NewAppError("SqlPostStore.AnalyticsPostCount", "We couldn't get post counts", err.Error()) } else { diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 0f73f73c34..efd8b7f33e 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -600,3 +600,30 @@ func (us SqlUserStore) PermanentDelete(userId string) StoreChannel { return storeChannel } + +func (us SqlUserStore) AnalyticsUniqueUserCount(teamId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + query := "SELECT COUNT(DISTINCT Email) FROM Users" + + if len(teamId) > 0 { + query += " WHERE TeamId = :TeamId" + } + + v, err := us.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}) + if err != nil { + result.Err = model.NewAppError("SqlUserStore.AnalyticsUniqueUserCount", "We couldn't get the unique user count", err.Error()) + } else { + result.Data = v + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/store.go b/store/store.go index 179cfecd7b..b91b08f272 100644 --- a/store/store.go +++ b/store/store.go @@ -125,6 +125,7 @@ type UserStore interface { GetTotalActiveUsersCount() StoreChannel GetSystemAdminProfiles() StoreChannel PermanentDelete(userId string) StoreChannel + AnalyticsUniqueUserCount(teamId string) StoreChannel } type SessionStore interface { diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx new file mode 100644 index 0000000000..4349719c1f --- /dev/null +++ b/web/react/components/admin_console/analytics.jsx @@ -0,0 +1,277 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Utils from '../../utils/utils.jsx'; +import Constants from '../../utils/constants.jsx'; +import LineChart from './line_chart.jsx'; + +var Tooltip = ReactBootstrap.Tooltip; +var OverlayTrigger = ReactBootstrap.OverlayTrigger; + +export default class Analytics extends React.Component { + constructor(props) { + super(props); + + this.state = {}; + } + + render() { // in the future, break down these into smaller components + var serverError = ''; + if (this.props.serverError) { + serverError =
; + } + + var totalCount = ( +
+
+
{'Total Users'}
+
{this.props.uniqueUserCount == null ? 'Loading...' : this.props.uniqueUserCount}
+
+
+ ); + + var openChannelCount = ( +
+
+
{'Public Channels'}
+
{this.props.channelOpenCount == null ? 'Loading...' : this.props.channelOpenCount}
+
+
+ ); + + var openPrivateCount = ( +
+
+
{'Private Groups'}
+
{this.props.channelPrivateCount == null ? 'Loading...' : this.props.channelPrivateCount}
+
+
+ ); + + var postCount = ( +
+
+
{'Total Posts'}
+
{this.props.postCount == null ? 'Loading...' : this.props.postCount}
+
+
+ ); + + var postCountsByDay = ( +
+
+
{'Total Posts'}
+
{'Loading...'}
+
+
+ ); + + if (this.props.postCountsDay != null) { + let content; + if (this.props.postCountsDay.labels.length === 0) { + content = 'Not enough data for a meaningful representation.'; + } else { + content = ( + + ); + } + postCountsByDay = ( +
+
+
{'Total Posts'}
+
+ {content} +
+
+
+ ); + } + + var usersWithPostsByDay = ( +
+
+
{'Total Posts'}
+
{'Loading...'}
+
+
+ ); + + if (this.props.userCountsWithPostsDay != null) { + let content; + if (this.props.userCountsWithPostsDay.labels.length === 0) { + content = 'Not enough data for a meaningful representation.'; + } else { + content = ( + + ); + } + usersWithPostsByDay = ( +
+
+
{'Active Users With Posts'}
+
+ {content} +
+
+
+ ); + } + + var recentActiveUser = ( +
+
{'Recent Active Users'}
+
{'Loading...'}
+
+ ); + + if (this.props.recentActiveUsers != null) { + recentActiveUser = ( +
+
+
{'Recent Active Users'}
+
+ + + { + this.props.recentActiveUsers.map((user) => { + const tooltip = ( + + {user.email} + + ); + + return ( + + + + + ); + }) + } + +
+ + + + {Utils.displayDateTime(user.last_activity_at)}
+
+
+
+ ); + } + + var newUsers = ( +
+
{'Newly Created Users'}
+
{'Loading...'}
+
+ ); + + if (this.props.newlyCreatedUsers != null) { + newUsers = ( +
+
+
{'Newly Created Users'}
+
+ + + { + this.props.newlyCreatedUsers.map((user) => { + const tooltip = ( + + {user.email} + + ); + + return ( + + + + + ); + }) + } + +
+ + + + {Utils.displayDateTime(user.create_at)}
+
+
+
+ ); + } + + return ( +
+

{'Statistics for ' + this.props.title}

+
+ {totalCount} + {postCount} + {openChannelCount} + {openPrivateCount} +
+
+ {postCountsByDay} +
+
+ {usersWithPostsByDay} +
+
+ {recentActiveUser} + {newUsers} +
+
+ ); + } +} + + +Analytics.defaultProps = { + title: null, + users: null, + channelOpenCount: null, + channelPrivateCount: null, + postCount: null, + postCountsDay: null, + userCountsWithPostsDay: null, + recentActiveUsers: null, + newlyCreatedUsers: null, + uniqueUserCount: null, + serverError: null +}; + +Analytics.propTypes = { + title: React.PropTypes.string, + users: React.PropTypes.object, + channelOpenCount: React.PropTypes.number, + channelPrivateCount: React.PropTypes.number, + postCount: React.PropTypes.number, + postCountsDay: React.PropTypes.object, + userCountsWithPostsDay: React.PropTypes.object, + recentActiveUsers: React.PropTypes.array, + newlyCreatedUsers: React.PropTypes.array, + uniqueUserCount: React.PropTypes.number, + serverError: React.PropTypes.string +}; diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx index fe7230946b..baa041baca 100644 --- a/web/react/components/admin_console/team_analytics.jsx +++ b/web/react/components/admin_console/team_analytics.jsx @@ -1,13 +1,8 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import Analytics from './analytics.jsx'; import * as Client from '../../utils/client.jsx'; -import * as Utils from '../../utils/utils.jsx'; -import Constants from '../../utils/constants.jsx'; -import LineChart from './line_chart.jsx'; - -var Tooltip = ReactBootstrap.Tooltip; -var OverlayTrigger = ReactBootstrap.OverlayTrigger; export default class TeamAnalytics extends React.Component { constructor(props) { @@ -15,7 +10,7 @@ export default class TeamAnalytics extends React.Component { this.getData = this.getData.bind(this); - this.state = { + this.state = { // most of this state should be from a store in the future users: null, serverError: null, channel_open_count: null, @@ -24,7 +19,8 @@ export default class TeamAnalytics extends React.Component { post_counts_day: null, user_counts_with_posts_day: null, recent_active_users: null, - newly_created_users: null + newly_created_users: null, + unique_user_count: null }; } @@ -32,8 +28,8 @@ export default class TeamAnalytics extends React.Component { this.getData(this.props.team.id); } - getData(teamId) { - Client.getAnalytics( + getData(teamId) { // should be moved to an action creator eventually + Client.getTeamAnalytics( teamId, 'standard', (data) => { @@ -49,6 +45,10 @@ export default class TeamAnalytics extends React.Component { if (data[index].name === 'post_count') { this.setState({post_count: data[index].value}); } + + if (data[index].name === 'unique_user_count') { + this.setState({unique_user_count: data[index].value}); + } } }, (err) => { @@ -56,7 +56,7 @@ export default class TeamAnalytics extends React.Component { } ); - Client.getAnalytics( + Client.getTeamAnalytics( teamId, 'post_counts_day', (data) => { @@ -91,7 +91,7 @@ export default class TeamAnalytics extends React.Component { } ); - Client.getAnalytics( + Client.getTeamAnalytics( teamId, 'user_counts_with_posts_day', (data) => { @@ -198,227 +198,29 @@ export default class TeamAnalytics extends React.Component { post_counts_day: null, user_counts_with_posts_day: null, recent_active_users: null, - newly_created_users: null + newly_created_users: null, + unique_user_count: null }); this.getData(newProps.team.id); } - componentWillUnmount() { - } - render() { - var serverError = ''; - if (this.state.serverError) { - serverError =
; - } - - var totalCount = ( -
-
-
{'Total Users'}
-
{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}
-
-
- ); - - var openChannelCount = ( -
-
-
{'Public Channels'}
-
{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}
-
-
- ); - - var openPrivateCount = ( -
-
-
{'Private Groups'}
-
{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}
-
-
- ); - - var postCount = ( -
-
-
{'Total Posts'}
-
{this.state.post_count == null ? 'Loading...' : this.state.post_count}
-
-
- ); - - var postCountsByDay = ( -
-
-
{'Total Posts'}
-
{'Loading...'}
-
-
- ); - - if (this.state.post_counts_day != null) { - postCountsByDay = ( -
-
-
{'Total Posts'}
-
- -
-
-
- ); - } - - var usersWithPostsByDay = ( -
-
-
{'Total Posts'}
-
{'Loading...'}
-
-
- ); - - if (this.state.user_counts_with_posts_day != null) { - usersWithPostsByDay = ( -
-
-
{'Active Users With Posts'}
-
- -
-
-
- ); - } - - var recentActiveUser = ( -
-
{'Recent Active Users'}
-
{'Loading...'}
-
- ); - - if (this.state.recent_active_users != null) { - recentActiveUser = ( -
-
-
{'Recent Active Users'}
-
- - - { - this.state.recent_active_users.map((user) => { - const tooltip = ( - - {user.email} - - ); - - return ( - - - - - ); - }) - } - -
- - - - {Utils.displayDateTime(user.last_activity_at)}
-
-
-
- ); - } - - var newUsers = ( -
-
{'Newly Created Users'}
-
{'Loading...'}
-
- ); - - if (this.state.newly_created_users != null) { - newUsers = ( -
-
-
{'Newly Created Users'}
-
- - - { - this.state.newly_created_users.map((user) => { - const tooltip = ( - - {user.email} - - ); - - return ( - - - - - ); - }) - } - -
- - - - {Utils.displayDateTime(user.create_at)}
-
-
-
- ); - } - return ( -
-

{'Statistics for ' + this.props.team.name}

- {serverError} -
- {totalCount} - {postCount} - {openChannelCount} - {openPrivateCount} -
-
- {postCountsByDay} -
-
- {usersWithPostsByDay} -
-
- {recentActiveUser} - {newUsers} -
+
+
); } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index d60fea8721..8484f3ccee 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -387,7 +387,7 @@ export function getConfig(success, error) { }); } -export function getAnalytics(teamId, name, success, error) { +export function getTeamAnalytics(teamId, name, success, error) { $.ajax({ url: '/api/v1/admin/analytics/' + teamId + '/' + name, dataType: 'json', @@ -395,7 +395,21 @@ export function getAnalytics(teamId, name, success, error) { type: 'GET', success, error: (xhr, status, err) => { - var e = handleError('getAnalytics', xhr, status, err); + var e = handleError('getTeamAnalytics', xhr, status, err); + error(e); + } + }); +} + +export function getServerAnalytics(name, success, error) { + $.ajax({ + url: '/api/v1/admin/analytics/' + name, + dataType: 'json', + contentType: 'application/json', + type: 'GET', + success, + error: (xhr, status, err) => { + var e = handleError('getServerAnalytics', xhr, status, err); error(e); } });