浏览代码

admin: adds paging to global user list

Currently there is a limit of 1000 users in the global
user list. This change introduces paging so that an
admin can see all users and not just the first 1000.

Adds a new route to the api - /api/users/search that
returns a list of users and a total count. It takes
two parameters perpage and page that enable paging.

Fixes #7469
Daniel Lee 9 年之前
父节点
当前提交
193d468ed3

+ 2 - 1
pkg/api/api.go

@@ -123,6 +123,7 @@ func (hs *HttpServer) registerRoutes() {
 		// users (admin permission required)
 		r.Group("/users", func() {
 			r.Get("/", wrap(SearchUsers))
+			r.Get("/search", wrap(SearchUsersWithPaging))
 			r.Get("/:id", wrap(GetUserById))
 			r.Get("/:id/orgs", wrap(GetUserOrgList))
 			// query parameters /users/lookup?loginOrEmail=admin@example.com
@@ -195,7 +196,7 @@ func (hs *HttpServer) registerRoutes() {
 
 		// Data sources
 		r.Group("/datasources", func() {
-			r.Get("/", GetDataSources)
+			r.Get("/", wrap(GetDataSources))
 			r.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), AddDataSource)
 			r.Put("/:id", bind(m.UpdateDataSourceCommand{}), wrap(UpdateDataSource))
 			r.Delete("/:id", DeleteDataSourceById)

+ 4 - 4
pkg/api/datasources.go

@@ -11,12 +11,11 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
-func GetDataSources(c *middleware.Context) {
+func GetDataSources(c *middleware.Context) Response {
 	query := m.GetDataSourcesQuery{OrgId: c.OrgId}
 
 	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(500, "Failed to query datasources", err)
-		return
+		return ApiError(500, "Failed to query datasources", err)
 	}
 
 	result := make(dtos.DataSourceList, 0)
@@ -46,7 +45,8 @@ func GetDataSources(c *middleware.Context) {
 	}
 
 	sort.Sort(result)
-	c.JSON(200, result)
+
+	return Json(200, &result)
 }
 
 func GetDataSourceById(c *middleware.Context) Response {

+ 25 - 6
pkg/api/datasources_test.go

@@ -59,7 +59,9 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 
-		sc := &scenarioContext{}
+		sc := &scenarioContext{
+			url: url,
+		}
 		viewsPath, _ := filepath.Abs("../../public/views")
 
 		sc.m = macaron.New()
@@ -71,16 +73,18 @@ func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
 		sc.m.Use(middleware.GetContextHandler())
 		sc.m.Use(middleware.Sessioner(&session.Options{}))
 
-		sc.defaultHandler = func(c *middleware.Context) {
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
 			sc.context = c
 			sc.context.UserId = TestUserID
 			sc.context.OrgId = TestOrgID
 			sc.context.OrgRole = models.ROLE_EDITOR
 			if sc.handlerFunc != nil {
-				sc.handlerFunc(sc.context)
+				return sc.handlerFunc(sc.context)
 			}
-		}
-		sc.m.SetAutoHead(true)
+
+			return nil
+		})
+
 		sc.m.Get(url, sc.defaultHandler)
 
 		fn(sc)
@@ -96,6 +100,20 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
 	return sc
 }
 
+func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
+	sc.resp = httptest.NewRecorder()
+	req, err := http.NewRequest(method, url, nil)
+	q := req.URL.Query()
+	for k, v := range queryParams {
+		q.Add(k, v)
+	}
+	req.URL.RawQuery = q.Encode()
+	So(err, ShouldBeNil)
+	sc.req = req
+
+	return sc
+}
+
 type scenarioContext struct {
 	m              *macaron.Macaron
 	context        *middleware.Context
@@ -103,6 +121,7 @@ type scenarioContext struct {
 	handlerFunc    handlerFunc
 	defaultHandler macaron.Handler
 	req            *http.Request
+	url            string
 }
 
 func (sc *scenarioContext) exec() {
@@ -110,4 +129,4 @@ func (sc *scenarioContext) exec() {
 }
 
 type scenarioFunc func(c *scenarioContext)
-type handlerFunc func(c *middleware.Context)
+type handlerFunc func(c *middleware.Context) Response

+ 34 - 2
pkg/api/user.go

@@ -210,14 +210,46 @@ func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand)
 
 // GET /api/users
 func SearchUsers(c *middleware.Context) Response {
-	query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000}
-	if err := bus.Dispatch(&query); err != nil {
+	query, err := searchUser(c)
+	if err != nil {
+		return ApiError(500, "Failed to fetch users", err)
+	}
+
+	return Json(200, query.Result.Users)
+}
+
+// GET /api/paged-users
+func SearchUsersWithPaging(c *middleware.Context) Response {
+	query, err := searchUser(c)
+	if err != nil {
 		return ApiError(500, "Failed to fetch users", err)
 	}
 
 	return Json(200, query.Result)
 }
 
+func searchUser(c *middleware.Context) (*m.SearchUsersQuery, error) {
+	perPage := c.QueryInt("perpage")
+	if perPage <= 0 {
+		perPage = 1000
+	}
+	page := c.QueryInt("page")
+
+	if page < 1 {
+		page = 1
+	}
+
+	query := &m.SearchUsersQuery{Query: "", Page: page, Limit: perPage}
+	if err := bus.Dispatch(query); err != nil {
+		return nil, err
+	}
+
+	query.Result.Page = page
+	query.Result.PerPage = perPage
+
+	return query, nil
+}
+
 func SetHelpFlag(c *middleware.Context) Response {
 	flag := c.ParamsInt64(":id")
 

+ 109 - 0
pkg/api/user_test.go

@@ -0,0 +1,109 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/models"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserApiEndpoint(t *testing.T) {
+	Convey("Given a user is logged in", t, func() {
+		mockResult := models.SearchUserQueryResult{
+			Users: []*models.UserSearchHitDTO{
+				{Name: "user1"},
+				{Name: "user2"},
+			},
+			TotalCount: 2,
+		}
+
+		loggedInUserScenario("When calling GET on", "/api/users", func(sc *scenarioContext) {
+			var sentLimit int
+			var sendPage int
+			bus.AddHandler("test", func(query *models.SearchUsersQuery) error {
+				query.Result = mockResult
+
+				sentLimit = query.Limit
+				sendPage = query.Page
+
+				return nil
+			})
+
+			sc.handlerFunc = SearchUsers
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+			So(sentLimit, ShouldEqual, 1000)
+			So(sendPage, ShouldEqual, 1)
+
+			respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+			So(err, ShouldBeNil)
+			So(len(respJSON.MustArray()), ShouldEqual, 2)
+		})
+
+		loggedInUserScenario("When calling GET with page and limit querystring parameters on", "/api/users", func(sc *scenarioContext) {
+			var sentLimit int
+			var sendPage int
+			bus.AddHandler("test", func(query *models.SearchUsersQuery) error {
+				query.Result = mockResult
+
+				sentLimit = query.Limit
+				sendPage = query.Page
+
+				return nil
+			})
+
+			sc.handlerFunc = SearchUsers
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
+
+			So(sentLimit, ShouldEqual, 10)
+			So(sendPage, ShouldEqual, 2)
+		})
+
+		loggedInUserScenario("When calling GET on", "/api/users/search", func(sc *scenarioContext) {
+			var sentLimit int
+			var sendPage int
+			bus.AddHandler("test", func(query *models.SearchUsersQuery) error {
+				query.Result = mockResult
+
+				sentLimit = query.Limit
+				sendPage = query.Page
+
+				return nil
+			})
+
+			sc.handlerFunc = SearchUsersWithPaging
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+			So(sentLimit, ShouldEqual, 1000)
+			So(sendPage, ShouldEqual, 1)
+
+			respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+			So(err, ShouldBeNil)
+
+			So(respJSON.Get("totalCount").MustInt(), ShouldEqual, 2)
+			So(len(respJSON.Get("users").MustArray()), ShouldEqual, 2)
+		})
+
+		loggedInUserScenario("When calling GET with page and perpage querystring parameters on", "/api/users/search", func(sc *scenarioContext) {
+			var sentLimit int
+			var sendPage int
+			bus.AddHandler("test", func(query *models.SearchUsersQuery) error {
+				query.Result = mockResult
+
+				sentLimit = query.Limit
+				sendPage = query.Page
+
+				return nil
+			})
+
+			sc.handlerFunc = SearchUsersWithPaging
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec()
+
+			So(sentLimit, ShouldEqual, 10)
+			So(sendPage, ShouldEqual, 2)
+		})
+	})
+}

+ 8 - 1
pkg/models/user.go

@@ -130,7 +130,14 @@ type SearchUsersQuery struct {
 	Page  int
 	Limit int
 
-	Result []*UserSearchHitDTO
+	Result SearchUserQueryResult
+}
+
+type SearchUserQueryResult struct {
+	TotalCount int64               `json:"totalCount"`
+	Users      []*UserSearchHitDTO `json:"users"`
+	Page       int                 `json:"page"`
+	PerPage    int                 `json:"perPage"`
 }
 
 type GetUserOrgListQuery struct {

+ 2 - 2
pkg/services/sqlstore/org_test.go

@@ -63,8 +63,8 @@ func TestAccountDataAccess(t *testing.T) {
 				err := SearchUsers(&query)
 
 				So(err, ShouldBeNil)
-				So(query.Result[0].Email, ShouldEqual, "ac1@test.com")
-				So(query.Result[1].Email, ShouldEqual, "ac2@test.com")
+				So(query.Result.Users[0].Email, ShouldEqual, "ac1@test.com")
+				So(query.Result.Users[1].Email, ShouldEqual, "ac2@test.com")
 			})
 
 			Convey("Given an added org user", func() {

+ 12 - 3
pkg/services/sqlstore/user.go

@@ -344,12 +344,21 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error {
 }
 
 func SearchUsers(query *m.SearchUsersQuery) error {
-	query.Result = make([]*m.UserSearchHitDTO, 0)
+	query.Result = m.SearchUserQueryResult{
+		Users: make([]*m.UserSearchHitDTO, 0),
+	}
 	sess := x.Table("user")
 	sess.Where("email LIKE ?", query.Query+"%")
-	sess.Limit(query.Limit, query.Limit*query.Page)
+	offset := query.Limit * (query.Page - 1)
+	sess.Limit(query.Limit, offset)
 	sess.Cols("id", "email", "name", "login", "is_admin")
-	err := sess.Find(&query.Result)
+	if err := sess.Find(&query.Result.Users); err != nil {
+		return err
+	}
+
+	user := m.User{}
+	count, err := x.Count(&user)
+	query.Result.TotalCount = count
 	return err
 }
 

+ 45 - 0
pkg/services/sqlstore/user_test.go

@@ -0,0 +1,45 @@
+package sqlstore
+
+import (
+	"fmt"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	"github.com/grafana/grafana/pkg/models"
+)
+
+func TestUserDataAccess(t *testing.T) {
+
+	Convey("Testing DB", t, func() {
+		InitTestDB(t)
+
+		var err error
+		for i := 0; i < 5; i++ {
+			err = CreateUser(&models.CreateUserCommand{
+				Email: fmt.Sprint("user", i, "@test.com"),
+				Name:  fmt.Sprint("user", i),
+				Login: fmt.Sprint("user", i),
+			})
+			So(err, ShouldBeNil)
+		}
+
+		Convey("Can return the first page of users and a total count", func() {
+			query := models.SearchUsersQuery{Query: "", Page: 1, Limit: 3}
+			err = SearchUsers(&query)
+
+			So(err, ShouldBeNil)
+			So(len(query.Result.Users), ShouldEqual, 3)
+			So(query.Result.TotalCount, ShouldEqual, 5)
+		})
+
+		Convey("Can return the second page of users and a total count", func() {
+			query := models.SearchUsersQuery{Query: "", Page: 2, Limit: 3}
+			err = SearchUsers(&query)
+
+			So(err, ShouldBeNil)
+			So(len(query.Result.Users), ShouldEqual, 2)
+			So(query.Result.TotalCount, ShouldEqual, 5)
+		})
+	})
+}

+ 1 - 0
public/app/core/routes/routes.ts

@@ -113,6 +113,7 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
   .when('/admin/users', {
     templateUrl: 'public/app/features/admin/partials/users.html',
     controller : 'AdminListUsersCtrl',
+    controllerAs: 'ctrl',
     resolve: loadAdminBundle,
   })
   .when('/admin/users/create', {

+ 2 - 1
public/app/features/admin/admin.ts

@@ -1,4 +1,4 @@
-import  './adminListUsersCtrl';
+import  AdminListUsersCtrl from './admin_list_users_ctrl';
 import  './adminListOrgsCtrl';
 import  './adminEditOrgCtrl';
 import  './adminEditUserCtrl';
@@ -37,3 +37,4 @@ export class AdminStatsCtrl {
 coreModule.controller('AdminSettingsCtrl', AdminSettingsCtrl);
 coreModule.controller('AdminHomeCtrl', AdminHomeCtrl);
 coreModule.controller('AdminStatsCtrl', AdminStatsCtrl);
+coreModule.controller('AdminListUsersCtrl', AdminListUsersCtrl);

+ 0 - 38
public/app/features/admin/adminListUsersCtrl.js

@@ -1,38 +0,0 @@
-define([
-  'angular',
-],
-function (angular) {
-  'use strict';
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('AdminListUsersCtrl', function($scope, backendSrv) {
-
-    $scope.init = function() {
-      $scope.getUsers();
-    };
-
-    $scope.getUsers = function() {
-      backendSrv.get('/api/users').then(function(users) {
-        $scope.users = users;
-      });
-    };
-
-    $scope.deleteUser = function(user) {
-      $scope.appEvent('confirm-modal', {
-        title: 'Delete',
-        text: 'Do you want to delete ' + user.login + '?',
-        icon: 'fa-trash',
-        yesText: 'Delete',
-        onConfirm: function() {
-          backendSrv.delete('/api/admin/users/' + user.id).then(function() {
-            $scope.getUsers();
-          });
-        }
-      });
-    };
-
-    $scope.init();
-
-  });
-});

+ 49 - 0
public/app/features/admin/admin_list_users_ctrl.ts

@@ -0,0 +1,49 @@
+///<reference path="../../headers/common.d.ts" />
+
+export default class AdminListUsersCtrl {
+  users: any;
+  pages = [];
+  perPage = 1000;
+  page = 1;
+  totalPages: number;
+  showPaging = false;
+
+  /** @ngInject */
+  constructor(private $scope, private backendSrv) {
+    this.getUsers();
+  }
+
+  getUsers() {
+    this.backendSrv.get(`/api/users/search?perpage=${this.perPage}&page=${this.page}`).then((result) => {
+      this.users = result.users;
+      this.page = result.page;
+      this.perPage = result.perPage;
+      this.totalPages = Math.ceil(result.totalCount / result.perPage);
+      this.showPaging = this.totalPages > 1;
+      this.pages = [];
+
+      for (var i = 1; i < this.totalPages+1; i++) {
+        this.pages.push({ page: i, current: i === this.page});
+      }
+    });
+  }
+
+  navigateToPage(page) {
+    this.page = page.page;
+    this.getUsers();
+  }
+
+  deleteUser(user) {
+    this.$scope.appEvent('confirm-modal', {
+      title: 'Delete',
+      text: 'Do you want to delete ' + user.login + '?',
+      icon: 'fa-trash',
+      yesText: 'Delete',
+      onConfirm: () => {
+        this.backendSrv.delete('/api/admin/users/' + user.id).then(() => {
+          this.getUsers();
+        });
+      }
+    });
+  }
+}

+ 55 - 42
public/app/features/admin/partials/users.html

@@ -1,49 +1,62 @@
 <navbar icon="fa fa-fw fa-cogs" title="Admin" title-url="admin">
-	<a href="admin/users" class="navbar-page-btn">
-		<i class="icon-gf icon-gf-users"></i>
-		Users
-	</a>
+  <a href="admin/users" class="navbar-page-btn">
+    <i class="icon-gf icon-gf-users"></i>
+    Users
+  </a>
 </navbar>
 
 <div class="page-container">
-	<div class="page-header">
-		<h1>Users</h1>
+  <div class="page-header">
+    <h1>Users</h1>
 
-		<a class="btn btn-success" href="admin/users/create">
-			<i class="fa fa-plus"></i>
-			Add new user
-		</a>
-	</div>
+    <a class="btn btn-success" href="admin/users/create">
+      <i class="fa fa-plus"></i>
+      Add new user
+    </a>
+  </div>
+  <div class="admin-list-table">
+    <table class="filter-table form-inline">
+      <thead>
+        <tr>
+          <th>Id</th>
+          <th>Name</th>
+          <th>Login</th>
+          <th>Email</th>
+          <th style="white-space: nowrap">Grafana Admin</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="user in ctrl.users">
+          <td>{{user.id}}</td>
+          <td>{{user.name}}</td>
+          <td>{{user.login}}</td>
+          <td>{{user.email}}</td>
+          <td>{{user.isAdmin}}</td>
+          <td class="text-right">
+            <a href="admin/users/edit/{{user.id}}" class="btn btn-inverse btn-small">
+              <i class="fa fa-edit"></i>
+              Edit
+            </a>
+            &nbsp;&nbsp;
+            <a ng-click="ctrl.deleteUser(user)" class="btn btn-danger btn-small">
+              <i class="fa fa-remove"></i>
+            </a>
+          </td>
+        </tr>
+      </tbody>
 
-	<table class="filter-table form-inline">
-		<thead>
-			<tr>
-				<th>Id</th>
-				<th>Name</th>
-				<th>Login</th>
-				<th>Email</th>
-				<th style="white-space: nowrap">Grafana Admin</th>
-				<th></th>
-			</tr>
-		</thead>
-		<tbody>
-			<tr ng-repeat="user in users">
-				<td>{{user.id}}</td>
-				<td>{{user.name}}</td>
-				<td>{{user.login}}</td>
-				<td>{{user.email}}</td>
-				<td>{{user.isAdmin}}</td>
-				<td class="text-right">
-					<a href="admin/users/edit/{{user.id}}" class="btn btn-inverse btn-small">
-						<i class="fa fa-edit"></i>
-						Edit
-					</a>
-					&nbsp;&nbsp;
-					<a ng-click="deleteUser(user)" class="btn btn-danger btn-small">
-						<i class="fa fa-remove"></i>
-					</a>
-				</td>
-			</tr>
-		</tbody>
-	</table>
+    </table>
+  </div>
+
+  <div class="admin-list-paging" ng-if="ctrl.showPaging">
+    <ol>
+      <li ng-repeat="page in ctrl.pages">
+        <button
+          class="btn btn-small"
+          ng-class="{'btn-secondary': page.current, 'btn-inverse': !page.current}"
+          ng-click="ctrl.navigateToPage(page)">{{page.page}}</button>
+      </li>
+    </ol>
+  </div>
 </div>

+ 12 - 0
public/sass/pages/_admin.scss

@@ -8,3 +8,15 @@ td.admin-settings-key {
   padding-left: 20px;
 }
 
+.admin-list-table {
+  margin-bottom: 20px;
+}
+
+.admin-list-paging {
+  float: right;
+  li {
+    display: inline-block;
+    padding-left: 10px;
+    margin-bottom: 5px;
+  }
+}