Sfoglia il codice sorgente

feat: store last seen date for users and present in stats and user lists, closes #9007

Torkel Ödegaard 8 anni fa
parent
commit
e8a20643d6

+ 11 - 9
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 {

+ 10 - 1
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
 	}
 

+ 7 - 5
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"`
 }

+ 17 - 14
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 {

+ 20 - 7
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 {

+ 4 - 0
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,
+	}))
 }

+ 11 - 3
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 {

+ 1 - 1
pkg/services/sqlstore/sqlstore.go

@@ -53,7 +53,7 @@ func EnsureAdminUser() {
 		return
 	}
 
-	if statsQuery.Result.UserCount > 0 {
+	if statsQuery.Result.Users > 0 {
 		return
 	}
 

+ 35 - 18
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
 	}

+ 56 - 14
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.OrgId > 0 {
+		whereConditions = append(whereConditions, "org_id = ?")
+		whereParams = append(whereParams, query.OrgId)
+	}
+
 	if query.Query != "" {
-		sess.Where("email LIKE ? OR name LIKE ? OR login like ?", queryWithWildcards, queryWithWildcards, queryWithWildcards)
+		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
 }
 

+ 34 - 0
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"
+}

+ 13 - 0
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")
+	})
+}

+ 13 - 9
public/app/features/admin/partials/stats.html

@@ -15,39 +15,43 @@
 		<tbody>
 			<tr>
 				<td>Total dashboards</td>
-				<td>{{ctrl.stats.dashboard_count}}</td>
+				<td>{{ctrl.stats.dashboards}}</td>
 			</tr>
 			<tr>
 				<td>Total users</td>
-				<td>{{ctrl.stats.user_count}}</td>
+				<td>{{ctrl.stats.users}}</td>
+			</tr>
+			<tr>
+				<td>Active users (seen last 14 days)</td>
+				<td>{{ctrl.stats.activeUsers}}</td>
 			</tr>
 			<tr>
 				<td>Total organizations</td>
-				<td>{{ctrl.stats.org_count}}</td>
+				<td>{{ctrl.stats.orgs}}</td>
 			</tr>
 			<tr>
 				<td>Total datasources</td>
-				<td>{{ctrl.stats.data_source_count}}</td>
+				<td>{{ctrl.stats.datasources}}</td>
 			</tr>
 			<tr>
 				<td>Total playlists</td>
-				<td>{{ctrl.stats.playlist_count}}</td>
+				<td>{{ctrl.stats.playlists}}</td>
 			</tr>
 			<tr>
 				<td>Total snapshots</td>
-				<td>{{ctrl.stats.db_snapshot_count}}</td>
+				<td>{{ctrl.stats.snapshots}}</td>
 			</tr>
 			<tr>
 				<td>Total dashboard tags</td>
-				<td>{{ctrl.stats.db_tag_count}}</td>
+				<td>{{ctrl.stats.tags}}</td>
 			</tr>
 			<tr>
 				<td>Total starred dashboards</td>
-				<td>{{ctrl.stats.starred_db_count}}</td>
+				<td>{{ctrl.stats.stars}}</td>
 			</tr>
       <tr>
 				<td>Total alerts</td>
-				<td>{{ctrl.stats.alert_count}}</td>
+				<td>{{ctrl.stats.alerts}}</td>
 			</tr>
 		</tbody>
 	</table>

+ 11 - 2
public/app/features/admin/partials/users.html

@@ -25,7 +25,11 @@
           <th>Name</th>
           <th>Login</th>
           <th>Email</th>
-          <th style="white-space: nowrap">Grafana Admin</th>
+          <th>
+						Seen
+						<tip>Time since user was seen using Grafana</tip>
+					</th>
+          <th></th>
           <th></th>
         </tr>
       </thead>
@@ -35,7 +39,12 @@
           <td>{{user.name}}</td>
           <td>{{user.login}}</td>
           <td>{{user.email}}</td>
-          <td>{{user.isAdmin}}</td>
+          <td>
+						{{user.lastSeenAtAge}}
+					</td>
+          <td>
+						<i class="fa fa-shield" ng-show="user.isAdmin" bs-tooltip="'Grafana Admin'"></i>
+					</td>
           <td class="text-right">
             <a href="admin/users/edit/{{user.id}}" class="btn btn-inverse btn-small">
               <i class="fa fa-edit"></i>

+ 5 - 0
public/app/features/org/partials/orgUsers.html

@@ -41,6 +41,10 @@
         <tr>
           <th>Login</th>
           <th>Email</th>
+					<th>
+						Seen
+						<tip>Time since user was seen using Grafana</tip>
+					</th>
           <th>Role</th>
           <th style="width: 34px;"></th>
         </tr>
@@ -48,6 +52,7 @@
       <tr ng-repeat="user in ctrl.users">
         <td>{{user.login}}</td>
         <td><span class="ellipsis">{{user.email}}</span></td>
+				<td>{{user.lastSeenAtAge}}</td>
         <td>
           <select type="text" ng-model="user.role" class="input-medium" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']" ng-change="ctrl.updateOrgUser(user)">
           </select>