diff --git a/api/admin.go b/api/admin.go index 0ea6341e28..0ca05287ed 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 2552e642c8..053a2a7301 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 c6d705819d..b1848e163a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3047,6 +3047,14 @@ "id": "store.sql_webhooks.update_outgoing.app_error", "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/i18n/es.json b/i18n/es.json index f0f1574749..0928aa0ebf 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -3047,6 +3047,14 @@ "id": "store.sql_webhooks.update_outgoing.app_error", "translation": "No pudimos actualizar el 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 decifrando la configuración del archivo={{.Filename}}, err={{.Error}}" diff --git a/store/sql_post_store.go b/store/sql_post_store.go index aeaa5922c6..c511dc3706 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 46b8d7678c..d69f7906c6 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 cdfb949f5f..5298b0b942 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 1a9d5be3b6..089e382449 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 3988f0c6af..cfc679706a 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 a22c26c346..0a159d2e38 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 =
; @@ -30,77 +81,129 @@ export default class Analytics extends React.Component { /> ); - var totalCount = ( -