diff options
28 files changed, 1416 insertions, 1076 deletions
diff --git a/api/admin.go b/api/admin.go index d04991353..feb70aae3 100644 --- a/api/admin.go +++ b/api/admin.go @@ -184,16 +184,18 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { name := params["name"] if name == "standard" { - var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 4) + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 5) 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} + rows[4] = &model.AnalyticsRow{"team_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, false, false) userChan := Srv.Store.User().AnalyticsUniqueUserCount(teamId) + teamChan := Srv.Store.Team().AnalyticsTeamCount() if r := <-openChan; r.Err != nil { c.Err = r.Err @@ -223,6 +225,13 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { rows[3].Value = float64(r.Data.(int64)) } + if r := <-teamChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[4].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 { @@ -239,16 +248,20 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson())) } } else if name == "extra_counts" { - var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 4) + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 6) rows[0] = &model.AnalyticsRow{"file_post_count", 0} rows[1] = &model.AnalyticsRow{"hashtag_post_count", 0} rows[2] = &model.AnalyticsRow{"incoming_webhook_count", 0} rows[3] = &model.AnalyticsRow{"outgoing_webhook_count", 0} + rows[4] = &model.AnalyticsRow{"command_count", 0} + rows[5] = &model.AnalyticsRow{"session_count", 0} fileChan := Srv.Store.Post().AnalyticsPostCount(teamId, true, false) hashtagChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, true) iHookChan := Srv.Store.Webhook().AnalyticsIncomingCount(teamId) oHookChan := Srv.Store.Webhook().AnalyticsOutgoingCount(teamId) + commandChan := Srv.Store.Command().AnalyticsCommandCount(teamId) + sessionChan := Srv.Store.Session().AnalyticsSessionCount(teamId) if r := <-fileChan; r.Err != nil { c.Err = r.Err @@ -278,6 +291,20 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { rows[3].Value = float64(r.Data.(int64)) } + if r := <-commandChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[4].Value = float64(r.Data.(int64)) + } + + if r := <-sessionChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[5].Value = float64(r.Data.(int64)) + } + w.Write([]byte(rows.ToJson())) } else { c.SetInvalidParam("getAnalytics", "name") diff --git a/api/admin_test.go b/api/admin_test.go index 8a9c82b44..bdea0bc5b 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -254,6 +254,16 @@ func TestGetTeamAnalyticsStandard(t *testing.T) { t.Log(rows.ToJson()) t.Fatal() } + + if rows[4].Name != "team_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[4].Value == 0 { + t.Log(rows.ToJson()) + t.Fatal() + } } if result, err := Client.GetSystemAnalytics("standard"); err != nil { @@ -300,6 +310,16 @@ func TestGetTeamAnalyticsStandard(t *testing.T) { t.Log(rows.ToJson()) t.Fatal() } + + if rows[4].Name != "team_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[4].Value == 0 { + t.Log(rows.ToJson()) + t.Fatal() + } } } @@ -469,6 +489,26 @@ func TestGetTeamAnalyticsExtra(t *testing.T) { t.Log(rows.ToJson()) t.Fatal() } + + if rows[4].Name != "command_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[4].Value != 0 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[5].Name != "session_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[5].Value == 0 { + t.Log(rows.ToJson()) + t.Fatal() + } } if result, err := Client.GetSystemAnalytics("extra_counts"); err != nil { @@ -500,5 +540,15 @@ func TestGetTeamAnalyticsExtra(t *testing.T) { t.Log(rows.ToJson()) t.Fatal() } + + if rows[4].Name != "command_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[5].Name != "session_count" { + t.Log(rows.ToJson()) + t.Fatal() + } } } diff --git a/i18n/en.json b/i18n/en.json index 79b6921a4..0e912f95c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2676,6 +2676,10 @@ "translation": "We couldn't update the command" }, { + "id": "store.sql_command.analytics_command_count.app_error", + "translation": "We couldn't count the commands" + }, + { "id": "store.sql_license.get.app_error", "translation": "We encountered an error getting the license" }, @@ -2948,6 +2952,10 @@ "translation": "We couldn't update the roles" }, { + "id": "store.sql_session.analytics_session_count.app_error", + "translation": "We couldn't count the sessions" + }, + { "id": "store.sql_system.get.app_error", "translation": "We encountered an error finding the system properties" }, @@ -3028,6 +3036,10 @@ "translation": "We couldn't update the team name" }, { + "id": "store.sql_team.analytics_team_count.app_error", + "translation": "We couldn't count the teams" + }, + { "id": "store.sql_user.analytics_unique_user_count.app_error", "translation": "We couldn't get the unique user count" }, @@ -3575,4 +3587,4 @@ "id": "web.watcher_fail.error", "translation": "Failed to add directory to watcher %v" } -]
\ No newline at end of file +] diff --git a/store/sql_command_store.go b/store/sql_command_store.go index 760235e10..074a6e588 100644 --- a/store/sql_command_store.go +++ b/store/sql_command_store.go @@ -151,18 +151,49 @@ func (s SqlCommandStore) PermanentDeleteByUser(userId string) StoreChannel { return storeChannel } -func (s SqlCommandStore) Update(hook *model.Command) StoreChannel { +func (s SqlCommandStore) Update(cmd *model.Command) StoreChannel { storeChannel := make(StoreChannel) go func() { result := StoreResult{} - hook.UpdateAt = model.GetMillis() + cmd.UpdateAt = model.GetMillis() - if _, err := s.GetMaster().Update(hook); err != nil { - result.Err = model.NewLocAppError("SqlCommandStore.Update", "store.sql_command.save.update.app_error", nil, "id="+hook.Id+", "+err.Error()) + if _, err := s.GetMaster().Update(cmd); err != nil { + result.Err = model.NewLocAppError("SqlCommandStore.Update", "store.sql_command.save.update.app_error", nil, "id="+cmd.Id+", "+err.Error()) } else { - result.Data = hook + result.Data = cmd + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlCommandStore) AnalyticsCommandCount(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + query := + `SELECT + COUNT(*) + FROM + Commands + WHERE + DeleteAt = 0` + + if len(teamId) > 0 { + query += " AND TeamId = :TeamId" + } + + if c, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewLocAppError("SqlCommandStore.AnalyticsCommandCount", "store.sql_command.analytics_command_count.app_error", nil, err.Error()) + } else { + result.Data = c } storeChannel <- result diff --git a/store/sql_command_store_test.go b/store/sql_command_store_test.go index b4610d4aa..644ebc9ae 100644 --- a/store/sql_command_store_test.go +++ b/store/sql_command_store_test.go @@ -153,3 +153,31 @@ func TestCommandStoreUpdate(t *testing.T) { t.Fatal(r2.Err) } } + +func TestCommandCount(t *testing.T) { + Setup() + + o1 := &model.Command{} + o1.CreatorId = model.NewId() + o1.Method = model.COMMAND_METHOD_POST + o1.TeamId = model.NewId() + o1.URL = "http://nowhere.com/" + + o1 = (<-store.Command().Save(o1)).Data.(*model.Command) + + if r1 := <-store.Command().AnalyticsCommandCount(""); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(int64) == 0 { + t.Fatal("should be at least 1 command") + } + } + + if r2 := <-store.Command().AnalyticsCommandCount(o1.TeamId); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if r2.Data.(int64) != 1 { + t.Fatal("should be 1 command") + } + } +} diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 6a614b6a7..3346534ab 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -947,7 +947,7 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH result := StoreResult{} query := - `SELECT + `SELECT COUNT(Posts.Id) AS Value FROM Posts, diff --git a/store/sql_session_store.go b/store/sql_session_store.go index 8dccc0770..6fe71db17 100644 --- a/store/sql_session_store.go +++ b/store/sql_session_store.go @@ -255,3 +255,32 @@ func (me SqlSessionStore) UpdateDeviceId(id, deviceId string) StoreChannel { return storeChannel } + +func (me SqlSessionStore) AnalyticsSessionCount(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + query := + `SELECT + COUNT(*) + FROM + Sessions` + + if len(teamId) > 0 { + query += " WHERE TeamId = :TeamId" + } + + if c, err := me.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewLocAppError("SqlSessionStore.AnalyticsSessionCount", "store.sql_session.analytics_session_count.app_error", nil, err.Error()) + } else { + result.Data = c + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_session_store_test.go b/store/sql_session_store_test.go index 34d3128a6..9b430eb30 100644 --- a/store/sql_session_store_test.go +++ b/store/sql_session_store_test.go @@ -200,3 +200,28 @@ func TestSessionStoreUpdateLastActivityAt(t *testing.T) { } } + +func TestSessionCount(t *testing.T) { + Setup() + + s1 := model.Session{} + s1.UserId = model.NewId() + s1.TeamId = model.NewId() + Must(store.Session().Save(&s1)) + + if r1 := <-store.Session().AnalyticsSessionCount(""); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(int64) == 0 { + t.Fatal("should have at least 1 session") + } + } + + if r2 := <-store.Session().AnalyticsSessionCount(s1.TeamId); r2.Err != nil { + t.Fatal(r2.Err) + } else { + if r2.Data.(int64) != 1 { + t.Fatal("should have 1 session") + } + } +} diff --git a/store/sql_team_store.go b/store/sql_team_store.go index 86ab9ac04..1893268c8 100644 --- a/store/sql_team_store.go +++ b/store/sql_team_store.go @@ -317,3 +317,22 @@ func (s SqlTeamStore) PermanentDelete(teamId string) StoreChannel { return storeChannel } + +func (s SqlTeamStore) AnalyticsTeamCount() StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if c, err := s.GetReplica().SelectInt("SELECT COUNT(*) FROM Teams WHERE DeleteAt = 0", map[string]interface{}{}); err != nil { + result.Err = model.NewLocAppError("SqlTeamStore.AnalyticsTeamCount", "store.sql_team.analytics_team_count.app_error", nil, err.Error()) + } else { + result.Data = c + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_team_store_test.go b/store/sql_team_store_test.go index 7dc31cbe2..743ef053f 100644 --- a/store/sql_team_store_test.go +++ b/store/sql_team_store_test.go @@ -261,3 +261,23 @@ func TestDelete(t *testing.T) { t.Fatal(r1.Err) } } + +func TestTeamCount(t *testing.T) { + Setup() + + o1 := model.Team{} + o1.DisplayName = "DisplayName" + o1.Name = "a" + model.NewId() + "b" + o1.Email = model.NewId() + "@nowhere.com" + o1.Type = model.TEAM_OPEN + o1.AllowTeamListing = true + Must(store.Team().Save(&o1)) + + if r1 := <-store.Team().AnalyticsTeamCount(); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(int64) == 0 { + t.Fatal("should be at least 1 team") + } + } +} diff --git a/store/store.go b/store/store.go index 397601543..b041cfa25 100644 --- a/store/store.go +++ b/store/store.go @@ -55,6 +55,7 @@ type TeamStore interface { GetAllTeamListing() StoreChannel GetByInviteId(inviteId string) StoreChannel PermanentDelete(teamId string) StoreChannel + AnalyticsTeamCount() StoreChannel } type ChannelStore interface { @@ -141,6 +142,7 @@ type SessionStore interface { UpdateLastActivityAt(sessionId string, time int64) StoreChannel UpdateRoles(userId string, roles string) StoreChannel UpdateDeviceId(id string, deviceId string) StoreChannel + AnalyticsSessionCount(teamId string) StoreChannel } type AuditStore interface { @@ -196,6 +198,7 @@ type CommandStore interface { Delete(commandId string, time int64) StoreChannel PermanentDeleteByUser(userId string) StoreChannel Update(hook *model.Command) StoreChannel + AnalyticsCommandCount(teamId string) StoreChannel } type PreferenceStore interface { diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index de0b085bc..32ed70a99 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -21,10 +21,10 @@ import TeamSettingsTab from './team_settings.jsx'; import ServiceSettingsTab from './service_settings.jsx'; import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx'; import TeamUsersTab from './team_users.jsx'; -import TeamAnalyticsTab from './team_analytics.jsx'; +import TeamAnalyticsTab from '../analytics/team_analytics.jsx'; import LdapSettingsTab from './ldap_settings.jsx'; import LicenseSettingsTab from './license_settings.jsx'; -import SystemAnalyticsTab from './system_analytics.jsx'; +import SystemAnalyticsTab from '../analytics/system_analytics.jsx'; export default class AdminController extends React.Component { constructor(props) { diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx deleted file mode 100644 index ec9ad4da0..000000000 --- a/web/react/components/admin_console/analytics.jsx +++ /dev/null @@ -1,489 +0,0 @@ -// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import Constants from '../../utils/constants.jsx'; -import LineChart from './line_chart.jsx'; -import DoughnutChart from './doughnut_chart.jsx'; -import StatisticCount from './statistic_count.jsx'; - -var Tooltip = ReactBootstrap.Tooltip; -var OverlayTrigger = ReactBootstrap.OverlayTrigger; - -import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedDate} from 'mm-intl'; - -const holders = defineMessages({ - analyticsTotalUsers: { - id: 'admin.analytics.totalUsers', - defaultMessage: 'Total Users' - }, - analyticsPublicChannels: { - id: 'admin.analytics.publicChannels', - defaultMessage: 'Public Channels' - }, - analyticsPrivateGroups: { - id: 'admin.analytics.privateGroups', - defaultMessage: 'Private Groups' - }, - analyticsTotalPosts: { - id: 'admin.analytics.totalPosts', - defaultMessage: 'Total Posts' - }, - analyticsFilePosts: { - id: 'admin.analytics.totalFilePosts', - defaultMessage: 'Posts with Files' - }, - analyticsHashtagPosts: { - id: 'admin.analytics.totalHashtagPosts', - defaultMessage: 'Posts with Hashtags' - }, - analyticsIncomingHooks: { - id: 'admin.analytics.totalIncomingWebhooks', - defaultMessage: 'Incoming Webhooks' - }, - analyticsOutgoingHooks: { - id: 'admin.analytics.totalOutgoingWebhooks', - defaultMessage: 'Outgoing Webhooks' - }, - analyticsChannelTypes: { - id: 'admin.analytics.channelTypes', - defaultMessage: 'Channel Types' - }, - analyticsTextPosts: { - id: 'admin.analytics.textPosts', - defaultMessage: 'Posts with Text-only' - }, - analyticsPostTypes: { - id: 'admin.analytics.postTypes', - defaultMessage: 'Posts, Files and Hashtags' - } -}); - -export default class Analytics extends React.Component { - constructor(props) { - super(props); - - this.state = {}; - } - - render() { // in the future, break down these into smaller components - const {formatMessage} = this.props.intl; - - var serverError = ''; - if (this.props.serverError) { - serverError = <div className='form-group has-error'><label className='control-label'>{this.props.serverError}</label></div>; - } - - let loading = ( - <h5> - <FormattedMessage - id='admin.analytics.loading' - defaultMessage='Loading...' - /> - </h5> - ); - - let firstRow; - let extraGraphs; - if (this.props.showAdvanced) { - firstRow = ( - <div className='row'> - <StatisticCount - title={formatMessage(holders.analyticsTotalUsers)} - icon='fa-users' - count={this.props.uniqueUserCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsTotalPosts)} - icon='fa-comment' - count={this.props.postCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsIncomingHooks)} - icon='fa-arrow-down' - count={this.props.incomingWebhookCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsOutgoingHooks)} - icon='fa-arrow-up' - count={this.props.outgoingWebhookCount} - /> - </div> - ); - - const channelTypeData = [ - { - value: this.props.channelOpenCount, - color: '#46BFBD', - highlight: '#5AD3D1', - label: formatMessage(holders.analyticsPublicChannels) - }, - { - value: this.props.channelPrivateCount, - color: '#FDB45C', - highlight: '#FFC870', - label: formatMessage(holders.analyticsPrivateGroups) - } - ]; - - const postTypeData = [ - { - value: this.props.filePostCount, - color: '#46BFBD', - highlight: '#5AD3D1', - label: formatMessage(holders.analyticsFilePosts) - }, - { - value: this.props.filePostCount, - color: '#F7464A', - highlight: '#FF5A5E', - label: formatMessage(holders.analyticsHashtagPosts) - }, - { - value: this.props.postCount - this.props.filePostCount - this.props.hashtagPostCount, - color: '#FDB45C', - highlight: '#FFC870', - label: formatMessage(holders.analyticsTextPosts) - } - ]; - - extraGraphs = ( - <div className='row'> - <DoughnutChart - title={formatMessage(holders.analyticsChannelTypes)} - data={channelTypeData} - width='300' - height='225' - /> - <DoughnutChart - title={formatMessage(holders.analyticsPostTypes)} - data={postTypeData} - width='300' - height='225' - /> - </div> - ); - } else { - firstRow = ( - <div className='row'> - <StatisticCount - title={formatMessage(holders.analyticsTotalUsers)} - icon='fa-users' - count={this.props.uniqueUserCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsPublicChannels)} - icon='fa-globe' - count={this.props.channelOpenCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsPrivateGroups)} - icon='fa-lock' - count={this.props.channelPrivateCount} - /> - <StatisticCount - title={formatMessage(holders.analyticsTotalPosts)} - icon='fa-comment' - count={this.props.postCount} - /> - </div> - ); - } - - let postCountsByDay; - if (this.props.postCountsDay == null) { - postCountsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.totalPosts' - defaultMessage='Total Posts' - /> - </div> - <div className='content'>{loading}</div> - </div> - </div> - ); - } else { - let content; - if (this.props.postCountsDay.labels.length === 0) { - content = ( - <h5> - <FormattedMessage - id='admin.analytics.meaningful' - defaultMessage='Not enough data for a meaningful representation.' - /> - </h5> - ); - } else { - content = ( - <LineChart - data={this.props.postCountsDay} - width='740' - height='225' - /> - ); - } - postCountsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.totalPosts' - defaultMessage='Total Posts' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - let usersWithPostsByDay; - if (this.props.userCountsWithPostsDay == null) { - usersWithPostsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.activeUsers' - defaultMessage='Active Users With Posts' - /> - </div> - <div className='content'>{loading}</div> - </div> - </div> - ); - } else { - let content; - if (this.props.userCountsWithPostsDay.labels.length === 0) { - content = ( - <h5> - <FormattedMessage - id='admin.analytics.meaningful' - defaultMessage='Not enough data for a meaningful representation.' - /> - </h5> - ); - } else { - content = ( - <LineChart - data={this.props.userCountsWithPostsDay} - width='740' - height='225' - /> - ); - } - usersWithPostsByDay = ( - <div className='col-sm-12'> - <div className='total-count by-day'> - <div className='title'> - <FormattedMessage - id='admin.analytics.activeUsers' - defaultMessage='Active Users With Posts' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - let recentActiveUser; - if (this.props.recentActiveUsers != null) { - let content; - if (this.props.recentActiveUsers.length === 0) { - content = loading; - } else { - content = ( - <table> - <tbody> - { - this.props.recentActiveUsers.map((user) => { - const tooltip = ( - <Tooltip id={'recent-user-email-tooltip-' + user.id}> - {user.email} - </Tooltip> - ); - - return ( - <tr key={'recent-user-table-entry-' + user.id}> - <td> - <OverlayTrigger - delayShow={Constants.OVERLAY_TIME_DELAY} - placement='top' - overlay={tooltip} - > - <time> - {user.username} - </time> - </OverlayTrigger> - </td> - <td> - <FormattedDate - value={user.last_activity_at} - day='numeric' - month='long' - year='numeric' - hour12={true} - hour='2-digit' - minute='2-digit' - /> - </td> - </tr> - ); - }) - } - </tbody> - </table> - ); - } - recentActiveUser = ( - <div className='col-sm-6'> - <div className='total-count recent-active-users'> - <div className='title'> - <FormattedMessage - id='admin.analytics.recentActive' - defaultMessage='Recent Active Users' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - let newUsers; - if (this.props.newlyCreatedUsers != null) { - let content; - if (this.props.newlyCreatedUsers.length === 0) { - content = loading; - } else { - content = ( - <table> - <tbody> - { - this.props.newlyCreatedUsers.map((user) => { - const tooltip = ( - <Tooltip id={'new-user-email-tooltip-' + user.id}> - {user.email} - </Tooltip> - ); - - return ( - <tr key={'new-user-table-entry-' + user.id}> - <td> - <OverlayTrigger - delayShow={Constants.OVERLAY_TIME_DELAY} - placement='top' - overlay={tooltip} - > - <time> - {user.username} - </time> - </OverlayTrigger> - </td> - <td> - <FormattedDate - value={user.create_at} - day='numeric' - month='long' - year='numeric' - hour12={true} - hour='2-digit' - minute='2-digit' - /> - </td> - </tr> - ); - }) - } - </tbody> - </table> - ); - } - newUsers = ( - <div className='col-sm-6'> - <div className='total-count recent-active-users'> - <div className='title'> - <FormattedMessage - id='admin.analytics.newlyCreated' - defaultMessage='Newly Created Users' - /> - </div> - <div className='content'> - {content} - </div> - </div> - </div> - ); - } - - return ( - <div className='wrapper--fixed team_statistics'> - <h3> - <FormattedMessage - id='admin.analytics.title' - defaultMessage='Statistics for {title}' - values={{ - title: this.props.title - }} - /> - </h3> - {serverError} - {firstRow} - {extraGraphs} - <div className='row'> - {postCountsByDay} - </div> - <div className='row'> - {usersWithPostsByDay} - </div> - <div className='row'> - {recentActiveUser} - {newUsers} - </div> - </div> - ); - } -} - -Analytics.defaultProps = { - title: null, - channelOpenCount: null, - channelPrivateCount: null, - postCount: null, - postCountsDay: null, - userCountsWithPostsDay: null, - recentActiveUsers: null, - newlyCreatedUsers: null, - uniqueUserCount: null, - serverError: null -}; - -Analytics.propTypes = { - intl: intlShape.isRequired, - title: React.PropTypes.string, - channelOpenCount: React.PropTypes.number, - channelPrivateCount: React.PropTypes.number, - postCount: React.PropTypes.number, - showAdvanced: React.PropTypes.bool, - filePostCount: React.PropTypes.number, - hashtagPostCount: React.PropTypes.number, - incomingWebhookCount: React.PropTypes.number, - outgoingWebhookCount: 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 -}; - -export default injectIntl(Analytics); diff --git a/web/react/components/admin_console/line_chart.jsx b/web/react/components/admin_console/line_chart.jsx deleted file mode 100644 index 7e2f95c84..000000000 --- a/web/react/components/admin_console/line_chart.jsx +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -export default class LineChart 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); - var ctx = el.getContext('2d'); - this.chart = new Chart(ctx).Line(props.data, props.options || {}); //eslint-disable-line new-cap - } - - render() { - return ( - <canvas - width={this.props.width} - height={this.props.height} - /> - ); - } -} - -LineChart.propTypes = { - width: React.PropTypes.string, - height: React.PropTypes.string, - data: React.PropTypes.object, - options: React.PropTypes.object -}; diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx deleted file mode 100644 index f983db177..000000000 --- a/web/react/components/admin_console/system_analytics.jsx +++ /dev/null @@ -1,216 +0,0 @@ -// 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 {injectIntl, intlShape, defineMessages} from 'mm-intl'; - -const labels = defineMessages({ - totalPosts: { - id: 'admin.system_analytics.totalPosts', - defaultMessage: 'Total Posts' - }, - activeUsers: { - id: 'admin.system_analytics.activeUsers', - defaultMessage: 'Active Users With Posts' - }, - title: { - id: 'admin.system_analytics.title', - defaultMessage: 'the System' - } -}); - -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 - const {formatMessage} = this.props.intl; - 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: formatMessage(labels.totalPosts), - 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: formatMessage(labels.activeUsers), - 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}); - } - ); - - if (global.window.mm_license.IsLicensed === 'true') { - Client.getSystemAnalytics( - 'extra_counts', - (data) => { - for (var index in data) { - if (data[index].name === 'file_post_count') { - this.setState({file_post_count: data[index].value}); - } - - if (data[index].name === 'hashtag_post_count') { - this.setState({hashtag_post_count: data[index].value}); - } - - if (data[index].name === 'incoming_webhook_count') { - this.setState({incoming_webhook_count: data[index].value}); - } - - if (data[index].name === 'outgoing_webhook_count') { - this.setState({outgoing_webhook_count: data[index].value}); - } - } - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - } - } - - componentWillReceiveProps() { - 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 ( - <div> - <Analytics - intl={this.props.intl} - title={this.props.intl.formatMessage(labels.title)} - channelOpenCount={this.state.channel_open_count} - channelPrivateCount={this.state.channel_private_count} - postCount={this.state.post_count} - showAdvanced={global.window.mm_license.IsLicensed === 'true'} - filePostCount={this.state.file_post_count} - hashtagPostCount={this.state.hashtag_post_count} - incomingWebhookCount={this.state.incoming_webhook_count} - outgoingWebhookCount={this.state.outgoing_webhook_count} - postCountsDay={this.state.post_counts_day} - userCountsWithPostsDay={this.state.user_counts_with_posts_day} - uniqueUserCount={this.state.unique_user_count} - serverError={this.state.serverError} - /> - </div> - ); - } -} - -SystemAnalytics.propTypes = { - intl: intlShape.isRequired, - team: React.PropTypes.object -}; - -export default injectIntl(SystemAnalytics); diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx deleted file mode 100644 index 808d8046d..000000000 --- a/web/react/components/admin_console/team_analytics.jsx +++ /dev/null @@ -1,253 +0,0 @@ -// 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 {injectIntl, intlShape, defineMessages} from 'mm-intl'; - -const labels = defineMessages({ - totalPosts: { - id: 'admin.team_analytics.totalPosts', - defaultMessage: 'Total Posts' - }, - activeUsers: { - id: 'admin.team_analytics.activeUsers', - defaultMessage: 'Active Users With Posts' - } -}); - -class TeamAnalytics 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(this.props.team.id); - } - - getData(teamId) { // should be moved to an action creator eventually - const {formatMessage} = this.props.intl; - Client.getTeamAnalytics( - teamId, - '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.getTeamAnalytics( - teamId, - 'post_counts_day', - (data) => { - data.reverse(); - - var chartData = { - labels: [], - datasets: [{ - label: formatMessage(labels.totalPosts), - 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.getTeamAnalytics( - teamId, - 'user_counts_with_posts_day', - (data) => { - data.reverse(); - - var chartData = { - labels: [], - datasets: [{ - label: formatMessage(labels.activeUsers), - 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}); - } - ); - - Client.getProfilesForTeam( - teamId, - (users) => { - this.setState({users}); - - var usersList = []; - for (var id in users) { - if (users.hasOwnProperty(id)) { - usersList.push(users[id]); - } - } - - usersList.sort((a, b) => { - if (a.last_activity_at < b.last_activity_at) { - return 1; - } - - if (a.last_activity_at > b.last_activity_at) { - return -1; - } - - return 0; - }); - - var recentActive = []; - for (let i = 0; i < usersList.length; i++) { - if (usersList[i].last_activity_at == null) { - continue; - } - - recentActive.push(usersList[i]); - if (i > 19) { - break; - } - } - - this.setState({recent_active_users: recentActive}); - - usersList.sort((a, b) => { - if (a.create_at < b.create_at) { - return 1; - } - - if (a.create_at > b.create_at) { - return -1; - } - - return 0; - }); - - var newlyCreated = []; - for (let i = 0; i < usersList.length; i++) { - newlyCreated.push(usersList[i]); - if (i > 19) { - break; - } - } - - this.setState({newly_created_users: newlyCreated}); - }, - (err) => { - this.setState({serverError: err.message}); - } - ); - } - - componentWillReceiveProps(newProps) { - this.setState({ - 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 - }); - - this.getData(newProps.team.id); - } - - render() { - return ( - <div> - <Analytics - intl={this.props.intl} - title={this.props.team.name} - users={this.state.users} - channelOpenCount={this.state.channel_open_count} - channelPrivateCount={this.state.channel_private_count} - postCount={this.state.post_count} - postCountsDay={this.state.post_counts_day} - userCountsWithPostsDay={this.state.user_counts_with_posts_day} - recentActiveUsers={this.state.recent_active_users} - newlyCreatedUsers={this.state.newly_created_users} - uniqueUserCount={this.state.unique_user_count} - serverError={this.state.serverError} - /> - </div> - ); - } -} - -TeamAnalytics.propTypes = { - intl: intlShape.isRequired, - team: React.PropTypes.object -}; - -export default injectIntl(TeamAnalytics); diff --git a/web/react/components/admin_console/doughnut_chart.jsx b/web/react/components/analytics/doughnut_chart.jsx index e2dc01528..00bb66f0a 100644 --- a/web/react/components/admin_console/doughnut_chart.jsx +++ b/web/react/components/analytics/doughnut_chart.jsx @@ -39,7 +39,7 @@ export default class DoughnutChart extends React.Component { if (this.props.data == null) { content = ( <FormattedMessage - id='admin.analytics.loading' + id='analytics.chart.loading' defaultMessage='Loading...' /> ); @@ -69,7 +69,7 @@ export default class DoughnutChart extends React.Component { } DoughnutChart.propTypes = { - title: React.PropTypes.string, + title: React.PropTypes.node, width: React.PropTypes.string, height: React.PropTypes.string, data: React.PropTypes.array, diff --git a/web/react/components/analytics/line_chart.jsx b/web/react/components/analytics/line_chart.jsx new file mode 100644 index 000000000..d1bb6b9cb --- /dev/null +++ b/web/react/components/analytics/line_chart.jsx @@ -0,0 +1,90 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {FormattedMessage} from 'mm-intl'; + +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/web/react/components/admin_console/statistic_count.jsx b/web/react/components/analytics/statistic_count.jsx index 118a0ad31..cf457310f 100644 --- a/web/react/components/admin_console/statistic_count.jsx +++ b/web/react/components/analytics/statistic_count.jsx @@ -7,7 +7,7 @@ export default class StatisticCount extends React.Component { render() { let loading = ( <FormattedMessage - id='admin.analytics.loading' + id='analytics.chart.loading' defaultMessage='Loading...' /> ); @@ -27,7 +27,7 @@ export default class StatisticCount extends React.Component { } StatisticCount.propTypes = { - title: React.PropTypes.string.isRequired, + title: React.PropTypes.node.isRequired, icon: React.PropTypes.string.isRequired, count: React.PropTypes.number }; diff --git a/web/react/components/analytics/system_analytics.jsx b/web/react/components/analytics/system_analytics.jsx new file mode 100644 index 000000000..a2b783a79 --- /dev/null +++ b/web/react/components/analytics/system_analytics.jsx @@ -0,0 +1,346 @@ +// 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 'mm-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' + } +}); + +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/web/react/components/analytics/table_chart.jsx b/web/react/components/analytics/table_chart.jsx new file mode 100644 index 000000000..c94fa300b --- /dev/null +++ b/web/react/components/analytics/table_chart.jsx @@ -0,0 +1,60 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Constants from '../../utils/constants.jsx'; + +const Tooltip = ReactBootstrap.Tooltip; +const OverlayTrigger = ReactBootstrap.OverlayTrigger; + +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/web/react/components/analytics/team_analytics.jsx b/web/react/components/analytics/team_analytics.jsx new file mode 100644 index 000000000..1236c070b --- /dev/null +++ b/web/react/components/analytics/team_analytics.jsx @@ -0,0 +1,235 @@ +// 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 'mm-intl'; + +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; +} diff --git a/web/react/stores/analytics_store.jsx b/web/react/stores/analytics_store.jsx new file mode 100644 index 000000000..ec827f6d7 --- /dev/null +++ b/web/react/stores/analytics_store.jsx @@ -0,0 +1,85 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import EventEmitter from 'events'; + +import Constants from '../utils/constants.jsx'; +const ActionTypes = Constants.ActionTypes; + +const CHANGE_EVENT = 'change'; + +class AnalyticsStoreClass extends EventEmitter { + constructor() { + super(); + this.systemStats = {}; + this.teamStats = {}; + } + + emitChange() { + this.emit(CHANGE_EVENT); + } + + addChangeListener(callback) { + this.on(CHANGE_EVENT, callback); + } + + removeChangeListener(callback) { + this.removeListener(CHANGE_EVENT, callback); + } + + getAllSystem() { + return JSON.parse(JSON.stringify(this.systemStats)); + } + + getAllTeam(id) { + if (id in this.teamStats) { + return JSON.parse(JSON.stringify(this.teamStats[id])); + } + + return {}; + } + + storeSystemStats(newStats) { + for (const stat in newStats) { + if (!newStats.hasOwnProperty(stat)) { + continue; + } + this.systemStats[stat] = newStats[stat]; + } + } + + storeTeamStats(id, newStats) { + if (!(id in this.teamStats)) { + this.teamStats[id] = {}; + } + + for (const stat in newStats) { + if (!newStats.hasOwnProperty(stat)) { + continue; + } + this.teamStats[id][stat] = newStats[stat]; + } + } + +} + +var AnalyticsStore = new AnalyticsStoreClass(); + +AnalyticsStore.dispatchToken = AppDispatcher.register((payload) => { + var action = payload.action; + + switch (action.type) { + case ActionTypes.RECEIVED_ANALYTICS: + if (action.teamId == null) { + AnalyticsStore.storeSystemStats(action.stats); + } else { + AnalyticsStore.storeTeamStats(action.teamId, action.stats); + } + AnalyticsStore.emitChange(); + break; + default: + } +}); + +export default AnalyticsStore; diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index ca9d81865..7d5e1bd0f 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -11,16 +11,17 @@ import UserStore from '../stores/user_store.jsx'; import * as utils from './utils.jsx'; import Constants from './constants.jsx'; -var ActionTypes = Constants.ActionTypes; +const ActionTypes = Constants.ActionTypes; +const StatTypes = Constants.StatTypes; // Used to track in progress async calls -var callTracker = {}; +const callTracker = {}; export function dispatchError(err, method) { AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_ERROR, - err: err, - method: method + err, + method }); } @@ -848,3 +849,264 @@ export function getFileInfo(filename) { } ); } + +export function getStandardAnalytics(teamId) { + const callName = 'getStandardAnaytics' + teamId; + + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + + client.getAnalytics( + 'standard', + teamId, + (data) => { + callTracker[callName] = 0; + + const stats = {}; + + for (const index in data) { + if (data[index].name === 'channel_open_count') { + stats[StatTypes.TOTAL_PUBLIC_CHANNELS] = data[index].value; + } + + if (data[index].name === 'channel_private_count') { + stats[StatTypes.TOTAL_PRIVATE_GROUPS] = data[index].value; + } + + if (data[index].name === 'post_count') { + stats[StatTypes.TOTAL_POSTS] = data[index].value; + } + + if (data[index].name === 'unique_user_count') { + stats[StatTypes.TOTAL_USERS] = data[index].value; + } + + if (data[index].name === 'team_count' && teamId == null) { + stats[StatTypes.TOTAL_TEAMS] = data[index].value; + } + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_ANALYTICS, + teamId, + stats + }); + }, + (err) => { + callTracker[callName] = 0; + + dispatchError(err, 'getStandardAnalytics'); + } + ); +} + +export function getAdvancedAnalytics(teamId) { + const callName = 'getAdvancedAnalytics' + teamId; + + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + + client.getAnalytics( + 'extra_counts', + teamId, + (data) => { + callTracker[callName] = 0; + + const stats = {}; + + for (const index in data) { + if (data[index].name === 'file_post_count') { + stats[StatTypes.TOTAL_FILE_POSTS] = data[index].value; + } + + if (data[index].name === 'hashtag_post_count') { + stats[StatTypes.TOTAL_HASHTAG_POSTS] = data[index].value; + } + + if (data[index].name === 'incoming_webhook_count') { + stats[StatTypes.TOTAL_IHOOKS] = data[index].value; + } + + if (data[index].name === 'outgoing_webhook_count') { + stats[StatTypes.TOTAL_OHOOKS] = data[index].value; + } + + if (data[index].name === 'command_count') { + stats[StatTypes.TOTAL_COMMANDS] = data[index].value; + } + + if (data[index].name === 'session_count') { + stats[StatTypes.TOTAL_SESSIONS] = data[index].value; + } + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_ANALYTICS, + teamId, + stats + }); + }, + (err) => { + callTracker[callName] = 0; + + dispatchError(err, 'getAdvancedAnalytics'); + } + ); +} + +export function getPostsPerDayAnalytics(teamId) { + const callName = 'getPostsPerDayAnalytics' + teamId; + + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + + client.getAnalytics( + 'post_counts_day', + teamId, + (data) => { + callTracker[callName] = 0; + + data.reverse(); + + const stats = {}; + stats[StatTypes.POST_PER_DAY] = data; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_ANALYTICS, + teamId, + stats + }); + }, + (err) => { + callTracker[callName] = 0; + + dispatchError(err, 'getPostsPerDayAnalytics'); + } + ); +} + +export function getUsersPerDayAnalytics(teamId) { + const callName = 'getUsersPerDayAnalytics' + teamId; + + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + + client.getAnalytics( + 'user_counts_with_posts_day', + teamId, + (data) => { + callTracker[callName] = 0; + + data.reverse(); + + const stats = {}; + stats[StatTypes.USERS_WITH_POSTS_PER_DAY] = data; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_ANALYTICS, + teamId, + stats + }); + }, + (err) => { + callTracker[callName] = 0; + + dispatchError(err, 'getUsersPerDayAnalytics'); + } + ); +} + +export function getRecentAndNewUsersAnalytics(teamId) { + const callName = 'getRecentAndNewUsersAnalytics' + teamId; + + if (isCallInProgress(callName)) { + return; + } + + callTracker[callName] = utils.getTimestamp(); + + client.getProfilesForTeam( + teamId, + (users) => { + const stats = {}; + + const usersList = []; + for (const id in users) { + if (users.hasOwnProperty(id)) { + usersList.push(users[id]); + } + } + + usersList.sort((a, b) => { + if (a.last_activity_at < b.last_activity_at) { + return 1; + } + + if (a.last_activity_at > b.last_activity_at) { + return -1; + } + + return 0; + }); + + const recentActive = []; + for (let i = 0; i < usersList.length; i++) { + if (usersList[i].last_activity_at == null) { + continue; + } + + recentActive.push(usersList[i]); + if (i >= Constants.STAT_MAX_ACTIVE_USERS) { + break; + } + } + + stats[StatTypes.RECENTLY_ACTIVE_USERS] = recentActive; + + usersList.sort((a, b) => { + if (a.create_at < b.create_at) { + return 1; + } + + if (a.create_at > b.create_at) { + return -1; + } + + return 0; + }); + + var newlyCreated = []; + for (let i = 0; i < usersList.length; i++) { + newlyCreated.push(usersList[i]); + if (i >= Constants.STAT_MAX_NEW_USERS) { + break; + } + } + + stats[StatTypes.NEWLY_CREATED_USERS] = newlyCreated; + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_ANALYTICS, + teamId, + stats + }); + }, + (err) => { + callTracker[callName] = 0; + + dispatchError(err, 'getRecentAndNewUsersAnalytics'); + } + ); +} diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index f647e2296..1a002bc8c 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -435,23 +435,16 @@ export function getConfig(success, error) { }); } -export function getTeamAnalytics(teamId, name, success, error) { - $.ajax({ - url: '/api/v1/admin/analytics/' + teamId + '/' + name, - dataType: 'json', - contentType: 'application/json', - type: 'GET', - success, - error: (xhr, status, err) => { - var e = handleError('getTeamAnalytics', xhr, status, err); - error(e); - } - }); -} +export function getAnalytics(name, teamId, success, error) { + let url = '/api/v1/admin/analytics/'; + if (teamId == null) { + url += name; + } else { + url += teamId + '/' + name; + } -export function getSystemAnalytics(name, success, error) { $.ajax({ - url: '/api/v1/admin/analytics/' + name, + url, dataType: 'json', contentType: 'application/json', type: 'GET', diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 0a4944708..88f01a475 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -71,6 +71,26 @@ export default { VIEW_ACTION: null }), + StatTypes: keyMirror({ + TOTAL_USERS: null, + TOTAL_PUBLIC_CHANNELS: null, + TOTAL_PRIVATE_GROUPS: null, + TOTAL_POSTS: null, + TOTAL_TEAMS: null, + TOTAL_FILE_POSTS: null, + TOTAL_HASHTAG_POSTS: null, + TOTAL_IHOOKS: null, + TOTAL_OHOOKS: null, + TOTAL_COMMANDS: null, + TOTAL_SESSIONS: null, + POST_PER_DAY: null, + USERS_WITH_POSTS_PER_DAY: null, + RECENTLY_ACTIVE_USERS: null, + NEWLY_CREATED_USERS: null + }), + STAT_MAX_ACTIVE_USERS: 20, + STAT_MAX_NEW_USERS: 20, + SocketEvents: { POSTED: 'posted', POST_EDITED: 'post_edited', diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json index 0d7be4b08..68942c61a 100644 --- a/web/static/i18n/en.json +++ b/web/static/i18n/en.json @@ -21,23 +21,33 @@ "activity_log_modal.android": "Android", "activity_log_modal.androidNativeApp": "Android Native App", "activity_log_modal.iphoneNativeApp": "iPhone Native App", - "admin.analytics.activeUsers": "Active Users With Posts", - "admin.analytics.channelTypes": "Channel Types", - "admin.analytics.loading": "Loading...", - "admin.analytics.meaningful": "Not enough data for a meaningful representation.", - "admin.analytics.newlyCreated": "Newly Created Users", - "admin.analytics.postTypes": "Posts, Files and Hashtags", - "admin.analytics.privateGroups": "Private Groups", - "admin.analytics.publicChannels": "Public Channels", - "admin.analytics.recentActive": "Recent Active Users", - "admin.analytics.textPosts": "Posts with Text-only", - "admin.analytics.title": "Statistics for {title}", - "admin.analytics.totalFilePosts": "Posts with Files", - "admin.analytics.totalHashtagPosts": "Posts with Hashtags", - "admin.analytics.totalIncomingWebhooks": "Incoming Webhooks", - "admin.analytics.totalOutgoingWebhooks": "Outgoing Webhooks", - "admin.analytics.totalPosts": "Total Posts", - "admin.analytics.totalUsers": "Total Users", + "analytics.chart.loading": "Loading...", + "analytics.chart.meaningful": "Not enough data for a meaningful representation.", + "analytics.system.activeUsers": "Active Users With Posts", + "analytics.system.channelTypes": "Channel Types", + "analytics.system.postTypes": "Posts, Files and Hashtags", + "analytics.system.privateGroups": "Private Groups", + "analytics.system.publicChannels": "Public Channels", + "analytics.system.textPosts": "Posts with Text-only", + "analytics.system.title": "System Statistics", + "analytics.system.totalFilePosts": "Posts with Files", + "analytics.system.totalHashtagPosts": "Posts with Hashtags", + "analytics.system.totalIncomingWebhooks": "Incoming Webhooks", + "analytics.system.totalOutgoingWebhooks": "Outgoing Webhooks", + "analytics.system.totalCommands": "Total Commands", + "analytics.system.totalSessions": "Total Sessions", + "analytics.system.totalPosts": "Total Posts", + "analytics.system.totalUsers": "Total Users", + "analytics.system.totalTeams": "Total Teams", + "analytics.system.totalChannels": "Total Channels", + "analytics.team.activeUsers": "Active Users With Posts", + "analytics.team.recentActive": "Recent Active Users", + "analytics.team.newlyCreated": "Newly Created Users", + "analytics.team.privateGroups": "Private Groups", + "analytics.team.publicChannels": "Public Channels", + "analytics.team.title": "Team Statistics for {team}", + "analytics.team.totalPosts": "Total Posts", + "analytics.team.totalUsers": "Total Users", "admin.audits.reload": "Reload", "admin.audits.title": "User Activity", "admin.email.allowEmailSignInDescription": "When true, Mattermost allows users to sign in using their email and password.", diff --git a/web/static/i18n/es.json b/web/static/i18n/es.json index ea1b4663a..ac9fd173f 100644 --- a/web/static/i18n/es.json +++ b/web/static/i18n/es.json @@ -21,23 +21,26 @@ "activity_log_modal.android": "Android", "activity_log_modal.androidNativeApp": "Android App Nativa", "activity_log_modal.iphoneNativeApp": "iPhone App Nativa", - "admin.analytics.activeUsers": "Usuarios Activos con Mensajes", - "admin.analytics.channelTypes": "Tipos de Canales", - "admin.analytics.loading": "Cargando...", - "admin.analytics.meaningful": "No hay suficiente data para tener una representación significativa.", - "admin.analytics.newlyCreated": "Nuevos Usuarios Creados", - "admin.analytics.postTypes": "Mesajes, Archivos y Hashtags", - "admin.analytics.privateGroups": "Grupos Privados", - "admin.analytics.publicChannels": "Canales Públicos", - "admin.analytics.recentActive": "Usuarios Recientemente Activos", - "admin.analytics.textPosts": "Mensajes de sólo Texto", - "admin.analytics.title": "Estadísticas para {title}", - "admin.analytics.totalFilePosts": "Mensajes con Archivos", - "admin.analytics.totalHashtagPosts": "Mensajes con Hashtags", - "admin.analytics.totalIncomingWebhooks": "Webhooks de Entrada", - "admin.analytics.totalOutgoingWebhooks": "Webhooks de Salida", - "admin.analytics.totalPosts": "Total de Mensajes", - "admin.analytics.totalUsers": "Total de Usuarios", + "analytics.chart.loading": "Cargando...", + "analytics.chart.meaningful": "No hay suficiente data para tener una representación significativa.", + "analytics.system.channelTypes": "Tipos de Canales", + "analytics.system.postTypes": "Mesajes, Archivos y Hashtags", + "analytics.system.privateGroups": "Grupos Privados", + "analytics.system.publicChannels": "Canales Públicos", + "analytics.system.textPosts": "Mensajes de sólo Texto", + "analytics.system.totalFilePosts": "Mensajes con Archivos", + "analytics.system.totalHashtagPosts": "Mensajes con Hashtags", + "analytics.system.totalIncomingWebhooks": "Webhooks de Entrada", + "analytics.system.totalOutgoingWebhooks": "Webhooks de Salida", + "analytics.system.totalPosts": "Total de Mensajes", + "analytics.system.totalUsers": "Total de Usuarios", + "analytics.team.activeUsers": "Usuarios Activos con Mensajes", + "analytics.team.newlyCreated": "Nuevos Usuarios Creados", + "analytics.team.privateGroups": "Grupos Privados", + "analytics.team.publicChannels": "Canales Públicos", + "analytics.team.recentActive": "Usuarios Recientemente Activos", + "analytics.team.totalPosts": "Total de Mensajes", + "analytics.team.totalUsers": "Total de Usuarios", "admin.audits.reload": "Recargar", "admin.audits.title": "Auditorías del Servidor", "admin.email.allowEmailSignInDescription": "Cuando es verdadero, Mattermost permite a los usuarios iniciar sesión utilizando el correo electrónico y contraseña.", |