summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--api/admin.go62
-rw-r--r--api/admin_test.go148
-rw-r--r--api/file.go22
-rw-r--r--api/post.go42
-rw-r--r--api/post_test.go40
-rw-r--r--api/user.go12
-rw-r--r--doc/install/Administration.md12
-rw-r--r--doc/install/Troubleshooting.md18
-rw-r--r--doc/integrations/webhooks/Incoming-Webhooks.md3
-rw-r--r--model/analytics_row.go55
-rw-r--r--model/analytics_row_test.go37
-rw-r--r--model/client.go9
-rw-r--r--model/incoming_webhook.go18
-rw-r--r--model/post_list.go9
-rw-r--r--model/post_list_test.go34
-rw-r--r--model/search_params.go88
-rw-r--r--model/search_params_test.go17
-rw-r--r--store/sql_channel_store.go31
-rw-r--r--store/sql_channel_store_test.go18
-rw-r--r--store/sql_post_store.go201
-rw-r--r--store/sql_post_store_test.go132
-rw-r--r--store/sql_store.go13
-rw-r--r--store/sql_team_store.go2
-rw-r--r--store/sql_user_store.go2
-rw-r--r--store/store.go4
-rw-r--r--utils/config.go7
-rw-r--r--web/react/.eslintrc3
-rw-r--r--web/react/components/admin_console/admin_controller.jsx5
-rw-r--r--web/react/components/admin_console/admin_sidebar.jsx11
-rw-r--r--web/react/components/admin_console/line_chart.jsx50
-rw-r--r--web/react/components/admin_console/team_analytics.jsx353
-rw-r--r--web/react/components/channel_header.jsx8
-rw-r--r--web/react/components/create_post.jsx10
-rw-r--r--web/react/components/error_bar.jsx12
-rw-r--r--web/react/components/mention_list.jsx13
-rw-r--r--web/react/components/more_direct_channels.jsx62
-rw-r--r--web/react/components/popover_list_members.jsx145
-rw-r--r--web/react/components/search_bar.jsx16
-rw-r--r--web/react/components/search_results.jsx10
-rw-r--r--web/react/components/search_results_item.jsx4
-rw-r--r--web/react/components/sidebar_right.jsx7
-rw-r--r--web/react/components/textbox.jsx6
-rw-r--r--web/react/components/user_settings/user_settings_general.jsx2
-rw-r--r--web/react/stores/post_store.jsx126
-rw-r--r--web/react/stores/search_store.jsx153
-rw-r--r--web/react/stores/socket_store.jsx4
-rw-r--r--web/react/utils/async_client.jsx4
-rw-r--r--web/react/utils/client.jsx16
-rw-r--r--web/react/utils/constants.jsx1
-rw-r--r--web/react/utils/markdown.jsx9
-rw-r--r--web/react/utils/text_formatting.jsx10
-rw-r--r--web/react/utils/utils.jsx42
-rw-r--r--web/sass-files/sass/partials/_admin-console.scss57
-rw-r--r--web/sass-files/sass/partials/_popover.scss33
-rw-r--r--web/static/js/Chart.min.js11
-rw-r--r--web/templates/admin_console.html1
-rw-r--r--web/web.go14
58 files changed, 1895 insertions, 345 deletions
diff --git a/README.md b/README.md
index 2585544e4..75db2cdce 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,12 @@ Joining the Mattermost community is a great way to build relationships with othe
- Review the [Mattermost Code Contribution Guidelines](http://docs.mattermost.org/developer/Code-Contribution-Guidelines/index.html) to submit patches for the core product
- Consider building tools that help developers and IT professionals manage Mattermost more effectively (API documentation coming in Beta2)
+##### Check out some projects for connecting to Mattermost:
+
+- [Matterbridge](https://github.com/42wim/matterbridge) - an IRC bridge connecting to Mattermost
+- [GitLab Integration Service for Mattermost](https://github.com/mattermost/mattermost-integration-gitlab) - connecting GitLab to Mattermost via incoming webhooks
+- [Giphy Integration Service for Mattermost](https://github.com/mattermost/mattermost-integration-giphy) - connecting Mattermost to Giphy via outgoing webhooks
+
#### Have other ideas or suggestions?
If there’s some other way you’d like to contribute, please contact us at info@mattermost.com. We’d love to meet you!
diff --git a/api/admin.go b/api/admin.go
index 7a5616ede..8e0a03e4b 100644
--- a/api/admin.go
+++ b/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")
+ }
+
+}
diff --git a/api/admin_test.go b/api/admin_test.go
index 0e51644d8..0db5caa4c 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -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()
+ }
+ }
+}
diff --git a/api/file.go b/api/file.go
index 94eea516a..f65be145d 100644
--- a/api/file.go
+++ b/api/file.go
@@ -52,6 +52,8 @@ const (
RotatedCCW = 6
RotatedCCWMirrored = 7
RotatedCW = 8
+
+ MaxImageSize = 4096 * 2160 // 4k resolution
)
var fileInfoCache *utils.Cache = utils.NewLru(1000)
@@ -125,6 +127,21 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
uid := model.NewId()
+ if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
+ imageNameList = append(imageNameList, uid+"/"+filename)
+ imageDataList = append(imageDataList, buf.Bytes())
+
+ // Decode image config first to check dimensions before loading the whole thing into memory later on
+ config, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes()))
+ if err != nil {
+ c.Err = model.NewAppError("uploadFile", "Unable to upload image file.", err.Error())
+ return
+ } else if config.Width*config.Height > MaxImageSize {
+ c.Err = model.NewAppError("uploadFile", "Unable to upload image file. File is too large.", err.Error())
+ return
+ }
+ }
+
path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename
if err := writeFile(buf.Bytes(), path); err != nil {
@@ -132,11 +149,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
- imageNameList = append(imageNameList, uid+"/"+filename)
- imageDataList = append(imageDataList, buf.Bytes())
- }
-
encName := utils.UrlEncode(filename)
fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName
diff --git a/api/post.go b/api/post.go
index 79f84e04d..b8588fe6a 100644
--- a/api/post.go
+++ b/api/post.go
@@ -820,45 +820,23 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
- plainSearchParams, hashtagSearchParams := model.ParseSearchParams(terms)
+ paramsList := model.ParseSearchParams(terms)
+ channels := []store.StoreChannel{}
- var hchan store.StoreChannel
- if hashtagSearchParams != nil {
- hchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, hashtagSearchParams)
+ for _, params := range paramsList {
+ channels = append(channels, Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, params))
}
- var pchan store.StoreChannel
- if plainSearchParams != nil {
- pchan = Srv.Store.Post().Search(c.Session.TeamId, c.Session.UserId, plainSearchParams)
- }
-
- mainList := &model.PostList{}
- if hchan != nil {
- if result := <-hchan; result.Err != nil {
+ posts := &model.PostList{}
+ for _, channel := range channels {
+ if result := <-channel; result.Err != nil {
c.Err = result.Err
return
} else {
- mainList = result.Data.(*model.PostList)
+ data := result.Data.(*model.PostList)
+ posts.Extend(data)
}
}
- plainList := &model.PostList{}
- if pchan != nil {
- if result := <-pchan; result.Err != nil {
- c.Err = result.Err
- return
- } else {
- plainList = result.Data.(*model.PostList)
- }
- }
-
- for _, postId := range plainList.Order {
- if _, ok := mainList.Posts[postId]; !ok {
- mainList.AddPost(plainList.Posts[postId])
- mainList.AddOrder(postId)
- }
-
- }
-
- w.Write([]byte(mainList.ToJson()))
+ w.Write([]byte(posts.ToJson()))
}
diff --git a/api/post_test.go b/api/post_test.go
index ac9d5668b..e54e9ef0c 100644
--- a/api/post_test.go
+++ b/api/post_test.go
@@ -427,12 +427,18 @@ func TestSearchPostsInChannel(t *testing.T) {
channel2 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
+ channel3 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
+ channel3 = Client.Must(Client.CreateChannel(channel3)).Data.(*model.Channel)
+
post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"}
post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
post3 := &model.Post{ChannelId: channel2.Id, Message: "other message with no return"}
post3 = Client.Must(Client.CreatePost(post3)).Data.(*model.Post)
+ post4 := &model.Post{ChannelId: channel3.Id, Message: "other message with no return"}
+ post4 = Client.Must(Client.CreatePost(post4)).Data.(*model.Post)
+
if result := Client.Must(Client.SearchPosts("channel:")).Data.(*model.PostList); len(result.Order) != 0 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
@@ -476,6 +482,10 @@ func TestSearchPostsInChannel(t *testing.T) {
if result := Client.Must(Client.SearchPosts("sgtitlereview channel: " + channel2.Name)).Data.(*model.PostList); len(result.Order) != 1 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
+
+ if result := Client.Must(Client.SearchPosts("channel: " + channel2.Name + " channel: " + channel3.Name)).Data.(*model.PostList); len(result.Order) != 3 {
+ t.Fatalf("wrong number of posts returned :) %v :) %v", result.Posts, result.Order)
+ }
}
func TestSearchPostsFromUser(t *testing.T) {
@@ -510,11 +520,12 @@ func TestSearchPostsFromUser(t *testing.T) {
post2 := &model.Post{ChannelId: channel2.Id, Message: "sgtitlereview\n with return"}
post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post)
+ // includes "X has joined the channel" messages for both user2 and user3
+
if result := Client.Must(Client.SearchPosts("from: " + user1.Username)).Data.(*model.PostList); len(result.Order) != 1 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
- // note that this includes the "User2 has joined the channel" system messages
if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
@@ -526,6 +537,33 @@ func TestSearchPostsFromUser(t *testing.T) {
if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " in:" + channel1.Name)).Data.(*model.PostList); len(result.Order) != 1 {
t.Fatalf("wrong number of posts returned %v", len(result.Order))
}
+
+ user3 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"}
+ user3 = Client.Must(Client.CreateUser(user3, "")).Data.(*model.User)
+ store.Must(Srv.Store.User().VerifyEmail(user3.Id))
+
+ Client.LoginByEmail(team.Name, user3.Email, "pwd")
+ Client.Must(Client.JoinChannel(channel1.Id))
+ Client.Must(Client.JoinChannel(channel2.Id))
+
+ // wait for the join/leave messages to be created for user3 since they're done asynchronously
+ time.Sleep(100 * time.Millisecond)
+
+ if result := Client.Must(Client.SearchPosts("from: " + user2.Username)).Data.(*model.PostList); len(result.Order) != 3 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username)).Data.(*model.PostList); len(result.Order) != 5 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username + " in:" + channel2.Name)).Data.(*model.PostList); len(result.Order) != 3 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
+
+ if result := Client.Must(Client.SearchPosts("from: " + user2.Username + " from: " + user3.Username + " in:" + channel2.Name + " joined")).Data.(*model.PostList); len(result.Order) != 2 {
+ t.Fatalf("wrong number of posts returned %v", len(result.Order))
+ }
}
func TestGetPostsCache(t *testing.T) {
diff --git a/api/user.go b/api/user.go
index 06e5336f1..3796a50ee 100644
--- a/api/user.go
+++ b/api/user.go
@@ -855,6 +855,18 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
+ // Decode image config first to check dimensions before loading the whole thing into memory later on
+ config, _, err := image.DecodeConfig(file)
+ if err != nil {
+ c.Err = model.NewAppError("uploadProfileFile", "Could not decode profile image config.", err.Error())
+ return
+ } else if config.Width*config.Height > MaxImageSize {
+ c.Err = model.NewAppError("uploadProfileFile", "Unable to upload profile image. File is too large.", err.Error())
+ return
+ }
+
+ file.Seek(0, 0)
+
// Decode image into Image object
img, _, err := image.Decode(file)
if err != nil {
diff --git a/doc/install/Administration.md b/doc/install/Administration.md
new file mode 100644
index 000000000..06151d81f
--- /dev/null
+++ b/doc/install/Administration.md
@@ -0,0 +1,12 @@
+# Administration
+
+This document provides instructions for common administrator tasks
+
+##### Creating System Administrator account from commandline
+ - If the System Administrator account becomes unavailable, a person leaving the organization for example, you can set a new system admin from the commandline using `./platform -assign_role -team_name="yourteam" -email="you@example.com" -role="system_admin"`.
+ - After assigning the role the user needs to log out and log back in before the System Administrator role is applied.
+
+##### Deactivating a user
+
+ - Team Admin or System Admin can go to **Main Menu** > **Manage Members** > **Make Inactive** to deactivate a user, which removes them from the team.
+ - To preserve audit history, users are never deleted from the system. It is highly recommended that System Administrators do not attempt to delete users manually from the database, as this may compromise system integrity and ability to upgrade in future.
diff --git a/doc/install/Troubleshooting.md b/doc/install/Troubleshooting.md
index 46efc61fa..1c00de39a 100644
--- a/doc/install/Troubleshooting.md
+++ b/doc/install/Troubleshooting.md
@@ -8,10 +8,22 @@
#### Common Issues
-##### Error message in logs when attempting to sign-up: `x509: certificate signed by unknown authority`
- - This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You can resolve this issue by setting up a load balancer like Ngnix. A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority).
-
##### Lost System Administrator account
- If the System Administrator account becomes unavailable, a person leaving the organization for example, you can set a new system admin from the commandline using `./platform -assign_role -team_name="yourteam" -email="you@example.com" -role="system_admin"`.
- After assigning the role the user needs to log out and log back in before the System Administrator role is applied.
+##### Deactivate a user
+
+ - Team Admin or System Admin can go to **Main Menu** > **Manage Members** > **Make Inactive** to deactivate a user, which removes them from the team.
+ - To preserve audit history, users are never deleted from the system. It is highly recommended that System Administrators do not attempt to delete users manually from the database, as this may compromise system integrity and ability to upgrade in future.
+
+#### Error Messages
+
+The following is a list of common error messages and solutions:
+
+###### `Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.`
+- Message appears in blue bar on team site. Check that [your websocket port is properly configured](https://github.com/mattermost/platform/blob/master/doc/install/Production-Ubuntu.md#set-up-nginx-server).
+
+
+###### `x509: certificate signed by unknown authority` in server logs when attempting to sign-up
+ - This error may appear when attempt to use a self-signed certificate to setup SSL, which is not yet supported by Mattermost. You can resolve this issue by setting up a load balancer like Ngnix. A ticket exists to [add support for self-signed certificates in future](x509: certificate signed by unknown authority).
diff --git a/doc/integrations/webhooks/Incoming-Webhooks.md b/doc/integrations/webhooks/Incoming-Webhooks.md
index 1216cb5db..b10b6e342 100644
--- a/doc/integrations/webhooks/Incoming-Webhooks.md
+++ b/doc/integrations/webhooks/Incoming-Webhooks.md
@@ -90,8 +90,9 @@ As mentioned above, Mattermost makes it easy to take integrations written for Sl
To see samples and community contributions, please visit <http://mattermost.org/webhooks>.
-#### Limitations
+#### Known Issues
- The `attachments` payload used in Slack is not yet supported
- Overriding of usernames does not yet apply to notifications
- Cannot supply `icon_emoji` to override the message icon
+- Webhook UI fails when connected to deleted channel
diff --git a/model/analytics_row.go b/model/analytics_row.go
new file mode 100644
index 000000000..ed1d69dd2
--- /dev/null
+++ b/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
+ }
+}
diff --git a/model/analytics_row_test.go b/model/analytics_row_test.go
new file mode 100644
index 000000000..1202d5b52
--- /dev/null
+++ b/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")
+ }
+}
diff --git a/model/client.go b/model/client.go
index 48a560838..4d2c49e70 100644
--- a/model/client.go
+++ b/model/client.go
@@ -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
diff --git a/model/incoming_webhook.go b/model/incoming_webhook.go
index 9b9969b96..be1984244 100644
--- a/model/incoming_webhook.go
+++ b/model/incoming_webhook.go
@@ -23,6 +23,13 @@ type IncomingWebhook struct {
TeamId string `json:"team_id"`
}
+type IncomingWebhookRequest struct {
+ Text string `json:"text"`
+ Username string `json:"username"`
+ IconURL string `json:"icon_url"`
+ ChannelName string `json:"channel"`
+}
+
func (o *IncomingWebhook) ToJson() string {
b, err := json.Marshal(o)
if err != nil {
@@ -104,3 +111,14 @@ func (o *IncomingWebhook) PreSave() {
func (o *IncomingWebhook) PreUpdate() {
o.UpdateAt = GetMillis()
}
+
+func IncomingWebhookRequestFromJson(data io.Reader) *IncomingWebhookRequest {
+ decoder := json.NewDecoder(data)
+ var o IncomingWebhookRequest
+ err := decoder.Decode(&o)
+ if err == nil {
+ return &o
+ } else {
+ return nil
+ }
+}
diff --git a/model/post_list.go b/model/post_list.go
index 862673ef3..4c0f5408e 100644
--- a/model/post_list.go
+++ b/model/post_list.go
@@ -54,6 +54,15 @@ func (o *PostList) AddPost(post *Post) {
o.Posts[post.Id] = post
}
+func (o *PostList) Extend(other *PostList) {
+ for _, postId := range other.Order {
+ if _, ok := o.Posts[postId]; !ok {
+ o.AddPost(other.Posts[postId])
+ o.AddOrder(postId)
+ }
+ }
+}
+
func (o *PostList) Etag() string {
id := "0"
diff --git a/model/post_list_test.go b/model/post_list_test.go
index 8a34327ce..9ce6447e1 100644
--- a/model/post_list_test.go
+++ b/model/post_list_test.go
@@ -34,3 +34,37 @@ func TestPostListJson(t *testing.T) {
t.Fatal("failed to serialize")
}
}
+
+func TestPostListExtend(t *testing.T) {
+ l1 := PostList{}
+
+ p1 := &Post{Id: NewId(), Message: NewId()}
+ l1.AddPost(p1)
+ l1.AddOrder(p1.Id)
+
+ p2 := &Post{Id: NewId(), Message: NewId()}
+ l1.AddPost(p2)
+ l1.AddOrder(p2.Id)
+
+ l2 := PostList{}
+
+ p3 := &Post{Id: NewId(), Message: NewId()}
+ l2.AddPost(p3)
+ l2.AddOrder(p3.Id)
+
+ l2.Extend(&l1)
+
+ if len(l1.Posts) != 2 || len(l1.Order) != 2 {
+ t.Fatal("extending l2 changed l1")
+ } else if len(l2.Posts) != 3 {
+ t.Fatal("failed to extend posts l2")
+ } else if l2.Order[0] != p3.Id || l2.Order[1] != p1.Id || l2.Order[2] != p2.Id {
+ t.Fatal("failed to extend order of l2")
+ }
+
+ if len(l1.Posts) != 2 || len(l1.Order) != 2 {
+ t.Fatal("extending l2 again changed l1")
+ } else if len(l2.Posts) != 3 || len(l2.Order) != 3 {
+ t.Fatal("extending l2 again changed l2")
+ }
+}
diff --git a/model/search_params.go b/model/search_params.go
index 7eeeed10f..144e8e461 100644
--- a/model/search_params.go
+++ b/model/search_params.go
@@ -8,10 +8,10 @@ import (
)
type SearchParams struct {
- Terms string
- IsHashtag bool
- InChannel string
- FromUser string
+ Terms string
+ IsHashtag bool
+ InChannels []string
+ FromUsers []string
}
var searchFlags = [...]string{"from", "channel", "in"}
@@ -31,9 +31,9 @@ func splitWords(text string) []string {
return words
}
-func parseSearchFlags(input []string) ([]string, map[string]string) {
+func parseSearchFlags(input []string) ([]string, [][2]string) {
words := []string{}
- flags := make(map[string]string)
+ flags := [][2]string{}
skipNextWord := false
for i, word := range input {
@@ -52,10 +52,10 @@ func parseSearchFlags(input []string) ([]string, map[string]string) {
// check for case insensitive equality
if strings.EqualFold(flag, searchFlag) {
if value != "" {
- flags[searchFlag] = value
+ flags = append(flags, [2]string{searchFlag, value})
isFlag = true
} else if i < len(input)-1 {
- flags[searchFlag] = input[i+1]
+ flags = append(flags, [2]string{searchFlag, input[i+1]})
skipNextWord = true
isFlag = true
}
@@ -75,56 +75,66 @@ func parseSearchFlags(input []string) ([]string, map[string]string) {
return words, flags
}
-func ParseSearchParams(text string) (*SearchParams, *SearchParams) {
+func ParseSearchParams(text string) []*SearchParams {
words, flags := parseSearchFlags(splitWords(text))
- hashtagTerms := []string{}
- plainTerms := []string{}
+ hashtagTermList := []string{}
+ plainTermList := []string{}
for _, word := range words {
if validHashtag.MatchString(word) {
- hashtagTerms = append(hashtagTerms, word)
+ hashtagTermList = append(hashtagTermList, word)
} else {
- plainTerms = append(plainTerms, word)
+ plainTermList = append(plainTermList, word)
}
}
- inChannel := flags["channel"]
- if inChannel == "" {
- inChannel = flags["in"]
+ hashtagTerms := strings.Join(hashtagTermList, " ")
+ plainTerms := strings.Join(plainTermList, " ")
+
+ inChannels := []string{}
+ fromUsers := []string{}
+
+ for _, flagPair := range flags {
+ flag := flagPair[0]
+ value := flagPair[1]
+
+ if flag == "in" || flag == "channel" {
+ inChannels = append(inChannels, value)
+ } else if flag == "from" {
+ fromUsers = append(fromUsers, value)
+ }
}
- fromUser := flags["from"]
+ paramsList := []*SearchParams{}
- var plainParams *SearchParams
if len(plainTerms) > 0 {
- plainParams = &SearchParams{
- Terms: strings.Join(plainTerms, " "),
- IsHashtag: false,
- InChannel: inChannel,
- FromUser: fromUser,
- }
+ paramsList = append(paramsList, &SearchParams{
+ Terms: plainTerms,
+ IsHashtag: false,
+ InChannels: inChannels,
+ FromUsers: fromUsers,
+ })
}
- var hashtagParams *SearchParams
if len(hashtagTerms) > 0 {
- hashtagParams = &SearchParams{
- Terms: strings.Join(hashtagTerms, " "),
- IsHashtag: true,
- InChannel: inChannel,
- FromUser: fromUser,
- }
+ paramsList = append(paramsList, &SearchParams{
+ Terms: hashtagTerms,
+ IsHashtag: true,
+ InChannels: inChannels,
+ FromUsers: fromUsers,
+ })
}
// special case for when no terms are specified but we still have a filter
- if plainParams == nil && hashtagParams == nil && (inChannel != "" || fromUser != "") {
- plainParams = &SearchParams{
- Terms: "",
- IsHashtag: false,
- InChannel: inChannel,
- FromUser: fromUser,
- }
+ if len(plainTerms) == 0 && len(hashtagTerms) == 0 {
+ paramsList = append(paramsList, &SearchParams{
+ Terms: "",
+ IsHashtag: true,
+ InChannels: inChannels,
+ FromUsers: fromUsers,
+ })
}
- return plainParams, hashtagParams
+ return paramsList
}
diff --git a/model/search_params_test.go b/model/search_params_test.go
index 2eba20f4c..e03e82c5a 100644
--- a/model/search_params_test.go
+++ b/model/search_params_test.go
@@ -28,25 +28,25 @@ func TestParseSearchFlags(t *testing.T) {
if words, flags := parseSearchFlags(splitWords("apple banana from:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words)
- } else if len(flags) != 1 || flags["from"] != "chan" {
+ } else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags)
}
if words, flags := parseSearchFlags(splitWords("apple banana from: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words)
- } else if len(flags) != 1 || flags["from"] != "chan" {
+ } else if len(flags) != 1 || flags[0][0] != "from" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags)
}
if words, flags := parseSearchFlags(splitWords("apple banana in: chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words)
- } else if len(flags) != 1 || flags["in"] != "chan" {
+ } else if len(flags) != 1 || flags[0][0] != "in" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags)
}
if words, flags := parseSearchFlags(splitWords("apple banana channel:chan")); len(words) != 2 || words[0] != "apple" || words[1] != "banana" {
t.Fatalf("got incorrect words %v", words)
- } else if len(flags) != 1 || flags["channel"] != "chan" {
+ } else if len(flags) != 1 || flags[0][0] != "channel" || flags[0][1] != "chan" {
t.Fatalf("got incorrect flags %v", flags)
}
@@ -64,7 +64,14 @@ func TestParseSearchFlags(t *testing.T) {
if words, flags := parseSearchFlags(splitWords("channel: first in: second from:")); len(words) != 1 || words[0] != "from:" {
t.Fatalf("got incorrect words %v", words)
- } else if len(flags) != 2 || flags["channel"] != "first" || flags["in"] != "second" {
+ } else if len(flags) != 2 || flags[0][0] != "channel" || flags[0][1] != "first" || flags[1][0] != "in" || flags[1][1] != "second" {
+ t.Fatalf("got incorrect flags %v", flags)
+ }
+
+ if words, flags := parseSearchFlags(splitWords("channel: first channel: second from: third from: fourth")); len(words) != 0 {
+ t.Fatalf("got incorrect words %v", words)
+ } else if len(flags) != 4 || flags[0][0] != "channel" || flags[0][1] != "first" || flags[1][0] != "channel" || flags[1][1] != "second" ||
+ flags[2][0] != "from" || flags[2][1] != "third" || flags[3][0] != "from" || flags[3][1] != "fourth" {
t.Fatalf("got incorrect flags %v", flags)
}
}
diff --git a/store/sql_channel_store.go b/store/sql_channel_store.go
index 8bedf0632..80fe75130 100644
--- a/store/sql_channel_store.go
+++ b/store/sql_channel_store.go
@@ -40,7 +40,7 @@ func NewSqlChannelStore(sqlStore *SqlStore) ChannelStore {
func (s SqlChannelStore) UpgradeSchemaIfNeeded() {
- // BEGIN REMOVE AFTER 1.1.0
+ // REMOVE AFTER 1.2 SHIP see PLT-828
if s.CreateColumnIfNotExists("ChannelMembers", "NotifyProps", "varchar(2000)", "varchar(2000)", "{}") {
// populate NotifyProps from existing NotifyLevel field
@@ -83,7 +83,6 @@ func (s SqlChannelStore) UpgradeSchemaIfNeeded() {
s.RemoveColumnIfExists("ChannelMembers", "NotifyLevel")
}
- // END REMOVE AFTER 1.1.0
}
func (s SqlChannelStore) CreateIndexesIfNotExists() {
@@ -829,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
+}
diff --git a/store/sql_channel_store_test.go b/store/sql_channel_store_test.go
index 60d3de56a..f6a0fb713 100644
--- a/store/sql_channel_store_test.go
+++ b/store/sql_channel_store_test.go
@@ -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) {
diff --git a/store/sql_post_store.go b/store/sql_post_store.go
index 6971de9d7..7894ff488 100644
--- a/store/sql_post_store.go
+++ b/store/sql_post_store.go
@@ -6,6 +6,7 @@ package store
import (
"fmt"
"regexp"
+ "strconv"
"strings"
"github.com/mattermost/platform/model"
@@ -413,10 +414,15 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
go func() {
result := StoreResult{}
+ queryParams := map[string]interface{}{
+ "TeamId": teamId,
+ "UserId": userId,
+ }
+
termMap := map[string]bool{}
terms := params.Terms
- if terms == "" && params.InChannel == "" && params.FromUser == "" {
+ if terms == "" && len(params.InChannels) == 0 && len(params.FromUsers) == 0 {
result.Data = []*model.Post{}
storeChannel <- result
return
@@ -468,13 +474,45 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
ORDER BY CreateAt DESC
LIMIT 100`
- if params.InChannel != "" {
+ if len(params.InChannels) > 1 {
+ inClause := ":InChannel0"
+ queryParams["InChannel0"] = params.InChannels[0]
+
+ for i := 1; i < len(params.InChannels); i++ {
+ paramName := "InChannel" + strconv.FormatInt(int64(i), 10)
+ inClause += ", :" + paramName
+ queryParams[paramName] = params.InChannels[i]
+ }
+
+ searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name IN ("+inClause+")", 1)
+ } else if len(params.InChannels) == 1 {
+ queryParams["InChannel"] = params.InChannels[0]
searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "AND Name = :InChannel", 1)
} else {
searchQuery = strings.Replace(searchQuery, "CHANNEL_FILTER", "", 1)
}
- if params.FromUser != "" {
+ if len(params.FromUsers) > 1 {
+ inClause := ":FromUser0"
+ queryParams["FromUser0"] = params.FromUsers[0]
+
+ for i := 1; i < len(params.FromUsers); i++ {
+ paramName := "FromUser" + strconv.FormatInt(int64(i), 10)
+ inClause += ", :" + paramName
+ queryParams[paramName] = params.FromUsers[i]
+ }
+
+ searchQuery = strings.Replace(searchQuery, "POST_FILTER", `
+ AND UserId IN (
+ SELECT
+ Id
+ FROM
+ Users
+ WHERE
+ TeamId = :TeamId
+ AND Username IN (`+inClause+`))`, 1)
+ } else if len(params.FromUsers) == 1 {
+ queryParams["FromUser"] = params.FromUsers[0]
searchQuery = strings.Replace(searchQuery, "POST_FILTER", `
AND UserId IN (
SELECT
@@ -506,13 +544,7 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP
searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", searchClause, 1)
}
- queryParams := map[string]interface{}{
- "TeamId": teamId,
- "UserId": userId,
- "Terms": terms,
- "InChannel": params.InChannel,
- "FromUser": params.FromUser,
- }
+ queryParams["Terms"] = terms
_, err := s.GetReplica().Select(&posts, searchQuery, queryParams)
if err != nil {
@@ -571,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
+}
diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go
index b2256417e..872423c5a 100644
--- a/store/sql_post_store_test.go
+++ b/store/sql_post_store_test.go
@@ -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")
+ }
+ }
+}
diff --git a/store/sql_store.go b/store/sql_store.go
index 0d1bfe41b..d5c84d522 100644
--- a/store/sql_store.go
+++ b/store/sql_store.go
@@ -73,7 +73,8 @@ func NewSqlStore() Store {
}
schemaVersion := sqlStore.GetCurrentSchemaVersion()
- isSchemaVersion07 := false
+ isSchemaVersion07 := false // REMOVE AFTER 1.2 SHIP see PLT-828
+ isSchemaVersion10 := false // REMOVE AFTER 1.2 SHIP see PLT-828
// If the version is already set then we are potentially in an 'upgrade needed' state
if schemaVersion != "" {
@@ -86,7 +87,11 @@ func NewSqlStore() Store {
isSchemaVersion07 = true
}
- if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 {
+ if schemaVersion == "1.0.0" {
+ isSchemaVersion10 = true
+ }
+
+ if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 || isSchemaVersion10 {
l4g.Warn("The database schema version of " + schemaVersion + " appears to be out of date")
l4g.Warn("Attempting to upgrade the database schema version to " + model.CurrentVersion)
} else {
@@ -98,7 +103,7 @@ func NewSqlStore() Store {
}
}
- // REMOVE in 1.2
+ // REMOVE AFTER 1.2 SHIP see PLT-828
if sqlStore.DoesTableExist("Sessions") {
if sqlStore.DoesColumnExist("Sessions", "AltId") {
sqlStore.GetMaster().Exec("DROP TABLE IF EXISTS Sessions")
@@ -140,7 +145,7 @@ func NewSqlStore() Store {
sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).CreateIndexesIfNotExists()
- if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 {
+ if model.IsPreviousVersion(schemaVersion) || isSchemaVersion07 || isSchemaVersion10 {
sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion})
l4g.Warn("The database schema has been upgraded to version " + model.CurrentVersion)
}
diff --git a/store/sql_team_store.go b/store/sql_team_store.go
index 380d979bd..8700a9d04 100644
--- a/store/sql_team_store.go
+++ b/store/sql_team_store.go
@@ -29,7 +29,7 @@ func NewSqlTeamStore(sqlStore *SqlStore) TeamStore {
}
func (s SqlTeamStore) UpgradeSchemaIfNeeded() {
- // REMOVE in 1.2
+ // REMOVE AFTER 1.2 SHIP see PLT-828
s.RemoveColumnIfExists("Teams", "AllowValet")
}
diff --git a/store/sql_user_store.go b/store/sql_user_store.go
index 5fab38ace..d825cda57 100644
--- a/store/sql_user_store.go
+++ b/store/sql_user_store.go
@@ -41,7 +41,7 @@ func NewSqlUserStore(sqlStore *SqlStore) UserStore {
}
func (us SqlUserStore) UpgradeSchemaIfNeeded() {
- // REMOVE in 1.2
+ // REMOVE AFTER 1.2 SHIP see PLT-828
us.CreateColumnIfNotExists("Users", "ThemeProps", "varchar(2000)", "character varying(2000)", "{}")
}
diff --git a/store/store.go b/store/store.go
index bd2c3681e..42329b036 100644
--- a/store/store.go
+++ b/store/store.go
@@ -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 {
diff --git a/utils/config.go b/utils/config.go
index fd9856a67..6b34c76ed 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -159,6 +159,13 @@ func LoadConfig(fileName string) {
configureLog(&config.LogSettings)
TestConnection(&config)
+ if config.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
+ dir := config.FileSettings.Directory
+ if len(dir) > 0 && dir[len(dir)-1:] != "/" {
+ config.FileSettings.Directory += "/"
+ }
+ }
+
Cfg = &config
SanitizeOptions = getSanitizeOptions(Cfg)
ClientCfg = getClientConfig(Cfg)
diff --git a/web/react/.eslintrc b/web/react/.eslintrc
index 6a35d3123..d78068882 100644
--- a/web/react/.eslintrc
+++ b/web/react/.eslintrc
@@ -20,7 +20,8 @@
"globals": {
"React": false,
"ReactDOM": false,
- "ReactBootstrap": false
+ "ReactBootstrap": false,
+ "Chart": false
},
"rules": {
"comma-dangle": [2, "never"],
diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx
index f770d166c..d309ced2e 100644
--- a/web/react/components/admin_console/admin_controller.jsx
+++ b/web/react/components/admin_console/admin_controller.jsx
@@ -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]} />;
+ }
}
}
diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx
index b0e01ff17..f2fb1c96d 100644
--- a/web/react/components/admin_console/admin_sidebar.jsx
+++ b/web/react/components/admin_console/admin_sidebar.jsx
@@ -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>
diff --git a/web/react/components/admin_console/line_chart.jsx b/web/react/components/admin_console/line_chart.jsx
new file mode 100644
index 000000000..7e2f95c84
--- /dev/null
+++ b/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
+};
diff --git a/web/react/components/admin_console/team_analytics.jsx b/web/react/components/admin_console/team_analytics.jsx
new file mode 100644
index 000000000..dd8812ad0
--- /dev/null
+++ b/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
+};
diff --git a/web/react/components/channel_header.jsx b/web/react/components/channel_header.jsx
index 1b709336f..d66777cc6 100644
--- a/web/react/components/channel_header.jsx
+++ b/web/react/components/channel_header.jsx
@@ -3,7 +3,7 @@
const ChannelStore = require('../stores/channel_store.jsx');
const UserStore = require('../stores/user_store.jsx');
-const PostStore = require('../stores/post_store.jsx');
+const SearchStore = require('../stores/search_store.jsx');
const NavbarSearchBox = require('./search_bar.jsx');
const AsyncClient = require('../utils/async_client.jsx');
const Client = require('../utils/client.jsx');
@@ -35,19 +35,19 @@ export default class ChannelHeader extends React.Component {
memberChannel: ChannelStore.getCurrentMember(),
memberTeam: UserStore.getCurrentUser(),
users: ChannelStore.getCurrentExtraInfo().members,
- searchVisible: PostStore.getSearchResults() !== null
+ searchVisible: SearchStore.getSearchResults() !== null
};
}
componentDidMount() {
ChannelStore.addChangeListener(this.onListenerChange);
ChannelStore.addExtraInfoChangeListener(this.onListenerChange);
- PostStore.addSearchChangeListener(this.onListenerChange);
+ SearchStore.addSearchChangeListener(this.onListenerChange);
UserStore.addChangeListener(this.onListenerChange);
}
componentWillUnmount() {
ChannelStore.removeChangeListener(this.onListenerChange);
ChannelStore.removeExtraInfoChangeListener(this.onListenerChange);
- PostStore.removeSearchChangeListener(this.onListenerChange);
+ SearchStore.removeSearchChangeListener(this.onListenerChange);
UserStore.addChangeListener(this.onListenerChange);
}
onListenerChange() {
diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx
index b74f1871c..32ee31efe 100644
--- a/web/react/components/create_post.jsx
+++ b/web/react/components/create_post.jsx
@@ -253,8 +253,14 @@ export default class CreatePost extends React.Component {
this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
}
handleUploadError(err, clientId) {
+ let message = err;
+ if (message && typeof message !== 'string') {
+ // err is an AppError from the server
+ message = err.message;
+ }
+
if (clientId === -1) {
- this.setState({serverError: err});
+ this.setState({serverError: message});
} else {
const draft = PostStore.getDraft(this.state.channelId);
@@ -265,7 +271,7 @@ export default class CreatePost extends React.Component {
PostStore.storeDraft(this.state.channelId, draft);
- this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: err});
+ this.setState({uploadsInProgress: draft.uploadsInProgress, serverError: message});
}
}
handleTextDrop(text) {
diff --git a/web/react/components/error_bar.jsx b/web/react/components/error_bar.jsx
index 6311d9460..f098384aa 100644
--- a/web/react/components/error_bar.jsx
+++ b/web/react/components/error_bar.jsx
@@ -9,12 +9,8 @@ export default class ErrorBar extends React.Component {
this.onErrorChange = this.onErrorChange.bind(this);
this.handleClose = this.handleClose.bind(this);
- this.prevTimer = null;
this.state = ErrorStore.getLastError();
- if (this.isValidError(this.state)) {
- this.prevTimer = setTimeout(this.handleClose, 10000);
- }
}
isValidError(s) {
@@ -56,16 +52,8 @@ export default class ErrorBar extends React.Component {
onErrorChange() {
var newState = ErrorStore.getLastError();
- if (this.prevTimer != null) {
- clearInterval(this.prevTimer);
- this.prevTimer = null;
- }
-
if (newState) {
this.setState(newState);
- if (!this.isConnectionError(newState)) {
- this.prevTimer = setTimeout(this.handleClose, 10000);
- }
} else {
this.setState({message: null});
}
diff --git a/web/react/components/mention_list.jsx b/web/react/components/mention_list.jsx
index 8c1da942d..61a24c09c 100644
--- a/web/react/components/mention_list.jsx
+++ b/web/react/components/mention_list.jsx
@@ -2,7 +2,7 @@
// See License.txt for license information.
var UserStore = require('../stores/user_store.jsx');
-var PostStore = require('../stores/post_store.jsx');
+var SearchStore = require('../stores/search_store.jsx');
var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var Mention = require('./mention.jsx');
@@ -66,7 +66,7 @@ export default class MentionList extends React.Component {
}
}
componentDidMount() {
- PostStore.addMentionDataChangeListener(this.onListenerChange);
+ SearchStore.addMentionDataChangeListener(this.onListenerChange);
$('.post-right__scroll').scroll(this.onScroll);
@@ -74,7 +74,7 @@ export default class MentionList extends React.Component {
$(document).click(this.onClick);
}
componentWillUnmount() {
- PostStore.removeMentionDataChangeListener(this.onListenerChange);
+ SearchStore.removeMentionDataChangeListener(this.onListenerChange);
$('body').off('keydown.mentionlist', '#' + this.props.id);
}
@@ -217,12 +217,17 @@ export default class MentionList extends React.Component {
if (this.state.selectedMention === index) {
isFocused = 'mentions-focus';
}
+
+ if (!users[i].secondary_text) {
+ users[i].secondary_text = Utils.getFullName(users[i]);
+ }
+
mentions[index] = (
<Mention
key={'mention_key_' + index}
ref={'mention' + index}
username={users[i].username}
- secondary_text={Utils.getFullName(users[i])}
+ secondary_text={users[i].secondary_text}
id={users[i].id}
listId={index}
isFocused={isFocused}
diff --git a/web/react/components/more_direct_channels.jsx b/web/react/components/more_direct_channels.jsx
index 41746d1d7..b0232fc08 100644
--- a/web/react/components/more_direct_channels.jsx
+++ b/web/react/components/more_direct_channels.jsx
@@ -1,13 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-const AsyncClient = require('../utils/async_client.jsx');
-const ChannelStore = require('../stores/channel_store.jsx');
-const Constants = require('../utils/constants.jsx');
-const Client = require('../utils/client.jsx');
const Modal = ReactBootstrap.Modal;
-const PreferenceStore = require('../stores/preference_store.jsx');
-const TeamStore = require('../stores/team_store.jsx');
const UserStore = require('../stores/user_store.jsx');
const Utils = require('../utils/utils.jsx');
@@ -70,52 +64,24 @@ export default class MoreDirectChannels extends React.Component {
}
handleShowDirectChannel(teammate, e) {
+ e.preventDefault();
+
if (this.state.loadingDMChannel !== -1) {
return;
}
- e.preventDefault();
-
- const channelName = Utils.getDirectChannelName(UserStore.getCurrentId(), teammate.id);
- let channel = ChannelStore.getByName(channelName);
-
- const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, teammate.id, 'true');
- AsyncClient.savePreferences([preference]);
-
- if (channel) {
- Utils.switchChannel(channel);
-
- this.handleHide();
- } else {
- this.setState({loadingDMChannel: teammate.id});
-
- channel = {
- name: channelName,
- last_post_at: 0,
- total_msg_count: 0,
- type: 'D',
- display_name: teammate.username,
- teammate_id: teammate.id,
- status: UserStore.getStatus(teammate.id)
- };
-
- Client.createDirectChannel(
- channel,
- teammate.id,
- (data) => {
- this.setState({loadingDMChannel: -1});
-
- AsyncClient.getChannel(data.id);
- Utils.switchChannel(data);
-
- this.handleHide();
- },
- () => {
- this.setState({loadingDMChannel: -1});
- window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName;
- }
- );
- }
+ this.setState({loadingDMChannel: teammate.id});
+ Utils.openDirectChannelToUser(
+ teammate,
+ (channel) => {
+ Utils.switchChannel(channel);
+ this.setState({loadingDMChannel: -1});
+ this.handleHide();
+ },
+ () => {
+ this.setState({loadingDMChannel: -1});
+ }
+ );
}
handleUserChange() {
diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx
index 155e88600..9cffa2400 100644
--- a/web/react/components/popover_list_members.jsx
+++ b/web/react/components/popover_list_members.jsx
@@ -3,9 +3,23 @@
var UserStore = require('../stores/user_store.jsx');
var Popover = ReactBootstrap.Popover;
-var OverlayTrigger = ReactBootstrap.OverlayTrigger;
+var Overlay = ReactBootstrap.Overlay;
+const Utils = require('../utils/utils.jsx');
+
+const ChannelStore = require('../stores/channel_store.jsx');
export default class PopoverListMembers extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleShowDirectChannel = this.handleShowDirectChannel.bind(this);
+ this.closePopover = this.closePopover.bind(this);
+ }
+
+ componentWillMount() {
+ this.setState({showPopover: false});
+ }
+
componentDidMount() {
const originalLeave = $.fn.popover.Constructor.prototype.leave;
$.fn.popover.Constructor.prototype.leave = function onLeave(obj) {
@@ -27,12 +41,36 @@ export default class PopoverListMembers extends React.Component {
}
};
}
+
+ handleShowDirectChannel(teammate, e) {
+ e.preventDefault();
+
+ Utils.openDirectChannelToUser(
+ teammate,
+ (channel, channelAlreadyExisted) => {
+ Utils.switchChannel(channel);
+ if (channelAlreadyExisted) {
+ this.closePopover();
+ }
+ },
+ () => {
+ this.closePopover();
+ }
+ );
+ }
+
+ closePopover() {
+ this.setState({showPopover: false});
+ }
+
render() {
let popoverHtml = [];
let count = 0;
let countText = '-';
const members = this.props.members;
const teamMembers = UserStore.getProfilesUsernameMap();
+ const currentUserId = UserStore.getCurrentId();
+ const ch = ChannelStore.getCurrent();
if (members && teamMembers) {
members.sort((a, b) => {
@@ -40,13 +78,74 @@ export default class PopoverListMembers extends React.Component {
});
members.forEach((m, i) => {
+ const details = [];
+
+ const fullName = Utils.getFullName(m);
+ if (fullName) {
+ details.push(
+ <span
+ key={`${m.id}__full-name`}
+ className='full-name'
+ >
+ {fullName}
+ </span>
+ );
+ }
+
+ if (m.nickname) {
+ const separator = fullName ? ' - ' : '';
+ details.push(
+ <span
+ key={`${m.nickname}__nickname`}
+ >
+ {separator + m.nickname}
+ </span>
+ );
+ }
+
+ let button = '';
+ if (currentUserId !== m.id && ch.type !== 'D') {
+ button = (
+ <button
+ type='button'
+ className='btn btn-primary btn-message'
+ onClick={(e) => this.handleShowDirectChannel(m, e)}
+ >
+ {'Message'}
+ </button>
+ );
+ }
+
if (teamMembers[m.username] && teamMembers[m.username].delete_at <= 0) {
popoverHtml.push(
<div
className='text--nowrap'
key={'popover-member-' + i}
>
- {m.username}
+
+ <img
+ className='profile-img pull-left'
+ width='38'
+ height='38'
+ src={`/api/v1/users/${m.id}/image?time=${m.update_at}&${Utils.getSessionIndex()}`}
+ />
+ <div className='pull-left'>
+ <div
+ className='more-name'
+ >
+ {m.username}
+ </div>
+ <div
+ className='more-description'
+ >
+ {details}
+ </div>
+ </div>
+ <div
+ className='pull-right profile-action'
+ >
+ {button}
+ </div>
</div>
);
count++;
@@ -61,29 +160,37 @@ export default class PopoverListMembers extends React.Component {
}
return (
- <OverlayTrigger
- trigger='click'
- placement='bottom'
- rootClose={true}
- overlay={
+ <div>
+ <div
+ id='member_popover'
+ ref='member_popover_target'
+ onClick={(e) => this.setState({popoverTarget: e.target, showPopover: !this.state.showPopover})}
+ >
+ <div>
+ {countText}
+ <span
+ className='fa fa-user'
+ aria-hidden='true'
+ />
+ </div>
+ </div>
+ <Overlay
+ rootClose={true}
+ onHide={this.closePopover}
+ show={this.state.showPopover}
+ target={() => this.state.popoverTarget}
+ placement='bottom'
+ >
<Popover
title='Members'
id='member-list-popover'
>
- {popoverHtml}
+ <div>
+ {popoverHtml}
+ </div>
</Popover>
- }
- >
- <div id='member_popover'>
- <div>
- {countText}
- <span
- className='fa fa-user'
- aria-hidden='true'
- />
- </div>
+ </Overlay>
</div>
- </OverlayTrigger>
);
}
}
diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx
index 0da43e8cd..83c10494a 100644
--- a/web/react/components/search_bar.jsx
+++ b/web/react/components/search_bar.jsx
@@ -3,7 +3,7 @@
var client = require('../utils/client.jsx');
var AsyncClient = require('../utils/async_client.jsx');
-var PostStore = require('../stores/post_store.jsx');
+var SearchStore = require('../stores/search_store.jsx');
var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
var utils = require('../utils/utils.jsx');
var Constants = require('../utils/constants.jsx');
@@ -30,17 +30,17 @@ export default class SearchBar extends React.Component {
this.state = state;
}
getSearchTermStateFromStores() {
- var term = PostStore.getSearchTerm() || '';
+ var term = SearchStore.getSearchTerm() || '';
return {
searchTerm: term
};
}
componentDidMount() {
- PostStore.addSearchTermChangeListener(this.onListenerChange);
+ SearchStore.addSearchTermChangeListener(this.onListenerChange);
this.mounted = true;
}
componentWillUnmount() {
- PostStore.removeSearchTermChangeListener(this.onListenerChange);
+ SearchStore.removeSearchTermChangeListener(this.onListenerChange);
this.mounted = false;
}
onListenerChange(doSearch, isMentionSearch) {
@@ -84,8 +84,8 @@ export default class SearchBar extends React.Component {
}
handleUserInput(e) {
var term = e.target.value;
- PostStore.storeSearchTerm(term);
- PostStore.emitSearchTermChange(false);
+ SearchStore.storeSearchTerm(term);
+ SearchStore.emitSearchTermChange(false);
this.setState({searchTerm: term});
this.refs.autocomplete.handleInputChange(e.target, term);
@@ -150,8 +150,8 @@ export default class SearchBar extends React.Component {
textbox.value = text;
utils.setCaretPosition(textbox, preText.length + word.length);
- PostStore.storeSearchTerm(text);
- PostStore.emitSearchTermChange(false);
+ SearchStore.storeSearchTerm(text);
+ SearchStore.emitSearchTermChange(false);
this.setState({searchTerm: text});
}
diff --git a/web/react/components/search_results.jsx b/web/react/components/search_results.jsx
index 30e15d0ad..ce19c48f0 100644
--- a/web/react/components/search_results.jsx
+++ b/web/react/components/search_results.jsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-var PostStore = require('../stores/post_store.jsx');
+var SearchStore = require('../stores/search_store.jsx');
var UserStore = require('../stores/user_store.jsx');
var SearchBox = require('./search_bar.jsx');
var Utils = require('../utils/utils.jsx');
@@ -9,7 +9,7 @@ var SearchResultsHeader = require('./search_results_header.jsx');
var SearchResultsItem = require('./search_results_item.jsx');
function getStateFromStores() {
- return {results: PostStore.getSearchResults()};
+ return {results: SearchStore.getSearchResults()};
}
export default class SearchResults extends React.Component {
@@ -30,7 +30,7 @@ export default class SearchResults extends React.Component {
componentDidMount() {
this.mounted = true;
- PostStore.addSearchChangeListener(this.onChange);
+ SearchStore.addSearchChangeListener(this.onChange);
this.resize();
window.addEventListener('resize', this.handleResize);
}
@@ -40,7 +40,7 @@ export default class SearchResults extends React.Component {
}
componentWillUnmount() {
- PostStore.removeSearchChangeListener(this.onChange);
+ SearchStore.removeSearchChangeListener(this.onChange);
this.mounted = false;
window.removeEventListener('resize', this.handleResize);
}
@@ -78,7 +78,7 @@ export default class SearchResults extends React.Component {
searchForm = <SearchBox />;
}
var noResults = (!results || !results.order || !results.order.length);
- var searchTerm = PostStore.getSearchTerm();
+ var searchTerm = SearchStore.getSearchTerm();
var ctls = null;
diff --git a/web/react/components/search_results_item.jsx b/web/react/components/search_results_item.jsx
index d212e47a3..a8bd4db2c 100644
--- a/web/react/components/search_results_item.jsx
+++ b/web/react/components/search_results_item.jsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-var PostStore = require('../stores/post_store.jsx');
+var SearchStore = require('../stores/search_store.jsx');
var ChannelStore = require('../stores/channel_store.jsx');
var UserStore = require('../stores/user_store.jsx');
var UserProfile = require('./user_profile.jsx');
@@ -32,7 +32,7 @@ export default class SearchResultsItem extends React.Component {
AppDispatcher.handleServerAction({
type: ActionTypes.RECIEVED_POST_SELECTED,
post_list: data,
- from_search: PostStore.getSearchTerm()
+ from_search: SearchStore.getSearchTerm()
});
AppDispatcher.handleServerAction({
diff --git a/web/react/components/sidebar_right.jsx b/web/react/components/sidebar_right.jsx
index 4e6985a86..51225cbbe 100644
--- a/web/react/components/sidebar_right.jsx
+++ b/web/react/components/sidebar_right.jsx
@@ -3,11 +3,12 @@
var SearchResults = require('./search_results.jsx');
var RhsThread = require('./rhs_thread.jsx');
+var SearchStore = require('../stores/search_store.jsx');
var PostStore = require('../stores/post_store.jsx');
var Utils = require('../utils/utils.jsx');
function getStateFromStores() {
- return {search_visible: PostStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: PostStore.getIsMentionSearch()};
+ return {search_visible: SearchStore.getSearchResults() != null, post_right_visible: PostStore.getSelectedPost() != null, is_mention_search: SearchStore.getIsMentionSearch()};
}
export default class SidebarRight extends React.Component {
@@ -22,11 +23,11 @@ export default class SidebarRight extends React.Component {
this.state = getStateFromStores();
}
componentDidMount() {
- PostStore.addSearchChangeListener(this.onSearchChange);
+ SearchStore.addSearchChangeListener(this.onSearchChange);
PostStore.addSelectedPostChangeListener(this.onSelectedChange);
}
componentWillUnmount() {
- PostStore.removeSearchChangeListener(this.onSearchChange);
+ SearchStore.removeSearchChangeListener(this.onSearchChange);
PostStore.removeSelectedPostChangeListener(this.onSelectedChange);
}
componentDidUpdate() {
diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx
index 86bb42f62..707033d8f 100644
--- a/web/react/components/textbox.jsx
+++ b/web/react/components/textbox.jsx
@@ -2,7 +2,7 @@
// See License.txt for license information.
const AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
-const PostStore = require('../stores/post_store.jsx');
+const SearchStore = require('../stores/search_store.jsx');
const CommandList = require('./command_list.jsx');
const ErrorStore = require('../stores/error_store.jsx');
@@ -54,7 +54,7 @@ export default class Textbox extends React.Component {
}
componentDidMount() {
- PostStore.addAddMentionListener(this.onListenerChange);
+ SearchStore.addAddMentionListener(this.onListenerChange);
ErrorStore.addChangeListener(this.onRecievedError);
this.resize();
@@ -62,7 +62,7 @@ export default class Textbox extends React.Component {
}
componentWillUnmount() {
- PostStore.removeAddMentionListener(this.onListenerChange);
+ SearchStore.removeAddMentionListener(this.onListenerChange);
ErrorStore.removeChangeListener(this.onRecievedError);
}
diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx
index 70e559c30..1c8ce3c79 100644
--- a/web/react/components/user_settings/user_settings_general.jsx
+++ b/web/react/components/user_settings/user_settings_general.jsx
@@ -171,7 +171,7 @@ export default class UserSettingsGeneralTab extends React.Component {
}.bind(this),
function imageUploadFailure(err) {
var state = this.setupInitialState(this.props);
- state.serverError = err;
+ state.serverError = err.message;
this.setState(state);
}.bind(this)
);
diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx
index 4a9314b31..a58fdde3a 100644
--- a/web/react/stores/post_store.jsx
+++ b/web/react/stores/post_store.jsx
@@ -12,11 +12,7 @@ var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
var CHANGE_EVENT = 'change';
-var SEARCH_CHANGE_EVENT = 'search_change';
-var SEARCH_TERM_CHANGE_EVENT = 'search_term_change';
var SELECTED_POST_CHANGE_EVENT = 'selected_post_change';
-var MENTION_DATA_CHANGE_EVENT = 'mention_data_change';
-var ADD_MENTION_EVENT = 'add_mention';
var EDIT_POST_EVENT = 'edit_post';
class PostStoreClass extends EventEmitter {
@@ -26,21 +22,15 @@ class PostStoreClass extends EventEmitter {
this.emitChange = this.emitChange.bind(this);
this.addChangeListener = this.addChangeListener.bind(this);
this.removeChangeListener = this.removeChangeListener.bind(this);
- this.emitSearchChange = this.emitSearchChange.bind(this);
- this.addSearchChangeListener = this.addSearchChangeListener.bind(this);
- this.removeSearchChangeListener = this.removeSearchChangeListener.bind(this);
- this.emitSearchTermChange = this.emitSearchTermChange.bind(this);
- this.addSearchTermChangeListener = this.addSearchTermChangeListener.bind(this);
- this.removeSearchTermChangeListener = this.removeSearchTermChangeListener.bind(this);
+
this.emitSelectedPostChange = this.emitSelectedPostChange.bind(this);
this.addSelectedPostChangeListener = this.addSelectedPostChangeListener.bind(this);
this.removeSelectedPostChangeListener = this.removeSelectedPostChangeListener.bind(this);
- this.emitMentionDataChange = this.emitMentionDataChange.bind(this);
- this.addMentionDataChangeListener = this.addMentionDataChangeListener.bind(this);
- this.removeMentionDataChangeListener = this.removeMentionDataChangeListener.bind(this);
- this.emitAddMention = this.emitAddMention.bind(this);
- this.addAddMentionListener = this.addAddMentionListener.bind(this);
- this.removeAddMentionListener = this.removeAddMentionListener.bind(this);
+
+ this.emitEditPost = this.emitEditPost.bind(this);
+ this.addEditPostListener = this.addEditPostListener.bind(this);
+ this.removeEditPostListener = this.removeEditPostListener.bind(this);
+
this.getCurrentPosts = this.getCurrentPosts.bind(this);
this.storePosts = this.storePosts.bind(this);
this.pStorePosts = this.pStorePosts.bind(this);
@@ -59,13 +49,8 @@ class PostStoreClass extends EventEmitter {
this.pRemovePendingPost = this.pRemovePendingPost.bind(this);
this.clearPendingPosts = this.clearPendingPosts.bind(this);
this.updatePendingPost = this.updatePendingPost.bind(this);
- this.storeSearchResults = this.storeSearchResults.bind(this);
- this.getSearchResults = this.getSearchResults.bind(this);
- this.getIsMentionSearch = this.getIsMentionSearch.bind(this);
this.storeSelectedPost = this.storeSelectedPost.bind(this);
this.getSelectedPost = this.getSelectedPost.bind(this);
- this.storeSearchTerm = this.storeSearchTerm.bind(this);
- this.getSearchTerm = this.getSearchTerm.bind(this);
this.getEmptyDraft = this.getEmptyDraft.bind(this);
this.storeCurrentDraft = this.storeCurrentDraft.bind(this);
this.getCurrentDraft = this.getCurrentDraft.bind(this);
@@ -77,9 +62,6 @@ class PostStoreClass extends EventEmitter {
this.clearCommentDraftUploads = this.clearCommentDraftUploads.bind(this);
this.storeLatestUpdate = this.storeLatestUpdate.bind(this);
this.getLatestUpdate = this.getLatestUpdate.bind(this);
- this.emitEditPost = this.emitEditPost.bind(this);
- this.addEditPostListener = this.addEditPostListener.bind(this);
- this.removeEditPostListener = this.removeEditPostListener.bind(this);
this.getCurrentUsersLatestPost = this.getCurrentUsersLatestPost.bind(this);
}
emitChange() {
@@ -94,30 +76,6 @@ class PostStoreClass extends EventEmitter {
this.removeListener(CHANGE_EVENT, callback);
}
- emitSearchChange() {
- this.emit(SEARCH_CHANGE_EVENT);
- }
-
- addSearchChangeListener(callback) {
- this.on(SEARCH_CHANGE_EVENT, callback);
- }
-
- removeSearchChangeListener(callback) {
- this.removeListener(SEARCH_CHANGE_EVENT, callback);
- }
-
- emitSearchTermChange(doSearch, isMentionSearch) {
- this.emit(SEARCH_TERM_CHANGE_EVENT, doSearch, isMentionSearch);
- }
-
- addSearchTermChangeListener(callback) {
- this.on(SEARCH_TERM_CHANGE_EVENT, callback);
- }
-
- removeSearchTermChangeListener(callback) {
- this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback);
- }
-
emitSelectedPostChange(fromSearch) {
this.emit(SELECTED_POST_CHANGE_EVENT, fromSearch);
}
@@ -130,30 +88,6 @@ class PostStoreClass extends EventEmitter {
this.removeListener(SELECTED_POST_CHANGE_EVENT, callback);
}
- emitMentionDataChange(id, mentionText) {
- this.emit(MENTION_DATA_CHANGE_EVENT, id, mentionText);
- }
-
- addMentionDataChangeListener(callback) {
- this.on(MENTION_DATA_CHANGE_EVENT, callback);
- }
-
- removeMentionDataChangeListener(callback) {
- this.removeListener(MENTION_DATA_CHANGE_EVENT, callback);
- }
-
- emitAddMention(id, username) {
- this.emit(ADD_MENTION_EVENT, id, username);
- }
-
- addAddMentionListener(callback) {
- this.on(ADD_MENTION_EVENT, callback);
- }
-
- removeAddMentionListener(callback) {
- this.removeListener(ADD_MENTION_EVENT, callback);
- }
-
emitEditPost(post) {
this.emit(EDIT_POST_EVENT, post);
}
@@ -181,9 +115,9 @@ class PostStoreClass extends EventEmitter {
var postList = makePostListNonNull(this.getPosts(channelId));
- for (let pid in newPostList.posts) {
+ for (const pid in newPostList.posts) {
if (newPostList.posts.hasOwnProperty(pid)) {
- var np = newPostList.posts[pid];
+ const np = newPostList.posts[pid];
if (np.delete_at === 0) {
postList.posts[pid] = np;
if (postList.order.indexOf(pid) === -1) {
@@ -194,7 +128,7 @@ class PostStoreClass extends EventEmitter {
delete postList.posts[pid];
}
- var index = postList.order.indexOf(pid);
+ const index = postList.order.indexOf(pid);
if (index !== -1) {
postList.order.splice(index, 1);
}
@@ -202,7 +136,7 @@ class PostStoreClass extends EventEmitter {
}
}
- postList.order.sort(function postSort(a, b) {
+ postList.order.sort((a, b) => {
if (postList.posts[a].create_at > postList.posts[b].create_at) {
return -1;
}
@@ -306,7 +240,7 @@ class PostStoreClass extends EventEmitter {
var posts = postList.posts;
// sort failed posts to the bottom
- postList.order.sort(function postSort(a, b) {
+ postList.order.sort((a, b) => {
if (posts[a].state === Constants.POST_LOADING && posts[b].state === Constants.POST_FAILED) {
return 1;
}
@@ -371,7 +305,7 @@ class PostStoreClass extends EventEmitter {
this.pStorePendingPosts(channelId, postList);
}
clearPendingPosts() {
- BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', function clearPending(key) {
+ BrowserStore.actionOnGlobalItemsWithPrefix('pending_posts_', (key) => {
BrowserStore.removeItem(key);
});
}
@@ -387,28 +321,12 @@ class PostStoreClass extends EventEmitter {
this.pStorePendingPosts(post.channel_id, postList);
this.emitChange();
}
- storeSearchResults(results, isMentionSearch) {
- BrowserStore.setItem('search_results', results);
- BrowserStore.setItem('is_mention_search', Boolean(isMentionSearch));
- }
- getSearchResults() {
- return BrowserStore.getItem('search_results');
- }
- getIsMentionSearch() {
- return BrowserStore.getItem('is_mention_search');
- }
storeSelectedPost(postList) {
BrowserStore.setItem('select_post', postList);
}
getSelectedPost() {
return BrowserStore.getItem('select_post');
}
- storeSearchTerm(term) {
- BrowserStore.setItem('search_term', term);
- }
- getSearchTerm() {
- return BrowserStore.getItem('search_term');
- }
getEmptyDraft() {
return {message: '', uploadsInProgress: [], previews: []};
}
@@ -433,7 +351,7 @@ class PostStoreClass extends EventEmitter {
return BrowserStore.getGlobalItem('comment_draft_' + parentPostId, this.getEmptyDraft());
}
clearDraftUploads() {
- BrowserStore.actionOnGlobalItemsWithPrefix('draft_', function clearUploads(key, value) {
+ BrowserStore.actionOnGlobalItemsWithPrefix('draft_', (key, value) => {
if (value) {
value.uploadsInProgress = [];
BrowserStore.setItem(key, value);
@@ -441,7 +359,7 @@ class PostStoreClass extends EventEmitter {
});
}
clearCommentDraftUploads() {
- BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', function clearUploads(key, value) {
+ BrowserStore.actionOnGlobalItemsWithPrefix('comment_draft_', (key, value) => {
if (value) {
value.uploadsInProgress = [];
BrowserStore.setItem(key, value);
@@ -458,7 +376,7 @@ class PostStoreClass extends EventEmitter {
var PostStore = new PostStoreClass();
-PostStore.dispatchToken = AppDispatcher.register(function registry(payload) {
+PostStore.dispatchToken = AppDispatcher.register((payload) => {
var action = payload.action;
switch (action.type) {
@@ -469,24 +387,10 @@ PostStore.dispatchToken = AppDispatcher.register(function registry(payload) {
PostStore.pStorePost(action.post);
PostStore.emitChange();
break;
- case ActionTypes.RECIEVED_SEARCH:
- PostStore.storeSearchResults(action.results, action.is_mention_search);
- PostStore.emitSearchChange();
- break;
- case ActionTypes.RECIEVED_SEARCH_TERM:
- PostStore.storeSearchTerm(action.term);
- PostStore.emitSearchTermChange(action.do_search, action.is_mention_search);
- break;
case ActionTypes.RECIEVED_POST_SELECTED:
PostStore.storeSelectedPost(action.post_list);
PostStore.emitSelectedPostChange(action.from_search);
break;
- case ActionTypes.RECIEVED_MENTION_DATA:
- PostStore.emitMentionDataChange(action.id, action.mention_text);
- break;
- case ActionTypes.RECIEVED_ADD_MENTION:
- PostStore.emitAddMention(action.id, action.username);
- break;
case ActionTypes.RECIEVED_EDIT_POST:
PostStore.emitEditPost(action);
break;
diff --git a/web/react/stores/search_store.jsx b/web/react/stores/search_store.jsx
new file mode 100644
index 000000000..95f0ea845
--- /dev/null
+++ b/web/react/stores/search_store.jsx
@@ -0,0 +1,153 @@
+// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+var AppDispatcher = require('../dispatcher/app_dispatcher.jsx');
+var EventEmitter = require('events').EventEmitter;
+
+var BrowserStore = require('../stores/browser_store.jsx');
+
+var Constants = require('../utils/constants.jsx');
+var ActionTypes = Constants.ActionTypes;
+
+var CHANGE_EVENT = 'change';
+var SEARCH_CHANGE_EVENT = 'search_change';
+var SEARCH_TERM_CHANGE_EVENT = 'search_term_change';
+var MENTION_DATA_CHANGE_EVENT = 'mention_data_change';
+var ADD_MENTION_EVENT = 'add_mention';
+
+class SearchStoreClass extends EventEmitter {
+ constructor() {
+ super();
+
+ this.emitChange = this.emitChange.bind(this);
+ this.addChangeListener = this.addChangeListener.bind(this);
+ this.removeChangeListener = this.removeChangeListener.bind(this);
+
+ this.emitSearchChange = this.emitSearchChange.bind(this);
+ this.addSearchChangeListener = this.addSearchChangeListener.bind(this);
+ this.removeSearchChangeListener = this.removeSearchChangeListener.bind(this);
+
+ this.emitSearchTermChange = this.emitSearchTermChange.bind(this);
+ this.addSearchTermChangeListener = this.addSearchTermChangeListener.bind(this);
+ this.removeSearchTermChangeListener = this.removeSearchTermChangeListener.bind(this);
+
+ this.emitMentionDataChange = this.emitMentionDataChange.bind(this);
+ this.addMentionDataChangeListener = this.addMentionDataChangeListener.bind(this);
+ this.removeMentionDataChangeListener = this.removeMentionDataChangeListener.bind(this);
+
+ this.getSearchResults = this.getSearchResults.bind(this);
+ this.getIsMentionSearch = this.getIsMentionSearch.bind(this);
+
+ this.storeSearchTerm = this.storeSearchTerm.bind(this);
+ this.getSearchTerm = this.getSearchTerm.bind(this);
+
+ this.storeSearchResults = this.storeSearchResults.bind(this);
+ }
+
+ emitChange() {
+ this.emit(CHANGE_EVENT);
+ }
+
+ addChangeListener(callback) {
+ this.on(CHANGE_EVENT, callback);
+ }
+
+ removeChangeListener(callback) {
+ this.removeListener(CHANGE_EVENT, callback);
+ }
+
+ emitSearchChange() {
+ this.emit(SEARCH_CHANGE_EVENT);
+ }
+
+ addSearchChangeListener(callback) {
+ this.on(SEARCH_CHANGE_EVENT, callback);
+ }
+
+ removeSearchChangeListener(callback) {
+ this.removeListener(SEARCH_CHANGE_EVENT, callback);
+ }
+
+ emitSearchTermChange(doSearch, isMentionSearch) {
+ this.emit(SEARCH_TERM_CHANGE_EVENT, doSearch, isMentionSearch);
+ }
+
+ addSearchTermChangeListener(callback) {
+ this.on(SEARCH_TERM_CHANGE_EVENT, callback);
+ }
+
+ removeSearchTermChangeListener(callback) {
+ this.removeListener(SEARCH_TERM_CHANGE_EVENT, callback);
+ }
+
+ getSearchResults() {
+ return BrowserStore.getItem('search_results');
+ }
+
+ getIsMentionSearch() {
+ return BrowserStore.getItem('is_mention_search');
+ }
+
+ storeSearchTerm(term) {
+ BrowserStore.setItem('search_term', term);
+ }
+
+ getSearchTerm() {
+ return BrowserStore.getItem('search_term');
+ }
+
+ emitMentionDataChange(id, mentionText) {
+ this.emit(MENTION_DATA_CHANGE_EVENT, id, mentionText);
+ }
+
+ addMentionDataChangeListener(callback) {
+ this.on(MENTION_DATA_CHANGE_EVENT, callback);
+ }
+
+ removeMentionDataChangeListener(callback) {
+ this.removeListener(MENTION_DATA_CHANGE_EVENT, callback);
+ }
+
+ emitAddMention(id, username) {
+ this.emit(ADD_MENTION_EVENT, id, username);
+ }
+
+ addAddMentionListener(callback) {
+ this.on(ADD_MENTION_EVENT, callback);
+ }
+
+ removeAddMentionListener(callback) {
+ this.removeListener(ADD_MENTION_EVENT, callback);
+ }
+
+ storeSearchResults(results, isMentionSearch) {
+ BrowserStore.setItem('search_results', results);
+ BrowserStore.setItem('is_mention_search', Boolean(isMentionSearch));
+ }
+}
+
+var SearchStore = new SearchStoreClass();
+
+SearchStore.dispatchToken = AppDispatcher.register((payload) => {
+ var action = payload.action;
+
+ switch (action.type) {
+ case ActionTypes.RECIEVED_SEARCH:
+ SearchStore.storeSearchResults(action.results, action.is_mention_search);
+ SearchStore.emitSearchChange();
+ break;
+ case ActionTypes.RECIEVED_SEARCH_TERM:
+ SearchStore.storeSearchTerm(action.term);
+ SearchStore.emitSearchTermChange(action.do_search, action.is_mention_search);
+ break;
+ case ActionTypes.RECIEVED_MENTION_DATA:
+ SearchStore.emitMentionDataChange(action.id, action.mention_text);
+ break;
+ case ActionTypes.RECIEVED_ADD_MENTION:
+ SearchStore.emitAddMention(action.id, action.username);
+ break;
+ default:
+ }
+});
+
+export default SearchStore;
diff --git a/web/react/stores/socket_store.jsx b/web/react/stores/socket_store.jsx
index 9410c1e9c..4d69a6716 100644
--- a/web/react/stores/socket_store.jsx
+++ b/web/react/stores/socket_store.jsx
@@ -86,7 +86,7 @@ class SocketStoreClass extends EventEmitter {
this.failCount = this.failCount + 1;
- ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.'});
+ ErrorStore.storeLastError({connErrorCount: this.failCount, message: 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.'});
ErrorStore.emitChange();
};
@@ -160,7 +160,7 @@ function handleNewPostEvent(msg) {
if (window.isActive) {
AsyncClient.updateLastViewedAt(true);
}
- } else {
+ } else if (UserStore.getCurrentId() !== msg.user_id || post.type !== Constants.POST_TYPE_JOIN_LEAVE) {
AsyncClient.getChannel(msg.channel_id);
}
diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx
index b1bc71d54..75dd35e3f 100644
--- a/web/react/utils/async_client.jsx
+++ b/web/react/utils/async_client.jsx
@@ -132,7 +132,7 @@ export function getChannel(id) {
callTracker['getChannel' + id] = utils.getTimestamp();
client.getChannel(id,
- function getChannelSuccess(data, textStatus, xhr) {
+ (data, textStatus, xhr) => {
callTracker['getChannel' + id] = 0;
if (xhr.status === 304 || !data) {
@@ -145,7 +145,7 @@ export function getChannel(id) {
member: data.member
});
},
- function getChannelFailure(err) {
+ (err) => {
callTracker['getChannel' + id] = 0;
dispatchError(err, 'getChannel');
}
diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx
index bc73f3c64..bf117b3b3 100644
--- a/web/react/utils/client.jsx
+++ b/web/react/utils/client.jsx
@@ -34,7 +34,7 @@ function handleError(methodName, xhr, status, err) {
if (oldError && oldError.connErrorCount) {
errorCount += oldError.connErrorCount;
- connectError = 'We cannot reach the Mattermost service. The service may be down or misconfigured. Please contact an administrator to make sure the WebSocket port is configured properly.';
+ connectError = 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.';
}
e = {message: connectError, connErrorCount: errorCount};
@@ -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',
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index cda04bf04..0e89b9470 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -98,6 +98,7 @@ module.exports = {
POST_LOADING: 'loading',
POST_FAILED: 'failed',
POST_DELETED: 'deleted',
+ POST_TYPE_JOIN_LEAVE: 'join_leave',
RESERVED_TEAM_NAMES: [
'www',
'web',
diff --git a/web/react/utils/markdown.jsx b/web/react/utils/markdown.jsx
index 01cc309b8..ad11a95ac 100644
--- a/web/react/utils/markdown.jsx
+++ b/web/react/utils/markdown.jsx
@@ -121,8 +121,11 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
paragraph(text) {
let outText = text;
+ // required so markdown does not strip '_' from @user_names
+ outText = TextFormatting.doFormatMentions(text);
+
if (!('emoticons' in this.options) || this.options.emoticon) {
- outText = TextFormatting.doFormatEmoticons(text);
+ outText = TextFormatting.doFormatEmoticons(outText);
}
if (this.formattingOptions.singleline) {
@@ -136,7 +139,7 @@ export class MattermostMarkdownRenderer extends marked.Renderer {
return `<table class="markdown__table"><thead>${header}</thead><tbody>${body}</tbody></table>`;
}
- text(text) {
- return TextFormatting.doFormatText(text, this.formattingOptions);
+ text(txt) {
+ return TextFormatting.doFormatText(txt, this.formattingOptions);
}
}
diff --git a/web/react/utils/text_formatting.jsx b/web/react/utils/text_formatting.jsx
index 4b6d87254..9f1a5a53f 100644
--- a/web/react/utils/text_formatting.jsx
+++ b/web/react/utils/text_formatting.jsx
@@ -47,8 +47,8 @@ export function doFormatText(text, options) {
const tokens = new Map();
// replace important words and phrases with tokens
- output = autolinkUrls(output, tokens);
output = autolinkAtMentions(output, tokens);
+ output = autolinkUrls(output, tokens);
output = autolinkHashtags(output, tokens);
if (!('emoticons' in options) || options.emoticon) {
@@ -78,6 +78,13 @@ export function doFormatEmoticons(text) {
return output;
}
+export function doFormatMentions(text) {
+ const tokens = new Map();
+ let output = autolinkAtMentions(text, tokens);
+ output = replaceTokens(output, tokens);
+ return output;
+}
+
export function sanitizeHtml(text) {
let output = text;
@@ -188,6 +195,7 @@ function autolinkAtMentions(text, tokens) {
let output = text;
output = output.replace(/(^|\s)(@([a-z0-9.\-_]*))/gi, replaceAtMentionWithToken);
+
return output;
}
diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx
index 7a876d518..fadab27a7 100644
--- a/web/react/utils/utils.jsx
+++ b/web/react/utils/utils.jsx
@@ -8,6 +8,7 @@ var PreferenceStore = require('../stores/preference_store.jsx');
var TeamStore = require('../stores/team_store.jsx');
var Constants = require('../utils/constants.jsx');
var ActionTypes = Constants.ActionTypes;
+var Client = require('./client.jsx');
var AsyncClient = require('./async_client.jsx');
var client = require('./client.jsx');
var Autolinker = require('autolinker');
@@ -1009,3 +1010,44 @@ export function windowWidth() {
export function windowHeight() {
return $(window).height();
}
+
+export function openDirectChannelToUser(user, successCb, errorCb) {
+ const channelName = this.getDirectChannelName(UserStore.getCurrentId(), user.id);
+ let channel = ChannelStore.getByName(channelName);
+
+ const preference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, user.id, 'true');
+ AsyncClient.savePreferences([preference]);
+
+ if (channel) {
+ if ($.isFunction(successCb)) {
+ successCb(channel, true);
+ }
+ } else {
+ channel = {
+ name: channelName,
+ last_post_at: 0,
+ total_msg_count: 0,
+ type: 'D',
+ display_name: user.username,
+ teammate_id: user.id,
+ status: UserStore.getStatus(user.id)
+ };
+
+ Client.createDirectChannel(
+ channel,
+ user.id,
+ (data) => {
+ AsyncClient.getChannel(data.id);
+ if ($.isFunction(successCb)) {
+ successCb(data, false);
+ }
+ },
+ () => {
+ window.location.href = TeamStore.getCurrentTeamUrl() + '/channels/' + channelName;
+ if ($.isFunction(errorCb)) {
+ errorCb();
+ }
+ }
+ );
+ }
+}
diff --git a/web/sass-files/sass/partials/_admin-console.scss b/web/sass-files/sass/partials/_admin-console.scss
index 14f1d9c2f..d0241f795 100644
--- a/web/sass-files/sass/partials/_admin-console.scss
+++ b/web/sass-files/sass/partials/_admin-console.scss
@@ -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;
diff --git a/web/sass-files/sass/partials/_popover.scss b/web/sass-files/sass/partials/_popover.scss
index 484e63c7c..4a2ad2748 100644
--- a/web/sass-files/sass/partials/_popover.scss
+++ b/web/sass-files/sass/partials/_popover.scss
@@ -61,3 +61,36 @@
@include opacity(1);
}
}
+
+#member-list-popover {
+ max-width: initial;
+ .popover-content > div {
+ max-height: 350px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ > div {
+ border-bottom: 1px solid rgba(51,51,51,0.1);
+ padding: 8px 8px 8px 15px;
+ width: 100%;
+ box-sizing: content-box;
+ @include clearfix;
+ .profile-img {
+ border-radius: 50px;
+ margin-right: 8px;
+ }
+ .more-name {
+ font-weight: 600;
+ font-size: 0.95em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .more-description {
+ @include opacity(0.7);
+ }
+ .profile-action {
+ margin-left: 8px;
+ margin-right: 18px;
+ }
+ }
+ }
+}
diff --git a/web/static/js/Chart.min.js b/web/static/js/Chart.min.js
new file mode 100644
index 000000000..3a0a2c873
--- /dev/null
+++ b/web/static/js/Chart.min.js
@@ -0,0 +1,11 @@
+/*!
+ * Chart.js
+ * http://chartjs.org/
+ * Version: 1.0.2
+ *
+ * Copyright 2015 Nick Downie
+ * Released under the MIT license
+ * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md
+ */
+(function(){"use strict";var t=this,i=t.Chart,e=function(t){this.canvas=t.canvas,this.ctx=t;var i=function(t,i){return t["offset"+i]?t["offset"+i]:document.defaultView.getComputedStyle(t).getPropertyValue(i)},e=this.width=i(t.canvas,"Width"),n=this.height=i(t.canvas,"Height");t.canvas.width=e,t.canvas.height=n;var e=this.width=t.canvas.width,n=this.height=t.canvas.height;return this.aspectRatio=this.width/this.height,s.retinaScale(this),this};e.defaults={global:{animation:!0,animationSteps:60,animationEasing:"easeOutQuart",showScale:!0,scaleOverride:!1,scaleSteps:null,scaleStepWidth:null,scaleStartValue:null,scaleLineColor:"rgba(0,0,0,.1)",scaleLineWidth:1,scaleShowLabels:!0,scaleLabel:"<%=value%>",scaleIntegersOnly:!0,scaleBeginAtZero:!1,scaleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",scaleFontSize:12,scaleFontStyle:"normal",scaleFontColor:"#666",responsive:!1,maintainAspectRatio:!0,showTooltips:!0,customTooltips:!1,tooltipEvents:["mousemove","touchstart","touchmove","mouseout"],tooltipFillColor:"rgba(0,0,0,0.8)",tooltipFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipFontSize:14,tooltipFontStyle:"normal",tooltipFontColor:"#fff",tooltipTitleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipTitleFontSize:14,tooltipTitleFontStyle:"bold",tooltipTitleFontColor:"#fff",tooltipYPadding:6,tooltipXPadding:6,tooltipCaretSize:8,tooltipCornerRadius:6,tooltipXOffset:10,tooltipTemplate:"<%if (label){%><%=label%>: <%}%><%= value %>",multiTooltipTemplate:"<%= value %>",multiTooltipKeyBackground:"#fff",onAnimationProgress:function(){},onAnimationComplete:function(){}}},e.types={};var s=e.helpers={},n=s.each=function(t,i,e){var s=Array.prototype.slice.call(arguments,3);if(t)if(t.length===+t.length){var n;for(n=0;n<t.length;n++)i.apply(e,[t[n],n].concat(s))}else for(var o in t)i.apply(e,[t[o],o].concat(s))},o=s.clone=function(t){var i={};return n(t,function(e,s){t.hasOwnProperty(s)&&(i[s]=e)}),i},a=s.extend=function(t){return n(Array.prototype.slice.call(arguments,1),function(i){n(i,function(e,s){i.hasOwnProperty(s)&&(t[s]=e)})}),t},h=s.merge=function(){var t=Array.prototype.slice.call(arguments,0);return t.unshift({}),a.apply(null,t)},l=s.indexOf=function(t,i){if(Array.prototype.indexOf)return t.indexOf(i);for(var e=0;e<t.length;e++)if(t[e]===i)return e;return-1},r=(s.where=function(t,i){var e=[];return s.each(t,function(t){i(t)&&e.push(t)}),e},s.findNextWhere=function(t,i,e){e||(e=-1);for(var s=e+1;s<t.length;s++){var n=t[s];if(i(n))return n}},s.findPreviousWhere=function(t,i,e){e||(e=t.length);for(var s=e-1;s>=0;s--){var n=t[s];if(i(n))return n}},s.inherits=function(t){var i=this,e=t&&t.hasOwnProperty("constructor")?t.constructor:function(){return i.apply(this,arguments)},s=function(){this.constructor=e};return s.prototype=i.prototype,e.prototype=new s,e.extend=r,t&&a(e.prototype,t),e.__super__=i.prototype,e}),c=s.noop=function(){},u=s.uid=function(){var t=0;return function(){return"chart-"+t++}}(),d=s.warn=function(t){window.console&&"function"==typeof window.console.warn&&console.warn(t)},p=s.amd="function"==typeof define&&define.amd,f=s.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},g=s.max=function(t){return Math.max.apply(Math,t)},m=s.min=function(t){return Math.min.apply(Math,t)},v=(s.cap=function(t,i,e){if(f(i)){if(t>i)return i}else if(f(e)&&e>t)return e;return t},s.getDecimalPlaces=function(t){return t%1!==0&&f(t)?t.toString().split(".")[1].length:0}),S=s.radians=function(t){return t*(Math.PI/180)},x=(s.getAngleFromPoint=function(t,i){var e=i.x-t.x,s=i.y-t.y,n=Math.sqrt(e*e+s*s),o=2*Math.PI+Math.atan2(s,e);return 0>e&&0>s&&(o+=2*Math.PI),{angle:o,distance:n}},s.aliasPixel=function(t){return t%2===0?0:.5}),y=(s.splineCurve=function(t,i,e,s){var n=Math.sqrt(Math.pow(i.x-t.x,2)+Math.pow(i.y-t.y,2)),o=Math.sqrt(Math.pow(e.x-i.x,2)+Math.pow(e.y-i.y,2)),a=s*n/(n+o),h=s*o/(n+o);return{inner:{x:i.x-a*(e.x-t.x),y:i.y-a*(e.y-t.y)},outer:{x:i.x+h*(e.x-t.x),y:i.y+h*(e.y-t.y)}}},s.calculateOrderOfMagnitude=function(t){return Math.floor(Math.log(t)/Math.LN10)}),C=(s.calculateScaleRange=function(t,i,e,s,n){var o=2,a=Math.floor(i/(1.5*e)),h=o>=a,l=g(t),r=m(t);l===r&&(l+=.5,r>=.5&&!s?r-=.5:l+=.5);for(var c=Math.abs(l-r),u=y(c),d=Math.ceil(l/(1*Math.pow(10,u)))*Math.pow(10,u),p=s?0:Math.floor(r/(1*Math.pow(10,u)))*Math.pow(10,u),f=d-p,v=Math.pow(10,u),S=Math.round(f/v);(S>a||a>2*S)&&!h;)if(S>a)v*=2,S=Math.round(f/v),S%1!==0&&(h=!0);else if(n&&u>=0){if(v/2%1!==0)break;v/=2,S=Math.round(f/v)}else v/=2,S=Math.round(f/v);return h&&(S=o,v=f/S),{steps:S,stepValue:v,min:p,max:p+S*v}},s.template=function(t,i){function e(t,i){var e=/\W/.test(t)?new Function("obj","var p=[],print=function(){p.push.apply(p,arguments);};with(obj){p.push('"+t.replace(/[\r\t\n]/g," ").split("<%").join(" ").replace(/((^|%>)[^\t]*)'/g,"$1\r").replace(/\t=(.*?)%>/g,"',$1,'").split(" ").join("');").split("%>").join("p.push('").split("\r").join("\\'")+"');}return p.join('');"):s[t]=s[t];return i?e(i):e}if(t instanceof Function)return t(i);var s={};return e(t,i)}),w=(s.generateLabels=function(t,i,e,s){var o=new Array(i);return labelTemplateString&&n(o,function(i,n){o[n]=C(t,{value:e+s*(n+1)})}),o},s.easingEffects={linear:function(t){return t},easeInQuad:function(t){return t*t},easeOutQuad:function(t){return-1*t*(t-2)},easeInOutQuad:function(t){return(t/=.5)<1?.5*t*t:-0.5*(--t*(t-2)-1)},easeInCubic:function(t){return t*t*t},easeOutCubic:function(t){return 1*((t=t/1-1)*t*t+1)},easeInOutCubic:function(t){return(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},easeInQuart:function(t){return t*t*t*t},easeOutQuart:function(t){return-1*((t=t/1-1)*t*t*t-1)},easeInOutQuart:function(t){return(t/=.5)<1?.5*t*t*t*t:-0.5*((t-=2)*t*t*t-2)},easeInQuint:function(t){return 1*(t/=1)*t*t*t*t},easeOutQuint:function(t){return 1*((t=t/1-1)*t*t*t*t+1)},easeInOutQuint:function(t){return(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},easeInSine:function(t){return-1*Math.cos(t/1*(Math.PI/2))+1},easeOutSine:function(t){return 1*Math.sin(t/1*(Math.PI/2))},easeInOutSine:function(t){return-0.5*(Math.cos(Math.PI*t/1)-1)},easeInExpo:function(t){return 0===t?1:1*Math.pow(2,10*(t/1-1))},easeOutExpo:function(t){return 1===t?1:1*(-Math.pow(2,-10*t/1)+1)},easeInOutExpo:function(t){return 0===t?0:1===t?1:(t/=.5)<1?.5*Math.pow(2,10*(t-1)):.5*(-Math.pow(2,-10*--t)+2)},easeInCirc:function(t){return t>=1?t:-1*(Math.sqrt(1-(t/=1)*t)-1)},easeOutCirc:function(t){return 1*Math.sqrt(1-(t=t/1-1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-0.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:1==(t/=1)?1:(e||(e=.3),s<Math.abs(1)?(s=1,i=e/4):i=e/(2*Math.PI)*Math.asin(1/s),-(s*Math.pow(2,10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e)))},easeOutElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:1==(t/=1)?1:(e||(e=.3),s<Math.abs(1)?(s=1,i=e/4):i=e/(2*Math.PI)*Math.asin(1/s),s*Math.pow(2,-10*t)*Math.sin(2*(1*t-i)*Math.PI/e)+1)},easeInOutElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:2==(t/=.5)?1:(e||(e=.3*1.5),s<Math.abs(1)?(s=1,i=e/4):i=e/(2*Math.PI)*Math.asin(1/s),1>t?-.5*s*Math.pow(2,10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e):s*Math.pow(2,-10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e)*.5+1)},easeInBack:function(t){var i=1.70158;return 1*(t/=1)*t*((i+1)*t-i)},easeOutBack:function(t){var i=1.70158;return 1*((t=t/1-1)*t*((i+1)*t+i)+1)},easeInOutBack:function(t){var i=1.70158;return(t/=.5)<1?.5*t*t*(((i*=1.525)+1)*t-i):.5*((t-=2)*t*(((i*=1.525)+1)*t+i)+2)},easeInBounce:function(t){return 1-w.easeOutBounce(1-t)},easeOutBounce:function(t){return(t/=1)<1/2.75?7.5625*t*t:2/2.75>t?1*(7.5625*(t-=1.5/2.75)*t+.75):2.5/2.75>t?1*(7.5625*(t-=2.25/2.75)*t+.9375):1*(7.5625*(t-=2.625/2.75)*t+.984375)},easeInOutBounce:function(t){return.5>t?.5*w.easeInBounce(2*t):.5*w.easeOutBounce(2*t-1)+.5}}),b=s.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)}}(),P=s.cancelAnimFrame=function(){return window.cancelAnimationFrame||window.webkitCancelAnimationFrame||window.mozCancelAnimationFrame||window.oCancelAnimationFrame||window.msCancelAnimationFrame||function(t){return window.clearTimeout(t,1e3/60)}}(),L=(s.animationLoop=function(t,i,e,s,n,o){var a=0,h=w[e]||w.linear,l=function(){a++;var e=a/i,r=h(e);t.call(o,r,e,a),s.call(o,r,e),i>a?o.animationFrame=b(l):n.apply(o)};b(l)},s.getRelativePosition=function(t){var i,e,s=t.originalEvent||t,n=t.currentTarget||t.srcElement,o=n.getBoundingClientRect();return s.touches?(i=s.touches[0].clientX-o.left,e=s.touches[0].clientY-o.top):(i=s.clientX-o.left,e=s.clientY-o.top),{x:i,y:e}},s.addEvent=function(t,i,e){t.addEventListener?t.addEventListener(i,e):t.attachEvent?t.attachEvent("on"+i,e):t["on"+i]=e}),k=s.removeEvent=function(t,i,e){t.removeEventListener?t.removeEventListener(i,e,!1):t.detachEvent?t.detachEvent("on"+i,e):t["on"+i]=c},F=(s.bindEvents=function(t,i,e){t.events||(t.events={}),n(i,function(i){t.events[i]=function(){e.apply(t,arguments)},L(t.chart.canvas,i,t.events[i])})},s.unbindEvents=function(t,i){n(i,function(i,e){k(t.chart.canvas,e,i)})}),R=s.getMaximumWidth=function(t){var i=t.parentNode;return i.clientWidth},T=s.getMaximumHeight=function(t){var i=t.parentNode;return i.clientHeight},A=(s.getMaximumSize=s.getMaximumWidth,s.retinaScale=function(t){var i=t.ctx,e=t.canvas.width,s=t.canvas.height;window.devicePixelRatio&&(i.canvas.style.width=e+"px",i.canvas.style.height=s+"px",i.canvas.height=s*window.devicePixelRatio,i.canvas.width=e*window.devicePixelRatio,i.scale(window.devicePixelRatio,window.devicePixelRatio))}),M=s.clear=function(t){t.ctx.clearRect(0,0,t.width,t.height)},W=s.fontString=function(t,i,e){return i+" "+t+"px "+e},z=s.longestText=function(t,i,e){t.font=i;var s=0;return n(e,function(i){var e=t.measureText(i).width;s=e>s?e:s}),s},B=s.drawRoundedRectangle=function(t,i,e,s,n,o){t.beginPath(),t.moveTo(i+o,e),t.lineTo(i+s-o,e),t.quadraticCurveTo(i+s,e,i+s,e+o),t.lineTo(i+s,e+n-o),t.quadraticCurveTo(i+s,e+n,i+s-o,e+n),t.lineTo(i+o,e+n),t.quadraticCurveTo(i,e+n,i,e+n-o),t.lineTo(i,e+o),t.quadraticCurveTo(i,e,i+o,e),t.closePath()};e.instances={},e.Type=function(t,i,s){this.options=i,this.chart=s,this.id=u(),e.instances[this.id]=this,i.responsive&&this.resize(),this.initialize.call(this,t)},a(e.Type.prototype,{initialize:function(){return this},clear:function(){return M(this.chart),this},stop:function(){return P(this.animationFrame),this},resize:function(t){this.stop();var i=this.chart.canvas,e=R(this.chart.canvas),s=this.options.maintainAspectRatio?e/this.chart.aspectRatio:T(this.chart.canvas);return i.width=this.chart.width=e,i.height=this.chart.height=s,A(this.chart),"function"==typeof t&&t.apply(this,Array.prototype.slice.call(arguments,1)),this},reflow:c,render:function(t){return t&&this.reflow(),this.options.animation&&!t?s.animationLoop(this.draw,this.options.animationSteps,this.options.animationEasing,this.options.onAnimationProgress,this.options.onAnimationComplete,this):(this.draw(),this.options.onAnimationComplete.call(this)),this},generateLegend:function(){return C(this.options.legendTemplate,this)},destroy:function(){this.clear(),F(this,this.events);var t=this.chart.canvas;t.width=this.chart.width,t.height=this.chart.height,t.style.removeProperty?(t.style.removeProperty("width"),t.style.removeProperty("height")):(t.style.removeAttribute("width"),t.style.removeAttribute("height")),delete e.instances[this.id]},showTooltip:function(t,i){"undefined"==typeof this.activeElements&&(this.activeElements=[]);var o=function(t){var i=!1;return t.length!==this.activeElements.length?i=!0:(n(t,function(t,e){t!==this.activeElements[e]&&(i=!0)},this),i)}.call(this,t);if(o||i){if(this.activeElements=t,this.draw(),this.options.customTooltips&&this.options.customTooltips(!1),t.length>0)if(this.datasets&&this.datasets.length>1){for(var a,h,r=this.datasets.length-1;r>=0&&(a=this.datasets[r].points||this.datasets[r].bars||this.datasets[r].segments,h=l(a,t[0]),-1===h);r--);var c=[],u=[],d=function(){var t,i,e,n,o,a=[],l=[],r=[];return s.each(this.datasets,function(i){t=i.points||i.bars||i.segments,t[h]&&t[h].hasValue()&&a.push(t[h])}),s.each(a,function(t){l.push(t.x),r.push(t.y),c.push(s.template(this.options.multiTooltipTemplate,t)),u.push({fill:t._saved.fillColor||t.fillColor,stroke:t._saved.strokeColor||t.strokeColor})},this),o=m(r),e=g(r),n=m(l),i=g(l),{x:n>this.chart.width/2?n:i,y:(o+e)/2}}.call(this,h);new e.MultiTooltip({x:d.x,y:d.y,xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,xOffset:this.options.tooltipXOffset,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,titleTextColor:this.options.tooltipTitleFontColor,titleFontFamily:this.options.tooltipTitleFontFamily,titleFontStyle:this.options.tooltipTitleFontStyle,titleFontSize:this.options.tooltipTitleFontSize,cornerRadius:this.options.tooltipCornerRadius,labels:c,legendColors:u,legendColorBackground:this.options.multiTooltipKeyBackground,title:t[0].label,chart:this.chart,ctx:this.chart.ctx,custom:this.options.customTooltips}).draw()}else n(t,function(t){var i=t.tooltipPosition();new e.Tooltip({x:Math.round(i.x),y:Math.round(i.y),xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,caretHeight:this.options.tooltipCaretSize,cornerRadius:this.options.tooltipCornerRadius,text:C(this.options.tooltipTemplate,t),chart:this.chart,custom:this.options.customTooltips}).draw()},this);return this}},toBase64Image:function(){return this.chart.canvas.toDataURL.apply(this.chart.canvas,arguments)}}),e.Type.extend=function(t){var i=this,s=function(){return i.apply(this,arguments)};if(s.prototype=o(i.prototype),a(s.prototype,t),s.extend=e.Type.extend,t.name||i.prototype.name){var n=t.name||i.prototype.name,l=e.defaults[i.prototype.name]?o(e.defaults[i.prototype.name]):{};e.defaults[n]=a(l,t.defaults),e.types[n]=s,e.prototype[n]=function(t,i){var o=h(e.defaults.global,e.defaults[n],i||{});return new s(t,o,this)}}else d("Name not provided for this chart, so it hasn't been registered");return i},e.Element=function(t){a(this,t),this.initialize.apply(this,arguments),this.save()},a(e.Element.prototype,{initialize:function(){},restore:function(t){return t?n(t,function(t){this[t]=this._saved[t]},this):a(this,this._saved),this},save:function(){return this._saved=o(this),delete this._saved._saved,this},update:function(t){return n(t,function(t,i){this._saved[i]=this[i],this[i]=t},this),this},transition:function(t,i){return n(t,function(t,e){this[e]=(t-this._saved[e])*i+this._saved[e]},this),this},tooltipPosition:function(){return{x:this.x,y:this.y}},hasValue:function(){return f(this.value)}}),e.Element.extend=r,e.Point=e.Element.extend({display:!0,inRange:function(t,i){var e=this.hitDetectionRadius+this.radius;return Math.pow(t-this.x,2)+Math.pow(i-this.y,2)<Math.pow(e,2)},draw:function(){if(this.display){var t=this.ctx;t.beginPath(),t.arc(this.x,this.y,this.radius,0,2*Math.PI),t.closePath(),t.strokeStyle=this.strokeColor,t.lineWidth=this.strokeWidth,t.fillStyle=this.fillColor,t.fill(),t.stroke()}}}),e.Arc=e.Element.extend({inRange:function(t,i){var e=s.getAngleFromPoint(this,{x:t,y:i}),n=e.angle>=this.startAngle&&e.angle<=this.endAngle,o=e.distance>=this.innerRadius&&e.distance<=this.outerRadius;return n&&o},tooltipPosition:function(){var t=this.startAngle+(this.endAngle-this.startAngle)/2,i=(this.outerRadius-this.innerRadius)/2+this.innerRadius;return{x:this.x+Math.cos(t)*i,y:this.y+Math.sin(t)*i}},draw:function(t){var i=this.ctx;i.beginPath(),i.arc(this.x,this.y,this.outerRadius,this.startAngle,this.endAngle),i.arc(this.x,this.y,this.innerRadius,this.endAngle,this.startAngle,!0),i.closePath(),i.strokeStyle=this.strokeColor,i.lineWidth=this.strokeWidth,i.fillStyle=this.fillColor,i.fill(),i.lineJoin="bevel",this.showStroke&&i.stroke()}}),e.Rectangle=e.Element.extend({draw:function(){var t=this.ctx,i=this.width/2,e=this.x-i,s=this.x+i,n=this.base-(this.base-this.y),o=this.strokeWidth/2;this.showStroke&&(e+=o,s-=o,n+=o),t.beginPath(),t.fillStyle=this.fillColor,t.strokeStyle=this.strokeColor,t.lineWidth=this.strokeWidth,t.moveTo(e,this.base),t.lineTo(e,n),t.lineTo(s,n),t.lineTo(s,this.base),t.fill(),this.showStroke&&t.stroke()},height:function(){return this.base-this.y},inRange:function(t,i){return t>=this.x-this.width/2&&t<=this.x+this.width/2&&i>=this.y&&i<=this.base}}),e.Tooltip=e.Element.extend({draw:function(){var t=this.chart.ctx;t.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.xAlign="center",this.yAlign="above";var i=this.caretPadding=2,e=t.measureText(this.text).width+2*this.xPadding,s=this.fontSize+2*this.yPadding,n=s+this.caretHeight+i;this.x+e/2>this.chart.width?this.xAlign="left":this.x-e/2<0&&(this.xAlign="right"),this.y-n<0&&(this.yAlign="below");var o=this.x-e/2,a=this.y-n;if(t.fillStyle=this.fillColor,this.custom)this.custom(this);else{switch(this.yAlign){case"above":t.beginPath(),t.moveTo(this.x,this.y-i),t.lineTo(this.x+this.caretHeight,this.y-(i+this.caretHeight)),t.lineTo(this.x-this.caretHeight,this.y-(i+this.caretHeight)),t.closePath(),t.fill();break;case"below":a=this.y+i+this.caretHeight,t.beginPath(),t.moveTo(this.x,this.y+i),t.lineTo(this.x+this.caretHeight,this.y+i+this.caretHeight),t.lineTo(this.x-this.caretHeight,this.y+i+this.caretHeight),t.closePath(),t.fill()}switch(this.xAlign){case"left":o=this.x-e+(this.cornerRadius+this.caretHeight);break;case"right":o=this.x-(this.cornerRadius+this.caretHeight)}B(t,o,a,e,s,this.cornerRadius),t.fill(),t.fillStyle=this.textColor,t.textAlign="center",t.textBaseline="middle",t.fillText(this.text,o+e/2,a+s/2)}}}),e.MultiTooltip=e.Element.extend({initialize:function(){this.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.titleFont=W(this.titleFontSize,this.titleFontStyle,this.titleFontFamily),this.height=this.labels.length*this.fontSize+(this.labels.length-1)*(this.fontSize/2)+2*this.yPadding+1.5*this.titleFontSize,this.ctx.font=this.titleFont;var t=this.ctx.measureText(this.title).width,i=z(this.ctx,this.font,this.labels)+this.fontSize+3,e=g([i,t]);this.width=e+2*this.xPadding;var s=this.height/2;this.y-s<0?this.y=s:this.y+s>this.chart.height&&(this.y=this.chart.height-s),this.x>this.chart.width/2?this.x-=this.xOffset+this.width:this.x+=this.xOffset},getLineHeight:function(t){var i=this.y-this.height/2+this.yPadding,e=t-1;return 0===t?i+this.titleFontSize/2:i+(1.5*this.fontSize*e+this.fontSize/2)+1.5*this.titleFontSize},draw:function(){if(this.custom)this.custom(this);else{B(this.ctx,this.x,this.y-this.height/2,this.width,this.height,this.cornerRadius);var t=this.ctx;t.fillStyle=this.fillColor,t.fill(),t.closePath(),t.textAlign="left",t.textBaseline="middle",t.fillStyle=this.titleTextColor,t.font=this.titleFont,t.fillText(this.title,this.x+this.xPadding,this.getLineHeight(0)),t.font=this.font,s.each(this.labels,function(i,e){t.fillStyle=this.textColor,t.fillText(i,this.x+this.xPadding+this.fontSize+3,this.getLineHeight(e+1)),t.fillStyle=this.legendColorBackground,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize),t.fillStyle=this.legendColors[e].fill,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize)},this)}}}),e.Scale=e.Element.extend({initialize:function(){this.fit()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}));this.yLabelWidth=this.display&&this.showLabels?z(this.ctx,this.font,this.yLabels):0},addXLabel:function(t){this.xLabels.push(t),this.valuesCount++,this.fit()},removeXLabel:function(){this.xLabels.shift(),this.valuesCount--,this.fit()},fit:function(){this.startPoint=this.display?this.fontSize:0,this.endPoint=this.display?this.height-1.5*this.fontSize-5:this.height,this.startPoint+=this.padding,this.endPoint-=this.padding;var t,i=this.endPoint-this.startPoint;for(this.calculateYRange(i),this.buildYLabels(),this.calculateXLabelRotation();i>this.endPoint-this.startPoint;)i=this.endPoint-this.startPoint,t=this.yLabelWidth,this.calculateYRange(i),this.buildYLabels(),t<this.yLabelWidth&&this.calculateXLabelRotation()},calculateXLabelRotation:function(){this.ctx.font=this.font;var t,i,e=this.ctx.measureText(this.xLabels[0]).width,s=this.ctx.measureText(this.xLabels[this.xLabels.length-1]).width;if(this.xScalePaddingRight=s/2+3,this.xScalePaddingLeft=e/2>this.yLabelWidth+10?e/2:this.yLabelWidth+10,this.xLabelRotation=0,this.display){var n,o=z(this.ctx,this.font,this.xLabels);this.xLabelWidth=o;for(var a=Math.floor(this.calculateX(1)-this.calculateX(0))-6;this.xLabelWidth>a&&0===this.xLabelRotation||this.xLabelWidth>a&&this.xLabelRotation<=90&&this.xLabelRotation>0;)n=Math.cos(S(this.xLabelRotation)),t=n*e,i=n*s,t+this.fontSize/2>this.yLabelWidth+8&&(this.xScalePaddingLeft=t+this.fontSize/2),this.xScalePaddingRight=this.fontSize/2,this.xLabelRotation++,this.xLabelWidth=n*o;this.xLabelRotation>0&&(this.endPoint-=Math.sin(S(this.xLabelRotation))*o+3)}else this.xLabelWidth=0,this.xScalePaddingRight=this.padding,this.xScalePaddingLeft=this.padding},calculateYRange:c,drawingArea:function(){return this.startPoint-this.endPoint},calculateY:function(t){var i=this.drawingArea()/(this.min-this.max);return this.endPoint-i*(t-this.min)},calculateX:function(t){var i=(this.xLabelRotation>0,this.width-(this.xScalePaddingLeft+this.xScalePaddingRight)),e=i/Math.max(this.valuesCount-(this.offsetGridLines?0:1),1),s=e*t+this.xScalePaddingLeft;return this.offsetGridLines&&(s+=e/2),Math.round(s)},update:function(t){s.extend(this,t),this.fit()},draw:function(){var t=this.ctx,i=(this.endPoint-this.startPoint)/this.steps,e=Math.round(this.xScalePaddingLeft);this.display&&(t.fillStyle=this.textColor,t.font=this.font,n(this.yLabels,function(n,o){var a=this.endPoint-i*o,h=Math.round(a),l=this.showHorizontalLines;t.textAlign="right",t.textBaseline="middle",this.showLabels&&t.fillText(n,e-10,a),0!==o||l||(l=!0),l&&t.beginPath(),o>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),h+=s.aliasPixel(t.lineWidth),l&&(t.moveTo(e,h),t.lineTo(this.width,h),t.stroke(),t.closePath()),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(e-5,h),t.lineTo(e,h),t.stroke(),t.closePath()},this),n(this.xLabels,function(i,e){var s=this.calculateX(e)+x(this.lineWidth),n=this.calculateX(e-(this.offsetGridLines?.5:0))+x(this.lineWidth),o=this.xLabelRotation>0,a=this.showVerticalLines;0!==e||a||(a=!0),a&&t.beginPath(),e>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),a&&(t.moveTo(n,this.endPoint),t.lineTo(n,this.startPoint-3),t.stroke(),t.closePath()),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(n,this.endPoint),t.lineTo(n,this.endPoint+5),t.stroke(),t.closePath(),t.save(),t.translate(s,o?this.endPoint+12:this.endPoint+8),t.rotate(-1*S(this.xLabelRotation)),t.font=this.font,t.textAlign=o?"right":"center",t.textBaseline=o?"middle":"top",t.fillText(i,0,0),t.restore()},this))}}),e.RadialScale=e.Element.extend({initialize:function(){this.size=m([this.height,this.width]),this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2},calculateCenterOffset:function(t){var i=this.drawingArea/(this.max-this.min);return(t-this.min)*i},update:function(){this.lineArc?this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2:this.setScaleSize(),this.buildYLabels()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}))},getCircumference:function(){return 2*Math.PI/this.valuesCount},setScaleSize:function(){var t,i,e,s,n,o,a,h,l,r,c,u,d=m([this.height/2-this.pointLabelFontSize-5,this.width/2]),p=this.width,g=0;for(this.ctx.font=W(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),i=0;i<this.valuesCount;i++)t=this.getPointPosition(i,d),e=this.ctx.measureText(C(this.templateString,{value:this.labels[i]})).width+5,0===i||i===this.valuesCount/2?(s=e/2,t.x+s>p&&(p=t.x+s,n=i),t.x-s<g&&(g=t.x-s,a=i)):i<this.valuesCount/2?t.x+e>p&&(p=t.x+e,n=i):i>this.valuesCount/2&&t.x-e<g&&(g=t.x-e,a=i);l=g,r=Math.ceil(p-this.width),o=this.getIndexAngle(n),h=this.getIndexAngle(a),c=r/Math.sin(o+Math.PI/2),u=l/Math.sin(h+Math.PI/2),c=f(c)?c:0,u=f(u)?u:0,this.drawingArea=d-(u+c)/2,this.setCenterPoint(u,c)},setCenterPoint:function(t,i){var e=this.width-i-this.drawingArea,s=t+this.drawingArea;this.xCenter=(s+e)/2,this.yCenter=this.height/2},getIndexAngle:function(t){var i=2*Math.PI/this.valuesCount;return t*i-Math.PI/2},getPointPosition:function(t,i){var e=this.getIndexAngle(t);return{x:Math.cos(e)*i+this.xCenter,y:Math.sin(e)*i+this.yCenter}},draw:function(){if(this.display){var t=this.ctx;if(n(this.yLabels,function(i,e){if(e>0){var s,n=e*(this.drawingArea/this.steps),o=this.yCenter-n;if(this.lineWidth>0)if(t.strokeStyle=this.lineColor,t.lineWidth=this.lineWidth,this.lineArc)t.beginPath(),t.arc(this.xCenter,this.yCenter,n,0,2*Math.PI),t.closePath(),t.stroke();else{t.beginPath();for(var a=0;a<this.valuesCount;a++)s=this.getPointPosition(a,this.calculateCenterOffset(this.min+e*this.stepValue)),0===a?t.moveTo(s.x,s.y):t.lineTo(s.x,s.y);t.closePath(),t.stroke()}if(this.showLabels){if(t.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.showLabelBackdrop){var h=t.measureText(i).width;t.fillStyle=this.backdropColor,t.fillRect(this.xCenter-h/2-this.backdropPaddingX,o-this.fontSize/2-this.backdropPaddingY,h+2*this.backdropPaddingX,this.fontSize+2*this.backdropPaddingY)}t.textAlign="center",t.textBaseline="middle",t.fillStyle=this.fontColor,t.fillText(i,this.xCenter,o)}}},this),!this.lineArc){t.lineWidth=this.angleLineWidth,t.strokeStyle=this.angleLineColor;for(var i=this.valuesCount-1;i>=0;i--){if(this.angleLineWidth>0){var e=this.getPointPosition(i,this.calculateCenterOffset(this.max));t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(e.x,e.y),t.stroke(),t.closePath()}var s=this.getPointPosition(i,this.calculateCenterOffset(this.max)+5);t.font=W(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),t.fillStyle=this.pointLabelFontColor;var o=this.labels.length,a=this.labels.length/2,h=a/2,l=h>i||i>o-h,r=i===h||i===o-h;t.textAlign=0===i?"center":i===a?"center":a>i?"left":"right",t.textBaseline=r?"middle":l?"bottom":"top",t.fillText(this.labels[i],s.x,s.y)}}}}}),s.addEvent(window,"resize",function(){var t;return function(){clearTimeout(t),t=setTimeout(function(){n(e.instances,function(t){t.options.responsive&&t.resize(t.render,!0)})},50)}}()),p?define(function(){return e}):"object"==typeof module&&module.exports&&(module.exports=e),t.Chart=e,e.noConflict=function(){return t.Chart=i,e}}).call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleBeginAtZero:!0,scaleShowGridLines:!0,scaleGridLineColor:"rgba(0,0,0,.05)",scaleGridLineWidth:1,scaleShowHorizontalLines:!0,scaleShowVerticalLines:!0,barShowStroke:!0,barStrokeWidth:2,barValueSpacing:5,barDatasetSpacing:1,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<datasets.length; i++){%><li><span style="background-color:<%=datasets[i].fillColor%>"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"Bar",defaults:s,initialize:function(t){var s=this.options;this.ScaleClass=i.Scale.extend({offsetGridLines:!0,calculateBarX:function(t,i,e){var n=this.calculateBaseWidth(),o=this.calculateX(e)-n/2,a=this.calculateBarWidth(t);return o+a*i+i*s.barDatasetSpacing+a/2},calculateBaseWidth:function(){return this.calculateX(1)-this.calculateX(0)-2*s.barValueSpacing},calculateBarWidth:function(t){var i=this.calculateBaseWidth()-(t-1)*s.barDatasetSpacing;return i/t}}),this.datasets=[],this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getBarsAtEvent(t):[];this.eachBars(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),this.BarClass=i.Rectangle.extend({strokeWidth:this.options.barStrokeWidth,showStroke:this.options.barShowStroke,ctx:this.chart.ctx}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,bars:[]};this.datasets.push(s),e.each(i.data,function(e,n){s.bars.push(new this.BarClass({value:e,label:t.labels[n],datasetLabel:i.label,strokeColor:i.strokeColor,fillColor:i.fillColor,highlightFill:i.highlightFill||i.fillColor,highlightStroke:i.highlightStroke||i.strokeColor}))},this)},this),this.buildScale(t.labels),this.BarClass.prototype.base=this.scale.endPoint,this.eachBars(function(t,i,s){e.extend(t,{width:this.scale.calculateBarWidth(this.datasets.length),x:this.scale.calculateBarX(this.datasets.length,s,i),y:this.scale.endPoint}),t.save()},this),this.render()},update:function(){this.scale.update(),e.each(this.activeElements,function(t){t.restore(["fillColor","strokeColor"])}),this.eachBars(function(t){t.save()}),this.render()},eachBars:function(t){e.each(this.datasets,function(i,s){e.each(i.bars,t,this,s)},this)},getBarsAtEvent:function(t){for(var i,s=[],n=e.getRelativePosition(t),o=function(t){s.push(t.bars[i])},a=0;a<this.datasets.length;a++)for(i=0;i<this.datasets[a].bars.length;i++)if(this.datasets[a].bars[i].inRange(n.x,n.y))return e.each(this.datasets,o),s;return s},buildScale:function(t){var i=this,s=function(){var t=[];return i.eachBars(function(i){t.push(i.value)}),t},n={templateString:this.options.scaleLabel,height:this.chart.height,width:this.chart.width,ctx:this.chart.ctx,textColor:this.options.scaleFontColor,fontSize:this.options.scaleFontSize,fontStyle:this.options.scaleFontStyle,fontFamily:this.options.scaleFontFamily,valuesCount:t.length,beginAtZero:this.options.scaleBeginAtZero,integersOnly:this.options.scaleIntegersOnly,calculateYRange:function(t){var i=e.calculateScaleRange(s(),t,this.fontSize,this.beginAtZero,this.integersOnly);e.extend(this,i)},xLabels:t,font:e.fontString(this.options.scaleFontSize,this.options.scaleFontStyle,this.options.scaleFontFamily),lineWidth:this.options.scaleLineWidth,lineColor:this.options.scaleLineColor,showHorizontalLines:this.options.scaleShowHorizontalLines,showVerticalLines:this.options.scaleShowVerticalLines,gridLineWidth:this.options.scaleShowGridLines?this.options.scaleGridLineWidth:0,gridLineColor:this.options.scaleShowGridLines?this.options.scaleGridLineColor:"rgba(0,0,0,0)",padding:this.options.showScale?0:this.options.barShowStroke?this.options.barStrokeWidth:0,showLabels:this.options.scaleShowLabels,display:this.options.showScale};this.options.scaleOverride&&e.extend(n,{calculateYRange:e.noop,steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}),this.scale=new this.ScaleClass(n)},addData:function(t,i){e.each(t,function(t,e){this.datasets[e].bars.push(new this.BarClass({value:t,label:i,x:this.scale.calculateBarX(this.datasets.length,e,this.scale.valuesCount+1),y:this.scale.endPoint,width:this.scale.calculateBarWidth(this.datasets.length),base:this.scale.endPoint,strokeColor:this.datasets[e].strokeColor,fillColor:this.datasets[e].fillColor}))
+},this),this.scale.addXLabel(i),this.update()},removeData:function(){this.scale.removeXLabel(),e.each(this.datasets,function(t){t.bars.shift()},this),this.update()},reflow:function(){e.extend(this.BarClass.prototype,{y:this.scale.endPoint,base:this.scale.endPoint});var t=e.extend({height:this.chart.height,width:this.chart.width});this.scale.update(t)},draw:function(t){var i=t||1;this.clear();this.chart.ctx;this.scale.draw(i),e.each(this.datasets,function(t,s){e.each(t.bars,function(t,e){t.hasValue()&&(t.base=this.scale.endPoint,t.transition({x:this.scale.calculateBarX(this.datasets.length,s,e),y:this.scale.calculateY(t.value),width:this.scale.calculateBarWidth(this.datasets.length)},i).draw())},this)},this)}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={segmentShowStroke:!0,segmentStrokeColor:"#fff",segmentStrokeWidth:2,percentageInnerCutout:50,animationSteps:100,animationEasing:"easeOutBounce",animateRotate:!0,animateScale:!1,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<segments.length; i++){%><li><span style="background-color:<%=segments[i].fillColor%>"></span><%if(segments[i].label){%><%=segments[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"Doughnut",defaults:s,initialize:function(t){this.segments=[],this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,this.SegmentArc=i.Arc.extend({ctx:this.chart.ctx,x:this.chart.width/2,y:this.chart.height/2}),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.calculateTotal(t),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({value:t.value,outerRadius:this.options.animateScale?0:this.outerRadius,innerRadius:this.options.animateScale?0:this.outerRadius/100*this.options.percentageInnerCutout,fillColor:t.color,highlightColor:t.highlight||t.color,showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,startAngle:1.5*Math.PI,circumference:this.options.animateRotate?0:this.calculateCircumference(t.value),label:t.label})),e||(this.reflow(),this.update())},calculateCircumference:function(t){return 2*Math.PI*(Math.abs(t)/this.total)},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=Math.abs(t.value)},this)},update:function(){this.calculateTotal(this.segments),e.each(this.activeElements,function(t){t.restore(["fillColor"])}),e.each(this.segments,function(t){t.save()}),this.render()},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,e.each(this.segments,function(t){t.update({outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout})},this)},draw:function(t){var i=t?t:1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.calculateCircumference(t.value),outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout},i),t.endAngle=t.startAngle+t.circumference,t.draw(),0===e&&(t.startAngle=1.5*Math.PI),e<this.segments.length-1&&(this.segments[e+1].startAngle=t.endAngle)},this)}}),i.types.Doughnut.extend({name:"Pie",defaults:e.merge(s,{percentageInnerCutout:0})})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleShowGridLines:!0,scaleGridLineColor:"rgba(0,0,0,.05)",scaleGridLineWidth:1,scaleShowHorizontalLines:!0,scaleShowVerticalLines:!0,bezierCurve:!0,bezierCurveTension:.4,pointDot:!0,pointDotRadius:4,pointDotStrokeWidth:1,pointHitDetectionRadius:20,datasetStroke:!0,datasetStrokeWidth:2,datasetFill:!0,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<datasets.length; i++){%><li><span style="background-color:<%=datasets[i].strokeColor%>"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"Line",defaults:s,initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx,inRange:function(t){return Math.pow(t-this.x,2)<Math.pow(this.radius+this.hitDetectionRadius,2)}}),this.datasets=[],this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getPointsAtEvent(t):[];this.eachPoints(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,pointColor:i.pointColor,pointStrokeColor:i.pointStrokeColor,points:[]};this.datasets.push(s),e.each(i.data,function(e,n){s.points.push(new this.PointClass({value:e,label:t.labels[n],datasetLabel:i.label,strokeColor:i.pointStrokeColor,fillColor:i.pointColor,highlightFill:i.pointHighlightFill||i.pointColor,highlightStroke:i.pointHighlightStroke||i.pointStrokeColor}))},this),this.buildScale(t.labels),this.eachPoints(function(t,i){e.extend(t,{x:this.scale.calculateX(i),y:this.scale.endPoint}),t.save()},this)},this),this.render()},update:function(){this.scale.update(),e.each(this.activeElements,function(t){t.restore(["fillColor","strokeColor"])}),this.eachPoints(function(t){t.save()}),this.render()},eachPoints:function(t){e.each(this.datasets,function(i){e.each(i.points,t,this)},this)},getPointsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.datasets,function(t){e.each(t.points,function(t){t.inRange(s.x,s.y)&&i.push(t)})},this),i},buildScale:function(t){var s=this,n=function(){var t=[];return s.eachPoints(function(i){t.push(i.value)}),t},o={templateString:this.options.scaleLabel,height:this.chart.height,width:this.chart.width,ctx:this.chart.ctx,textColor:this.options.scaleFontColor,fontSize:this.options.scaleFontSize,fontStyle:this.options.scaleFontStyle,fontFamily:this.options.scaleFontFamily,valuesCount:t.length,beginAtZero:this.options.scaleBeginAtZero,integersOnly:this.options.scaleIntegersOnly,calculateYRange:function(t){var i=e.calculateScaleRange(n(),t,this.fontSize,this.beginAtZero,this.integersOnly);e.extend(this,i)},xLabels:t,font:e.fontString(this.options.scaleFontSize,this.options.scaleFontStyle,this.options.scaleFontFamily),lineWidth:this.options.scaleLineWidth,lineColor:this.options.scaleLineColor,showHorizontalLines:this.options.scaleShowHorizontalLines,showVerticalLines:this.options.scaleShowVerticalLines,gridLineWidth:this.options.scaleShowGridLines?this.options.scaleGridLineWidth:0,gridLineColor:this.options.scaleShowGridLines?this.options.scaleGridLineColor:"rgba(0,0,0,0)",padding:this.options.showScale?0:this.options.pointDotRadius+this.options.pointDotStrokeWidth,showLabels:this.options.scaleShowLabels,display:this.options.showScale};this.options.scaleOverride&&e.extend(o,{calculateYRange:e.noop,steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}),this.scale=new i.Scale(o)},addData:function(t,i){e.each(t,function(t,e){this.datasets[e].points.push(new this.PointClass({value:t,label:i,x:this.scale.calculateX(this.scale.valuesCount+1),y:this.scale.endPoint,strokeColor:this.datasets[e].pointStrokeColor,fillColor:this.datasets[e].pointColor}))},this),this.scale.addXLabel(i),this.update()},removeData:function(){this.scale.removeXLabel(),e.each(this.datasets,function(t){t.points.shift()},this),this.update()},reflow:function(){var t=e.extend({height:this.chart.height,width:this.chart.width});this.scale.update(t)},draw:function(t){var i=t||1;this.clear();var s=this.chart.ctx,n=function(t){return null!==t.value},o=function(t,i,s){return e.findNextWhere(i,n,s)||t},a=function(t,i,s){return e.findPreviousWhere(i,n,s)||t};this.scale.draw(i),e.each(this.datasets,function(t){var h=e.where(t.points,n);e.each(t.points,function(t,e){t.hasValue()&&t.transition({y:this.scale.calculateY(t.value),x:this.scale.calculateX(e)},i)},this),this.options.bezierCurve&&e.each(h,function(t,i){var s=i>0&&i<h.length-1?this.options.bezierCurveTension:0;t.controlPoints=e.splineCurve(a(t,h,i),t,o(t,h,i),s),t.controlPoints.outer.y>this.scale.endPoint?t.controlPoints.outer.y=this.scale.endPoint:t.controlPoints.outer.y<this.scale.startPoint&&(t.controlPoints.outer.y=this.scale.startPoint),t.controlPoints.inner.y>this.scale.endPoint?t.controlPoints.inner.y=this.scale.endPoint:t.controlPoints.inner.y<this.scale.startPoint&&(t.controlPoints.inner.y=this.scale.startPoint)},this),s.lineWidth=this.options.datasetStrokeWidth,s.strokeStyle=t.strokeColor,s.beginPath(),e.each(h,function(t,i){if(0===i)s.moveTo(t.x,t.y);else if(this.options.bezierCurve){var e=a(t,h,i);s.bezierCurveTo(e.controlPoints.outer.x,e.controlPoints.outer.y,t.controlPoints.inner.x,t.controlPoints.inner.y,t.x,t.y)}else s.lineTo(t.x,t.y)},this),s.stroke(),this.options.datasetFill&&h.length>0&&(s.lineTo(h[h.length-1].x,this.scale.endPoint),s.lineTo(h[0].x,this.scale.endPoint),s.fillStyle=t.fillColor,s.closePath(),s.fill()),e.each(h,function(t){t.draw()})},this)}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleShowLabelBackdrop:!0,scaleBackdropColor:"rgba(255,255,255,0.75)",scaleBeginAtZero:!0,scaleBackdropPaddingY:2,scaleBackdropPaddingX:2,scaleShowLine:!0,segmentShowStroke:!0,segmentStrokeColor:"#fff",segmentStrokeWidth:2,animationSteps:100,animationEasing:"easeOutBounce",animateRotate:!0,animateScale:!1,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<segments.length; i++){%><li><span style="background-color:<%=segments[i].fillColor%>"></span><%if(segments[i].label){%><%=segments[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"PolarArea",defaults:s,initialize:function(t){this.segments=[],this.SegmentArc=i.Arc.extend({showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,ctx:this.chart.ctx,innerRadius:0,x:this.chart.width/2,y:this.chart.height/2}),this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,lineArc:!0,width:this.chart.width,height:this.chart.height,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,valuesCount:t.length}),this.updateScaleRange(t),this.scale.update(),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({fillColor:t.color,highlightColor:t.highlight||t.color,label:t.label,value:t.value,outerRadius:this.options.animateScale?0:this.scale.calculateCenterOffset(t.value),circumference:this.options.animateRotate?0:this.scale.getCircumference(),startAngle:1.5*Math.PI})),e||(this.reflow(),this.update())},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=t.value},this),this.scale.valuesCount=this.segments.length},updateScaleRange:function(t){var i=[];e.each(t,function(t){i.push(t.value)});var s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s,{size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2})},update:function(){this.calculateTotal(this.segments),e.each(this.segments,function(t){t.save()}),this.reflow(),this.render()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.updateScaleRange(this.segments),this.scale.update(),e.extend(this.scale,{xCenter:this.chart.width/2,yCenter:this.chart.height/2}),e.each(this.segments,function(t){t.update({outerRadius:this.scale.calculateCenterOffset(t.value)})},this)},draw:function(t){var i=t||1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.scale.getCircumference(),outerRadius:this.scale.calculateCenterOffset(t.value)},i),t.endAngle=t.startAngle+t.circumference,0===e&&(t.startAngle=1.5*Math.PI),e<this.segments.length-1&&(this.segments[e+1].startAngle=t.endAngle),t.draw()},this),this.scale.draw()}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers;i.Type.extend({name:"Radar",defaults:{scaleShowLine:!0,angleShowLineOut:!0,scaleShowLabels:!1,scaleBeginAtZero:!0,angleLineColor:"rgba(0,0,0,.1)",angleLineWidth:1,pointLabelFontFamily:"'Arial'",pointLabelFontStyle:"normal",pointLabelFontSize:10,pointLabelFontColor:"#666",pointDot:!0,pointDotRadius:3,pointDotStrokeWidth:1,pointHitDetectionRadius:20,datasetStroke:!0,datasetStrokeWidth:2,datasetFill:!0,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<datasets.length; i++){%><li><span style="background-color:<%=datasets[i].strokeColor%>"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'},initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx}),this.datasets=[],this.buildScale(t),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getPointsAtEvent(t):[];this.eachPoints(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,pointColor:i.pointColor,pointStrokeColor:i.pointStrokeColor,points:[]};this.datasets.push(s),e.each(i.data,function(e,n){var o;this.scale.animation||(o=this.scale.getPointPosition(n,this.scale.calculateCenterOffset(e))),s.points.push(new this.PointClass({value:e,label:t.labels[n],datasetLabel:i.label,x:this.options.animation?this.scale.xCenter:o.x,y:this.options.animation?this.scale.yCenter:o.y,strokeColor:i.pointStrokeColor,fillColor:i.pointColor,highlightFill:i.pointHighlightFill||i.pointColor,highlightStroke:i.pointHighlightStroke||i.pointStrokeColor}))},this)},this),this.render()},eachPoints:function(t){e.each(this.datasets,function(i){e.each(i.points,t,this)},this)},getPointsAtEvent:function(t){var i=e.getRelativePosition(t),s=e.getAngleFromPoint({x:this.scale.xCenter,y:this.scale.yCenter},i),n=2*Math.PI/this.scale.valuesCount,o=Math.round((s.angle-1.5*Math.PI)/n),a=[];return(o>=this.scale.valuesCount||0>o)&&(o=0),s.distance<=this.scale.drawingArea&&e.each(this.datasets,function(t){a.push(t.points[o])}),a},buildScale:function(t){this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,angleLineColor:this.options.angleLineColor,angleLineWidth:this.options.angleShowLineOut?this.options.angleLineWidth:0,pointLabelFontColor:this.options.pointLabelFontColor,pointLabelFontSize:this.options.pointLabelFontSize,pointLabelFontFamily:this.options.pointLabelFontFamily,pointLabelFontStyle:this.options.pointLabelFontStyle,height:this.chart.height,width:this.chart.width,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,labels:t.labels,valuesCount:t.datasets[0].data.length}),this.scale.setScaleSize(),this.updateScaleRange(t.datasets),this.scale.buildYLabels()},updateScaleRange:function(t){var i=function(){var i=[];return e.each(t,function(t){t.data?i=i.concat(t.data):e.each(t.points,function(t){i.push(t.value)})}),i}(),s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s)},addData:function(t,i){this.scale.valuesCount++,e.each(t,function(t,e){var s=this.scale.getPointPosition(this.scale.valuesCount,this.scale.calculateCenterOffset(t));this.datasets[e].points.push(new this.PointClass({value:t,label:i,x:s.x,y:s.y,strokeColor:this.datasets[e].pointStrokeColor,fillColor:this.datasets[e].pointColor}))},this),this.scale.labels.push(i),this.reflow(),this.update()},removeData:function(){this.scale.valuesCount--,this.scale.labels.shift(),e.each(this.datasets,function(t){t.points.shift()},this),this.reflow(),this.update()},update:function(){this.eachPoints(function(t){t.save()}),this.reflow(),this.render()},reflow:function(){e.extend(this.scale,{width:this.chart.width,height:this.chart.height,size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2}),this.updateScaleRange(this.datasets),this.scale.setScaleSize(),this.scale.buildYLabels()},draw:function(t){var i=t||1,s=this.chart.ctx;this.clear(),this.scale.draw(),e.each(this.datasets,function(t){e.each(t.points,function(t,e){t.hasValue()&&t.transition(this.scale.getPointPosition(e,this.scale.calculateCenterOffset(t.value)),i)},this),s.lineWidth=this.options.datasetStrokeWidth,s.strokeStyle=t.strokeColor,s.beginPath(),e.each(t.points,function(t,i){0===i?s.moveTo(t.x,t.y):s.lineTo(t.x,t.y)},this),s.closePath(),s.stroke(),s.fillStyle=t.fillColor,s.fill(),e.each(t.points,function(t){t.hasValue()&&t.draw()})},this)}})}.call(this); \ No newline at end of file
diff --git a/web/templates/admin_console.html b/web/templates/admin_console.html
index 574caf730..0e37a4660 100644
--- a/web/templates/admin_console.html
+++ b/web/templates/admin_console.html
@@ -4,6 +4,7 @@
<html>
{{template "head" . }}
<body>
+<script src="/static/js/Chart.min.js"></script>
<div id='error_bar'></div>
diff --git a/web/web.go b/web/web.go
index 5f290ec99..bffe4858e 100644
--- a/web/web.go
+++ b/web/web.go
@@ -969,20 +969,20 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
r.ParseForm()
- var props map[string]string
+ var parsedRequest *model.IncomingWebhookRequest
if r.Header.Get("Content-Type") == "application/json" {
- props = model.MapFromJson(r.Body)
+ parsedRequest = model.IncomingWebhookRequestFromJson(r.Body)
} else {
- props = model.MapFromJson(strings.NewReader(r.FormValue("payload")))
+ parsedRequest = model.IncomingWebhookRequestFromJson(strings.NewReader(r.FormValue("payload")))
}
- text := props["text"]
+ text := parsedRequest.Text
if len(text) == 0 {
c.Err = model.NewAppError("incomingWebhook", "No text specified", "")
return
}
- channelName := props["channel"]
+ channelName := parsedRequest.ChannelName
var hook *model.IncomingWebhook
if result := <-hchan; result.Err != nil {
@@ -1012,8 +1012,8 @@ func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) {
cchan = api.Srv.Store.Channel().Get(hook.ChannelId)
}
- overrideUsername := props["username"]
- overrideIconUrl := props["icon_url"]
+ overrideUsername := parsedRequest.Username
+ overrideIconUrl := parsedRequest.IconURL
if result := <-cchan; result.Err != nil {
c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message)