diff --git a/pkg/api/api.go b/pkg/api/api.go
index 7d3a5563892..2698a2fc001 100644
--- a/pkg/api/api.go
+++ b/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", DeleteDataSource)
diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go
index 43f4d308ed6..54656d70e5d 100644
--- a/pkg/api/datasources.go
+++ b/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 {
diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go
index aa3f6106afd..5ae752bea91 100644
--- a/pkg/api/datasources_test.go
+++ b/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
diff --git a/pkg/api/user.go b/pkg/api/user.go
index 7bce599d692..9a978503761 100644
--- a/pkg/api/user.go
+++ b/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")
diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go
new file mode 100644
index 00000000000..6aa9dd9adbf
--- /dev/null
+++ b/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)
+ })
+ })
+}
diff --git a/pkg/models/user.go b/pkg/models/user.go
index e14f4486ba3..e0a36be8c0a 100644
--- a/pkg/models/user.go
+++ b/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 {
diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go
index f52175c2e5c..e7c718fc9a8 100644
--- a/pkg/services/sqlstore/org_test.go
+++ b/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() {
diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go
index b26a9153f55..791a38f2d01 100644
--- a/pkg/services/sqlstore/user.go
+++ b/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
}
diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go
new file mode 100644
index 00000000000..53a50f9631c
--- /dev/null
+++ b/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)
+ })
+ })
+}
diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts
index 28e1dcf1cd1..3ccf89bae01 100644
--- a/public/app/core/routes/routes.ts
+++ b/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', {
diff --git a/public/app/features/admin/admin.ts b/public/app/features/admin/admin.ts
index b93fd07a059..6809e4c54ab 100644
--- a/public/app/features/admin/admin.ts
+++ b/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);
diff --git a/public/app/features/admin/adminListUsersCtrl.js b/public/app/features/admin/adminListUsersCtrl.js
deleted file mode 100644
index 721adcfb98c..00000000000
--- a/public/app/features/admin/adminListUsersCtrl.js
+++ /dev/null
@@ -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();
-
- });
-});
diff --git a/public/app/features/admin/admin_list_users_ctrl.ts b/public/app/features/admin/admin_list_users_ctrl.ts
new file mode 100644
index 00000000000..1347457bc06
--- /dev/null
+++ b/public/app/features/admin/admin_list_users_ctrl.ts
@@ -0,0 +1,49 @@
+///
+
+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();
+ });
+ }
+ });
+ }
+}
diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html
index a1c86391088..57714380c67 100644
--- a/public/app/features/admin/partials/users.html
+++ b/public/app/features/admin/partials/users.html
@@ -1,49 +1,62 @@
-
-
- Users
-
+
+
+ Users
+
-
+
+
+
+
+ -
+
+
+
+
diff --git a/public/sass/pages/_admin.scss b/public/sass/pages/_admin.scss
index 30bc3ca8b34..b2be062849d 100644
--- a/public/sass/pages/_admin.scss
+++ b/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;
+ }
+}