Merge pull request #1190 from mattermost/PLT-25
PLT-25 adding stats to admin console
Этот коммит содержится в:
62
api/admin.go
62
api/admin.go
@@ -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
Обычный файл
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
Обычный файл
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>
|
||||
|
||||
50
web/react/components/admin_console/line_chart.jsx
Обычный файл
50
web/react/components/admin_console/line_chart.jsx
Обычный файл
@@ -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
|
||||
};
|
||||
353
web/react/components/admin_console/team_analytics.jsx
Обычный файл
353
web/react/components/admin_console/team_analytics.jsx
Обычный файл
@@ -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
поставляемый
Обычный файл
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>
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user