summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/admin.go44
-rw-r--r--api/admin_test.go110
-rw-r--r--i18n/en.json8
-rw-r--r--store/sql_post_store.go13
-rw-r--r--store/sql_post_store_test.go2
-rw-r--r--store/sql_webhook_store.go62
-rw-r--r--store/sql_webhook_store_test.go39
-rw-r--r--store/store.go4
-rw-r--r--web/react/components/admin_console/analytics.jsx274
-rw-r--r--web/react/components/admin_console/doughnut_chart.jsx77
-rw-r--r--web/react/components/admin_console/statistic_count.jsx37
-rw-r--r--web/react/components/admin_console/system_analytics.jsx36
-rw-r--r--web/react/components/admin_console/team_analytics.jsx3
-rw-r--r--web/sass-files/sass/partials/_statistics.scss5
-rw-r--r--web/static/i18n/en.json9
-rw-r--r--web/static/i18n/es.json2
16 files changed, 630 insertions, 95 deletions
diff --git a/api/admin.go b/api/admin.go
index 0ea6341e2..0ca05287e 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -161,9 +161,10 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
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)
+ postChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, false)
userChan := Srv.Store.User().AnalyticsUniqueUserCount(teamId)
if r := <-openChan; r.Err != nil {
@@ -209,6 +210,47 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson()))
}
+ } else if name == "extra_counts" {
+ var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 4)
+ 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}
+
+ 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)
+
+ if r := <-fileChan; r.Err != nil {
+ c.Err = r.Err
+ return
+ } else {
+ rows[0].Value = float64(r.Data.(int64))
+ }
+
+ if r := <-hashtagChan; r.Err != nil {
+ c.Err = r.Err
+ return
+ } else {
+ rows[1].Value = float64(r.Data.(int64))
+ }
+
+ if r := <-iHookChan; r.Err != nil {
+ c.Err = r.Err
+ return
+ } else {
+ rows[2].Value = float64(r.Data.(int64))
+ }
+
+ if r := <-oHookChan; r.Err != nil {
+ c.Err = r.Err
+ return
+ } else {
+ rows[3].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 2552e642c..053a2a730 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -362,3 +362,113 @@ func TestUserCountsWithPostsByDay(t *testing.T) {
}
}
}
+
+func TestGetTeamAnalyticsExtra(t *testing.T) {
+ Setup()
+
+ team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
+ team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team)
+
+ user := &model.User{TeamId: team.Id, Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user.Id))
+
+ Client.LoginByEmail(team.Name, user.Email, "pwd")
+
+ channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id}
+ channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
+
+ post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"}
+ post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post)
+
+ post2 := &model.Post{ChannelId: channel1.Id, Message: "#test a" + model.NewId() + "a"}
+ post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
+
+ if _, err := Client.GetTeamAnalytics("", "extra_counts"); err == nil {
+ t.Fatal("Shouldn't have permissions")
+ }
+
+ c := &Context{}
+ c.RequestId = model.NewId()
+ c.IpAddress = "cmd_line"
+ UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN)
+
+ Client.LoginByEmail(team.Name, user.Email, "pwd")
+
+ if result, err := Client.GetTeamAnalytics(team.Id, "extra_counts"); err != nil {
+ t.Fatal(err)
+ } else {
+ rows := result.Data.(model.AnalyticsRows)
+
+ if rows[0].Name != "file_post_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[0].Value != 0 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[1].Name != "hashtag_post_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[1].Value != 1 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[2].Name != "incoming_webhook_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[2].Value != 0 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[3].Name != "outgoing_webhook_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[3].Value != 0 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+ }
+
+ if result, err := Client.GetSystemAnalytics("extra_counts"); err != nil {
+ t.Fatal(err)
+ } else {
+ rows := result.Data.(model.AnalyticsRows)
+
+ if rows[0].Name != "file_post_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[1].Name != "hashtag_post_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[1].Value < 1 {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[2].Name != "incoming_webhook_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+
+ if rows[3].Name != "outgoing_webhook_count" {
+ t.Log(rows.ToJson())
+ t.Fatal()
+ }
+ }
+}
diff --git a/i18n/en.json b/i18n/en.json
index 8cbdd4993..2d86e1ee5 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -3080,6 +3080,14 @@
"translation": "We couldn't update the webhook"
},
{
+ "id": "store.sql_webhooks.analytics_incoming_count.app_error",
+ "translation": "We couldn't count the incoming webhooks"
+ },
+ {
+ "id": "store.sql_webhooks.analytics_outgoing_count.app_error",
+ "translation": "We couldn't count the outgoing webhooks"
+ },
+ {
"id": "utils.config.load_config.decoding.panic",
"translation": "Error decoding config file={{.Filename}}, err={{.Error}}"
},
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index aeaa5922c..c511dc370 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -940,7 +940,7 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel {
return storeChannel
}
-func (s SqlPostStore) AnalyticsPostCount(teamId string) StoreChannel {
+func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustHaveHashtag bool) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
@@ -959,8 +959,15 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string) StoreChannel {
query += " AND Channels.TeamId = :TeamId"
}
- v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId})
- if err != nil {
+ if mustHaveFile {
+ query += " AND Posts.Filenames != '[]'"
+ }
+
+ if mustHaveHashtag {
+ query += " AND Posts.Hashtags != ''"
+ }
+
+ if v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil {
result.Err = model.NewLocAppError("SqlPostStore.AnalyticsPostCount", "store.sql_post.analytics_posts_count.app_error", nil, err.Error())
} else {
result.Data = v
diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go
index 46b8d7678..d69f7906c 100644
--- a/store/sql_post_store_test.go
+++ b/store/sql_post_store_test.go
@@ -887,7 +887,7 @@ func TestPostCountsByDay(t *testing.T) {
}
}
- if r1 := <-store.Post().AnalyticsPostCount(t1.Id); r1.Err != nil {
+ if r1 := <-store.Post().AnalyticsPostCount(t1.Id, false, false); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 4 {
diff --git a/store/sql_webhook_store.go b/store/sql_webhook_store.go
index cdfb949f5..5298b0b94 100644
--- a/store/sql_webhook_store.go
+++ b/store/sql_webhook_store.go
@@ -350,3 +350,65 @@ func (s SqlWebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) StoreChanne
return storeChannel
}
+
+func (s SqlWebhookStore) AnalyticsIncomingCount(teamId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ query :=
+ `SELECT
+ COUNT(*)
+ FROM
+ IncomingWebhooks
+ WHERE
+ DeleteAt = 0`
+
+ if len(teamId) > 0 {
+ query += " AND TeamId = :TeamId"
+ }
+
+ if v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil {
+ result.Err = model.NewLocAppError("SqlWebhookStore.AnalyticsIncomingCount", "store.sql_webhooks.analytics_incoming_count.app_error", nil, "team_id="+teamId+", err="+err.Error())
+ } else {
+ result.Data = v
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
+
+func (s SqlWebhookStore) AnalyticsOutgoingCount(teamId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ query :=
+ `SELECT
+ COUNT(*)
+ FROM
+ OutgoingWebhooks
+ WHERE
+ DeleteAt = 0`
+
+ if len(teamId) > 0 {
+ query += " AND TeamId = :TeamId"
+ }
+
+ if v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil {
+ result.Err = model.NewLocAppError("SqlWebhookStore.AnalyticsOutgoingCount", "store.sql_webhooks.analytics_outgoing_count.app_error", nil, "team_id="+teamId+", err="+err.Error())
+ } else {
+ result.Data = v
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+ }()
+
+ return storeChannel
+}
diff --git a/store/sql_webhook_store_test.go b/store/sql_webhook_store_test.go
index 1a9d5be3b..089e38244 100644
--- a/store/sql_webhook_store_test.go
+++ b/store/sql_webhook_store_test.go
@@ -332,3 +332,42 @@ func TestWebhookStoreUpdateOutgoing(t *testing.T) {
t.Fatal(r2.Err)
}
}
+
+func TestWebhookStoreCountIncoming(t *testing.T) {
+ Setup()
+
+ o1 := &model.IncomingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.UserId = model.NewId()
+ o1.TeamId = model.NewId()
+
+ o1 = (<-store.Webhook().SaveIncoming(o1)).Data.(*model.IncomingWebhook)
+
+ if r := <-store.Webhook().AnalyticsIncomingCount(""); r.Err != nil {
+ t.Fatal(r.Err)
+ } else {
+ if r.Data.(int64) == 0 {
+ t.Fatal("should have at least 1 incoming hook")
+ }
+ }
+}
+
+func TestWebhookStoreCountOutgoing(t *testing.T) {
+ Setup()
+
+ o1 := &model.OutgoingWebhook{}
+ o1.ChannelId = model.NewId()
+ o1.CreatorId = model.NewId()
+ o1.TeamId = model.NewId()
+ o1.CallbackURLs = []string{"http://nowhere.com/"}
+
+ o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook)
+
+ if r := <-store.Webhook().AnalyticsOutgoingCount(""); r.Err != nil {
+ t.Fatal(r.Err)
+ } else {
+ if r.Data.(int64) == 0 {
+ t.Fatal("should have at least 1 outgoing hook")
+ }
+ }
+}
diff --git a/store/store.go b/store/store.go
index 3988f0c6a..cfc679706 100644
--- a/store/store.go
+++ b/store/store.go
@@ -100,7 +100,7 @@ type PostStore interface {
GetForExport(channelId string) StoreChannel
AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel
AnalyticsPostCountsByDay(teamId string) StoreChannel
- AnalyticsPostCount(teamId string) StoreChannel
+ AnalyticsPostCount(teamId string, mustHaveFile bool, mustHaveHashtag bool) StoreChannel
}
type UserStore interface {
@@ -182,6 +182,8 @@ type WebhookStore interface {
DeleteOutgoing(webhookId string, time int64) StoreChannel
PermanentDeleteOutgoingByUser(userId string) StoreChannel
UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel
+ AnalyticsIncomingCount(teamId string) StoreChannel
+ AnalyticsOutgoingCount(teamId string) StoreChannel
}
type PreferenceStore interface {
diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx
index a22c26c34..0a159d2e3 100644
--- a/web/react/components/admin_console/analytics.jsx
+++ b/web/react/components/admin_console/analytics.jsx
@@ -4,11 +4,60 @@
import * as Utils from '../../utils/utils.jsx';
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 {FormattedMessage} from 'mm-intl';
+import {injectIntl, intlShape, defineMessages, FormattedMessage} 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) {
@@ -18,6 +67,8 @@ export default class Analytics extends React.Component {
}
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>;
@@ -30,77 +81,129 @@ export default class Analytics extends React.Component {
/>
);
- var totalCount = (
- <div className='col-sm-3'>
- <div className='total-count'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.totalUsers'
- defaultMessage='Total Users'
- />
- <i className='fa fa-users'/></div>
- <div className='content'>{this.props.uniqueUserCount == null ? loading : this.props.uniqueUserCount}</div>
+ 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>
- </div>
- );
+ );
- var openChannelCount = (
- <div className='col-sm-3'>
- <div className='total-count'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.publicChannels'
- defaultMessage='Public Channels'
- />
- <i className='fa fa-globe'/></div>
- <div className='content'>{this.props.channelOpenCount == null ? loading : this.props.channelOpenCount}</div>
- </div>
- </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)
+ }
+ ];
- var openPrivateCount = (
- <div className='col-sm-3'>
- <div className='total-count'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.privateGroups'
- defaultMessage='Private Groups'
- />
- <i className='fa fa-lock'/></div>
- <div className='content'>{this.props.channelPrivateCount == null ? loading : this.props.channelPrivateCount}</div>
- </div>
- </div>
- );
+ 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)
+ }
+ ];
- var postCount = (
- <div className='col-sm-3'>
- <div className='total-count'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.totalPosts'
- defaultMessage='Total Posts'
- />
- <i className='fa fa-comment'/></div>
- <div className='content'>{this.props.postCount == null ? loading : this.props.postCount}</div>
+ 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>
- </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>
+ );
+ }
- var postCountsByDay = (
- <div className='col-sm-12'>
- <div className='total-count by-day'>
- <div className='title'>
- <FormattedMessage
- id='admin.analytics.totalPosts'
- defaultMessage='Total Posts'
- />
+ 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 className='content'>{loading}</div>
</div>
- </div>
- );
-
- if (this.props.postCountsDay != null) {
+ );
+ } else {
let content;
if (this.props.postCountsDay.labels.length === 0) {
content = (
@@ -137,21 +240,22 @@ export default class Analytics extends React.Component {
);
}
- var 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'
- />
+ 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 className='content'>{loading}</div>
</div>
- </div>
- );
-
- if (this.props.userCountsWithPostsDay != null) {
+ );
+ } else {
let content;
if (this.props.userCountsWithPostsDay.labels.length === 0) {
content = (
@@ -312,12 +416,8 @@ export default class Analytics extends React.Component {
/>
</h3>
{serverError}
- <div className='row'>
- {totalCount}
- {postCount}
- {openChannelCount}
- {openPrivateCount}
- </div>
+ {firstRow}
+ {extraGraphs}
<div className='row'>
{postCountsByDay}
</div>
@@ -347,10 +447,16 @@ Analytics.defaultProps = {
};
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,
@@ -358,3 +464,5 @@ Analytics.propTypes = {
uniqueUserCount: React.PropTypes.number,
serverError: React.PropTypes.string
};
+
+export default injectIntl(Analytics);
diff --git a/web/react/components/admin_console/doughnut_chart.jsx b/web/react/components/admin_console/doughnut_chart.jsx
new file mode 100644
index 000000000..e2dc01528
--- /dev/null
+++ b/web/react/components/admin_console/doughnut_chart.jsx
@@ -0,0 +1,77 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {FormattedMessage} from 'mm-intl';
+
+export default class DoughnutChart extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.initChart = this.initChart.bind(this);
+ this.chart = null;
+ }
+
+ componentDidMount() {
+ this.initChart(this.props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (this.chart) {
+ this.chart.destroy();
+ this.initChart(nextProps);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ }
+
+ initChart(props) {
+ var el = ReactDOM.findDOMNode(this.refs.canvas);
+ var ctx = el.getContext('2d');
+ this.chart = new Chart(ctx).Doughnut(props.data, props.options || {}); //eslint-disable-line new-cap
+ }
+
+ render() {
+ let content;
+ if (this.props.data == null) {
+ content = (
+ <FormattedMessage
+ id='admin.analytics.loading'
+ defaultMessage='Loading...'
+ />
+ );
+ } else {
+ content = (
+ <canvas
+ ref='canvas'
+ width={this.props.width}
+ height={this.props.height}
+ />
+ );
+ }
+
+ return (
+ <div className='col-sm-6'>
+ <div className='total-count'>
+ <div className='title'>
+ {this.props.title}
+ </div>
+ <div className='content'>
+ {content}
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+DoughnutChart.propTypes = {
+ title: React.PropTypes.string,
+ width: React.PropTypes.string,
+ height: React.PropTypes.string,
+ data: React.PropTypes.array,
+ options: React.PropTypes.object
+};
diff --git a/web/react/components/admin_console/statistic_count.jsx b/web/react/components/admin_console/statistic_count.jsx
new file mode 100644
index 000000000..57af0ed1b
--- /dev/null
+++ b/web/react/components/admin_console/statistic_count.jsx
@@ -0,0 +1,37 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {FormattedMessage} from 'mm-intl';
+
+export default class StatisticCount extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ let loading = (
+ <FormattedMessage
+ id='admin.analytics.loading'
+ defaultMessage='Loading...'
+ />
+ );
+
+ return (
+ <div className='col-sm-3'>
+ <div className='total-count'>
+ <div className='title'>
+ {this.props.title}
+ <i className={'fa ' + this.props.icon}/>
+ </div>
+ <div className='content'>{this.props.count == null ? loading : this.props.count}</div>
+ </div>
+ </div>
+ );
+ }
+}
+
+StatisticCount.propTypes = {
+ title: React.PropTypes.string.isRequired,
+ icon: React.PropTypes.string.isRequired,
+ count: React.PropTypes.number
+};
diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx
index 2dd833fb2..f983db177 100644
--- a/web/react/components/admin_console/system_analytics.jsx
+++ b/web/react/components/admin_console/system_analytics.jsx
@@ -140,6 +140,34 @@ class SystemAnalytics extends React.Component {
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() {
@@ -160,10 +188,16 @@ class SystemAnalytics extends React.Component {
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}
@@ -179,4 +213,4 @@ SystemAnalytics.propTypes = {
team: React.PropTypes.object
};
-export default injectIntl(SystemAnalytics); \ No newline at end of file
+export default injectIntl(SystemAnalytics);
diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx
index ee59b0e66..808d8046d 100644
--- a/web/react/components/admin_console/team_analytics.jsx
+++ b/web/react/components/admin_console/team_analytics.jsx
@@ -227,6 +227,7 @@ class TeamAnalytics extends React.Component {
return (
<div>
<Analytics
+ intl={this.props.intl}
title={this.props.team.name}
users={this.state.users}
channelOpenCount={this.state.channel_open_count}
@@ -249,4 +250,4 @@ TeamAnalytics.propTypes = {
team: React.PropTypes.object
};
-export default injectIntl(TeamAnalytics); \ No newline at end of file
+export default injectIntl(TeamAnalytics);
diff --git a/web/sass-files/sass/partials/_statistics.scss b/web/sass-files/sass/partials/_statistics.scss
index edd3c9bf3..f86740270 100644
--- a/web/sass-files/sass/partials/_statistics.scss
+++ b/web/sass-files/sass/partials/_statistics.scss
@@ -14,10 +14,11 @@
padding: 7px 10px;
border-bottom: 1px solid #ddd;
text-align: left;
+ font-size: 13px;
.fa {
float: right;
- margin: 3px 0 0;
+ margin: 0px 0 0;
color: #555;
font-size: 16px;
}
@@ -83,4 +84,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json
index fd2861dde..e163b6a54 100644
--- a/web/static/i18n/en.json
+++ b/web/static/i18n/en.json
@@ -101,6 +101,13 @@
"admin.analytics.publicChannels": "Public Channels",
"admin.analytics.privateGroups": "Private Groups",
"admin.analytics.totalPosts": "Total Posts",
+ "admin.analytics.totalFilePosts": "Posts with Files",
+ "admin.analytics.totalHashtagPosts": "Posts with Hashtags",
+ "admin.analytics.totalIncomingWebhooks": "Incoming Webhooks",
+ "admin.analytics.totalOutgoingWebhooks": "Outgoing Webhooks",
+ "admin.analytics.channelTypes": "Channel Types",
+ "admin.analytics.textPosts": "Posts with Text-only",
+ "admin.analytics.postTypes": "Posts, Files and Hashtags",
"admin.analytics.meaningful": "Not enough data for a meaningful representation.",
"admin.analytics.activeUsers": "Active Users With Posts",
"admin.analytics.recentActive": "Recent Active Users",
@@ -1147,4 +1154,4 @@
"user.settings.security.title": "Security Settings",
"user.settings.security.viewHistory": "View Access History",
"user.settings.security.logoutActiveSessions": "View and Logout of Active Sessions"
-} \ No newline at end of file
+}
diff --git a/web/static/i18n/es.json b/web/static/i18n/es.json
index 74b5666c4..92f3ba2ea 100644
--- a/web/static/i18n/es.json
+++ b/web/static/i18n/es.json
@@ -1135,4 +1135,4 @@
"user.settings.security.title": "ConfiguraciĆ³n de Seguridad",
"user.settings.security.viewHistory": "Visualizar historial de acceso",
"user_profile.notShared": "Correo no compartido"
-} \ No newline at end of file
+}