diff --git a/pkg/metrics/publish.go b/pkg/metrics/publish.go index f653698b36a..d7eb86b8c56 100644 --- a/pkg/metrics/publish.go +++ b/pkg/metrics/publish.go @@ -67,10 +67,10 @@ func updateTotalStats() { return } - M_StatTotal_Dashboards.Update(statsQuery.Result.DashboardCount) - M_StatTotal_Users.Update(statsQuery.Result.UserCount) - M_StatTotal_Playlists.Update(statsQuery.Result.PlaylistCount) - M_StatTotal_Orgs.Update(statsQuery.Result.OrgCount) + M_StatTotal_Dashboards.Update(statsQuery.Result.Dashboards) + M_StatTotal_Users.Update(statsQuery.Result.Users) + M_StatTotal_Playlists.Update(statsQuery.Result.Playlists) + M_StatTotal_Orgs.Update(statsQuery.Result.Orgs) } } @@ -97,14 +97,16 @@ func sendUsageStats() { return } - metrics["stats.dashboards.count"] = statsQuery.Result.DashboardCount - metrics["stats.users.count"] = statsQuery.Result.UserCount - metrics["stats.orgs.count"] = statsQuery.Result.OrgCount - metrics["stats.playlist.count"] = statsQuery.Result.PlaylistCount + metrics["stats.dashboards.count"] = statsQuery.Result.Dashboards + metrics["stats.users.count"] = statsQuery.Result.Users + metrics["stats.orgs.count"] = statsQuery.Result.Orgs + metrics["stats.playlist.count"] = statsQuery.Result.Playlists metrics["stats.plugins.apps.count"] = len(plugins.Apps) metrics["stats.plugins.panels.count"] = len(plugins.Panels) metrics["stats.plugins.datasources.count"] = len(plugins.DataSources) - metrics["stats.alerts.count"] = statsQuery.Result.AlertCount + metrics["stats.alerts.count"] = statsQuery.Result.Alerts + metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers + metrics["stats.datasources.count"] = statsQuery.Result.Datasources dsStats := m.GetDataSourceStatsQuery{} if err := bus.Dispatch(&dsStats); err != nil { diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index eaa300c8611..949a9c16766 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -62,6 +62,15 @@ func GetContextHandler() macaron.Handler { ctx.Data["ctx"] = ctx c.Map(ctx) + + // update last seen at + // update last seen every 5min + if ctx.ShouldUpdateLastSeenAt() { + ctx.Logger.Debug("Updating last user_seen_at", "user_id", ctx.UserId) + if err := bus.Dispatch(&m.UpdateUserLastSeenAtCommand{UserId: ctx.UserId}); err != nil { + ctx.Logger.Error("Failed to update last_seen_at", "error", err) + } + } } } @@ -99,7 +108,7 @@ func initContextWithUserSessionCookie(ctx *Context, orgId int64) bool { query := m.GetSignedInUserQuery{UserId: userId, OrgId: orgId} if err := bus.Dispatch(&query); err != nil { - ctx.Logger.Error("Failed to get user with id", "userId", userId) + ctx.Logger.Error("Failed to get user with id", "userId", userId, "error", err) return false } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index d7a918751ef..67b081fd1ce 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -103,9 +103,11 @@ type GetOrgUsersQuery struct { // Projections and DTOs type OrgUserDTO struct { - OrgId int64 `json:"orgId"` - UserId int64 `json:"userId"` - Email string `json:"email"` - Login string `json:"login"` - Role string `json:"role"` + OrgId int64 `json:"orgId"` + UserId int64 `json:"userId"` + Email string `json:"email"` + Login string `json:"login"` + Role string `json:"role"` + LastSeenAt time.Time `json:"lastSeenAt"` + LastSeenAtAge string `json:"lastSeenAtAge"` } diff --git a/pkg/models/stats.go b/pkg/models/stats.go index 09c251b6cd7..0d982c3f4bd 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -1,11 +1,13 @@ package models type SystemStats struct { - DashboardCount int64 - UserCount int64 - OrgCount int64 - PlaylistCount int64 - AlertCount int64 + Dashboards int64 + Datasources int64 + Users int64 + ActiveUsers int64 + Orgs int64 + Playlists int64 + Alerts int64 } type DataSourceStats struct { @@ -22,15 +24,16 @@ type GetDataSourceStatsQuery struct { } type AdminStats struct { - UserCount int `json:"user_count"` - OrgCount int `json:"org_count"` - DashboardCount int `json:"dashboard_count"` - DbSnapshotCount int `json:"db_snapshot_count"` - DbTagCount int `json:"db_tag_count"` - DataSourceCount int `json:"data_source_count"` - PlaylistCount int `json:"playlist_count"` - StarredDbCount int `json:"starred_db_count"` - AlertCount int `json:"alert_count"` + Users int `json:"users"` + Orgs int `json:"orgs"` + Dashboards int `json:"dashboards"` + Snapshots int `json:"snapshots"` + Tags int `json:"tags"` + Datasources int `json:"datasources"` + Playlists int `json:"playlists"` + Stars int `json:"stars"` + Alerts int `json:"alerts"` + ActiveUsers int `json:"activeUsers"` } type GetAdminStatsQuery struct { diff --git a/pkg/models/user.go b/pkg/models/user.go index e3981dc3880..77938b63a0b 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -33,8 +33,9 @@ type User struct { IsAdmin bool OrgId int64 - Created time.Time - Updated time.Time + Created time.Time + Updated time.Time + LastSeenAt time.Time } func (u *User) NameOrFallback() string { @@ -127,6 +128,7 @@ type GetUserProfileQuery struct { } type SearchUsersQuery struct { + OrgId int64 Query string Page int Limit int @@ -160,6 +162,15 @@ type SignedInUser struct { ApiKeyId int64 IsGrafanaAdmin bool HelpFlags1 HelpFlags1 + LastSeenAt time.Time +} + +func (u *SignedInUser) ShouldUpdateLastSeenAt() bool { + return u.UserId > 0 && time.Since(u.LastSeenAt) > time.Minute*5 +} + +type UpdateUserLastSeenAtCommand struct { + UserId int64 } type UserProfileDTO struct { @@ -173,11 +184,13 @@ type UserProfileDTO struct { } type UserSearchHitDTO struct { - Id int64 `json:"id"` - Name string `json:"name"` - Login string `json:"login"` - Email string `json:"email"` - IsAdmin bool `json:"isAdmin"` + Id int64 `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + Email string `json:"email"` + IsAdmin bool `json:"isAdmin"` + LastSeenAt time.Time `json:"lastSeenAt"` + LastSeenAtAge string `json:"lastSeenAtAge"` } type UserIdDTO struct { diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go index a12c4987792..edcfbb7b889 100644 --- a/pkg/services/sqlstore/migrations/user_mig.go +++ b/pkg/services/sqlstore/migrations/user_mig.go @@ -103,4 +103,8 @@ func addUserMigrations(mg *Migrator) { {Name: "company", Type: DB_NVarchar, Length: 255, Nullable: true}, {Name: "theme", Type: DB_NVarchar, Length: 255, Nullable: true}, })) + + mg.AddMigration("Add last_seen_at column to user", NewAddColumnMigration(userV2, &Column{ + Name: "last_seen_at", Type: DB_DateTime, Nullable: true, + })) } diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index e1b9dcc1da7..60800d1cb13 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" ) func init() { @@ -71,11 +72,18 @@ func GetOrgUsers(query *m.GetOrgUsersQuery) error { sess := x.Table("org_user") sess.Join("INNER", "user", fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user"))) sess.Where("org_user.org_id=?", query.OrgId) - sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role") + sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role", "user.last_seen_at") sess.Asc("user.email", "user.login") - err := sess.Find(&query.Result) - return err + if err := sess.Find(&query.Result); err != nil { + return err + } + + for _, user := range query.Result { + user.LastSeenAtAge = util.GetAgeString(user.LastSeenAt) + } + + return nil } func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index ce75aa06f0b..b45f97875f8 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -53,7 +53,7 @@ func EnsureAdminUser() { return } - if statsQuery.Result.UserCount > 0 { + if statsQuery.Result.Users > 0 { return } diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index dd1a8111332..aa01c8a3761 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -1,6 +1,8 @@ package sqlstore import ( + "time" + "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" ) @@ -11,6 +13,8 @@ func init() { bus.AddHandler("sql", GetAdminStats) } +var activeUserTimeLimit time.Duration = time.Hour * 24 * 14 + func GetDataSourceStats(query *m.GetDataSourceStatsQuery) error { var rawSql = `SELECT COUNT(*) as count, type FROM data_source GROUP BY type` query.Result = make([]*m.DataSourceStats, 0) @@ -27,27 +31,35 @@ func GetSystemStats(query *m.GetSystemStatsQuery) error { ( SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` - ) AS user_count, + ) AS users, ( SELECT COUNT(*) FROM ` + dialect.Quote("org") + ` - ) AS org_count, + ) AS orgs, ( SELECT COUNT(*) FROM ` + dialect.Quote("dashboard") + ` - ) AS dashboard_count, + ) AS dashboards, + ( + SELECT COUNT(*) + FROM ` + dialect.Quote("data_source") + ` + ) AS datasources, ( SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + ` - ) AS playlist_count, + ) AS playlists, ( SELECT COUNT(*) FROM ` + dialect.Quote("alert") + ` - ) AS alert_count + ) AS alerts, + ( + SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` where last_seen_at > ? + ) as active_users ` + activeUserDeadlineDate := time.Now().Add(-activeUserTimeLimit) var stats m.SystemStats - _, err := x.Sql(rawSql).Get(&stats) + _, err := x.Sql(rawSql, activeUserDeadlineDate).Get(&stats) if err != nil { return err } @@ -61,43 +73,48 @@ func GetAdminStats(query *m.GetAdminStatsQuery) error { ( SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` - ) AS user_count, + ) AS users, ( SELECT COUNT(*) FROM ` + dialect.Quote("org") + ` - ) AS org_count, + ) AS orgs, ( SELECT COUNT(*) FROM ` + dialect.Quote("dashboard") + ` - ) AS dashboard_count, + ) AS dashboards, ( SELECT COUNT(*) FROM ` + dialect.Quote("dashboard_snapshot") + ` - ) AS db_snapshot_count, + ) AS snapshots, ( SELECT COUNT( DISTINCT ( ` + dialect.Quote("term") + ` )) FROM ` + dialect.Quote("dashboard_tag") + ` - ) AS db_tag_count, + ) AS tags, ( SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + ` - ) AS data_source_count, + ) AS datasources, ( SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + ` - ) AS playlist_count, + ) AS playlists, ( - SELECT COUNT(DISTINCT ` + dialect.Quote("dashboard_id") + ` ) - FROM ` + dialect.Quote("star") + ` - ) AS starred_db_count, + SELECT COUNT(*) FROM ` + dialect.Quote("star") + ` + ) AS stars, ( SELECT COUNT(*) FROM ` + dialect.Quote("alert") + ` - ) AS alert_count + ) AS alerts, + ( + SELECT COUNT(*) + from ` + dialect.Quote("user") + ` where last_seen_at > ? + ) as active_users ` + activeUserDeadlineDate := time.Now().Add(-activeUserTimeLimit) + var stats m.AdminStats - _, err := x.Sql(rawSql).Get(&stats) + _, err := x.Sql(rawSql, activeUserDeadlineDate).Get(&stats) if err != nil { return err } diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 177f465dc96..0ff60b5f2e3 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -22,6 +22,7 @@ func init() { bus.AddHandler("sql", GetUserByLogin) bus.AddHandler("sql", GetUserByEmail) bus.AddHandler("sql", SetUsingOrg) + bus.AddHandler("sql", UpdateUserLastSeenAt) bus.AddHandler("sql", GetUserProfile) bus.AddHandler("sql", GetSignedInUser) bus.AddHandler("sql", SearchUsers) @@ -260,6 +261,24 @@ func ChangeUserPassword(cmd *m.ChangeUserPasswordCommand) error { }) } +func UpdateUserLastSeenAt(cmd *m.UpdateUserLastSeenAtCommand) error { + return inTransaction(func(sess *DBSession) error { + if cmd.UserId <= 0 { + } + + user := m.User{ + Id: cmd.UserId, + LastSeenAt: time.Now(), + } + + if _, err := sess.Id(cmd.UserId).Update(&user); err != nil { + return err + } + + return nil + }) +} + func SetUsingOrg(cmd *m.SetUsingOrgCommand) error { getOrgsForUserCmd := &m.GetUserOrgListQuery{UserId: cmd.UserId} GetUserOrgList(getOrgsForUserCmd) @@ -324,15 +343,16 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { } var rawSql = `SELECT - u.id as user_id, - u.is_admin as is_grafana_admin, - u.email as email, - u.login as login, - u.name as name, - u.help_flags1 as help_flags1, - org.name as org_name, - org_user.role as org_role, - org.id as org_id + u.id as user_id, + u.is_admin as is_grafana_admin, + u.email as email, + u.login as login, + u.name as name, + u.help_flags1 as help_flags1, + u.last_seen_at as last_seen_at, + org.name as org_name, + org_user.role as org_role, + org.id as org_id FROM ` + dialect.Quote("user") + ` as u LEFT OUTER JOIN org_user on org_user.org_id = ` + orgId + ` and org_user.user_id = u.id LEFT OUTER JOIN org on org.id = org_user.org_id ` @@ -367,27 +387,49 @@ func SearchUsers(query *m.SearchUsersQuery) error { query.Result = m.SearchUserQueryResult{ Users: make([]*m.UserSearchHitDTO, 0), } + queryWithWildcards := "%" + query.Query + "%" + whereConditions := make([]string, 0) + whereParams := make([]interface{}, 0) sess := x.Table("user") - if query.Query != "" { - sess.Where("email LIKE ? OR name LIKE ? OR login like ?", queryWithWildcards, queryWithWildcards, queryWithWildcards) + + if query.OrgId > 0 { + whereConditions = append(whereConditions, "org_id = ?") + whereParams = append(whereParams, query.OrgId) } + + if query.Query != "" { + whereConditions = append(whereConditions, "(email LIKE ? OR name LIKE ? OR login like ?)") + whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards) + } + + if len(whereConditions) > 0 { + sess.Where(strings.Join(whereConditions, " AND "), whereParams...) + } + offset := query.Limit * (query.Page - 1) sess.Limit(query.Limit, offset) - sess.Cols("id", "email", "name", "login", "is_admin") + sess.Cols("id", "email", "name", "login", "is_admin", "last_seen_at") if err := sess.Find(&query.Result.Users); err != nil { return err } + // get total user := m.User{} - countSess := x.Table("user") - if query.Query != "" { - countSess.Where("email LIKE ? OR name LIKE ? OR login like ?", queryWithWildcards, queryWithWildcards, queryWithWildcards) + + if len(whereConditions) > 0 { + countSess.Where(strings.Join(whereConditions, " AND "), whereParams...) } + count, err := countSess.Count(&user) query.Result.TotalCount = count + + for _, user := range query.Result.Users { + user.LastSeenAtAge = util.GetAgeString(user.LastSeenAt) + } + return err } diff --git a/pkg/util/strings.go b/pkg/util/strings.go index 3ccf8b8ce7e..854c009d1b1 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -1,7 +1,10 @@ package util import ( + "fmt" + "math" "regexp" + "time" ) func StringsFallback2(val1 string, val2 string) string { @@ -28,3 +31,34 @@ func SplitString(str string) []string { return regexp.MustCompile("[, ]+").Split(str, -1) } + +func GetAgeString(t time.Time) string { + if t.IsZero() { + return "?" + } + + sinceNow := time.Since(t) + minutes := sinceNow.Minutes() + years := int(math.Floor(minutes / 525600)) + months := int(math.Floor(minutes / 43800)) + days := int(math.Floor(minutes / 1440)) + hours := int(math.Floor(minutes / 60)) + + if years > 0 { + return fmt.Sprintf("%dy", years) + } + if months > 0 { + return fmt.Sprintf("%dM", months) + } + if days > 0 { + return fmt.Sprintf("%dd", days) + } + if hours > 0 { + return fmt.Sprintf("%dh", hours) + } + if int(minutes) > 0 { + return fmt.Sprintf("%dm", int(minutes)) + } + + return "< 1m" +} diff --git a/pkg/util/strings_test.go b/pkg/util/strings_test.go index ac8653158cc..0cc1905baff 100644 --- a/pkg/util/strings_test.go +++ b/pkg/util/strings_test.go @@ -2,6 +2,7 @@ package util import ( "testing" + "time" . "github.com/smartystreets/goconvey/convey" ) @@ -24,3 +25,15 @@ func TestSplitString(t *testing.T) { So(SplitString("test1 , test2 test3"), ShouldResemble, []string{"test1", "test2", "test3"}) }) } + +func TestDateAge(t *testing.T) { + Convey("GetAgeString", t, func() { + So(GetAgeString(time.Time{}), ShouldEqual, "?") + So(GetAgeString(time.Now().Add(-time.Second*2)), ShouldEqual, "< 1m") + So(GetAgeString(time.Now().Add(-time.Minute*2)), ShouldEqual, "2m") + So(GetAgeString(time.Now().Add(-time.Hour*2)), ShouldEqual, "2h") + So(GetAgeString(time.Now().Add(-time.Hour*24*3)), ShouldEqual, "3d") + So(GetAgeString(time.Now().Add(-time.Hour*24*67)), ShouldEqual, "2M") + So(GetAgeString(time.Now().Add(-time.Hour*24*409)), ShouldEqual, "1y") + }) +} diff --git a/public/app/features/admin/partials/stats.html b/public/app/features/admin/partials/stats.html index 4ad231f1cbd..8cc9134cad0 100644 --- a/public/app/features/admin/partials/stats.html +++ b/public/app/features/admin/partials/stats.html @@ -15,39 +15,43 @@