Merge pull request #1190 from mattermost/PLT-25

PLT-25 adding stats to admin console
Этот коммит содержится в:
Corey Hulen
2015-10-27 11:40:49 -07:00
родитель 4dbde3e1d8 399e9c6f4b
Коммит e0f69060fa
19 изменённых файлов: 1144 добавлений и 3 удалений

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

@@ -26,7 +26,7 @@ func InitAdmin(r *mux.Router) {
sr.Handle("/test_email", ApiUserRequired(testEmail)).Methods("POST")
sr.Handle("/client_props", ApiAppHandler(getClientConfig)).Methods("GET")
sr.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST")
sr.Handle("/analytics/{id:[A-Za-z0-9]+}/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET")
}
func getLogs(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -142,3 +142,63 @@ func testEmail(c *Context, w http.ResponseWriter, r *http.Request) {
m["SUCCESS"] = "true"
w.Write([]byte(model.MapToJson(m)))
}
func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.HasSystemAdminPermissions("getAnalytics") {
return
}
params := mux.Vars(r)
teamId := params["id"]
name := params["name"]
if name == "standard" {
var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 3)
rows[0] = &model.AnalyticsRow{"channel_open_count", 0}
rows[1] = &model.AnalyticsRow{"channel_private_count", 0}
rows[2] = &model.AnalyticsRow{"post_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)
if r := <-openChan; r.Err != nil {
c.Err = r.Err
return
} else {
rows[0].Value = float64(r.Data.(int64))
}
if r := <-privateChan; r.Err != nil {
c.Err = r.Err
return
} else {
rows[1].Value = float64(r.Data.(int64))
}
if r := <-postChan; r.Err != nil {
c.Err = r.Err
return
} else {
rows[2].Value = float64(r.Data.(int64))
}
w.Write([]byte(rows.ToJson()))
} else if name == "post_counts_day" {
if r := <-Srv.Store.Post().AnalyticsPostCountsByDay(teamId); r.Err != nil {
c.Err = r.Err
return
} else {
w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson()))
}
} else if name == "user_counts_with_posts_day" {
if r := <-Srv.Store.Post().AnalyticsUserCountsWithPostsByDay(teamId); r.Err != nil {
c.Err = r.Err
return
} else {
w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson()))
}
} else {
c.SetInvalidParam("getAnalytics", "name")
}
}

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

@@ -150,3 +150,151 @@ func TestEmailTest(t *testing.T) {
t.Fatal(err)
}
}
func TestGetAnalyticsStandard(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() + "corey@test.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)
if _, err := Client.GetAnalytics(team.Id, "standard"); 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.GetAnalytics(team.Id, "standard"); err != nil {
t.Fatal(err)
} else {
rows := result.Data.(model.AnalyticsRows)
if rows[0].Name != "channel_open_count" {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[0].Value != 2 {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[1].Name != "channel_private_count" {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[1].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[2].Name != "post_count" {
t.Log(rows.ToJson())
t.Fatal()
}
if rows[2].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
}
}
func TestGetPostCount(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() + "corey@test.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)
if _, err := Client.GetAnalytics(team.Id, "post_counts_day"); 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.GetAnalytics(team.Id, "post_counts_day"); err != nil {
t.Fatal(err)
} else {
rows := result.Data.(model.AnalyticsRows)
if rows[0].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
}
}
func TestUserCountsWithPostsByDay(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() + "corey@test.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)
if _, err := Client.GetAnalytics(team.Id, "user_counts_with_posts_day"); 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.GetAnalytics(team.Id, "user_counts_with_posts_day"); err != nil {
t.Fatal(err)
} else {
rows := result.Data.(model.AnalyticsRows)
if rows[0].Value != 1 {
t.Log(rows.ToJson())
t.Fatal()
}
}
}

55
model/analytics_row.go Обычный файл
Просмотреть файл

@@ -0,0 +1,55 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
import (
"encoding/json"
"io"
)
type AnalyticsRow struct {
Name string `json:"name"`
Value float64 `json:"value"`
}
type AnalyticsRows []*AnalyticsRow
func (me *AnalyticsRow) ToJson() string {
b, err := json.Marshal(me)
if err != nil {
return ""
} else {
return string(b)
}
}
func AnalyticsRowFromJson(data io.Reader) *AnalyticsRow {
decoder := json.NewDecoder(data)
var me AnalyticsRow
err := decoder.Decode(&me)
if err == nil {
return &me
} else {
return nil
}
}
func (me AnalyticsRows) ToJson() string {
if b, err := json.Marshal(me); err != nil {
return "[]"
} else {
return string(b)
}
}
func AnalyticsRowsFromJson(data io.Reader) AnalyticsRows {
decoder := json.NewDecoder(data)
var me AnalyticsRows
err := decoder.Decode(&me)
if err == nil {
return me
} else {
return nil
}
}

37
model/analytics_row_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,37 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
import (
"strings"
"testing"
)
func TestAnalyticsRowJson(t *testing.T) {
a1 := AnalyticsRow{}
a1.Name = "2015-10-12"
a1.Value = 12345.0
json := a1.ToJson()
ra1 := AnalyticsRowFromJson(strings.NewReader(json))
if a1.Name != ra1.Name {
t.Fatal("days didn't match")
}
}
func TestAnalyticsRowsJson(t *testing.T) {
a1 := AnalyticsRow{}
a1.Name = "2015-10-12"
a1.Value = 12345.0
var a1s AnalyticsRows = make([]*AnalyticsRow, 1)
a1s[0] = &a1
ljson := a1s.ToJson()
results := AnalyticsRowsFromJson(strings.NewReader(ljson))
if a1s[0].Name != results[0].Name {
t.Fatal("Ids do not match")
}
}

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

@@ -416,6 +416,15 @@ func (c *Client) TestEmail(config *Config) (*Result, *AppError) {
}
}
func (c *Client) GetAnalytics(teamId, name string) (*Result, *AppError) {
if r, err := c.DoApiGet("/admin/analytics/"+teamId+"/"+name, "", ""); err != nil {
return nil, err
} else {
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), AnalyticsRowsFromJson(r.Body)}, nil
}
}
func (c *Client) CreateChannel(channel *Channel) (*Result, *AppError) {
if r, err := c.DoApiPost("/channels/create", channel.ToJson()); err != nil {
return nil, err

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

@@ -828,3 +828,31 @@ func (s SqlChannelStore) GetForExport(teamId string) StoreChannel {
return storeChannel
}
func (s SqlChannelStore) AnalyticsTypeCount(teamId string, channelType string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
v, err := s.GetReplica().SelectInt(
`SELECT
COUNT(Id) AS Value
FROM
Channels
WHERE
TeamId = :TeamId
AND Type = :ChannelType`,
map[string]interface{}{"TeamId": teamId, "ChannelType": channelType})
if err != nil {
result.Err = model.NewAppError("SqlChannelStore.AnalyticsTypeCount", "We couldn't get channel type counts", err.Error())
} else {
result.Data = v
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}

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

@@ -561,6 +561,24 @@ func TestChannelStoreGetMoreChannels(t *testing.T) {
if list.Channels[0].Name != o3.Name {
t.Fatal("missing channel")
}
if r1 := <-store.Channel().AnalyticsTypeCount(o1.TeamId, model.CHANNEL_OPEN); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 2 {
t.Log(r1.Data)
t.Fatal("wrong value")
}
}
if r1 := <-store.Channel().AnalyticsTypeCount(o1.TeamId, model.CHANNEL_PRIVATE); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 2 {
t.Log(r1.Data)
t.Fatal("wrong value")
}
}
}
func TestChannelStoreGetChannelCounts(t *testing.T) {

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

@@ -603,3 +603,152 @@ func (s SqlPostStore) GetForExport(channelId string) StoreChannel {
return storeChannel
}
func (s SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
query :=
`SELECT
t1.Name, COUNT(t1.UserId) AS Value
FROM
(SELECT DISTINCT
DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name,
Posts.UserId
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
ORDER BY Name DESC) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
query =
`SELECT
TO_CHAR(t1.Name, 'YYYY-MM-DD') AS Name, COUNT(t1.UserId) AS Value
FROM
(SELECT DISTINCT
DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) AS Name,
Posts.UserId
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
ORDER BY Name DESC) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
}
var rows model.AnalyticsRows
_, err := s.GetReplica().Select(
&rows,
query,
map[string]interface{}{"TeamId": teamId, "Time": model.GetMillis() - 1000*60*60*24*31})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsUserCountsWithPostsByDay", "We couldn't get user counts with posts", err.Error())
} else {
result.Data = rows
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
query :=
`SELECT
Name, COUNT(Value) AS Value
FROM
(SELECT
DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name,
'1' AS Value
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
AND Posts.CreateAt >:Time) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES {
query =
`SELECT
Name, COUNT(Value) AS Value
FROM
(SELECT
TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name,
'1' AS Value
FROM
Posts, Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId
AND Posts.CreateAt > :Time) AS t1
GROUP BY Name
ORDER BY Name DESC
LIMIT 30`
}
var rows model.AnalyticsRows
_, err := s.GetReplica().Select(
&rows,
query,
map[string]interface{}{"TeamId": teamId, "Time": model.GetMillis() - 1000*60*60*24*31})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsPostCountsByDay", "We couldn't get post counts by day", err.Error())
} else {
result.Data = rows
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlPostStore) AnalyticsPostCount(teamId string) StoreChannel {
storeChannel := make(StoreChannel)
go func() {
result := StoreResult{}
v, err := s.GetReplica().SelectInt(
`SELECT
COUNT(Posts.Id) AS Value
FROM
Posts,
Channels
WHERE
Posts.ChannelId = Channels.Id
AND Channels.TeamId = :TeamId`,
map[string]interface{}{"TeamId": teamId})
if err != nil {
result.Err = model.NewAppError("SqlPostStore.AnalyticsPostCount", "We couldn't get post counts", err.Error())
} else {
result.Data = v
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}

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

@@ -580,3 +580,135 @@ func TestPostStoreSearch(t *testing.T) {
t.Fatal("returned wrong search result")
}
}
func TestUserCountsWithPostsByDay(t *testing.T) {
Setup()
t1 := &model.Team{}
t1.DisplayName = "DisplayName"
t1.Name = "a" + model.NewId() + "b"
t1.Email = model.NewId() + "@nowhere.com"
t1.Type = model.TEAM_OPEN
t1 = Must(store.Team().Save(t1)).(*model.Team)
c1 := &model.Channel{}
c1.TeamId = t1.Id
c1.DisplayName = "Channel2"
c1.Name = "a" + model.NewId() + "b"
c1.Type = model.CHANNEL_OPEN
c1 = Must(store.Channel().Save(c1)).(*model.Channel)
o1 := &model.Post{}
o1.ChannelId = c1.Id
o1.UserId = model.NewId()
o1.CreateAt = model.GetMillis()
o1.Message = "a" + model.NewId() + "b"
o1 = Must(store.Post().Save(o1)).(*model.Post)
o1a := &model.Post{}
o1a.ChannelId = c1.Id
o1a.UserId = model.NewId()
o1a.CreateAt = o1.CreateAt
o1a.Message = "a" + model.NewId() + "b"
o1a = Must(store.Post().Save(o1a)).(*model.Post)
o2 := &model.Post{}
o2.ChannelId = c1.Id
o2.UserId = model.NewId()
o2.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24)
o2.Message = "a" + model.NewId() + "b"
o2 = Must(store.Post().Save(o2)).(*model.Post)
o2a := &model.Post{}
o2a.ChannelId = c1.Id
o2a.UserId = o2.UserId
o2a.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24)
o2a.Message = "a" + model.NewId() + "b"
o2a = Must(store.Post().Save(o2a)).(*model.Post)
if r1 := <-store.Post().AnalyticsUserCountsWithPostsByDay(t1.Id); r1.Err != nil {
t.Fatal(r1.Err)
} else {
row1 := r1.Data.(model.AnalyticsRows)[0]
if row1.Value != 2 {
t.Fatal("wrong value")
}
row2 := r1.Data.(model.AnalyticsRows)[1]
if row2.Value != 1 {
t.Fatal("wrong value")
}
}
}
func TestPostCountsByDay(t *testing.T) {
Setup()
t1 := &model.Team{}
t1.DisplayName = "DisplayName"
t1.Name = "a" + model.NewId() + "b"
t1.Email = model.NewId() + "@nowhere.com"
t1.Type = model.TEAM_OPEN
t1 = Must(store.Team().Save(t1)).(*model.Team)
c1 := &model.Channel{}
c1.TeamId = t1.Id
c1.DisplayName = "Channel2"
c1.Name = "a" + model.NewId() + "b"
c1.Type = model.CHANNEL_OPEN
c1 = Must(store.Channel().Save(c1)).(*model.Channel)
o1 := &model.Post{}
o1.ChannelId = c1.Id
o1.UserId = model.NewId()
o1.CreateAt = model.GetMillis()
o1.Message = "a" + model.NewId() + "b"
o1 = Must(store.Post().Save(o1)).(*model.Post)
o1a := &model.Post{}
o1a.ChannelId = c1.Id
o1a.UserId = model.NewId()
o1a.CreateAt = o1.CreateAt
o1a.Message = "a" + model.NewId() + "b"
o1a = Must(store.Post().Save(o1a)).(*model.Post)
o2 := &model.Post{}
o2.ChannelId = c1.Id
o2.UserId = model.NewId()
o2.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24 * 2)
o2.Message = "a" + model.NewId() + "b"
o2 = Must(store.Post().Save(o2)).(*model.Post)
o2a := &model.Post{}
o2a.ChannelId = c1.Id
o2a.UserId = o2.UserId
o2a.CreateAt = o1.CreateAt - (1000 * 60 * 60 * 24 * 2)
o2a.Message = "a" + model.NewId() + "b"
o2a = Must(store.Post().Save(o2a)).(*model.Post)
time.Sleep(1 * time.Second)
t.Log(t1.Id)
if r1 := <-store.Post().AnalyticsPostCountsByDay(t1.Id); r1.Err != nil {
t.Fatal(r1.Err)
} else {
row1 := r1.Data.(model.AnalyticsRows)[0]
if row1.Value != 2 {
t.Log(row1)
t.Fatal("wrong value")
}
row2 := r1.Data.(model.AnalyticsRows)[1]
if row2.Value != 2 {
t.Fatal("wrong value")
}
}
if r1 := <-store.Post().AnalyticsPostCount(t1.Id); r1.Err != nil {
t.Fatal(r1.Err)
} else {
if r1.Data.(int64) != 4 {
t.Fatal("wrong value")
}
}
}

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

@@ -75,6 +75,7 @@ type ChannelStore interface {
CheckPermissionsToByName(teamId string, channelName string, userId string) StoreChannel
UpdateLastViewedAt(channelId string, userId string) StoreChannel
IncrementMentionCount(channelId string, userId string) StoreChannel
AnalyticsTypeCount(teamId string, channelType string) StoreChannel
}
type PostStore interface {
@@ -87,6 +88,9 @@ type PostStore interface {
GetEtag(channelId string) StoreChannel
Search(teamId string, userId string, params *model.SearchParams) StoreChannel
GetForExport(channelId string) StoreChannel
AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel
AnalyticsPostCountsByDay(teamId string) StoreChannel
AnalyticsPostCount(teamId string) StoreChannel
}
type UserStore interface {

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

@@ -20,7 +20,8 @@
"globals": {
"React": false,
"ReactDOM": false,
"ReactBootstrap": false
"ReactBootstrap": false,
"Chart": false
},
"rules": {
"comma-dangle": [2, "never"],

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

@@ -18,6 +18,7 @@ var SqlSettingsTab = require('./sql_settings.jsx');
var TeamSettingsTab = require('./team_settings.jsx');
var ServiceSettingsTab = require('./service_settings.jsx');
var TeamUsersTab = require('./team_users.jsx');
var TeamAnalyticsTab = require('./team_analytics.jsx');
export default class AdminController extends React.Component {
constructor(props) {
@@ -149,6 +150,10 @@ export default class AdminController extends React.Component {
if (this.state.teams) {
tab = <TeamUsersTab team={this.state.teams[this.state.selectedTeam]} />;
}
} else if (this.state.selected === 'team_analytics') {
if (this.state.teams) {
tab = <TeamAnalyticsTab team={this.state.teams[this.state.selectedTeam]} />;
}
}
}

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

@@ -24,7 +24,7 @@ export default class AdminSidebar extends React.Component {
handleClick(name, teamId, e) {
e.preventDefault();
this.props.selectTab(name, teamId);
history.pushState({name: name, teamId: teamId}, null, `/admin_console/${name}/${teamId || ''}`);
history.pushState({name, teamId}, null, `/admin_console/${name}/${teamId || ''}`);
}
isSelected(name, teamId) {
@@ -121,6 +121,15 @@ export default class AdminSidebar extends React.Component {
{'- Users'}
</a>
</li>
<li>
<a
href='#'
className={this.isSelected('team_analytics', team.id)}
onClick={this.handleClick.bind(this, 'team_analytics', team.id)}
>
{'- Statistics'}
</a>
</li>
</ul>
</li>
</ul>

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

@@ -0,0 +1,50 @@
// 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
};

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

@@ -0,0 +1,353 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
var Client = require('../../utils/client.jsx');
var Utils = require('../../utils/utils.jsx');
var LineChart = require('./line_chart.jsx');
export default class TeamAnalytics extends React.Component {
constructor(props) {
super(props);
this.getData = this.getData.bind(this);
this.state = {
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
};
}
componentDidMount() {
this.getData(this.props.team.id);
}
getData(teamId) {
Client.getAnalytics(
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});
}
}
},
(err) => {
this.setState({serverError: err.message});
}
);
Client.getAnalytics(
teamId,
'post_counts_day',
(data) => {
var chartData = {
labels: [],
datasets: [{
label: 'Total Posts',
fillColor: 'rgba(151,187,205,0.2)',
strokeColor: 'rgba(151,187,205,1)',
pointColor: 'rgba(151,187,205,1)',
pointStrokeColor: '#fff',
pointHighlightFill: '#fff',
pointHighlightStroke: 'rgba(151,187,205,1)',
data: []
}]
};
for (var index in data) {
if (data[index]) {
var row = data[index];
chartData.labels.push(row.name);
chartData.datasets[0].data.push(row.value);
}
}
this.setState({post_counts_day: chartData});
},
(err) => {
this.setState({serverError: err.message});
}
);
Client.getAnalytics(
teamId,
'user_counts_with_posts_day',
(data) => {
var chartData = {
labels: [],
datasets: [{
label: 'Active Users With Posts',
fillColor: 'rgba(151,187,205,0.2)',
strokeColor: 'rgba(151,187,205,1)',
pointColor: 'rgba(151,187,205,1)',
pointStrokeColor: '#fff',
pointHighlightFill: '#fff',
pointHighlightStroke: 'rgba(151,187,205,1)',
data: []
}]
};
for (var index in data) {
if (data[index]) {
var row = data[index];
chartData.labels.push(row.name);
chartData.datasets[0].data.push(row.value);
}
}
this.setState({user_counts_with_posts_day: chartData});
},
(err) => {
this.setState({serverError: err.message});
}
);
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++) {
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
});
this.getData(newProps.team.id);
}
componentWillUnmount() {
}
render() {
var serverError = '';
if (this.state.serverError) {
serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>;
}
var totalCount = (
<div className='total-count text-center'>
<div>{'Total Users'}</div>
<div>{this.state.users == null ? 'Loading...' : Object.keys(this.state.users).length}</div>
</div>
);
var openChannelCount = (
<div className='total-count text-center'>
<div>{'Public Groups'}</div>
<div>{this.state.channel_open_count == null ? 'Loading...' : this.state.channel_open_count}</div>
</div>
);
var openPrivateCount = (
<div className='total-count text-center'>
<div>{'Private Groups'}</div>
<div>{this.state.channel_private_count == null ? 'Loading...' : this.state.channel_private_count}</div>
</div>
);
var postCount = (
<div className='total-count text-center'>
<div>{'Total Posts'}</div>
<div>{this.state.post_count == null ? 'Loading...' : this.state.post_count}</div>
</div>
);
var postCountsByDay = (
<div className='total-count-by-day'>
<div>{'Total Posts'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.post_counts_day != null) {
postCountsByDay = (
<div className='total-count-by-day'>
<div>{'Total Posts'}</div>
<LineChart
data={this.state.post_counts_day}
width='740'
height='225'
/>
</div>
);
}
var usersWithPostsByDay = (
<div className='total-count-by-day'>
<div>{'Total Posts'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.user_counts_with_posts_day != null) {
usersWithPostsByDay = (
<div className='total-count-by-day'>
<div>{'Active Users With Posts'}</div>
<LineChart
data={this.state.user_counts_with_posts_day}
width='740'
height='225'
/>
</div>
);
}
var recentActiveUser = (
<div className='recent-active-users'>
<div>{'Recent Active Users'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.recent_active_users != null) {
recentActiveUser = (
<div className='recent-active-users'>
<div>{'Recent Active Users'}</div>
<table width='90%'>
<tbody>
{
this.state.recent_active_users.map((user) => {
return (
<tr key={user.id}>
<td className='recent-active-users-td'>{user.email}</td>
<td className='recent-active-users-td'>{Utils.displayDateTime(user.last_activity_at)}</td>
</tr>
);
})
}
</tbody>
</table>
</div>
);
}
var newUsers = (
<div className='recent-active-users'>
<div>{'Newly Created Users'}</div>
<div>{'Loading...'}</div>
</div>
);
if (this.state.newly_created_users != null) {
newUsers = (
<div className='recent-active-users'>
<div>{'Newly Created Users'}</div>
<table width='90%'>
<tbody>
{
this.state.newly_created_users.map((user) => {
return (
<tr key={user.id}>
<td className='recent-active-users-td'>{user.email}</td>
<td className='recent-active-users-td'>{Utils.displayDateTime(user.create_at)}</td>
</tr>
);
})
}
</tbody>
</table>
</div>
);
}
return (
<div className='wrapper--fixed'>
<h2>{'Statistics for ' + this.props.team.name}</h2>
{serverError}
{totalCount}
{postCount}
{openChannelCount}
{openPrivateCount}
{postCountsByDay}
{usersWithPostsByDay}
{recentActiveUser}
{newUsers}
</div>
);
}
}
TeamAnalytics.propTypes = {
team: React.PropTypes.object
};

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

@@ -328,6 +328,20 @@ export function getConfig(success, error) {
});
}
export function getAnalytics(teamId, name, success, error) {
$.ajax({
url: '/api/v1/admin/analytics/' + teamId + '/' + name,
dataType: 'json',
contentType: 'application/json',
type: 'GET',
success,
error: (xhr, status, err) => {
var e = handleError('getAnalytics', xhr, status, err);
error(e);
}
});
}
export function saveConfig(config, success, error) {
$.ajax({
url: '/api/v1/admin/save_config',

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

@@ -5,6 +5,63 @@
.table {
background: #fff;
}
.total-count {
width: 175px;
height: 100px;
border: 1px solid #ddd;
padding: 22px 10px 10px 10px;
margin: 10px 10px 10px 10px;
background: #fff;
float: left;
> div {
font-size: 18px;
font-weight: 300;
}
}
.total-count-by-day {
width: 760px;
height: 275px;
border: 1px solid #ddd;
padding: 5px 10px 10px 10px;
margin: 10px 10px 10px 10px;
background: #fff;
clear: both;
> div {
font-size: 18px;
font-weight: 300;
}
}
.recent-active-users {
width: 365px;
height: 375px;
border: 1px solid #ddd;
padding: 5px 10px 10px 10px;
margin: 10px 10px 10px 10px;
background: #fff;
float: left;
> div {
font-size: 18px;
font-weight: 300;
}
> table {
margin: 10px 10px 10px 10px;
}
.recent-active-users-td {
font-size: 14px;
font-weight: 300;
border: 1px solid #ddd;
padding: 3px 3px 3px 5px;
}
}
.sidebar--left {
&.sidebar--collapsable {
background: #333;

11
web/static/js/Chart.min.js поставляемый Обычный файл

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

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

@@ -4,6 +4,7 @@
<html>
{{template "head" . }}
<body>
<script src="/static/js/Chart.min.js"></script>
<div id='error_bar'></div>