From 2a26d857574f2160e3ee5538ad3a84ec47082f86 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Thu, 21 Jan 2016 12:14:17 -0500 Subject: [PATCH 1/5] 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); } }); From cefdad6d8c71ff6adf0ae919bd9f9139e02a6caa Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Thu, 21 Jan 2016 12:59:32 -0500 Subject: [PATCH 2/5] Add system analytics page --- .../admin_console/admin_controller.jsx | 5 +- .../admin_console/admin_sidebar.jsx | 19 +++ .../components/admin_console/analytics.jsx | 158 ++++++++--------- .../admin_console/system_analytics.jsx | 161 ++++++++++++++++++ web/react/utils/client.jsx | 4 +- 5 files changed, 266 insertions(+), 81 deletions(-) create mode 100644 web/react/components/admin_console/system_analytics.jsx diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index 0f85c238da..db98d8f35b 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -1,4 +1,4 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import AdminSidebar from './admin_sidebar.jsx'; @@ -23,6 +23,7 @@ import TeamUsersTab from './team_users.jsx'; import TeamAnalyticsTab from './team_analytics.jsx'; import LdapSettingsTab from './ldap_settings.jsx'; import LicenseSettingsTab from './license_settings.jsx'; +import SystemAnalyticsTab from './system_analytics.jsx'; export default class AdminController extends React.Component { constructor(props) { @@ -165,6 +166,8 @@ export default class AdminController extends React.Component { if (this.state.teams) { tab = ; } + } else if (this.state.selected === 'system_analytics') { + tab = ; } } diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index 5a5eaa055d..66f82c55b2 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -192,6 +192,25 @@ export default class AdminSidebar extends React.Component {
  • +
      +
    • +

      + + {'SITE REPORTS'} +

      +
    • +
    +
    • diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx index 4349719c1f..8fdf538f8a 100644 --- a/web/react/components/admin_console/analytics.jsx +++ b/web/react/components/admin_console/analytics.jsx @@ -94,8 +94,8 @@ export default class Analytics extends React.Component { var usersWithPostsByDay = (
      -
      {'Total Posts'}
      -
      {'Loading...'}
      +
      {'Active Users With Posts'}
      +
      {'Loading...'}
      ); @@ -125,98 +125,102 @@ export default class Analytics extends React.Component { ); } - var recentActiveUser = ( -
      -
      {'Recent Active Users'}
      -
      {'Loading...'}
      -
      - ); - + let recentActiveUser; if (this.props.recentActiveUsers != null) { + let content; + if (this.props.recentActiveUsers.length === 0) { + content = 'Loading...'; + } else { + content = ( + + + { + this.props.recentActiveUsers.map((user) => { + const tooltip = ( + + {user.email} + + ); + + return ( + + + + + ); + }) + } + +
      + + + + {Utils.displayDateTime(user.last_activity_at)}
      + ); + } recentActiveUser = (
      {'Recent Active Users'}
      - - - { - this.props.recentActiveUsers.map((user) => { - const tooltip = ( - - {user.email} - - ); - - return ( - - - - - ); - }) - } - -
      - - - - {Utils.displayDateTime(user.last_activity_at)}
      + {content}
      ); } - var newUsers = ( -
      -
      {'Newly Created Users'}
      -
      {'Loading...'}
      -
      - ); - + let newUsers; if (this.props.newlyCreatedUsers != null) { + let content; + if (this.props.newlyCreatedUsers.length === 0) { + content = 'Loading...'; + } else { + content = ( + + + { + this.props.newlyCreatedUsers.map((user) => { + const tooltip = ( + + {user.email} + + ); + + return ( + + + + + ); + }) + } + +
      + + + + {Utils.displayDateTime(user.create_at)}
      + ); + } newUsers = (
      {'Newly Created Users'}
      - - - { - this.props.newlyCreatedUsers.map((user) => { - const tooltip = ( - - {user.email} - - ); - - return ( - - - - - ); - }) - } - -
      - - - - {Utils.displayDateTime(user.create_at)}
      + {content}
      @@ -250,7 +254,6 @@ export default class Analytics extends React.Component { Analytics.defaultProps = { title: null, - users: null, channelOpenCount: null, channelPrivateCount: null, postCount: null, @@ -264,7 +267,6 @@ Analytics.defaultProps = { Analytics.propTypes = { title: React.PropTypes.string, - users: React.PropTypes.object, channelOpenCount: React.PropTypes.number, channelPrivateCount: React.PropTypes.number, postCount: React.PropTypes.number, diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx new file mode 100644 index 0000000000..640f17ff00 --- /dev/null +++ b/web/react/components/admin_console/system_analytics.jsx @@ -0,0 +1,161 @@ +// 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'; + +export default class SystemAnalytics extends React.Component { + constructor(props) { + super(props); + + this.getData = this.getData.bind(this); + + this.state = { // most of this state should be from a store in the future + users: null, + serverError: null, + channel_open_count: null, + channel_private_count: null, + post_count: null, + post_counts_day: null, + user_counts_with_posts_day: null, + recent_active_users: null, + newly_created_users: null, + unique_user_count: null + }; + } + + componentDidMount() { + this.getData(); + } + + getData() { // should be moved to an action creator eventually + Client.getSystemAnalytics( + 'standard', + (data) => { + for (var index in data) { + if (data[index].name === 'channel_open_count') { + this.setState({channel_open_count: data[index].value}); + } + + if (data[index].name === 'channel_private_count') { + this.setState({channel_private_count: data[index].value}); + } + + 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) => { + this.setState({serverError: err.message}); + } + ); + + Client.getSystemAnalytics( + 'post_counts_day', + (data) => { + data.reverse(); + + var chartData = { + labels: [], + datasets: [{ + label: 'Total Posts', + 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); + } + } + + this.setState({post_counts_day: chartData}); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + + Client.getSystemAnalytics( + 'user_counts_with_posts_day', + (data) => { + data.reverse(); + + var chartData = { + labels: [], + datasets: [{ + label: 'Active Users With Posts', + 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); + } + } + + this.setState({user_counts_with_posts_day: chartData}); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + componentWillReceiveProps(newProps) { + this.setState({ + serverError: null, + channel_open_count: null, + channel_private_count: null, + post_count: null, + post_counts_day: null, + user_counts_with_posts_day: null, + unique_user_count: null + }); + + this.getData(); + } + + render() { + return ( +
      + +
      + ); + } +} + +SystemAnalytics.propTypes = { + team: React.PropTypes.object +}; diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 8484f3ccee..96fa342a3b 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -401,7 +401,7 @@ export function getTeamAnalytics(teamId, name, success, error) { }); } -export function getServerAnalytics(name, success, error) { +export function getSystemAnalytics(name, success, error) { $.ajax({ url: '/api/v1/admin/analytics/' + name, dataType: 'json', @@ -409,7 +409,7 @@ export function getServerAnalytics(name, success, error) { type: 'GET', success, error: (xhr, status, err) => { - var e = handleError('getServerAnalytics', xhr, status, err); + var e = handleError('getSystemAnalytics', xhr, status, err); error(e); } }); From bbcf00f02e469bcf04a45c1cdf8a7932e30ccfc0 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 22 Jan 2016 09:53:17 -0500 Subject: [PATCH 3/5] Add create_at back to profile fields to fix analytics --- api/license.go | 13 +++++++++++++ model/user.go | 1 - utils/license.go | 3 ++- .../components/admin_console/system_analytics.jsx | 2 +- .../components/admin_console/team_analytics.jsx | 4 ++++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/api/license.go b/api/license.go index 5b3809651b..fc2fd43842 100644 --- a/api/license.go +++ b/api/license.go @@ -5,6 +5,7 @@ package api import ( "bytes" + "fmt" l4g "github.com/alecthomas/log4go" "github.com/gorilla/mux" "github.com/mattermost/platform/model" @@ -63,6 +64,18 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { if success, licenseStr := utils.ValidateLicense(data); success { license = model.LicenseFromJson(strings.NewReader(licenseStr)) + if result := <-Srv.Store.User().AnalyticsUniqueUserCount(""); result.Err != nil { + c.Err = model.NewAppError("addLicense", "Unable to count total unique users.", fmt.Sprintf("err=%v", result.Err.Error())) + return + } else { + uniqueUserCount := result.Data.(int64) + + if uniqueUserCount > int64(*license.Features.Users) { + c.Err = model.NewAppError("addLicense", fmt.Sprintf("This license only supports %d users, when your system has %d unique users. Unique users are counted distinctly by email address. You can see total user count under Site Reports -> View Statistics.", *license.Features.Users, uniqueUserCount), "") + return + } + } + if ok := utils.SetLicense(license); !ok { c.LogAudit("failed - expired or non-started license") c.Err = model.NewAppError("addLicense", "License is either expired or has not yet started.", "") diff --git a/model/user.go b/model/user.go index 7744b00730..44228d93fc 100644 --- a/model/user.go +++ b/model/user.go @@ -236,7 +236,6 @@ func (u *User) Sanitize(options map[string]bool) { } func (u *User) ClearNonProfileFields() { - u.CreateAt = 0 u.UpdateAt = 0 u.Password = "" u.AuthData = "" diff --git a/utils/license.go b/utils/license.go index 7594e33af2..4fba94d4d9 100644 --- a/utils/license.go +++ b/utils/license.go @@ -55,6 +55,7 @@ func LoadLicense() { if success, licenseStr := ValidateLicense(buf.Bytes()); success { license := model.LicenseFromJson(strings.NewReader(licenseStr)) SetLicense(license) + return } l4g.Warn("No valid enterprise license found") @@ -105,7 +106,7 @@ func ValidateLicense(signed []byte) (bool, string) { } // remove null terminator - if decoded[len(decoded)-1] == byte(0) { + for decoded[len(decoded)-1] == byte(0) { decoded = decoded[:len(decoded)-1] } diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx index 640f17ff00..fffe7cc53b 100644 --- a/web/react/components/admin_console/system_analytics.jsx +++ b/web/react/components/admin_console/system_analytics.jsx @@ -142,7 +142,7 @@ export default class SystemAnalytics extends React.Component { return (
      19) { break; From 0d239a1a9e82a1279685b8962920eaf5c1b8f571 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 22 Jan 2016 10:19:02 -0500 Subject: [PATCH 4/5] Added unit test and fixed errors --- api/admin_test.go | 70 +++++++++++++++++-- model/client.go | 11 ++- .../components/admin_console/analytics.jsx | 4 +- .../admin_console/system_analytics.jsx | 2 +- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/api/admin_test.go b/api/admin_test.go index f7b6a7eebc..c2f4e9c764 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -151,7 +151,7 @@ func TestEmailTest(t *testing.T) { } } -func TestGetAnalyticsStandard(t *testing.T) { +func TestGetTeamAnalyticsStandard(t *testing.T) { Setup() team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} @@ -169,7 +169,7 @@ func TestGetAnalyticsStandard(t *testing.T) { post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"} post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post) - if _, err := Client.GetAnalytics(team.Id, "standard"); err == nil { + if _, err := Client.GetTeamAnalytics(team.Id, "standard"); err == nil { t.Fatal("Shouldn't have permissions") } @@ -180,7 +180,7 @@ func TestGetAnalyticsStandard(t *testing.T) { Client.LoginByEmail(team.Name, user.Email, "pwd") - if result, err := Client.GetAnalytics(team.Id, "standard"); err != nil { + if result, err := Client.GetTeamAnalytics(team.Id, "standard"); err != nil { t.Fatal(err) } else { rows := result.Data.(model.AnalyticsRows) @@ -214,6 +214,62 @@ func TestGetAnalyticsStandard(t *testing.T) { t.Log(rows.ToJson()) t.Fatal() } + + if rows[3].Name != "unique_user_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[3].Value != 1 { + t.Log(rows.ToJson()) + t.Fatal() + } + } + + if result, err := Client.GetSystemAnalytics("standard"); err != nil { + t.Fatal(err) + } else { + rows := result.Data.(model.AnalyticsRows) + + if rows[0].Name != "channel_open_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[0].Value < 2 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[1].Name != "channel_private_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[1].Value == 0 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[2].Name != "post_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[2].Value == 0 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[3].Name != "unique_user_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[3].Value == 0 { + t.Log(rows.ToJson()) + t.Fatal() + } } } @@ -239,7 +295,7 @@ func TestGetPostCount(t *testing.T) { Srv.Store.(*store.SqlStore).GetMaster().Exec("UPDATE Posts SET CreateAt = :CreateAt WHERE ChannelId = :ChannelId", map[string]interface{}{"ChannelId": channel1.Id, "CreateAt": utils.MillisFromTime(utils.Yesterday())}) - if _, err := Client.GetAnalytics(team.Id, "post_counts_day"); err == nil { + if _, err := Client.GetTeamAnalytics(team.Id, "post_counts_day"); err == nil { t.Fatal("Shouldn't have permissions") } @@ -250,7 +306,7 @@ func TestGetPostCount(t *testing.T) { Client.LoginByEmail(team.Name, user.Email, "pwd") - if result, err := Client.GetAnalytics(team.Id, "post_counts_day"); err != nil { + if result, err := Client.GetTeamAnalytics(team.Id, "post_counts_day"); err != nil { t.Fatal(err) } else { rows := result.Data.(model.AnalyticsRows) @@ -284,7 +340,7 @@ func TestUserCountsWithPostsByDay(t *testing.T) { Srv.Store.(*store.SqlStore).GetMaster().Exec("UPDATE Posts SET CreateAt = :CreateAt WHERE ChannelId = :ChannelId", map[string]interface{}{"ChannelId": channel1.Id, "CreateAt": utils.MillisFromTime(utils.Yesterday())}) - if _, err := Client.GetAnalytics(team.Id, "user_counts_with_posts_day"); err == nil { + if _, err := Client.GetTeamAnalytics(team.Id, "user_counts_with_posts_day"); err == nil { t.Fatal("Shouldn't have permissions") } @@ -295,7 +351,7 @@ func TestUserCountsWithPostsByDay(t *testing.T) { Client.LoginByEmail(team.Name, user.Email, "pwd") - if result, err := Client.GetAnalytics(team.Id, "user_counts_with_posts_day"); err != nil { + if result, err := Client.GetTeamAnalytics(team.Id, "user_counts_with_posts_day"); err != nil { t.Fatal(err) } else { rows := result.Data.(model.AnalyticsRows) diff --git a/model/client.go b/model/client.go index 75b93c9710..b8e7c4894d 100644 --- a/model/client.go +++ b/model/client.go @@ -434,7 +434,7 @@ func (c *Client) TestEmail(config *Config) (*Result, *AppError) { } } -func (c *Client) GetAnalytics(teamId, name string) (*Result, *AppError) { +func (c *Client) GetTeamAnalytics(teamId, name string) (*Result, *AppError) { if r, err := c.DoApiGet("/admin/analytics/"+teamId+"/"+name, "", ""); err != nil { return nil, err } else { @@ -443,6 +443,15 @@ func (c *Client) GetAnalytics(teamId, name string) (*Result, *AppError) { } } +func (c *Client) GetSystemAnalytics(name string) (*Result, *AppError) { + if r, err := c.DoApiGet("/admin/analytics/"+name, "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), AnalyticsRowsFromJson(r.Body)}, nil + } +} + func (c *Client) CreateChannel(channel *Channel) (*Result, *AppError) { if r, err := c.DoApiPost("/channels/create", channel.ToJson()); err != nil { return nil, err diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx index 8fdf538f8a..70ef1ecab9 100644 --- a/web/react/components/admin_console/analytics.jsx +++ b/web/react/components/admin_console/analytics.jsx @@ -180,7 +180,7 @@ export default class Analytics extends React.Component { if (this.props.newlyCreatedUsers != null) { let content; if (this.props.newlyCreatedUsers.length === 0) { - content = 'Loading...'; + content = 'Loading...'; } else { content = ( @@ -230,6 +230,7 @@ export default class Analytics extends React.Component { return (

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

      + {serverError}
      {totalCount} {postCount} @@ -251,7 +252,6 @@ export default class Analytics extends React.Component { } } - Analytics.defaultProps = { title: null, channelOpenCount: null, diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx index fffe7cc53b..f54813a947 100644 --- a/web/react/components/admin_console/system_analytics.jsx +++ b/web/react/components/admin_console/system_analytics.jsx @@ -124,7 +124,7 @@ export default class SystemAnalytics extends React.Component { ); } - componentWillReceiveProps(newProps) { + componentWillReceiveProps() { this.setState({ serverError: null, channel_open_count: null, From 60a73ebabba6798d2b45fa8c8ac0f2bfa6144689 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 22 Jan 2016 10:24:37 -0500 Subject: [PATCH 5/5] Change default system console page to statistics --- web/react/components/admin_console/admin_controller.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index db98d8f35b..efd1630170 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -46,7 +46,7 @@ export default class AdminController extends React.Component { config: AdminStore.getConfig(), teams: AdminStore.getAllTeams(), selectedTeams, - selected: props.tab || 'service_settings', + selected: props.tab || 'system_analytics', selectedTeam: props.teamId || null };