Merge pull request #2258 from mattermost/plt-1796

PLT-1796 Refactor and modularize analytics on the client
Этот коммит содержится в:
Corey Hulen
2016-03-02 12:22:11 -08:00
родитель cddc9df7c4 fbb71cab4b
Коммит c52153bf60
29 изменённых файлов: 1438 добавлений и 1093 удалений

Просмотреть файл

@@ -184,16 +184,18 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
name := params["name"] name := params["name"]
if name == "standard" { 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[0] = &model.AnalyticsRow{"channel_open_count", 0}
rows[1] = &model.AnalyticsRow{"channel_private_count", 0} rows[1] = &model.AnalyticsRow{"channel_private_count", 0}
rows[2] = &model.AnalyticsRow{"post_count", 0} rows[2] = &model.AnalyticsRow{"post_count", 0}
rows[3] = &model.AnalyticsRow{"unique_user_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) openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN)
privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE) privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE)
postChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, false) postChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, false)
userChan := Srv.Store.User().AnalyticsUniqueUserCount(teamId) userChan := Srv.Store.User().AnalyticsUniqueUserCount(teamId)
teamChan := Srv.Store.Team().AnalyticsTeamCount()
if r := <-openChan; r.Err != nil { if r := <-openChan; r.Err != nil {
c.Err = r.Err 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)) 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())) w.Write([]byte(rows.ToJson()))
} else if name == "post_counts_day" { } else if name == "post_counts_day" {
if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil { 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())) w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson()))
} }
} else if name == "extra_counts" { } 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[0] = &model.AnalyticsRow{"file_post_count", 0}
rows[1] = &model.AnalyticsRow{"hashtag_post_count", 0} rows[1] = &model.AnalyticsRow{"hashtag_post_count", 0}
rows[2] = &model.AnalyticsRow{"incoming_webhook_count", 0} rows[2] = &model.AnalyticsRow{"incoming_webhook_count", 0}
rows[3] = &model.AnalyticsRow{"outgoing_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) fileChan := Srv.Store.Post().AnalyticsPostCount(teamId, true, false)
hashtagChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, true) hashtagChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, true)
iHookChan := Srv.Store.Webhook().AnalyticsIncomingCount(teamId) iHookChan := Srv.Store.Webhook().AnalyticsIncomingCount(teamId)
oHookChan := Srv.Store.Webhook().AnalyticsOutgoingCount(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 { if r := <-fileChan; r.Err != nil {
c.Err = r.Err 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)) 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())) w.Write([]byte(rows.ToJson()))
} else { } else {
c.SetInvalidParam("getAnalytics", "name") c.SetInvalidParam("getAnalytics", "name")

Просмотреть файл

@@ -254,6 +254,16 @@ func TestGetTeamAnalyticsStandard(t *testing.T) {
t.Log(rows.ToJson()) t.Log(rows.ToJson())
t.Fatal() 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 { if result, err := Client.GetSystemAnalytics("standard"); err != nil {
@@ -300,6 +310,16 @@ func TestGetTeamAnalyticsStandard(t *testing.T) {
t.Log(rows.ToJson()) t.Log(rows.ToJson())
t.Fatal() 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.Log(rows.ToJson())
t.Fatal() 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 { if result, err := Client.GetSystemAnalytics("extra_counts"); err != nil {
@@ -500,5 +540,15 @@ func TestGetTeamAnalyticsExtra(t *testing.T) {
t.Log(rows.ToJson()) t.Log(rows.ToJson())
t.Fatal() 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()
}
} }
} }

Просмотреть файл

@@ -2675,6 +2675,10 @@
"id": "store.sql_command.save.update.app_error", "id": "store.sql_command.save.update.app_error",
"translation": "We couldn't update the command" "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", "id": "store.sql_license.get.app_error",
"translation": "We encountered an error getting the license" "translation": "We encountered an error getting the license"
@@ -2947,6 +2951,10 @@
"id": "store.sql_session.update_roles.app_error", "id": "store.sql_session.update_roles.app_error",
"translation": "We couldn't update the roles" "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", "id": "store.sql_system.get.app_error",
"translation": "We encountered an error finding the system properties" "translation": "We encountered an error finding the system properties"
@@ -3027,6 +3035,10 @@
"id": "store.sql_team.update_display_name.app_error", "id": "store.sql_team.update_display_name.app_error",
"translation": "We couldn't update the team name" "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", "id": "store.sql_user.analytics_unique_user_count.app_error",
"translation": "We couldn't get the unique user count" "translation": "We couldn't get the unique user count"
@@ -3575,4 +3587,4 @@
"id": "web.watcher_fail.error", "id": "web.watcher_fail.error",
"translation": "Failed to add directory to watcher %v" "translation": "Failed to add directory to watcher %v"
} }
] ]

Просмотреть файл

@@ -151,18 +151,49 @@ func (s SqlCommandStore) PermanentDeleteByUser(userId string) StoreChannel {
return storeChannel return storeChannel
} }
func (s SqlCommandStore) Update(hook *model.Command) StoreChannel { func (s SqlCommandStore) Update(cmd *model.Command) StoreChannel {
storeChannel := make(StoreChannel) storeChannel := make(StoreChannel)
go func() { go func() {
result := StoreResult{} result := StoreResult{}
hook.UpdateAt = model.GetMillis() cmd.UpdateAt = model.GetMillis()
if _, err := s.GetMaster().Update(hook); err != nil { if _, err := s.GetMaster().Update(cmd); err != nil {
result.Err = model.NewLocAppError("SqlCommandStore.Update", "store.sql_command.save.update.app_error", nil, "id="+hook.Id+", "+err.Error()) result.Err = model.NewLocAppError("SqlCommandStore.Update", "store.sql_command.save.update.app_error", nil, "id="+cmd.Id+", "+err.Error())
} else { } 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 storeChannel <- result

Просмотреть файл

@@ -153,3 +153,31 @@ func TestCommandStoreUpdate(t *testing.T) {
t.Fatal(r2.Err) 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")
}
}
}

Просмотреть файл

@@ -947,7 +947,7 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH
result := StoreResult{} result := StoreResult{}
query := query :=
`SELECT `SELECT
COUNT(Posts.Id) AS Value COUNT(Posts.Id) AS Value
FROM FROM
Posts, Posts,

Просмотреть файл

@@ -255,3 +255,33 @@ func (me SqlSessionStore) UpdateDeviceId(id, deviceId string) StoreChannel {
return storeChannel return storeChannel
} }
func (me SqlSessionStore) AnalyticsSessionCount(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
query :=
`SELECT
COUNT(*)
FROM
Sessions
WHERE ExpiresAt > :Time`
if len(teamId) > 0 {
query += " AND TeamId = :TeamId"
}
if c, err := me.GetReplica().SelectInt(query, map[string]interface{}{"Time": model.GetMillis(), "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
}

Просмотреть файл

@@ -200,3 +200,29 @@ func TestSessionStoreUpdateLastActivityAt(t *testing.T) {
} }
} }
func TestSessionCount(t *testing.T) {
Setup()
s1 := model.Session{}
s1.UserId = model.NewId()
s1.TeamId = model.NewId()
s1.ExpiresAt = model.GetMillis() + 100000
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")
}
}
}

Просмотреть файл

@@ -317,3 +317,22 @@ func (s SqlTeamStore) PermanentDelete(teamId string) StoreChannel {
return 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
}

Просмотреть файл

@@ -261,3 +261,23 @@ func TestDelete(t *testing.T) {
t.Fatal(r1.Err) 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")
}
}
}

Просмотреть файл

@@ -55,6 +55,7 @@ type TeamStore interface {
GetAllTeamListing() StoreChannel GetAllTeamListing() StoreChannel
GetByInviteId(inviteId string) StoreChannel GetByInviteId(inviteId string) StoreChannel
PermanentDelete(teamId string) StoreChannel PermanentDelete(teamId string) StoreChannel
AnalyticsTeamCount() StoreChannel
} }
type ChannelStore interface { type ChannelStore interface {
@@ -141,6 +142,7 @@ type SessionStore interface {
UpdateLastActivityAt(sessionId string, time int64) StoreChannel UpdateLastActivityAt(sessionId string, time int64) StoreChannel
UpdateRoles(userId string, roles string) StoreChannel UpdateRoles(userId string, roles string) StoreChannel
UpdateDeviceId(id string, deviceId string) StoreChannel UpdateDeviceId(id string, deviceId string) StoreChannel
AnalyticsSessionCount(teamId string) StoreChannel
} }
type AuditStore interface { type AuditStore interface {
@@ -196,6 +198,7 @@ type CommandStore interface {
Delete(commandId string, time int64) StoreChannel Delete(commandId string, time int64) StoreChannel
PermanentDeleteByUser(userId string) StoreChannel PermanentDeleteByUser(userId string) StoreChannel
Update(hook *model.Command) StoreChannel Update(hook *model.Command) StoreChannel
AnalyticsCommandCount(teamId string) StoreChannel
} }
type PreferenceStore interface { type PreferenceStore interface {

Просмотреть файл

@@ -21,10 +21,10 @@ import TeamSettingsTab from './team_settings.jsx';
import ServiceSettingsTab from './service_settings.jsx'; import ServiceSettingsTab from './service_settings.jsx';
import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx'; import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx';
import TeamUsersTab from './team_users.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 LdapSettingsTab from './ldap_settings.jsx';
import LicenseSettingsTab from './license_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 { export default class AdminController extends React.Component {
constructor(props) { constructor(props) {

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -39,7 +39,7 @@ export default class DoughnutChart extends React.Component {
if (this.props.data == null) { if (this.props.data == null) {
content = ( content = (
<FormattedMessage <FormattedMessage
id='admin.analytics.loading' id='analytics.chart.loading'
defaultMessage='Loading...' defaultMessage='Loading...'
/> />
); );
@@ -69,7 +69,7 @@ export default class DoughnutChart extends React.Component {
} }
DoughnutChart.propTypes = { DoughnutChart.propTypes = {
title: React.PropTypes.string, title: React.PropTypes.node,
width: React.PropTypes.string, width: React.PropTypes.string,
height: React.PropTypes.string, height: React.PropTypes.string,
data: React.PropTypes.array, data: React.PropTypes.array,

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -7,7 +7,7 @@ export default class StatisticCount extends React.Component {
render() { render() {
let loading = ( let loading = (
<FormattedMessage <FormattedMessage
id='admin.analytics.loading' id='analytics.chart.loading'
defaultMessage='Loading...' defaultMessage='Loading...'
/> />
); );
@@ -27,7 +27,7 @@ export default class StatisticCount extends React.Component {
} }
StatisticCount.propTypes = { StatisticCount.propTypes = {
title: React.PropTypes.string.isRequired, title: React.PropTypes.node.isRequired,
icon: React.PropTypes.string.isRequired, icon: React.PropTypes.string.isRequired,
count: React.PropTypes.number count: React.PropTypes.number
}; };

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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
};

Просмотреть файл

@@ -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;
}

85
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;

Просмотреть файл

@@ -11,16 +11,17 @@ import UserStore from '../stores/user_store.jsx';
import * as utils from './utils.jsx'; import * as utils from './utils.jsx';
import Constants from './constants.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 // Used to track in progress async calls
var callTracker = {}; const callTracker = {};
export function dispatchError(err, method) { export function dispatchError(err, method) {
AppDispatcher.handleServerAction({ AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_ERROR, type: ActionTypes.RECEIVED_ERROR,
err: err, err,
method: method 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');
}
);
}

Просмотреть файл

@@ -435,23 +435,16 @@ export function getConfig(success, error) {
}); });
} }
export function getTeamAnalytics(teamId, name, success, error) { export function getAnalytics(name, teamId, success, error) {
$.ajax({ let url = '/api/v1/admin/analytics/';
url: '/api/v1/admin/analytics/' + teamId + '/' + name, if (teamId == null) {
dataType: 'json', url += name;
contentType: 'application/json', } else {
type: 'GET', url += teamId + '/' + name;
success, }
error: (xhr, status, err) => {
var e = handleError('getTeamAnalytics', xhr, status, err);
error(e);
}
});
}
export function getSystemAnalytics(name, success, error) {
$.ajax({ $.ajax({
url: '/api/v1/admin/analytics/' + name, url,
dataType: 'json', dataType: 'json',
contentType: 'application/json', contentType: 'application/json',
type: 'GET', type: 'GET',

Просмотреть файл

@@ -71,6 +71,26 @@ export default {
VIEW_ACTION: null 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: { SocketEvents: {
POSTED: 'posted', POSTED: 'posted',
POST_EDITED: 'post_edited', POST_EDITED: 'post_edited',

Просмотреть файл

@@ -21,23 +21,33 @@
"activity_log_modal.android": "Android", "activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "Android Native App", "activity_log_modal.androidNativeApp": "Android Native App",
"activity_log_modal.iphoneNativeApp": "iPhone Native App", "activity_log_modal.iphoneNativeApp": "iPhone Native App",
"admin.analytics.activeUsers": "Active Users With Posts", "analytics.chart.loading": "Loading...",
"admin.analytics.channelTypes": "Channel Types", "analytics.chart.meaningful": "Not enough data for a meaningful representation.",
"admin.analytics.loading": "Loading...", "analytics.system.activeUsers": "Active Users With Posts",
"admin.analytics.meaningful": "Not enough data for a meaningful representation.", "analytics.system.channelTypes": "Channel Types",
"admin.analytics.newlyCreated": "Newly Created Users", "analytics.system.postTypes": "Posts, Files and Hashtags",
"admin.analytics.postTypes": "Posts, Files and Hashtags", "analytics.system.privateGroups": "Private Groups",
"admin.analytics.privateGroups": "Private Groups", "analytics.system.publicChannels": "Public Channels",
"admin.analytics.publicChannels": "Public Channels", "analytics.system.textPosts": "Posts with Text-only",
"admin.analytics.recentActive": "Recent Active Users", "analytics.system.title": "System Statistics",
"admin.analytics.textPosts": "Posts with Text-only", "analytics.system.totalFilePosts": "Posts with Files",
"admin.analytics.title": "Statistics for {title}", "analytics.system.totalHashtagPosts": "Posts with Hashtags",
"admin.analytics.totalFilePosts": "Posts with Files", "analytics.system.totalIncomingWebhooks": "Incoming Webhooks",
"admin.analytics.totalHashtagPosts": "Posts with Hashtags", "analytics.system.totalOutgoingWebhooks": "Outgoing Webhooks",
"admin.analytics.totalIncomingWebhooks": "Incoming Webhooks", "analytics.system.totalCommands": "Total Commands",
"admin.analytics.totalOutgoingWebhooks": "Outgoing Webhooks", "analytics.system.totalSessions": "Total Sessions",
"admin.analytics.totalPosts": "Total Posts", "analytics.system.totalPosts": "Total Posts",
"admin.analytics.totalUsers": "Total Users", "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.reload": "Reload",
"admin.audits.title": "User Activity", "admin.audits.title": "User Activity",
"admin.email.allowEmailSignInDescription": "When true, Mattermost allows users to sign in using their email and password.", "admin.email.allowEmailSignInDescription": "When true, Mattermost allows users to sign in using their email and password.",

Просмотреть файл

@@ -21,23 +21,26 @@
"activity_log_modal.android": "Android", "activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "Android App Nativa", "activity_log_modal.androidNativeApp": "Android App Nativa",
"activity_log_modal.iphoneNativeApp": "iPhone App Nativa", "activity_log_modal.iphoneNativeApp": "iPhone App Nativa",
"admin.analytics.activeUsers": "Usuarios Activos con Mensajes", "analytics.chart.loading": "Cargando...",
"admin.analytics.channelTypes": "Tipos de Canales", "analytics.chart.meaningful": "No hay suficiente data para tener una representación significativa.",
"admin.analytics.loading": "Cargando...", "analytics.system.channelTypes": "Tipos de Canales",
"admin.analytics.meaningful": "No hay suficiente data para tener una representación significativa.", "analytics.system.postTypes": "Mesajes, Archivos y Hashtags",
"admin.analytics.newlyCreated": "Nuevos Usuarios Creados", "analytics.system.privateGroups": "Grupos Privados",
"admin.analytics.postTypes": "Mesajes, Archivos y Hashtags", "analytics.system.publicChannels": "Canales Públicos",
"admin.analytics.privateGroups": "Grupos Privados", "analytics.system.textPosts": "Mensajes de sólo Texto",
"admin.analytics.publicChannels": "Canales Públicos", "analytics.system.totalFilePosts": "Mensajes con Archivos",
"admin.analytics.recentActive": "Usuarios Recientemente Activos", "analytics.system.totalHashtagPosts": "Mensajes con Hashtags",
"admin.analytics.textPosts": "Mensajes de sólo Texto", "analytics.system.totalIncomingWebhooks": "Webhooks de Entrada",
"admin.analytics.title": "Estadísticas para {title}", "analytics.system.totalOutgoingWebhooks": "Webhooks de Salida",
"admin.analytics.totalFilePosts": "Mensajes con Archivos", "analytics.system.totalPosts": "Total de Mensajes",
"admin.analytics.totalHashtagPosts": "Mensajes con Hashtags", "analytics.system.totalUsers": "Total de Usuarios",
"admin.analytics.totalIncomingWebhooks": "Webhooks de Entrada", "analytics.team.activeUsers": "Usuarios Activos con Mensajes",
"admin.analytics.totalOutgoingWebhooks": "Webhooks de Salida", "analytics.team.newlyCreated": "Nuevos Usuarios Creados",
"admin.analytics.totalPosts": "Total de Mensajes", "analytics.team.privateGroups": "Grupos Privados",
"admin.analytics.totalUsers": "Total de Usuarios", "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.reload": "Recargar",
"admin.audits.title": "Auditorías del Servidor", "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.", "admin.email.allowEmailSignInDescription": "Cuando es verdadero, Mattermost permite a los usuarios iniciar sesión utilizando el correo electrónico y contraseña.",

Просмотреть файл

@@ -21,23 +21,26 @@
"activity_log_modal.android": "Android", "activity_log_modal.android": "Android",
"activity_log_modal.androidNativeApp": "App Nativo Android", "activity_log_modal.androidNativeApp": "App Nativo Android",
"activity_log_modal.iphoneNativeApp": "App Nativo para iPhone", "activity_log_modal.iphoneNativeApp": "App Nativo para iPhone",
"admin.analytics.activeUsers": "Usuários Ativos Com Postagens", "analytics.chart.loading": "Carregando...",
"admin.analytics.channelTypes": "Tipos de Canal", "analytics.chart.meaningful": "Não há dados suficientes para uma representação significativa.",
"admin.analytics.loading": "Carregando...", "analytics.system.channelTypes": "Tipos de Canal",
"admin.analytics.meaningful": "Não há dados suficientes para uma representação significativa.", "analytics.system.postTypes": "Postagens, Arquivos e Hashtags",
"admin.analytics.newlyCreated": "Novos Usuários Criados", "analytics.system.privateGroups": "Grupos Privados",
"admin.analytics.postTypes": "Postagens, Arquivos e Hashtags", "analytics.system.publicChannels": "Canais Publicos",
"admin.analytics.privateGroups": "Grupos Privados", "analytics.system.textPosts": "Post com texto somente",
"admin.analytics.publicChannels": "Canais Publicos", "analytics.system.totalFilePosts": "Posts com Arquivos",
"admin.analytics.recentActive": "Usuários Ativos Recentes", "analytics.system.totalHashtagPosts": "Posts com Hashtags",
"admin.analytics.textPosts": "Post com texto somente", "analytics.system.totalIncomingWebhooks": "Webhooks Entrada",
"admin.analytics.title": "Estatísticas para {title}", "analytics.system.totalOutgoingWebhooks": "Webhooks Saída",
"admin.analytics.totalFilePosts": "Posts com Arquivos", "analytics.system.totalPosts": "Total Posts",
"admin.analytics.totalHashtagPosts": "Posts com Hashtags", "analytics.system.totalUsers": "Total de Usuários",
"admin.analytics.totalIncomingWebhooks": "Webhooks Entrada", "analytics.team.activeUsers": "Usuários Ativos Com Postagens",
"admin.analytics.totalOutgoingWebhooks": "Webhooks Saída", "analytics.team.newlyCreated": "Novos Usuários Criados",
"admin.analytics.totalPosts": "Total Posts", "analytics.team.privateGroups": "Grupos Privados",
"admin.analytics.totalUsers": "Total de Usuários", "analytics.team.publicChannels": "Canais Publicos",
"analytics.team.recentActive": "Usuários Ativos Recentes",
"analytics.team.totalPosts": "Total Posts",
"analytics.team.totalUsers": "Total de Usuários",
"admin.audits.reload": "Recarregar", "admin.audits.reload": "Recarregar",
"admin.audits.title": "Atividade de Usuário", "admin.audits.title": "Atividade de Usuário",
"admin.email.allowEmailSignInDescription": "Quando verdadeiro, Mattermost permite aos usuários fazer login usando o e-mail e senha.", "admin.email.allowEmailSignInDescription": "Quando verdadeiro, Mattermost permite aos usuários fazer login usando o e-mail e senha.",