Browse Source

Merge branch 'master' into ldap

Torkel Ödegaard 10 years ago
parent
commit
2c7d33cdfa
100 changed files with 2009 additions and 872 deletions
  1. 6 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 1 0
      conf/defaults.ini
  4. 2 1
      conf/sample.ini
  5. 1 1
      docs/mkdocs.yml
  6. 1 1
      docs/sources/datasources/opentsdb.md
  7. 2 2
      docs/sources/guides/gettingstarted.md
  8. 2 2
      docs/sources/installation/configuration.md
  9. 1 1
      docs/sources/installation/mac.md
  10. 1 1
      docs/sources/project/building_from_source.md
  11. 2 2
      docs/sources/reference/annotations.md
  12. 1 1
      docs/sources/reference/graph.md
  13. 227 3
      docs/sources/reference/http_api.md
  14. 1 1
      docs/sources/reference/scripting.md
  15. 1 1
      docs/sources/reference/timerange.md
  16. 1 1
      latest.json
  17. 1 1
      pkg/api/admin_settings.go
  18. 0 56
      pkg/api/admin_users.go
  19. 51 31
      pkg/api/api.go
  20. 12 16
      pkg/api/apikey.go
  21. 122 0
      pkg/api/common.go
  22. 1 0
      pkg/api/dashboard.go
  23. 14 0
      pkg/api/dataproxy.go
  24. 3 1
      pkg/api/datasources.go
  25. 1 0
      pkg/api/dtos/models.go
  26. 5 0
      pkg/api/frontendsettings.go
  27. 1 1
      pkg/api/index.go
  28. 2 0
      pkg/api/login_oauth.go
  29. 48 17
      pkg/api/org.go
  30. 72 34
      pkg/api/org_users.go
  31. 3 3
      pkg/api/search.go
  32. 12 22
      pkg/api/stars.go
  33. 70 41
      pkg/api/user.go
  34. 1 1
      pkg/components/renderer/renderer.go
  35. 1 1
      pkg/models/datasource.go
  36. 10 6
      pkg/models/org.go
  37. 5 4
      pkg/models/org_user.go
  38. 1 0
      pkg/models/user.go
  39. 41 2
      pkg/search/handlers.go
  40. 61 0
      pkg/search/handlers_test.go
  41. 4 7
      pkg/search/json_index.go
  42. 9 2
      pkg/search/json_index_test.go
  43. 1 3
      pkg/search/models.go
  44. 14 13
      pkg/services/eventpublisher/eventpublisher.go
  45. 1 10
      pkg/services/sqlstore/dashboard.go
  46. 0 12
      pkg/services/sqlstore/dashboard_test.go
  47. 14 3
      pkg/services/sqlstore/org.go
  48. 8 1
      pkg/services/sqlstore/org_test.go
  49. 19 11
      pkg/services/sqlstore/org_users.go
  50. 11 4
      pkg/services/sqlstore/user.go
  51. 2 0
      pkg/setting/setting.go
  52. 145 28
      pkg/social/social.go
  53. 14 2
      public/app/components/extend-jquery.js
  54. 3 1
      public/app/components/kbn.js
  55. 6 6
      public/app/components/panelmeta.js
  56. 12 34
      public/app/controllers/search.js
  57. 4 3
      public/app/directives/all.js
  58. 49 0
      public/app/directives/annotationTooltip.js
  59. 0 134
      public/app/directives/bootstrap-tagsinput.js
  60. 13 5
      public/app/directives/metric.segment.js
  61. 49 0
      public/app/directives/misc.js
  62. 137 0
      public/app/directives/tags.js
  63. 234 115
      public/app/directives/variableValueSelect.js
  64. 50 3
      public/app/features/admin/adminEditUserCtrl.js
  65. 1 1
      public/app/features/admin/adminUsersCtrl.js
  66. 68 14
      public/app/features/admin/partials/edit_user.html
  67. 3 3
      public/app/features/admin/partials/new_user.html
  68. 1 1
      public/app/features/admin/partials/users.html
  69. 14 36
      public/app/features/annotations/annotationsSrv.js
  70. 31 19
      public/app/features/dashboard/partials/variableValueSelect.html
  71. 3 3
      public/app/features/dashboard/shareModalCtrl.js
  72. 5 1
      public/app/features/dashboard/submenuCtrl.js
  73. 1 1
      public/app/features/dashboard/timeSrv.js
  74. 4 3
      public/app/features/dashboard/viewStateSrv.js
  75. 3 2
      public/app/features/dashlinks/editor.html
  76. 3 3
      public/app/features/dashlinks/module.js
  77. 42 13
      public/app/features/org/datasourceEditCtrl.js
  78. 1 1
      public/app/features/org/newOrgCtrl.js
  79. 2 0
      public/app/features/org/orgApiKeysCtrl.js
  80. 16 5
      public/app/features/org/partials/datasourceEdit.html
  81. 1 1
      public/app/features/org/partials/datasourceHttpConfig.html
  82. 4 4
      public/app/features/org/partials/orgUsers.html
  83. 38 24
      public/app/features/panel/panelMenu.js
  84. 0 8
      public/app/features/panel/panelSrv.js
  85. 2 2
      public/app/features/profile/partials/profile.html
  86. 40 4
      public/app/features/templating/partials/editor.html
  87. 31 2
      public/app/features/templating/templateValuesSrv.js
  88. 4 4
      public/app/panels/dashlist/editor.html
  89. 5 2
      public/app/panels/dashlist/module.js
  90. 3 0
      public/app/panels/graph/graph.js
  91. 7 6
      public/app/panels/singlestat/module.js
  92. 2 2
      public/app/partials/login.html
  93. 10 7
      public/app/partials/search.html
  94. 4 1
      public/app/partials/submenu.html
  95. 7 0
      public/app/plugins/datasource/graphite/datasource.js
  96. 3 1
      public/app/plugins/datasource/graphite/partials/query.editor.html
  97. 7 10
      public/app/plugins/datasource/graphite/queryCtrl.js
  98. 28 24
      public/app/plugins/datasource/influxdb/datasource.js
  99. 1 1
      public/app/plugins/datasource/influxdb/funcEditor.js
  100. 26 10
      public/app/plugins/datasource/influxdb/influxSeries.js

+ 6 - 0
CHANGELOG.md

@@ -6,20 +6,26 @@
 - [Issue #1888](https://github.com/grafana/grafana/issues/1144). Templating: Repeat panel or row for each selected template variable value
 - [Issue #1888](https://github.com/grafana/grafana/issues/1944). Dashboard: Custom Navigation links & dynamic links to related dashboards
 - [Issue #590](https://github.com/grafana/grafana/issues/590).   Graph: Define series color using regex rule
+- [Issue #2096](https://github.com/grafana/grafana/issues/2096). Dashboard list panel: Now supports search by multiple tags
 
 **User or Organization admin**
 - [Issue #1899](https://github.com/grafana/grafana/issues/1899). Organization: You can now update the organization user role directly (without removing and readding the organization user).
+- [Issue #2088](https://github.com/grafana/grafana/issues/2088). Roles: New user role `Read Only Editor` that replaces the old `Viewer` role behavior
 
 **Backend**
+- [Issue #2095](https://github.com/grafana/grafana/issues/2095). Search: Search now supports filtering by multiple dashboard tags
 - [Issue #1905](https://github.com/grafana/grafana/issues/1905). Github OAuth: You can now configure a Github team membership requirement, thx @dewski
+- [Issue #2052](https://github.com/grafana/grafana/issues/2052). Github OAuth: You can now configure a Github organization requirement, thx @indrekj
 - [Issue #1891](https://github.com/grafana/grafana/issues/1891). Security: New config option to disable the use of gravatar for profile images
 - [Issue #1921](https://github.com/grafana/grafana/issues/1921). Auth: Support for user authentication via reverse proxy header (like X-Authenticated-User, or X-WEBAUTH-USER)
 - [Issue #960](https://github.com/grafana/grafana/issues/960).   Search: Backend can now index a folder with json files, will be available in search (saving back to folder is not supported, this feature is meant for static generated json dashboards)
 
 **Breaking changes**
+- [Issue #1826](https://github.com/grafana/grafana/issues/1826). User role 'Viewer' are now prohibited from entering edit mode (and doing other transient dashboard edits). A new role `Read Only Editor` will replace the old Viewer behavior
 - [Issue #1928](https://github.com/grafana/grafana/issues/1928). HTTP API: GET /api/dashboards/db/:slug response changed property `model` to `dashboard` to match the POST request nameing
 - Backend render URL changed from `/render/dashboard/solo` `render/dashboard-solo/` (in order to have consistent dashboard url `/dashboard/:type/:slug`)
 - Search HTTP API response has changed (simplified), tags list moved to seperate HTTP resource URI
+- Datasource HTTP api breaking change, ADD datasource is now POST /api/datasources/, update is now PUT /api/datasources/:id
 
 # 2.0.3 (unreleased - 2.0.x branch)
 

+ 1 - 1
README.md

@@ -87,7 +87,7 @@ go get github.com/grafana/grafana
 ```
 cd $GOPATH/src/github.com/grafana/grafana
 go run build.go setup            (only needed once to install godep)
-godep restore                    (will pull down all golang lib dependecies in your current GOPATH)
+godep restore                    (will pull down all golang lib dependencies in your current GOPATH)
 go build .
 ```
 

+ 1 - 0
conf/defaults.ini

@@ -153,6 +153,7 @@ token_url = https://github.com/login/oauth/access_token
 api_url = https://api.github.com/user
 team_ids =
 allowed_domains =
+allowed_organizations =
 
 #################################### Google Auth ##########################
 [auth.google]

+ 2 - 1
conf/sample.ini

@@ -146,12 +146,13 @@
 ;allow_sign_up = false
 ;client_id = some_id
 ;client_secret = some_secret
-;scopes = user:email
+;scopes = user:email,read:org
 ;auth_url = https://github.com/login/oauth/authorize
 ;token_url = https://github.com/login/oauth/access_token
 ;api_url = https://api.github.com/user
 ;team_ids =
 ;allowed_domains =
+;allowed_organizations =
 
 #################################### Google Auth ##########################
 [auth.google]

+ 1 - 1
docs/mkdocs.yml

@@ -61,7 +61,7 @@ pages:
 - ['datasources/influxdb.md', 'Data Sources', 'InfluxDB']
 - ['datasources/opentsdb.md', 'Data Sources', 'OpenTSDB']
 
-- ['project/building_from_source.md', 'Project', 'Building from souce']
+- ['project/building_from_source.md', 'Project', 'Building from source']
 - ['project/cla.md', 'Project', 'Contributor License Agreement']
 
 - ['jsearch.md', '**HIDDEN**']

+ 1 - 1
docs/sources/datasources/opentsdb.md

@@ -27,7 +27,7 @@ Open a graph in edit mode by click the title.
 
 ![](/img/v2/opentsdb_query_editor.png)
 
-For details on opentsdb metric queries checkout the offical [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html)
+For details on opentsdb metric queries checkout the official [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html)
 
 
 

+ 2 - 2
docs/sources/guides/gettingstarted.md

@@ -19,7 +19,7 @@ The image above shows you the top header for a dashboard.
 
 1. Side menubar toggle: This toggles the side menu, allowing you to focus on the data presented in the dashboard. The side menu provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources.
 2. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage Dashboard playlists.
-3. Star Dashboard: Star (or unstar) the current Dashboar. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in.
+3. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in.
 4. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing.
 5. Save dashboard: The current Dashboard will be saved with the current Dashboard name.
 6. Settings: Manage Dashboard settings and features such as Templating and Annotations.
@@ -28,7 +28,7 @@ The image above shows you the top header for a dashboard.
 Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a number of Rows.
 By adjusting the display properties of Panels and Rows, you can customize the perfect Dashboard for your exact needs.
 Each panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, and KairosDB).
-This allows you to create a single dashboard that unifies the data across your organization. Panels use the time range specificed
+This allows you to create a single dashboard that unifies the data across your organization. Panels use the time range specified
 in the main Time Picker in the upper right, but they can also have relative time overrides.
 
 <img src="/img/v2/dashboard_annotated.png" class="no-shadow">

+ 2 - 2
docs/sources/installation/configuration.md

@@ -296,12 +296,12 @@ Secret. Specify these in the Grafana configuration file. For example:
     scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
     auth_url = https://accounts.google.com/o/oauth2/auth
     token_url = https://accounts.google.com/o/oauth2/token
-    allowed_domains = mycompany.com
+    allowed_domains = mycompany.com mycompany.org
     allow_sign_up = false
 
 Restart the Grafana back-end. You should now see a Google login button
 on the login page. You can now login or sign up with your Google
-accounts. The `allowed_domains` option is optional.
+accounts. The `allowed_domains` option is optional, and domains were separated by space.
 
 You may allow users to sign-up via Google authentication by setting the
 `allow_sign_up` option to `true`. When this option is set to `true`, any

+ 1 - 1
docs/sources/installation/mac.md

@@ -7,7 +7,7 @@ page_keywords: grafana, installation, mac, osx, guide
 # Installing on Mac
 
 There is currently no binary build for Mac. But read the [build from
-source](../project/building_from_source) page for instructions on how to
+source](/project/building_from_source) page for instructions on how to
 build it yourself.
 
 

+ 1 - 1
docs/sources/project/building_from_source.md

@@ -72,4 +72,4 @@ You only need to add the options you want to override. Config files are applied
 
 ## Create a pull requests
 
-Before or after your create a pull requests, sign the [contributor license aggrement](/docs/contributing/cla.html).
+Before or after your create a pull requests, sign the [contributor license agreement](/docs/contributing/cla.html).

+ 2 - 2
docs/sources/reference/annotations.md

@@ -18,9 +18,9 @@ dropdown. This will open the `Annotations` edit view. Click the `Add` tab to add
 Graphite supports two ways to query annotations.
 
 - A regular metric query, use the `Graphite target expression` text input for this
-- Graphite events query, use the `Graphite event tags` text input, especify an tag or wildcard (leave empty should also work)
+- Graphite events query, use the `Graphite event tags` text input, specify an tag or wildcard (leave empty should also work)
 
-## Elasticsearch annoations
+## Elasticsearch annotations
 ![](/img/v2/annotations_es.png)
 
 Grafana can query any Elasticsearch index for annotation events. The index name can be the name of an alias or an index wildcard pattern.

+ 1 - 1
docs/sources/reference/graph.md

@@ -62,7 +62,7 @@ The ``Left Y`` and ``Right Y`` can be customized using:
 
 - ``Unit`` - The display unit for the Y value
 - ``Grid Max`` - The maximum Y value. (default auto)
-- ``Grid Min`` - The minium Y value. (default auto)
+- ``Grid Min`` - The minimum Y value. (default auto)
 - ``Label`` - The Y axis label (default "")
 
 Axes can also be hidden by unchecking the appropriate box from `Show Axis`.

+ 227 - 3
docs/sources/reference/http_api.md

@@ -84,8 +84,8 @@ Status Codes:
 - **401** – Unauthorized
 - **412** – Precondition failed
 
-The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the verison that was sent). The
-same status code is also used if another dashboar exists with the same title. The response body will look like this:
+The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the version that was sent). The
+same status code is also used if another dashboard exists with the same title. The response body will look like this:
 
     HTTP/1.1 412 Precondition Failed
     Content-Type: application/json; charset=UTF-8
@@ -141,12 +141,236 @@ Will return the dashboard given the dashboard slug. Slug is the url friendly ver
 
 The above will delete the dashboard with the specified slug. The slug is the url friendly (unique) version of the dashboard title.
 
+### Gets the home dashboard
+
+`GET /api/dashboards/home`
+
+### Tags for Dashboard
+
+`GET /api/dashboards/tags`
+
+### Dashboard from JSON file
+
+`GET /file/:file`
+
+### Search Dashboards
+
+`GET /api/search/`
+
+Status Codes:
+
+- **query** – Search Query
+- **tags** – Tags to use
+- **starred** – Flag indicating if only starred Dashboards should be returned
+- **tagcloud** - Flag indicating if a tagcloud should be returned
+
+**Example Request**:
+
+        GET /api/search?query=MyDashboard&starred=true&tag=prod HTTP/1.1
+        Accept: application/json
+        Content-Type: application/json
+        Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
 ## Data sources
 
+### Get all datasources
+
+`GET /api/datasources`
+
+### Get a single data sources by Id
+
+`GET /api/datasources/:datasourceId`
+
 ### Create data source
 
-## Organizations
+`PUT /api/datasources`
+
+**Example Response**:
+
+        HTTP/1.1 200
+        Content-Type: application/json
+
+        {"message":"Datasource added"}
+
+### Edit an existing data source
+
+`POST /api/datasources`
+
+### Delete an existing data source
+
+`DELETE /api/datasources/:datasourceId`
+
+**Example Response**:
+
+        HTTP/1.1 200
+        Content-Type: application/json
+
+       {"message":"Data source deleted"}
+
+### Available data source types
+
+`GET /api/datasources/plugins`
+
+## Data source proxy calls
+
+`GET /api/datasources/proxy/:datasourceId/*`
+
+Proxies all calls to the actual datasource.
+
+## Organisation
+
+### Get current Organisation
+
+`GET /api/org`
+
+### Get all users within the actual organisation
+
+`GET /api/org/users`
+
+### Add a new user to the actual organisation
+
+`POST /api/org/users`
+
+Adds a global user to the actual organisation.
+
+### Updates the given user
+
+`PATCH /api/org/users/:userId`
+
+### Delete user in actual organisation
+
+`DELETE /api/org/users/:userId`
+
+### Get all Users
+
+`GET /api/org/users`
+
+## Organisations
+
+### Search all Organisations
+
+`GET /api/orgs`
+
+### Update Organisation
+
+`PUT /api/orgs/:orgId`
+
+### Get Users in Organisation
+
+`GET /api/orgs/:orgId/users`
+
+### Add User in Organisation
+
+`POST /api/orgs/:orgId/users`
+
+### Update Users in Organisation
+
+`PATCH /api/orgs/:orgId/users/:userId`
+
+### Delete User in Organisation
+
+`DELETE /api/orgs/:orgId/users/:userId`    
 
 ## Users
 
+### Search Users
+
+`GET /api/users`
+
+### Get single user by Id
+
+`GET /api/users/:id`
+
+### User Update
+
+`PUT /api/users/:id`
+
+### Get Organisations for user
+
+`GET /api/users/:id/orgs`
+
+## User
+
+### Change Password
+
+`PUT /api/user/password`
+
+Changes the password for the user
+
+### Actual User
+
+`GET /api/user`
+
+The above will return the current user.
+
+### Switch user context
+
+`POST /api/user/using/:organisationId`
+
+Switch user context to the given organisation.
+
+### Organisations of the actual User 
+
+`GET /api/user/orgs`
+
+The above will return a list of all organisations of the current user.
+
+### Star a dashboard
+
+`POST /api/user/stars/dashboard/:dashboardId`
+
+Stars the given Dashboard for the actual user.
+
+### Unstar a dashboard
+
+`DELETE /api/user/stars/dashboard/:dashboardId`
+
+Deletes the staring of the given Dashboard for the actual user.
+
+## Snapshots
+
+### Create new snapshot
+
+`POST /api/snapshots`
+
+### Get Snapshot by Id
+
+`GET /api/snapshots/:key`
+
+### Delete Snapshot by Id
+
+`DELETE /api/snapshots-delete/:key`
+
+## Frontend Settings
+
+### Get Settings
+
+`GET /api/frontend/settings`
+
+## Login
+
+### Renew session based on remember cookie
+
+`GET /api/login/ping`
+
+## Admin
+
+### Settings
+
+`GET /api/admin/settings`
+
+### Global Users
+
+`POST /api/admin/users`
+
+### Password for User
+
+`PUT /api/admin/users/:id/password`
+
+### Permissions
+
+`PUT /api/admin/users/:id/permissions`
+
+### Delete global User
 
+`DELETE /api/admin/users/:id`

+ 1 - 1
docs/sources/reference/scripting.md

@@ -12,7 +12,7 @@ With scripted dashboards you can dynamically create your dashboards using javasc
 under `public/dashboards/` there is a file named `scripted.js`. This file contains an example of a scripted dashboard. You can access it by using the url:
 `http://grafana_url/dashboard/script/scripted.js?rows=3&name=myName`
 
-If you open scripted.js you can see how it reads url paramters from ARGS variable and then adds rows and panels.
+If you open scripted.js you can see how it reads url parameters from ARGS variable and then adds rows and panels.
 
 ## Example
 

+ 1 - 1
docs/sources/reference/timerange.md

@@ -24,7 +24,7 @@ All of this applies to all Panels in the Dashboard (except those with Panel Time
 
 It's possible to customize the options displayed for relative time and the auto-refresh options. 
 
-From Dashboard setttings, click the Timepicker tab. From here you can specify the relative and auto refresh intervals. The Timepicker tab settings are saved on a per Dashboard basis.  Entries are comma seperated and accept a number followed by one of the following units: s (seconds), m (minutes), h (hours), d (days), w (weeks), M (months), y (years).
+From Dashboard settings, click the Timepicker tab. From here you can specify the relative and auto refresh intervals. The Timepicker tab settings are saved on a per Dashboard basis.  Entries are comma separated and accept a number followed by one of the following units: s (seconds), m (minutes), h (hours), d (days), w (weeks), M (months), y (years).
 
 ![](/img/v1/timepicker_editor.png)
 

+ 1 - 1
latest.json

@@ -1,3 +1,3 @@
 {
-	"version": "2.0.1",
+	"version": "2.0.2"
 }

+ 1 - 1
pkg/api/admin_settings.go

@@ -17,7 +17,7 @@ func AdminGetSettings(c *middleware.Context) {
 		for _, key := range section.Keys() {
 			keyName := key.Name()
 			value := key.Value()
-			if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") {
+			if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") || (strings.Contains(keyName, "provider_config") && strings.Contains(value, "@")) {
 				value = "************"
 			}
 

+ 0 - 56
pkg/api/admin_users.go

@@ -9,36 +9,6 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
-func AdminSearchUsers(c *middleware.Context) {
-	query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000}
-	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(500, "Failed to fetch users", err)
-		return
-	}
-
-	c.JSON(200, query.Result)
-}
-
-func AdminGetUser(c *middleware.Context) {
-	userId := c.ParamsInt64(":id")
-
-	query := m.GetUserByIdQuery{Id: userId}
-
-	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(500, "Failed to fetch user", err)
-		return
-	}
-
-	result := dtos.AdminUserListItem{
-		Name:           query.Result.Name,
-		Email:          query.Result.Email,
-		Login:          query.Result.Login,
-		IsGrafanaAdmin: query.Result.IsAdmin,
-	}
-
-	c.JSON(200, result)
-}
-
 func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) {
 	cmd := m.CreateUserCommand{
 		Login:    form.Login,
@@ -70,32 +40,6 @@ func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) {
 	c.JsonOK("User created")
 }
 
-func AdminUpdateUser(c *middleware.Context, form dtos.AdminUpdateUserForm) {
-	userId := c.ParamsInt64(":id")
-
-	cmd := m.UpdateUserCommand{
-		UserId: userId,
-		Login:  form.Login,
-		Email:  form.Email,
-		Name:   form.Name,
-	}
-
-	if len(cmd.Login) == 0 {
-		cmd.Login = cmd.Email
-		if len(cmd.Login) == 0 {
-			c.JsonApiErr(400, "Validation error, need specify either username or email", nil)
-			return
-		}
-	}
-
-	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "failed to update user", err)
-		return
-	}
-
-	c.JsonOK("User updated")
-}
-
 func AdminUpdateUserPassword(c *middleware.Context, form dtos.AdminUpdateUserPasswordForm) {
 	userId := c.ParamsInt64(":id")
 

+ 51 - 31
pkg/api/api.go

@@ -13,7 +13,7 @@ func Register(r *macaron.Macaron) {
 	reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
-	reqAccountAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
+	regOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
 	bind := binding.Bind
 
 	// not logged in views
@@ -53,48 +53,71 @@ func Register(r *macaron.Macaron) {
 
 	// authed api
 	r.Group("/api", func() {
-		// user
+
+		// user (signed in)
 		r.Group("/user", func() {
-			r.Get("/", GetUser)
-			r.Put("/", bind(m.UpdateUserCommand{}), UpdateUser)
-			r.Post("/using/:id", UserSetUsingOrg)
-			r.Get("/orgs", GetUserOrgList)
-			r.Post("/stars/dashboard/:id", StarDashboard)
-			r.Delete("/stars/dashboard/:id", UnstarDashboard)
-			r.Put("/password", bind(m.ChangeUserPasswordCommand{}), ChangeUserPassword)
+			r.Get("/", wrap(GetSignedInUser))
+			r.Put("/", bind(m.UpdateUserCommand{}), wrap(UpdateSignedInUser))
+			r.Post("/using/:id", wrap(UserSetUsingOrg))
+			r.Get("/orgs", wrap(GetSignedInUserOrgList))
+			r.Post("/stars/dashboard/:id", wrap(StarDashboard))
+			r.Delete("/stars/dashboard/:id", wrap(UnstarDashboard))
+			r.Put("/password", bind(m.ChangeUserPasswordCommand{}), wrap(ChangeUserPassword))
 		})
 
-		// account
+		// users (admin permission required)
+		r.Group("/users", func() {
+			r.Get("/", wrap(SearchUsers))
+			r.Get("/:id", wrap(GetUserById))
+			r.Get("/:id/orgs", wrap(GetUserOrgList))
+			r.Put("/:id", bind(m.UpdateUserCommand{}), wrap(UpdateUser))
+		}, reqGrafanaAdmin)
+
+		// current org
 		r.Group("/org", func() {
-			r.Get("/", GetOrg)
-			r.Post("/", bind(m.CreateOrgCommand{}), CreateOrg)
-			r.Put("/", bind(m.UpdateOrgCommand{}), UpdateOrg)
-			r.Post("/users", bind(m.AddOrgUserCommand{}), AddOrgUser)
-			r.Get("/users", GetOrgUsers)
-			r.Patch("/users/:id", bind(m.UpdateOrgUserCommand{}), UpdateOrgUser)
-			r.Delete("/users/:id", RemoveOrgUser)
-		}, reqAccountAdmin)
+			r.Get("/", wrap(GetOrgCurrent))
+			r.Put("/", bind(m.UpdateOrgCommand{}), wrap(UpdateOrgCurrent))
+			r.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg))
+			r.Get("/users", wrap(GetOrgUsersForCurrentOrg))
+			r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg))
+			r.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg))
+		}, regOrgAdmin)
+
+		// create new org
+		r.Post("/orgs", bind(m.CreateOrgCommand{}), wrap(CreateOrg))
+
+		// search all orgs
+		r.Get("/orgs", reqGrafanaAdmin, wrap(SearchOrgs))
+
+		// orgs (admin routes)
+		r.Group("/orgs/:orgId", func() {
+			r.Put("/", bind(m.UpdateOrgCommand{}), wrap(UpdateOrg))
+			r.Get("/users", wrap(GetOrgUsers))
+			r.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUser))
+			r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUser))
+			r.Delete("/users/:userId", wrap(RemoveOrgUser))
+		}, reqGrafanaAdmin)
 
 		// auth api keys
 		r.Group("/auth/keys", func() {
-			r.Get("/", GetApiKeys)
-			r.Post("/", bind(m.AddApiKeyCommand{}), AddApiKey)
-			r.Delete("/:id", DeleteApiKey)
-		}, reqAccountAdmin)
+			r.Get("/", wrap(GetApiKeys))
+			r.Post("/", bind(m.AddApiKeyCommand{}), wrap(AddApiKey))
+			r.Delete("/:id", wrap(DeleteApiKey))
+		}, regOrgAdmin)
 
 		// Data sources
 		r.Group("/datasources", func() {
-			r.Combo("/").
-				Get(GetDataSources).
-				Put(bind(m.AddDataSourceCommand{}), AddDataSource).
-				Post(bind(m.UpdateDataSourceCommand{}), UpdateDataSource)
+			r.Get("/", GetDataSources)
+			r.Post("/", bind(m.AddDataSourceCommand{}), AddDataSource)
+			r.Put("/:id", bind(m.UpdateDataSourceCommand{}), UpdateDataSource)
 			r.Delete("/:id", DeleteDataSource)
 			r.Get("/:id", GetDataSourceById)
 			r.Get("/plugins", GetDataSourcePlugins)
-		}, reqAccountAdmin)
+		}, regOrgAdmin)
 
 		r.Get("/frontend/settings/", GetFrontendSettings)
 		r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest)
+		r.Any("/datasources/proxy/:id", reqSignedIn, ProxyDataSourceRequest)
 
 		// Dashboard
 		r.Group("/dashboards", func() {
@@ -115,10 +138,7 @@ func Register(r *macaron.Macaron) {
 	// admin api
 	r.Group("/api/admin", func() {
 		r.Get("/settings", AdminGetSettings)
-		r.Get("/users", AdminSearchUsers)
-		r.Get("/users/:id", AdminGetUser)
 		r.Post("/users", bind(dtos.AdminCreateUserForm{}), AdminCreateUser)
-		r.Put("/users/:id/details", bind(dtos.AdminUpdateUserForm{}), AdminUpdateUser)
 		r.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword)
 		r.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions)
 		r.Delete("/users/:id", AdminDeleteUser)
@@ -127,5 +147,5 @@ func Register(r *macaron.Macaron) {
 	// rendering
 	r.Get("/render/*", reqSignedIn, RenderToPng)
 
-	r.NotFound(NotFound)
+	r.NotFound(NotFoundHandler)
 }

+ 12 - 16
pkg/api/apikey.go

@@ -8,12 +8,11 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 )
 
-func GetApiKeys(c *middleware.Context) {
+func GetApiKeys(c *middleware.Context) Response {
 	query := m.GetApiKeysQuery{OrgId: c.OrgId}
 
 	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(500, "Failed to list api keys", err)
-		return
+		return ApiError(500, "Failed to list api keys", err)
 	}
 
 	result := make([]*m.ApiKeyDTO, len(query.Result))
@@ -24,27 +23,26 @@ func GetApiKeys(c *middleware.Context) {
 			Role: t.Role,
 		}
 	}
-	c.JSON(200, result)
+
+	return Json(200, result)
 }
 
-func DeleteApiKey(c *middleware.Context) {
+func DeleteApiKey(c *middleware.Context) Response {
 	id := c.ParamsInt64(":id")
 
 	cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId}
 
 	err := bus.Dispatch(cmd)
 	if err != nil {
-		c.JsonApiErr(500, "Failed to delete API key", err)
-		return
+		return ApiError(500, "Failed to delete API key", err)
 	}
 
-	c.JsonOK("API key deleted")
+	return ApiSuccess("API key deleted")
 }
 
-func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) {
+func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) Response {
 	if !cmd.Role.IsValid() {
-		c.JsonApiErr(400, "Invalid role specified", nil)
-		return
+		return ApiError(400, "Invalid role specified", nil)
 	}
 
 	cmd.OrgId = c.OrgId
@@ -53,14 +51,12 @@ func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) {
 	cmd.Key = newKeyInfo.HashedKey
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to add API key", err)
-		return
+		return ApiError(500, "Failed to add API key", err)
 	}
 
 	result := &dtos.NewApiKeyResult{
 		Name: cmd.Result.Name,
-		Key:  newKeyInfo.ClientSecret,
-	}
+		Key:  newKeyInfo.ClientSecret}
 
-	c.JSON(200, result)
+	return Json(200, result)
 }

+ 122 - 0
pkg/api/common.go

@@ -0,0 +1,122 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/Unknwon/macaron"
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/metrics"
+	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+var (
+	NotFound    = ApiError(404, "Not found", nil)
+	ServerError = ApiError(500, "Server error", nil)
+)
+
+type Response interface {
+	WriteTo(out http.ResponseWriter)
+}
+
+type NormalResponse struct {
+	status int
+	body   []byte
+	header http.Header
+}
+
+func wrap(action interface{}) macaron.Handler {
+
+	return func(c *middleware.Context) {
+		var res Response
+		val, err := c.Invoke(action)
+		if err == nil && val != nil && len(val) > 0 {
+			res = val[0].Interface().(Response)
+		} else {
+			res = ServerError
+		}
+
+		res.WriteTo(c.Resp)
+	}
+}
+
+func (r *NormalResponse) WriteTo(out http.ResponseWriter) {
+	header := out.Header()
+	for k, v := range r.header {
+		header[k] = v
+	}
+	out.WriteHeader(r.status)
+	out.Write(r.body)
+}
+
+func (r *NormalResponse) Cache(ttl string) *NormalResponse {
+	return r.Header("Cache-Control", "public,max-age="+ttl)
+}
+
+func (r *NormalResponse) Header(key, value string) *NormalResponse {
+	r.header.Set(key, value)
+	return r
+}
+
+// functions to create responses
+
+func Empty(status int) *NormalResponse {
+	return Respond(status, nil)
+}
+
+func Json(status int, body interface{}) *NormalResponse {
+	return Respond(status, body).Header("Content-Type", "application/json")
+}
+
+func ApiSuccess(message string) *NormalResponse {
+	resp := make(map[string]interface{})
+	resp["message"] = message
+	return Respond(200, resp)
+}
+
+func ApiError(status int, message string, err error) *NormalResponse {
+	resp := make(map[string]interface{})
+
+	if err != nil {
+		log.Error(4, "%s: %v", message, err)
+		if setting.Env != setting.PROD {
+			resp["error"] = err.Error()
+		}
+	}
+
+	switch status {
+	case 404:
+		resp["message"] = "Not Found"
+		metrics.M_Api_Status_500.Inc(1)
+	case 500:
+		metrics.M_Api_Status_404.Inc(1)
+		resp["message"] = "Internal Server Error"
+	}
+
+	if message != "" {
+		resp["message"] = message
+	}
+
+	return Json(status, resp)
+}
+
+func Respond(status int, body interface{}) *NormalResponse {
+	var b []byte
+	var err error
+	switch t := body.(type) {
+	case []byte:
+		b = t
+	case string:
+		b = []byte(t)
+	default:
+		if b, err = json.Marshal(body); err != nil {
+			return ApiError(500, "body json marshal", err)
+		}
+	}
+	return &NormalResponse{
+		body:   b,
+		status: status,
+		header: make(http.Header),
+	}
+}

+ 1 - 0
pkg/api/dashboard.go

@@ -55,6 +55,7 @@ func GetDashboard(c *middleware.Context) {
 			Type:      m.DashTypeDB,
 			CanStar:   c.IsSignedIn,
 			CanSave:   c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR,
+			CanEdit:   c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_READ_ONLY_EDITOR,
 		},
 	}
 

+ 14 - 0
pkg/api/dataproxy.go

@@ -1,9 +1,12 @@
 package api
 
 import (
+	"crypto/tls"
+	"net"
 	"net/http"
 	"net/http/httputil"
 	"net/url"
+	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
@@ -11,6 +14,16 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
+var dataProxyTransport = &http.Transport{
+	TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+	Proxy:           http.ProxyFromEnvironment,
+	Dial: (&net.Dialer{
+		Timeout:   30 * time.Second,
+		KeepAlive: 30 * time.Second,
+	}).Dial,
+	TLSHandshakeTimeout: 10 * time.Second,
+}
+
 func NewReverseProxy(ds *m.DataSource, proxyPath string) *httputil.ReverseProxy {
 	target, _ := url.Parse(ds.Url)
 
@@ -56,5 +69,6 @@ func ProxyDataSourceRequest(c *middleware.Context) {
 
 	proxyPath := c.Params("*")
 	proxy := NewReverseProxy(&query.Result, proxyPath)
+	proxy.Transport = dataProxyTransport
 	proxy.ServeHTTP(c.RW(), c.Req.Request)
 }

+ 3 - 1
pkg/api/datasources.go

@@ -6,6 +6,7 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/util"
 )
 
 func GetDataSources(c *middleware.Context) {
@@ -94,11 +95,12 @@ func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) {
 		return
 	}
 
-	c.JsonOK("Datasource added")
+	c.JSON(200, util.DynMap{"message": "Datasource added", "id": cmd.Result.Id})
 }
 
 func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) {
 	cmd.OrgId = c.OrgId
+	cmd.Id = c.ParamsInt64(":id")
 
 	err := bus.Dispatch(&cmd)
 	if err != nil {

+ 1 - 0
pkg/api/dtos/models.go

@@ -34,6 +34,7 @@ type DashboardMeta struct {
 	IsSnapshot bool      `json:"isSnapshot,omitempty"`
 	Type       string    `json:"type,omitempty"`
 	CanSave    bool      `json:"canSave"`
+	CanEdit    bool      `json:"canEdit"`
 	CanStar    bool      `json:"canStar"`
 	Slug       string    `json:"slug"`
 	Expires    time.Time `json:"expires"`

+ 5 - 0
pkg/api/frontendsettings.go

@@ -54,6 +54,10 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
 			defaultDatasource = ds.Name
 		}
 
+		if len(ds.JsonData) > 0 {
+			dsMap["jsonData"] = ds.JsonData
+		}
+
 		if ds.Access == m.DS_ACCESS_DIRECT {
 			if ds.BasicAuth {
 				dsMap["basicAuth"] = util.GetBasicAuthHeader(ds.BasicAuthUser, ds.BasicAuthPassword)
@@ -95,6 +99,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
 		"defaultDatasource": defaultDatasource,
 		"datasources":       datasources,
 		"appSubUrl":         setting.AppSubUrl,
+		"viewerRoleMode":    setting.ViewerRoleMode,
 		"buildInfo": map[string]interface{}{
 			"version":    setting.BuildVersion,
 			"commit":     setting.BuildCommit,

+ 1 - 1
pkg/api/index.go

@@ -59,7 +59,7 @@ func Index(c *middleware.Context) {
 	c.HTML(200, "index")
 }
 
-func NotFound(c *middleware.Context) {
+func NotFoundHandler(c *middleware.Context) {
 	if c.IsApiRequest() {
 		c.JsonApiErr(404, "Not found", nil)
 		return

+ 2 - 0
pkg/api/login_oauth.go

@@ -48,6 +48,8 @@ func OAuthLogin(ctx *middleware.Context) {
 	if err != nil {
 		if err == social.ErrMissingTeamMembership {
 			ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github team membership not fulfilled"))
+		} else if err == social.ErrMissingOrganizationMembership {
+			ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github organization membership not fulfilled"))
 		} else {
 			ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
 		}

+ 48 - 17
pkg/api/org.go

@@ -8,17 +8,25 @@ import (
 	"github.com/grafana/grafana/pkg/setting"
 )
 
-func GetOrg(c *middleware.Context) {
-	query := m.GetOrgByIdQuery{Id: c.OrgId}
+// GET /api/org
+func GetOrgCurrent(c *middleware.Context) Response {
+	return getOrgHelper(c.OrgId)
+}
+
+// GET /api/orgs/:orgId
+func GetOrgById(c *middleware.Context) Response {
+	return getOrgHelper(c.ParamsInt64(":orgId"))
+}
+
+func getOrgHelper(orgId int64) Response {
+	query := m.GetOrgByIdQuery{Id: orgId}
 
 	if err := bus.Dispatch(&query); err != nil {
 		if err == m.ErrOrgNotFound {
-			c.JsonApiErr(404, "Organization not found", err)
-			return
+			return ApiError(404, "Organization not found", err)
 		}
 
-		c.JsonApiErr(500, "Failed to get organization", err)
-		return
+		return ApiError(500, "Failed to get organization", err)
 	}
 
 	org := m.OrgDTO{
@@ -26,33 +34,56 @@ func GetOrg(c *middleware.Context) {
 		Name: query.Result.Name,
 	}
 
-	c.JSON(200, &org)
+	return Json(200, &org)
 }
 
-func CreateOrg(c *middleware.Context, cmd m.CreateOrgCommand) {
+// POST /api/orgs
+func CreateOrg(c *middleware.Context, cmd m.CreateOrgCommand) Response {
 	if !setting.AllowUserOrgCreate && !c.IsGrafanaAdmin {
-		c.JsonApiErr(401, "Access denied", nil)
-		return
+		return ApiError(401, "Access denied", nil)
 	}
 
 	cmd.UserId = c.UserId
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to create organization", err)
-		return
+		return ApiError(500, "Failed to create organization", err)
 	}
 
 	metrics.M_Api_Org_Create.Inc(1)
 
-	c.JsonOK("Organization created")
+	return ApiSuccess("Organization created")
 }
 
-func UpdateOrg(c *middleware.Context, cmd m.UpdateOrgCommand) {
+// PUT /api/org
+func UpdateOrgCurrent(c *middleware.Context, cmd m.UpdateOrgCommand) Response {
 	cmd.OrgId = c.OrgId
+	return updateOrgHelper(cmd)
+}
+
+// PUT /api/orgs/:orgId
+func UpdateOrg(c *middleware.Context, cmd m.UpdateOrgCommand) Response {
+	cmd.OrgId = c.ParamsInt64(":orgId")
+	return updateOrgHelper(cmd)
+}
 
+func updateOrgHelper(cmd m.UpdateOrgCommand) Response {
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to update organization", err)
-		return
+		return ApiError(500, "Failed to update organization", err)
+	}
+
+	return ApiSuccess("Organization updated")
+}
+
+func SearchOrgs(c *middleware.Context) Response {
+	query := m.SearchOrgsQuery{
+		Query: c.Query("query"),
+		Name:  c.Query("name"),
+		Page:  0,
+		Limit: 1000,
+	}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to search orgs", err)
 	}
 
-	c.JsonOK("Organization updated")
+	return Json(200, query.Result)
 }

+ 72 - 34
pkg/api/org_users.go

@@ -6,77 +6,115 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 )
 
-func AddOrgUser(c *middleware.Context, cmd m.AddOrgUserCommand) {
+// POST /api/org/users
+func AddOrgUserToCurrentOrg(c *middleware.Context, cmd m.AddOrgUserCommand) Response {
+	cmd.OrgId = c.OrgId
+	return addOrgUserHelper(cmd)
+}
+
+// POST /api/orgs/:orgId/users
+func AddOrgUser(c *middleware.Context, cmd m.AddOrgUserCommand) Response {
+	cmd.OrgId = c.ParamsInt64(":orgId")
+	return addOrgUserHelper(cmd)
+}
+
+func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
 	if !cmd.Role.IsValid() {
-		c.JsonApiErr(400, "Invalid role specified", nil)
-		return
+		return ApiError(400, "Invalid role specified", nil)
 	}
 
 	userQuery := m.GetUserByLoginQuery{LoginOrEmail: cmd.LoginOrEmail}
 	err := bus.Dispatch(&userQuery)
 	if err != nil {
-		c.JsonApiErr(404, "User not found", nil)
-		return
+		return ApiError(404, "User not found", nil)
 	}
 
 	userToAdd := userQuery.Result
 
-	if userToAdd.Id == c.UserId {
-		c.JsonApiErr(400, "Cannot add yourself as user", nil)
-		return
-	}
+	// if userToAdd.Id == c.UserId {
+	// 	return ApiError(400, "Cannot add yourself as user", nil)
+	// }
 
-	cmd.OrgId = c.OrgId
 	cmd.UserId = userToAdd.Id
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Could not add user to organization", err)
-		return
+		return ApiError(500, "Could not add user to organization", err)
 	}
 
-	c.JsonOK("User added to organization")
+	return ApiSuccess("User added to organization")
+}
+
+// GET /api/org/users
+func GetOrgUsersForCurrentOrg(c *middleware.Context) Response {
+	return getOrgUsersHelper(c.OrgId)
 }
 
-func GetOrgUsers(c *middleware.Context) {
-	query := m.GetOrgUsersQuery{OrgId: c.OrgId}
+// GET /api/orgs/:orgId/users
+func GetOrgUsers(c *middleware.Context) Response {
+	return getOrgUsersHelper(c.ParamsInt64(":orgId"))
+}
+
+func getOrgUsersHelper(orgId int64) Response {
+	query := m.GetOrgUsersQuery{OrgId: orgId}
 
 	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(500, "Failed to get account user", err)
-		return
+		return ApiError(500, "Failed to get account user", err)
 	}
 
-	c.JSON(200, query.Result)
+	return Json(200, query.Result)
 }
 
-func UpdateOrgUser(c *middleware.Context, cmd m.UpdateOrgUserCommand) {
+// PATCH /api/org/users/:userId
+func UpdateOrgUserForCurrentOrg(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response {
+	cmd.OrgId = c.OrgId
+	cmd.UserId = c.ParamsInt64(":userId")
+	return updateOrgUserHelper(cmd)
+}
+
+// PATCH /api/orgs/:orgId/users/:userId
+func UpdateOrgUser(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response {
+	cmd.OrgId = c.ParamsInt64(":orgId")
+	cmd.UserId = c.ParamsInt64(":userId")
+	return updateOrgUserHelper(cmd)
+}
+
+func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response {
 	if !cmd.Role.IsValid() {
-		c.JsonApiErr(400, "Invalid role specified", nil)
-		return
+		return ApiError(400, "Invalid role specified", nil)
 	}
 
-	cmd.UserId = c.ParamsInt64(":id")
-	cmd.OrgId = c.OrgId
-
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed update org user", err)
-		return
+		if err == m.ErrLastOrgAdmin {
+			return ApiError(400, "Cannot change role so that there is no organization admin left", nil)
+		}
+		return ApiError(500, "Failed update org user", err)
 	}
 
-	c.JsonOK("Organization user updated")
+	return ApiSuccess("Organization user updated")
 }
 
-func RemoveOrgUser(c *middleware.Context) {
-	userId := c.ParamsInt64(":id")
+// DELETE /api/org/users/:userId
+func RemoveOrgUserForCurrentOrg(c *middleware.Context) Response {
+	userId := c.ParamsInt64(":userId")
+	return removeOrgUserHelper(c.OrgId, userId)
+}
+
+// DELETE /api/orgs/:orgId/users/:userId
+func RemoveOrgUser(c *middleware.Context) Response {
+	userId := c.ParamsInt64(":userId")
+	orgId := c.ParamsInt64(":orgId")
+	return removeOrgUserHelper(orgId, userId)
+}
 
-	cmd := m.RemoveOrgUserCommand{OrgId: c.OrgId, UserId: userId}
+func removeOrgUserHelper(orgId int64, userId int64) Response {
+	cmd := m.RemoveOrgUserCommand{OrgId: orgId, UserId: userId}
 
 	if err := bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrLastOrgAdmin {
-			c.JsonApiErr(400, "Cannot remove last organization admin", nil)
-			return
+			return ApiError(400, "Cannot remove last organization admin", nil)
 		}
-		c.JsonApiErr(500, "Failed to remove user from organization", err)
+		return ApiError(500, "Failed to remove user from organization", err)
 	}
 
-	c.JsonOK("User removed from organization")
+	return ApiSuccess("User removed from organization")
 }

+ 3 - 3
pkg/api/search.go

@@ -8,17 +8,17 @@ import (
 
 func Search(c *middleware.Context) {
 	query := c.Query("query")
-	tag := c.Query("tag")
+	tags := c.QueryStrings("tag")
 	starred := c.Query("starred")
 	limit := c.QueryInt("limit")
 
 	if limit == 0 {
-		limit = 200
+		limit = 1000
 	}
 
 	searchQuery := search.Query{
 		Title:     query,
-		Tag:       tag,
+		Tags:      tags,
 		UserId:    c.UserId,
 		Limit:     limit,
 		IsStarred: starred == "true",

+ 12 - 22
pkg/api/stars.go

@@ -6,45 +6,35 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 )
 
-func StarDashboard(c *middleware.Context) {
+func StarDashboard(c *middleware.Context) Response {
 	if !c.IsSignedIn {
-		c.JsonApiErr(412, "You need to sign in to star dashboards", nil)
-		return
+		return ApiError(412, "You need to sign in to star dashboards", nil)
 	}
 
-	var cmd = m.StarDashboardCommand{
-		UserId:      c.UserId,
-		DashboardId: c.ParamsInt64(":id"),
-	}
+	cmd := m.StarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")}
 
 	if cmd.DashboardId <= 0 {
-		c.JsonApiErr(400, "Missing dashboard id", nil)
-		return
+		return ApiError(400, "Missing dashboard id", nil)
 	}
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to star dashboard", err)
-		return
+		return ApiError(500, "Failed to star dashboard", err)
 	}
 
-	c.JsonOK("Dashboard starred!")
+	return ApiSuccess("Dashboard starred!")
 }
 
-func UnstarDashboard(c *middleware.Context) {
-	var cmd = m.UnstarDashboardCommand{
-		UserId:      c.UserId,
-		DashboardId: c.ParamsInt64(":id"),
-	}
+func UnstarDashboard(c *middleware.Context) Response {
+
+	cmd := m.UnstarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")}
 
 	if cmd.DashboardId <= 0 {
-		c.JsonApiErr(400, "Missing dashboard id", nil)
-		return
+		return ApiError(400, "Missing dashboard id", nil)
 	}
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to unstar dashboard", err)
-		return
+		return ApiError(500, "Failed to unstar dashboard", err)
 	}
 
-	c.JsonOK("Dashboard unstarred")
+	return ApiSuccess("Dashboard unstarred")
 }

+ 70 - 41
pkg/api/user.go

@@ -7,44 +7,71 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
-func GetUser(c *middleware.Context) {
-	query := m.GetUserProfileQuery{UserId: c.UserId}
+// GET /api/user  (current authenticated user)
+func GetSignedInUser(c *middleware.Context) Response {
+	return getUserUserProfile(c.UserId)
+}
+
+// GET /api/user/:id
+func GetUserById(c *middleware.Context) Response {
+	return getUserUserProfile(c.ParamsInt64(":id"))
+}
+
+func getUserUserProfile(userId int64) Response {
+	query := m.GetUserProfileQuery{UserId: userId}
 
 	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(500, "Failed to get user", err)
-		return
+		return ApiError(500, "Failed to get user", err)
 	}
 
-	c.JSON(200, query.Result)
+	return Json(200, query.Result)
 }
 
-func UpdateUser(c *middleware.Context, cmd m.UpdateUserCommand) {
+// POST /api/user
+func UpdateSignedInUser(c *middleware.Context, cmd m.UpdateUserCommand) Response {
 	cmd.UserId = c.UserId
+	return handleUpdateUser(cmd)
+}
+
+// POST /api/users/:id
+func UpdateUser(c *middleware.Context, cmd m.UpdateUserCommand) Response {
+	cmd.UserId = c.ParamsInt64(":id")
+	return handleUpdateUser(cmd)
+}
+
+func handleUpdateUser(cmd m.UpdateUserCommand) Response {
+	if len(cmd.Login) == 0 {
+		cmd.Login = cmd.Email
+		if len(cmd.Login) == 0 {
+			return ApiError(400, "Validation error, need specify either username or email", nil)
+		}
+	}
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(400, "Failed to update user", err)
-		return
+		return ApiError(500, "failed to update user", err)
 	}
 
-	c.JsonOK("User updated")
+	return ApiSuccess("User updated")
 }
 
-func GetUserOrgList(c *middleware.Context) {
-	query := m.GetUserOrgListQuery{UserId: c.UserId}
+// GET /api/user/orgs
+func GetSignedInUserOrgList(c *middleware.Context) Response {
+	return getUserOrgList(c.UserId)
+}
 
-	if err := bus.Dispatch(&query); err != nil {
-		c.JsonApiErr(500, "Failed to get user organizations", err)
-		return
-	}
+// GET /api/user/:id/orgs
+func GetUserOrgList(c *middleware.Context) Response {
+	return getUserOrgList(c.ParamsInt64(":id"))
+}
 
-	for _, ac := range query.Result {
-		if ac.OrgId == c.OrgId {
-			ac.IsUsing = true
-			break
-		}
+func getUserOrgList(userId int64) Response {
+	query := m.GetUserOrgListQuery{UserId: userId}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Faile to get user organziations", err)
 	}
 
-	c.JSON(200, query.Result)
+	return Json(200, query.Result)
 }
 
 func validateUsingOrg(userId int64, orgId int64) bool {
@@ -65,53 +92,55 @@ func validateUsingOrg(userId int64, orgId int64) bool {
 	return valid
 }
 
-func UserSetUsingOrg(c *middleware.Context) {
+// POST /api/user/using/:id
+func UserSetUsingOrg(c *middleware.Context) Response {
 	orgId := c.ParamsInt64(":id")
 
 	if !validateUsingOrg(c.UserId, orgId) {
-		c.JsonApiErr(401, "Not a valid organization", nil)
-		return
+		return ApiError(401, "Not a valid organization", nil)
 	}
 
-	cmd := m.SetUsingOrgCommand{
-		UserId: c.UserId,
-		OrgId:  orgId,
-	}
+	cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgId}
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed change active organization", err)
-		return
+		return ApiError(500, "Failed change active organization", err)
 	}
 
-	c.JsonOK("Active organization changed")
+	return ApiSuccess("Active organization changed")
 }
 
-func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) {
+func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) Response {
 	userQuery := m.GetUserByIdQuery{Id: c.UserId}
 
 	if err := bus.Dispatch(&userQuery); err != nil {
-		c.JsonApiErr(500, "Could not read user from database", err)
-		return
+		return ApiError(500, "Could not read user from database", err)
 	}
 
 	passwordHashed := util.EncodePassword(cmd.OldPassword, userQuery.Result.Salt)
 	if passwordHashed != userQuery.Result.Password {
-		c.JsonApiErr(401, "Invalid old password", nil)
-		return
+		return ApiError(401, "Invalid old password", nil)
 	}
 
 	if len(cmd.NewPassword) < 4 {
-		c.JsonApiErr(400, "New password too short", nil)
-		return
+		return ApiError(400, "New password too short", nil)
 	}
 
 	cmd.UserId = c.UserId
 	cmd.NewPassword = util.EncodePassword(cmd.NewPassword, userQuery.Result.Salt)
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		c.JsonApiErr(500, "Failed to change user password", err)
-		return
+		return ApiError(500, "Failed to change user password", err)
+	}
+
+	return ApiSuccess("User password changed")
+}
+
+// GET /api/users
+func SearchUsers(c *middleware.Context) Response {
+	query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000}
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to fetch users", err)
 	}
 
-	c.JsonOK("User password changed")
+	return Json(200, query.Result)
 }

+ 1 - 1
pkg/components/renderer/renderer.go

@@ -26,7 +26,7 @@ func RenderToPng(params *RenderOpts) (string, error) {
 	pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20)))
 	pngPath = pngPath + ".png"
 
-	cmd := exec.Command(binPath, "--ignore-ssl-errors=true", scriptPath, "url="+params.Url, "width="+params.Width,
+	cmd := exec.Command(binPath, "--ignore-ssl-errors=true", "--ssl-protocol=any", scriptPath, "url="+params.Url, "width="+params.Width,
 		"height="+params.Height, "png="+pngPath, "cookiename="+setting.SessionOptions.CookieName,
 		"domain="+setting.Domain, "sessionid="+params.SessionId)
 	stdout, err := cmd.StdoutPipe()

+ 1 - 1
pkg/models/datasource.go

@@ -69,7 +69,6 @@ type AddDataSourceCommand struct {
 
 // Also acts as api DTO
 type UpdateDataSourceCommand struct {
-	Id                int64                  `json:"id" binding:"Required"`
 	Name              string                 `json:"name" binding:"Required"`
 	Type              string                 `json:"type" binding:"Required"`
 	Access            DsAccess               `json:"access" binding:"Required"`
@@ -84,6 +83,7 @@ type UpdateDataSourceCommand struct {
 	JsonData          map[string]interface{} `json:"jsonData"`
 
 	OrgId int64 `json:"-"`
+	Id    int64 `json:"-"`
 }
 
 type DeleteDataSourceCommand struct {

+ 10 - 6
pkg/models/org.go

@@ -48,8 +48,13 @@ type GetOrgByNameQuery struct {
 	Result *Org
 }
 
-type GetOrgListQuery struct {
-	Result []*Org
+type SearchOrgsQuery struct {
+	Query string
+	Name  string
+	Limit int
+	Page  int
+
+	Result []*OrgDTO
 }
 
 type OrgDTO struct {
@@ -58,8 +63,7 @@ type OrgDTO struct {
 }
 
 type UserOrgDTO struct {
-	OrgId   int64    `json:"orgId"`
-	Name    string   `json:"name"`
-	Role    RoleType `json:"role"`
-	IsUsing bool     `json:"isUsing"`
+	OrgId int64    `json:"orgId"`
+	Name  string   `json:"name"`
+	Role  RoleType `json:"role"`
 }

+ 5 - 4
pkg/models/org_user.go

@@ -15,13 +15,14 @@ var (
 type RoleType string
 
 const (
-	ROLE_VIEWER RoleType = "Viewer"
-	ROLE_EDITOR RoleType = "Editor"
-	ROLE_ADMIN  RoleType = "Admin"
+	ROLE_VIEWER           RoleType = "Viewer"
+	ROLE_EDITOR           RoleType = "Editor"
+	ROLE_READ_ONLY_EDITOR RoleType = "Read Only Editor"
+	ROLE_ADMIN            RoleType = "Admin"
 )
 
 func (r RoleType) IsValid() bool {
-	return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR
+	return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR
 }
 
 type OrgUser struct {

+ 1 - 0
pkg/models/user.go

@@ -133,6 +133,7 @@ type UserProfileDTO struct {
 	Name           string `json:"name"`
 	Login          string `json:"login"`
 	Theme          string `json:"theme"`
+	OrgId          int64  `json:"orgId"`
 	IsGrafanaAdmin bool   `json:"isGrafanaAdmin"`
 }
 

+ 41 - 2
pkg/search/handlers.go

@@ -33,9 +33,7 @@ func searchHandler(query *Query) error {
 
 	dashQuery := FindPersistedDashboardsQuery{
 		Title:     query.Title,
-		Tag:       query.Tag,
 		UserId:    query.UserId,
-		Limit:     query.Limit,
 		IsStarred: query.IsStarred,
 		OrgId:     query.OrgId,
 	}
@@ -55,8 +53,30 @@ func searchHandler(query *Query) error {
 		hits = append(hits, jsonHits...)
 	}
 
+	// filter out results with tag filter
+	if len(query.Tags) > 0 {
+		filtered := HitList{}
+		for _, hit := range hits {
+			if hasRequiredTags(query.Tags, hit.Tags) {
+				filtered = append(filtered, hit)
+			}
+		}
+		hits = filtered
+	}
+
+	// sort main result array
 	sort.Sort(hits)
 
+	if len(hits) > query.Limit {
+		hits = hits[0:query.Limit]
+	}
+
+	// sort tags
+	for _, hit := range hits {
+		sort.Strings(hit.Tags)
+	}
+
+	// add isStarred info
 	if err := setIsStarredFlagOnSearchResults(query.UserId, hits); err != nil {
 		return err
 	}
@@ -65,6 +85,25 @@ func searchHandler(query *Query) error {
 	return nil
 }
 
+func stringInSlice(a string, list []string) bool {
+	for _, b := range list {
+		if b == a {
+			return true
+		}
+	}
+	return false
+}
+
+func hasRequiredTags(queryTags, hitTags []string) bool {
+	for _, queryTag := range queryTags {
+		if !stringInSlice(queryTag, hitTags) {
+			return false
+		}
+	}
+
+	return true
+}
+
 func setIsStarredFlagOnSearchResults(userId int64, hits []*Hit) error {
 	query := m.GetUserStarsQuery{UserId: userId}
 	if err := bus.Dispatch(&query); err != nil {

+ 61 - 0
pkg/search/handlers_test.go

@@ -0,0 +1,61 @@
+package search
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestSearch(t *testing.T) {
+
+	Convey("Given search query", t, func() {
+		jsonDashIndex = NewJsonDashIndex("../../public/dashboards/")
+		query := Query{Limit: 2000}
+
+		bus.AddHandler("test", func(query *FindPersistedDashboardsQuery) error {
+			query.Result = HitList{
+				&Hit{Id: 16, Title: "CCAA", Tags: []string{"BB", "AA"}},
+				&Hit{Id: 10, Title: "AABB", Tags: []string{"CC", "AA"}},
+				&Hit{Id: 15, Title: "BBAA", Tags: []string{"EE", "AA", "BB"}},
+			}
+			return nil
+		})
+
+		bus.AddHandler("test", func(query *m.GetUserStarsQuery) error {
+			query.Result = map[int64]bool{10: true, 12: true}
+			return nil
+		})
+
+		Convey("That is empty", func() {
+			err := searchHandler(&query)
+			So(err, ShouldBeNil)
+
+			Convey("should return sorted results", func() {
+				So(query.Result[0].Title, ShouldEqual, "AABB")
+				So(query.Result[1].Title, ShouldEqual, "BBAA")
+				So(query.Result[2].Title, ShouldEqual, "CCAA")
+			})
+
+			Convey("should return sorted tags", func() {
+				So(query.Result[1].Tags[0], ShouldEqual, "AA")
+				So(query.Result[1].Tags[1], ShouldEqual, "BB")
+				So(query.Result[1].Tags[2], ShouldEqual, "EE")
+			})
+		})
+
+		Convey("That filters by tag", func() {
+			query.Tags = []string{"BB", "AA"}
+			err := searchHandler(&query)
+			So(err, ShouldBeNil)
+
+			Convey("should return correct results", func() {
+				So(len(query.Result), ShouldEqual, 2)
+				So(query.Result[0].Title, ShouldEqual, "BBAA")
+				So(query.Result[1].Title, ShouldEqual, "CCAA")
+			})
+
+		})
+	})
+}

+ 4 - 7
pkg/search/json_index.go

@@ -47,18 +47,15 @@ func (index *JsonDashIndex) updateLoop() {
 func (index *JsonDashIndex) Search(query *Query) ([]*Hit, error) {
 	results := make([]*Hit, 0)
 
+	if query.IsStarred {
+		return results, nil
+	}
+
 	for _, item := range index.items {
 		if len(results) > query.Limit {
 			break
 		}
 
-		// filter out results with tag filter
-		if query.Tag != "" {
-			if !strings.Contains(item.TagsCsv, query.Tag) {
-				continue
-			}
-		}
-
 		// add results with matchig title filter
 		if strings.Contains(item.TitleLower, query.Title) {
 			results = append(results, &Hit{

+ 9 - 2
pkg/search/json_index_test.go

@@ -17,19 +17,26 @@ func TestJsonDashIndex(t *testing.T) {
 		})
 
 		Convey("Should be able to search index", func() {
-			res, err := index.Search(&Query{Title: "", Tag: "", Limit: 20})
+			res, err := index.Search(&Query{Title: "", Limit: 20})
 			So(err, ShouldBeNil)
 
 			So(len(res), ShouldEqual, 3)
 		})
 
 		Convey("Should be able to search index by title", func() {
-			res, err := index.Search(&Query{Title: "home", Tag: "", Limit: 20})
+			res, err := index.Search(&Query{Title: "home", Limit: 20})
 			So(err, ShouldBeNil)
 
 			So(len(res), ShouldEqual, 1)
 			So(res[0].Title, ShouldEqual, "Home")
 		})
 
+		Convey("Should not return when starred is filtered", func() {
+			res, err := index.Search(&Query{Title: "", IsStarred: true})
+			So(err, ShouldBeNil)
+
+			So(len(res), ShouldEqual, 0)
+		})
+
 	})
 }

+ 1 - 3
pkg/search/models.go

@@ -26,7 +26,7 @@ func (s HitList) Less(i, j int) bool { return s[i].Title < s[j].Title }
 
 type Query struct {
 	Title     string
-	Tag       string
+	Tags      []string
 	OrgId     int64
 	UserId    int64
 	Limit     int
@@ -37,10 +37,8 @@ type Query struct {
 
 type FindPersistedDashboardsQuery struct {
 	Title     string
-	Tag       string
 	OrgId     int64
 	UserId    int64
-	Limit     int
 	IsStarred bool
 
 	Result HitList

+ 14 - 13
pkg/services/eventpublisher/eventpublisher.go

@@ -109,25 +109,26 @@ func Setup() error {
 }
 
 func publish(routingKey string, msgString []byte) {
-	err := channel.Publish(
-		exchange,   //exchange
-		routingKey, // routing key
-		false,      // mandatory
-		false,      // immediate
-		amqp.Publishing{
-			ContentType: "application/json",
-			Body:        msgString,
-		},
-	)
-	if err != nil {
+	for {
+		err := channel.Publish(
+			exchange,   //exchange
+			routingKey, // routing key
+			false,      // mandatory
+			false,      // immediate
+			amqp.Publishing{
+				ContentType: "application/json",
+				Body:        msgString,
+			},
+		)
+		if err == nil {
+			return
+		}
 		// failures are most likely because the connection was lost.
 		// the connection will be re-established, so just keep
 		// retrying every 2seconds until we successfully publish.
 		time.Sleep(2 * time.Second)
 		fmt.Println("publish failed, retrying.")
-		publish(routingKey, msgString)
 	}
-	return
 }
 
 func eventListener(event interface{}) error {

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

@@ -150,16 +150,7 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
 		params = append(params, "%"+query.Title+"%")
 	}
 
-	if len(query.Tag) > 0 {
-		sql.WriteString(" AND dashboard_tag.term=?")
-		params = append(params, query.Tag)
-	}
-
-	if query.Limit == 0 || query.Limit > 10000 {
-		query.Limit = 300
-	}
-
-	sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT %d", query.Limit))
+	sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000"))
 
 	var res []DashboardSearchProjection
 	err := x.Sql(sql.String(), params...).Find(&res)

+ 0 - 12
pkg/services/sqlstore/dashboard_test.go

@@ -99,18 +99,6 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(len(hit.Tags), ShouldEqual, 2)
 			})
 
-			Convey("Should be able to search for dashboards using tags", func() {
-				query1 := search.FindPersistedDashboardsQuery{Tag: "webapp", OrgId: 1}
-				query2 := search.FindPersistedDashboardsQuery{Tag: "tagdoesnotexist", OrgId: 1}
-
-				err := SearchDashboards(&query1)
-				err = SearchDashboards(&query2)
-				So(err, ShouldBeNil)
-
-				So(len(query1.Result), ShouldEqual, 1)
-				So(len(query2.Result), ShouldEqual, 0)
-			})
-
 			Convey("Should not be able to save dashboard with same name", func() {
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,

+ 14 - 3
pkg/services/sqlstore/org.go

@@ -14,12 +14,23 @@ func init() {
 	bus.AddHandler("sql", CreateOrg)
 	bus.AddHandler("sql", UpdateOrg)
 	bus.AddHandler("sql", GetOrgByName)
-	bus.AddHandler("sql", GetOrgList)
+	bus.AddHandler("sql", SearchOrgs)
 	bus.AddHandler("sql", DeleteOrg)
 }
 
-func GetOrgList(query *m.GetOrgListQuery) error {
-	return x.Find(&query.Result)
+func SearchOrgs(query *m.SearchOrgsQuery) error {
+	query.Result = make([]*m.OrgDTO, 0)
+	sess := x.Table("org")
+	if query.Query != "" {
+		sess.Where("name LIKE ?", query.Query+"%")
+	}
+	if query.Name != "" {
+		sess.Where("name=?", query.Name)
+	}
+	sess.Limit(query.Limit, query.Limit*query.Page)
+	sess.Cols("id", "name")
+	err := sess.Find(&query.Result)
+	return err
 }
 
 func GetOrgById(query *m.GetOrgByIdQuery) error {

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

@@ -142,11 +142,18 @@ func TestAccountDataAccess(t *testing.T) {
 					})
 				})
 
-				Convey("Cannot delete last admin account user", func() {
+				Convey("Cannot delete last admin org user", func() {
 					cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id}
 					err := RemoveOrgUser(&cmd)
 					So(err, ShouldEqual, m.ErrLastOrgAdmin)
 				})
+
+				Convey("Cannot update role so no one is admin user", func() {
+					cmd := m.UpdateOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id, Role: m.ROLE_VIEWER}
+					err := UpdateOrgUser(&cmd)
+					So(err, ShouldEqual, m.ErrLastOrgAdmin)
+				})
+
 			})
 		})
 	})

+ 19 - 11
pkg/services/sqlstore/org_users.go

@@ -48,7 +48,11 @@ func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error {
 		orgUser.Role = cmd.Role
 		orgUser.Updated = time.Now()
 		_, err = sess.Id(orgUser.Id).Update(&orgUser)
-		return err
+		if err != nil {
+			return err
+		}
+
+		return validateOneAdminLeftInOrg(cmd.OrgId, sess)
 	})
 }
 
@@ -72,16 +76,20 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
 			return err
 		}
 
-		// validate that there is an admin user left
-		res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and role='Admin'", cmd.OrgId)
-		if err != nil {
-			return err
-		}
-
-		if len(res) == 0 {
-			return m.ErrLastOrgAdmin
-		}
+		return validateOneAdminLeftInOrg(cmd.OrgId, sess)
+	})
+}
 
+func validateOneAdminLeftInOrg(orgId int64, sess *xorm.Session) error {
+	// validate that there is an admin user left
+	res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and role='Admin'", orgId)
+	if err != nil {
 		return err
-	})
+	}
+
+	if len(res) == 0 {
+		return m.ErrLastOrgAdmin
+	}
+
+	return err
 }

+ 11 - 4
pkg/services/sqlstore/user.go

@@ -231,10 +231,12 @@ func GetUserProfile(query *m.GetUserProfileQuery) error {
 	}
 
 	query.Result = m.UserProfileDTO{
-		Name:  user.Name,
-		Email: user.Email,
-		Login: user.Login,
-		Theme: user.Theme,
+		Name:           user.Name,
+		Email:          user.Email,
+		Login:          user.Login,
+		Theme:          user.Theme,
+		IsGrafanaAdmin: user.IsAdmin,
+		OrgId:          user.OrgId,
 	}
 
 	return err
@@ -282,6 +284,11 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error {
 		return m.ErrUserNotFound
 	}
 
+	if user.OrgRole == "" {
+		user.OrgId = -1
+		user.OrgName = "Org missing"
+	}
+
 	query.Result = &user
 	return err
 }

+ 2 - 0
pkg/setting/setting.go

@@ -79,6 +79,7 @@ var (
 	AllowUserOrgCreate bool
 	AutoAssignOrg      bool
 	AutoAssignOrgRole  string
+	ViewerRoleMode     string
 
 	// Http auth
 	AdminUser     string
@@ -383,6 +384,7 @@ func NewConfigContext(args *CommandLineArgs) {
 	AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true)
 	AutoAssignOrg = users.Key("auto_assign_org").MustBool(true)
 	AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"})
+	ViewerRoleMode = users.Key("viewer_role_mode").In("default", []string{"default", "strinct"})
 
 	// anonymous access
 	AnonymousEnabled = Cfg.Section("auth.anonymous").Key("enabled").MustBool(false)

+ 145 - 28
pkg/social/social.go

@@ -78,12 +78,14 @@ func NewOAuthService() {
 		if name == "github" {
 			setting.OAuthService.GitHub = true
 			teamIds := sec.Key("team_ids").Ints(",")
+			allowedOrganizations := sec.Key("allowed_organizations").Strings(" ")
 			SocialMap["github"] = &SocialGithub{
-				Config:         &config,
-				allowedDomains: info.AllowedDomains,
-				apiUrl:         info.ApiUrl,
-				allowSignup:    info.AllowSignup,
-				teamIds:        teamIds,
+				Config:               &config,
+				allowedDomains:       info.AllowedDomains,
+				apiUrl:               info.ApiUrl,
+				allowSignup:          info.AllowSignup,
+				teamIds:              teamIds,
+				allowedOrganizations: allowedOrganizations,
 			}
 		}
 
@@ -115,16 +117,21 @@ func isEmailAllowed(email string, allowedDomains []string) bool {
 
 type SocialGithub struct {
 	*oauth2.Config
-	allowedDomains []string
-	apiUrl         string
-	allowSignup    bool
-	teamIds        []int
+	allowedDomains       []string
+	allowedOrganizations []string
+	apiUrl               string
+	allowSignup          bool
+	teamIds              []int
 }
 
 var (
 	ErrMissingTeamMembership = errors.New("User not a member of one of the required teams")
 )
 
+var (
+	ErrMissingOrganizationMembership = errors.New("User not a member of one of the required organizations")
+)
+
 func (s *SocialGithub) Type() int {
 	return int(models.GITHUB)
 }
@@ -137,26 +144,131 @@ func (s *SocialGithub) IsSignupAllowed() bool {
 	return s.allowSignup
 }
 
-func (s *SocialGithub) IsTeamMember(client *http.Client, username string, teamId int) bool {
-	var data struct {
-		Url   string `json:"url"`
-		State string `json:"state"`
+func (s *SocialGithub) IsTeamMember(client *http.Client) bool {
+	if len(s.teamIds) == 0 {
+		return true
 	}
 
-	membershipUrl := fmt.Sprintf("https://api.github.com/teams/%d/memberships/%s", teamId, username)
-	r, err := client.Get(membershipUrl)
+	teamMemberships, err := s.FetchTeamMemberships(client)
 	if err != nil {
 		return false
 	}
 
-	defer r.Body.Close()
+	for _, teamId := range s.teamIds {
+		for _, membershipId := range teamMemberships {
+			if teamId == membershipId {
+				return true
+			}
+		}
+	}
 
-	if err = json.NewDecoder(r.Body).Decode(&data); err != nil {
+	return false
+}
+
+func (s *SocialGithub) IsOrganizationMember(client *http.Client) bool {
+	if len(s.allowedOrganizations) == 0 {
+		return true
+	}
+
+	organizations, err := s.FetchOrganizations(client)
+	if err != nil {
 		return false
 	}
 
-	active := data.State == "active"
-	return active
+	for _, allowedOrganization := range s.allowedOrganizations {
+		for _, organization := range organizations {
+			if organization == allowedOrganization {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) {
+	type Record struct {
+		Email    string `json:"email"`
+		Primary  bool   `json:"primary"`
+		Verified bool   `json:"verified"`
+	}
+
+	emailsUrl := fmt.Sprintf("https://api.github.com/user/emails")
+	r, err := client.Get(emailsUrl)
+	if err != nil {
+		return "", err
+	}
+
+	defer r.Body.Close()
+
+	var records []Record
+
+	if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
+		return "", err
+	}
+
+	var email = ""
+	for _, record := range records {
+		if record.Primary {
+			email = record.Email
+		}
+	}
+
+	return email, nil
+}
+
+func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error) {
+	type Record struct {
+		Id int `json:"id"`
+	}
+
+	membershipUrl := fmt.Sprintf("https://api.github.com/user/teams")
+	r, err := client.Get(membershipUrl)
+	if err != nil {
+		return nil, err
+	}
+
+	defer r.Body.Close()
+
+	var records []Record
+
+	if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
+		return nil, err
+	}
+
+	var ids = make([]int, len(records))
+	for i, record := range records {
+		ids[i] = record.Id
+	}
+
+	return ids, nil
+}
+
+func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error) {
+	type Record struct {
+		Login string `json:"login"`
+	}
+
+	url := fmt.Sprintf("https://api.github.com/user/orgs")
+	r, err := client.Get(url)
+	if err != nil {
+		return nil, err
+	}
+
+	defer r.Body.Close()
+
+	var records []Record
+
+	if err = json.NewDecoder(r.Body).Decode(&records); err != nil {
+		return nil, err
+	}
+
+	var logins = make([]string, len(records))
+	for i, record := range records {
+		logins[i] = record.Login
+	}
+
+	return logins, nil
 }
 
 func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) {
@@ -185,17 +297,22 @@ func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) {
 		Email:    data.Email,
 	}
 
-	if len(s.teamIds) > 0 {
-		for _, teamId := range s.teamIds {
-			if s.IsTeamMember(client, data.Name, teamId) {
-				return userInfo, nil
-			}
-		}
-
+	if !s.IsTeamMember(client) {
 		return nil, ErrMissingTeamMembership
-	} else {
-		return userInfo, nil
 	}
+
+	if !s.IsOrganizationMember(client) {
+		return nil, ErrMissingOrganizationMembership
+	}
+
+	if userInfo.Email == "" {
+		userInfo.Email, err = s.FetchPrivateEmail(client)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return userInfo, nil
 }
 
 //   ________                     .__

+ 14 - 2
public/app/components/extend-jquery.js

@@ -1,5 +1,5 @@
-define(['jquery'],
-function ($) {
+define(['jquery', 'angular', 'lodash'],
+function ($, angular, _) {
   'use strict';
 
   /**
@@ -14,6 +14,7 @@ function ($) {
 
     return function (x, y, opts) {
       opts = $.extend(true, {}, defaults, opts);
+
       return this.each(function () {
         var $tooltip = $(this), width, height;
 
@@ -22,6 +23,17 @@ function ($) {
         $("#tooltip").remove();
         $tooltip.appendTo(document.body);
 
+        if (opts.compile) {
+          angular.element(document).injector().invoke(function($compile, $rootScope) {
+            var tmpScope = $rootScope.$new(true);
+            _.extend(tmpScope, opts.scopeData);
+
+            $compile($tooltip)(tmpScope);
+            tmpScope.$digest();
+            //tmpScope.$destroy();
+          });
+        }
+
         width = $tooltip.outerWidth(true);
         height = $tooltip.outerHeight(true);
 

+ 3 - 1
public/app/components/kbn.js

@@ -383,8 +383,9 @@ function($, _, moment) {
   kbn.valueFormats.mbytes = kbn.formatFuncCreator(1024, [' MiB', ' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']);
   kbn.valueFormats.gbytes = kbn.formatFuncCreator(1024, [' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']);
   kbn.valueFormats.bps = kbn.formatFuncCreator(1000, [' bps', ' Kbps', ' Mbps', ' Gbps', ' Tbps', ' Pbps', ' Ebps', ' Zbps', ' Ybps']);
+  kbn.valueFormats.pps = kbn.formatFuncCreator(1000, [' pps', ' Kpps', ' Mpps', ' Gpps', ' Tpps', ' Ppps', ' Epps', ' Zpps', ' Ypps']);
   kbn.valueFormats.Bps = kbn.formatFuncCreator(1000, [' Bps', ' KBps', ' MBps', ' GBps', ' TBps', ' PBps', ' EBps', ' ZBps', ' YBps']);
-  kbn.valueFormats.short = kbn.formatFuncCreator(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Qaudr', ' Quint', ' Sext', ' Sept']);
+  kbn.valueFormats.short = kbn.formatFuncCreator(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']);
   kbn.valueFormats.joule = kbn.formatFuncCreator(1000, [' J', ' kJ', ' MJ', ' GJ', ' TJ', ' PJ', ' EJ', ' ZJ', ' YJ']);
   kbn.valueFormats.amp = kbn.formatFuncCreator(1000, [' A', ' kA', ' MA', ' GA', ' TA', ' PA', ' EA', ' ZA', ' YA']);
   kbn.valueFormats.volt = kbn.formatFuncCreator(1000, [' V', ' kV', ' MV', ' GV', ' TV', ' PV', ' EV', ' ZV', ' YV']);
@@ -564,6 +565,7 @@ function($, _, moment) {
       {
         text: 'data rate',
         submenu: [
+          {text: 'packets/sec', value: 'pps'},
           {text: 'bits/sec', value: 'bps'},
           {text: 'bytes/sec', value: 'Bps'},
         ]

+ 6 - 6
public/app/components/panelmeta.js

@@ -16,8 +16,8 @@ function () {
       this.addMenuItem('view', 'icon-eye-open', 'toggleFullscreen(false); dismiss();');
     }
 
-    this.addMenuItem('edit', 'icon-cog', 'editPanel(); dismiss();');
-    this.addMenuItem('duplicate', 'icon-copy', 'duplicatePanel()');
+    this.addMenuItem('edit', 'icon-cog', 'editPanel(); dismiss();', 'Editor');
+    this.addMenuItem('duplicate', 'icon-copy', 'duplicatePanel()', 'Editor');
     this.addMenuItem('share', 'icon-share', 'sharePanel(); dismiss();');
 
     this.addEditorTab('General', 'app/partials/panelgeneral.html');
@@ -29,12 +29,12 @@ function () {
     this.addExtendedMenuItem('Panel JSON', '', 'editPanelJson(); dismiss();');
   }
 
-  PanelMeta.prototype.addMenuItem = function(text, icon, click) {
-    this.menu.push({text: text, icon: icon, click: click});
+  PanelMeta.prototype.addMenuItem = function(text, icon, click, role) {
+    this.menu.push({text: text, icon: icon, click: click, role: role});
   };
 
-  PanelMeta.prototype.addExtendedMenuItem = function(text, icon, click) {
-    this.extendedMenu.push({text: text, icon: icon, click: click});
+  PanelMeta.prototype.addExtendedMenuItem = function(text, icon, click, role) {
+    this.extendedMenu.push({text: text, icon: icon, click: click, role: role});
   };
 
   PanelMeta.prototype.addEditorTab = function(title, src) {

+ 12 - 34
public/app/controllers/search.js

@@ -13,8 +13,8 @@ function (angular, _, config) {
     $scope.init = function() {
       $scope.giveSearchFocus = 0;
       $scope.selectedIndex = -1;
-      $scope.results = {dashboards: [], tags: [], metrics: []};
-      $scope.query = { query: '', tag: '', starred: false };
+      $scope.results = [];
+      $scope.query = { query: '', tag: [], starred: false };
       $scope.currentSearchId = 0;
 
       if ($scope.dashboardViewState.fullscreen) {
@@ -26,7 +26,6 @@ function (angular, _, config) {
         $scope.query.query = '';
         $scope.search();
       }, 100);
-
     };
 
     $scope.keyDown = function (evt) {
@@ -83,12 +82,11 @@ function (angular, _, config) {
 
     $scope.queryHasNoFilters = function() {
       var query = $scope.query;
-      return query.query === '' && query.starred === false && query.tag === '';
+      return query.query === '' && query.starred === false && query.tag.length === 0;
     };
 
     $scope.filterByTag = function(tag, evt) {
-      $scope.query.tag = tag;
-      $scope.query.tagcloud = false;
+      $scope.query.tag.push(tag);
       $scope.search();
       $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
       if (evt) {
@@ -97,6 +95,14 @@ function (angular, _, config) {
       }
     };
 
+    $scope.removeTag = function(tag, evt) {
+      $scope.query.tag = _.without($scope.query.tag, tag);
+      $scope.search();
+      $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
+      evt.stopPropagation();
+      evt.preventDefault();
+    };
+
     $scope.getTags = function() {
       return backendSrv.get('/api/dashboards/tags').then(function(results) {
         $scope.tagsMode = true;
@@ -123,32 +129,4 @@ function (angular, _, config) {
 
   });
 
-  module.directive('tagColorFromName', function() {
-
-    function djb2(str) {
-      var hash = 5381;
-      for (var i = 0; i < str.length; i++) {
-        hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */
-      }
-      return hash;
-    }
-
-    return {
-      scope: { tag: "=" },
-      link: function (scope, element) {
-        var name = scope.tag;
-        var hash = djb2(name.toLowerCase());
-        var colors = [
-          "#E24D42","#1F78C1","#BA43A9","#705DA0","#466803",
-          "#508642","#447EBC","#C15C17","#890F02","#757575",
-          "#0A437C","#6D1F62","#584477","#629E51","#2F4F4F",
-          "#BF1B00","#806EB7","#8a2eb8", "#699e00","#000000",
-          "#3F6833","#2F575E","#99440A","#E0752D","#0E4AB4",
-          "#58140C","#052B51","#511749","#3F2B5B",
-        ];
-        var color = colors[Math.abs(hash % colors.length)];
-        element.css("background-color", color);
-      }
-    };
-  });
 });

+ 4 - 3
public/app/directives/all.js

@@ -5,16 +5,17 @@ define([
   './ngBlur',
   './dashEditLink',
   './ngModelOnBlur',
-  './tip',
+  './misc',
   './confirmClick',
   './configModal',
   './spectrumPicker',
-  './bootstrap-tagsinput',
+  './tags',
   './bodyClass',
   './variableValueSelect',
-  './graphiteSegment',
+  './metric.segment',
   './grafanaVersionCheck',
   './dropdown.typeahead',
   './topnav',
   './giveFocus',
+  './annotationTooltip',
 ], function () {});

+ 49 - 0
public/app/directives/annotationTooltip.js

@@ -0,0 +1,49 @@
+define([
+  'angular',
+  'jquery',
+  'lodash'
+],
+function (angular, $, _) {
+  'use strict';
+
+  angular
+  .module('grafana.directives')
+  .directive('annotationTooltip', function($sanitize, dashboardSrv, $compile) {
+    return {
+      link: function (scope, element) {
+        var event = scope.event;
+        var title = $sanitize(event.title);
+        var dashboard = dashboardSrv.getCurrent();
+        var time = '<i>' + dashboard.formatDate(event.min) + '</i>';
+
+        var tooltip = '<div class="graph-tooltip small"><div class="graph-tooltip-time">' + title + ' ' + time + '</div> ' ;
+
+        if (event.text) {
+          var text = $sanitize(event.text);
+          tooltip += text.replace(/\n/g, '<br>') + '<br>';
+        }
+
+        var tags = event.tags;
+        if (_.isString(event.tags)) {
+          tags = event.tags.split(',');
+          if (tags.length === 1) {
+            tags = event.tags.split(' ');
+          }
+        }
+
+        if (tags && tags.length) {
+          scope.tags = tags;
+          tooltip += '<span class="label label-tag" ng-repeat="tag in tags" tag-color-from-name="tag">{{tag}}</span><br/>';
+        }
+
+        tooltip += "</div>";
+
+        var $tooltip = $(tooltip);
+        $tooltip.appendTo(element);
+
+        $compile(element.contents())(scope);
+      }
+    };
+  });
+
+});

+ 0 - 134
public/app/directives/bootstrap-tagsinput.js

@@ -1,134 +0,0 @@
-define([
-  'angular',
-  'jquery',
-  'bootstrap-tagsinput'
-],
-function (angular, $) {
-  'use strict';
-
-  angular
-    .module('grafana.directives')
-    .directive('bootstrapTagsinput', function() {
-
-      function getItemProperty(scope, property) {
-        if (!property) {
-          return undefined;
-        }
-
-        if (angular.isFunction(scope.$parent[property])) {
-          return scope.$parent[property];
-        }
-
-        return function(item) {
-          return item[property];
-        };
-      }
-
-      return {
-        restrict: 'EA',
-        scope: {
-          model: '=ngModel'
-        },
-        template: '<select multiple></select>',
-        replace: false,
-        link: function(scope, element, attrs) {
-
-          if (!angular.isArray(scope.model)) {
-            scope.model = [];
-          }
-
-          var select = $('select', element);
-
-          if (attrs.placeholder) {
-            select.attr('placeholder', attrs.placeholder);
-          }
-
-          select.tagsinput({
-            typeahead : {
-              source   : angular.isFunction(scope.$parent[attrs.typeaheadSource]) ? scope.$parent[attrs.typeaheadSource] : null
-            },
-            itemValue: getItemProperty(scope, attrs.itemvalue),
-            itemText : getItemProperty(scope, attrs.itemtext),
-            tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ?
-              scope.$parent[attrs.tagclass] : function() { return attrs.tagclass; }
-          });
-
-          select.on('itemAdded', function(event) {
-            if (scope.model.indexOf(event.item) === -1) {
-              scope.model.push(event.item);
-            }
-          });
-
-          select.on('itemRemoved', function(event) {
-            var idx = scope.model.indexOf(event.item);
-            if (idx !== -1) {
-              scope.model.splice(idx, 1);
-            }
-          });
-
-          scope.$watch("model", function() {
-            if (!angular.isArray(scope.model)) {
-              scope.model = [];
-            }
-
-            select.tagsinput('removeAll');
-
-            for (var i = 0; i < scope.model.length; i++) {
-              select.tagsinput('add', scope.model[i]);
-            }
-
-          }, true);
-
-        }
-      };
-    });
-
-  angular
-    .module('grafana.directives')
-    .directive('gfDropdown', function ($parse, $compile, $timeout) {
-
-      function buildTemplate(items, placement) {
-        var upclass = placement === 'top' ? 'dropup' : '';
-        var ul = [
-          '<ul class="dropdown-menu ' + upclass + '" role="menu" aria-labelledby="drop1">',
-          '</ul>'
-        ];
-
-        angular.forEach(items, function (item, index) {
-          if (item.divider) {
-            return ul.splice(index + 1, 0, '<li class="divider"></li>');
-          }
-
-          var li = '<li' + (item.submenu && item.submenu.length ? ' class="dropdown-submenu"' : '') + '>' +
-            '<a tabindex="-1" ng-href="' + (item.href || '') + '"' + (item.click ? ' ng-click="' + item.click + '"' : '') +
-              (item.target ? ' target="' + item.target + '"' : '') + (item.method ? ' data-method="' + item.method + '"' : '') +
-              (item.configModal ? ' dash-editor-link="' + item.configModal + '"' : "") +
-              '>' + (item.text || '') + '</a>';
-
-          if (item.submenu && item.submenu.length) {
-            li += buildTemplate(item.submenu).join('\n');
-          }
-
-          li += '</li>';
-          ul.splice(index + 1, 0, li);
-        });
-        return ul;
-      }
-
-      return {
-        restrict: 'EA',
-        scope: true,
-        link: function postLink(scope, iElement, iAttrs) {
-          var getter = $parse(iAttrs.gfDropdown), items = getter(scope);
-          $timeout(function () {
-            var placement = iElement.data('placement');
-            var dropdown = angular.element(buildTemplate(items, placement).join(''));
-            dropdown.insertAfter(iElement);
-            $compile(iElement.next('ul.dropdown-menu'))(scope);
-          });
-
-          iElement.addClass('dropdown-toggle').attr('data-toggle', 'dropdown');
-        }
-      };
-    });
-});

+ 13 - 5
public/app/directives/graphiteSegment.js → public/app/directives/metric.segment.js

@@ -9,14 +9,21 @@ function (angular, app, _, $) {
 
   angular
     .module('grafana.directives')
-    .directive('graphiteSegment', function($compile, $sce) {
+    .directive('metricSegment', function($compile, $sce) {
       var inputTemplate = '<input type="text" data-provide="typeahead" ' +
                             ' class="tight-form-clear-input input-medium"' +
                             ' spellcheck="false" style="display:none"></input>';
 
-      var buttonTemplate = '<a class="tight-form-item" tabindex="1" focus-me="segment.focus" ng-bind-html="segment.html"></a>';
+      var buttonTemplate = '<a class="tight-form-item" ng-class="segment.cssClass" ' +
+        'tabindex="1" focus-me="segment.focus" ng-bind-html="segment.html"></a>';
 
       return {
+        scope: {
+          segment: "=",
+          getAltSegments: "&",
+          onValueChanged: "&"
+        },
+
         link: function($scope, elem) {
           var $input = $(inputTemplate);
           var $button = $(buttonTemplate);
@@ -46,7 +53,7 @@ function (angular, app, _, $) {
                 segment.expandable = true;
                 segment.fake = false;
               }
-              $scope.segmentValueChanged(segment, $scope.$index);
+              $scope.onValueChanged();
             });
           };
 
@@ -61,7 +68,7 @@ function (angular, app, _, $) {
             else {
               // need to have long delay because the blur
               // happens long before the click event on the typeahead options
-              cancelBlur = setTimeout($scope.switchToLink, 350);
+              cancelBlur = setTimeout($scope.switchToLink, 50);
             }
           };
 
@@ -69,7 +76,8 @@ function (angular, app, _, $) {
             if (options) { return options; }
 
             $scope.$apply(function() {
-              $scope.getAltSegments($scope.$index).then(function() {
+              $scope.getAltSegments().then(function(altSegments) {
+                $scope.altSegments = altSegments;
                 options = _.map($scope.altSegments, function(alt) { return alt.value; });
 
                 // add custom values

+ 49 - 0
public/app/directives/tip.js → public/app/directives/misc.js

@@ -78,4 +78,53 @@ function (angular, kbn) {
       };
     });
 
+  angular
+    .module('grafana.directives')
+    .directive('gfDropdown', function ($parse, $compile, $timeout) {
+
+      function buildTemplate(items, placement) {
+        var upclass = placement === 'top' ? 'dropup' : '';
+        var ul = [
+          '<ul class="dropdown-menu ' + upclass + '" role="menu" aria-labelledby="drop1">',
+          '</ul>'
+        ];
+
+        angular.forEach(items, function (item, index) {
+          if (item.divider) {
+            return ul.splice(index + 1, 0, '<li class="divider"></li>');
+          }
+
+          var li = '<li' + (item.submenu && item.submenu.length ? ' class="dropdown-submenu"' : '') + '>' +
+            '<a tabindex="-1" ng-href="' + (item.href || '') + '"' + (item.click ? ' ng-click="' + item.click + '"' : '') +
+            (item.target ? ' target="' + item.target + '"' : '') + (item.method ? ' data-method="' + item.method + '"' : '') +
+            (item.configModal ? ' dash-editor-link="' + item.configModal + '"' : "") +
+            '>' + (item.text || '') + '</a>';
+
+          if (item.submenu && item.submenu.length) {
+            li += buildTemplate(item.submenu).join('\n');
+          }
+
+          li += '</li>';
+          ul.splice(index + 1, 0, li);
+        });
+        return ul;
+      }
+
+      return {
+        restrict: 'EA',
+        scope: true,
+        link: function postLink(scope, iElement, iAttrs) {
+          var getter = $parse(iAttrs.gfDropdown), items = getter(scope);
+          $timeout(function () {
+            var placement = iElement.data('placement');
+            var dropdown = angular.element(buildTemplate(items, placement).join(''));
+            dropdown.insertAfter(iElement);
+            $compile(iElement.next('ul.dropdown-menu'))(scope);
+          });
+
+          iElement.addClass('dropdown-toggle').attr('data-toggle', 'dropdown');
+        }
+      };
+    });
+
 });

+ 137 - 0
public/app/directives/tags.js

@@ -0,0 +1,137 @@
+define([
+  'angular',
+  'jquery',
+  'bootstrap-tagsinput'
+],
+function (angular, $) {
+  'use strict';
+
+  function djb2(str) {
+    var hash = 5381;
+    for (var i = 0; i < str.length; i++) {
+      hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */
+    }
+    return hash;
+  }
+
+  function setColor(name, element) {
+    var hash = djb2(name.toLowerCase());
+    var colors = [
+      "#E24D42","#1F78C1","#BA43A9","#705DA0","#466803",
+      "#508642","#447EBC","#C15C17","#890F02","#757575",
+      "#0A437C","#6D1F62","#584477","#629E51","#2F4F4F",
+      "#BF1B00","#806EB7","#8a2eb8", "#699e00","#000000",
+      "#3F6833","#2F575E","#99440A","#E0752D","#0E4AB4",
+      "#58140C","#052B51","#511749","#3F2B5B",
+    ];
+    var borderColors = [
+      "#FF7368","#459EE7","#E069CF","#9683C6","#6C8E29",
+      "#76AC68","#6AA4E2","#E7823D","#AF3528","#9B9B9B",
+      "#3069A2","#934588","#7E6A9D","#88C477","#557575",
+      "#E54126","#A694DD","#B054DE", "#8FC426","#262626",
+      "#658E59","#557D84","#BF6A30","#FF9B53","#3470DA",
+      "#7E3A32","#2B5177","#773D6F","#655181",
+    ];
+    var color = colors[Math.abs(hash % colors.length)];
+    var borderColor = borderColors[Math.abs(hash % borderColors.length)];
+    element.css("background-color", color);
+    element.css("border-color", borderColor);
+  }
+
+  angular
+  .module('grafana.directives')
+  .directive('tagColorFromName', function() {
+    return {
+      scope: { tagColorFromName: "=" },
+      link: function (scope, element) {
+        setColor(scope.tagColorFromName, element);
+      }
+    };
+  });
+
+  angular
+  .module('grafana.directives')
+  .directive('bootstrapTagsinput', function() {
+
+    function getItemProperty(scope, property) {
+      if (!property) {
+        return undefined;
+      }
+
+      if (angular.isFunction(scope.$parent[property])) {
+        return scope.$parent[property];
+      }
+
+      return function(item) {
+        return item[property];
+      };
+    }
+
+    return {
+      restrict: 'EA',
+      scope: {
+        model: '=ngModel',
+        onTagsUpdated: "&",
+      },
+      template: '<select multiple></select>',
+      replace: false,
+      link: function(scope, element, attrs) {
+
+        if (!angular.isArray(scope.model)) {
+          scope.model = [];
+        }
+
+        var select = $('select', element);
+
+        if (attrs.placeholder) {
+          select.attr('placeholder', attrs.placeholder);
+        }
+
+        select.tagsinput({
+          typeahead: {
+            source: angular.isFunction(scope.$parent[attrs.typeaheadSource]) ? scope.$parent[attrs.typeaheadSource] : null
+          },
+          itemValue: getItemProperty(scope, attrs.itemvalue),
+          itemText : getItemProperty(scope, attrs.itemtext),
+          tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ?
+            scope.$parent[attrs.tagclass] : function() { return attrs.tagclass; }
+        });
+
+        select.on('itemAdded', function(event) {
+          if (scope.model.indexOf(event.item) === -1) {
+            scope.model.push(event.item);
+            if (scope.onTagsUpdated) {
+              scope.onTagsUpdated();
+            }
+          }
+          var tagElement = select.next().children("span").filter(function() { return $(this).text() === event.item; });
+          setColor(event.item, tagElement);
+        });
+
+        select.on('itemRemoved', function(event) {
+          var idx = scope.model.indexOf(event.item);
+          if (idx !== -1) {
+            scope.model.splice(idx, 1);
+            if (scope.onTagsUpdated) {
+              scope.onTagsUpdated();
+            }
+          }
+        });
+
+        scope.$watch("model", function() {
+          if (!angular.isArray(scope.model)) {
+            scope.model = [];
+          }
+
+          select.tagsinput('removeAll');
+
+          for (var i = 0; i < scope.model.length; i++) {
+            select.tagsinput('add', scope.model[i]);
+          }
+
+        }, true);
+      }
+    };
+  });
+
+});

+ 234 - 115
public/app/directives/variableValueSelect.js

@@ -8,150 +8,269 @@ function (angular, app, _) {
   'use strict';
 
   angular
-    .module('grafana.directives')
-    .directive('variableValueSelect', function($compile, $window, $timeout) {
-      return {
-        scope: {
-          variable: "=",
-          onUpdated: "&"
-        },
-        templateUrl: 'app/features/dashboard/partials/variableValueSelect.html',
-        link: function(scope, elem) {
-          var bodyEl = angular.element($window.document.body);
-          var variable = scope.variable;
+    .module('grafana.controllers')
+    .controller('SelectDropdownCtrl', function($q) {
+      var vm = this;
 
-          scope.show = function() {
-            if (scope.selectorOpen) {
-              return;
-            }
+      vm.show = function() {
+        vm.oldVariableText = vm.variable.current.text;
+        vm.highlightIndex = -1;
 
-            scope.selectorOpen = true;
-            scope.giveFocus = 1;
-            scope.oldCurrentText = variable.current.text;
-            scope.highlightIndex = -1;
+        var currentValues = vm.variable.current.value;
+        if (_.isString(currentValues)) {
+          currentValues  = [currentValues];
+        }
 
-            var currentValues = variable.current.value;
+        vm.options = _.map(vm.variable.options, function(option) {
+          if (_.indexOf(currentValues, option.value) >= 0) { option.selected = true; }
+          return option;
+        });
 
-            if (_.isString(currentValues)) {
-              currentValues  = [currentValues];
-            }
+        _.sortBy(vm.options, 'text');
 
-            scope.options = _.map(variable.options, function(option) {
-              if (_.indexOf(currentValues, option.value) >= 0) {
-                option.selected = true;
-              }
-              return option;
-            });
+        vm.selectedValues = _.filter(vm.options, {selected: true});
 
-            scope.search = {query: '', options: scope.options};
+        vm.tags = _.map(vm.variable.tags, function(value) {
+          return { text: value, selected: false };
+        });
 
-            $timeout(function() {
-              bodyEl.on('click', scope.bodyOnClick);
-            }, 0, false);
-          };
+        vm.search = {query: '', options: vm.options};
+        vm.dropdownVisible = true;
+      };
 
-          scope.queryChanged = function() {
-            scope.highlightIndex = -1;
-            scope.search.options = _.filter(scope.options, function(option) {
-              return option.text.toLowerCase().indexOf(scope.search.query.toLowerCase()) !== -1;
-            });
-          };
+      vm.updateLinkText = function() {
+        var current = vm.variable.current;
+        var currentValues = current.value;
 
-          scope.keyDown = function (evt) {
-            if (evt.keyCode === 27) {
-              scope.hide();
-            }
-            if (evt.keyCode === 40) {
-              scope.moveHighlight(1);
-            }
-            if (evt.keyCode === 38) {
-              scope.moveHighlight(-1);
+        if (_.isArray(currentValues) && current.tags.length) {
+          // filer out values that are in selected tags
+          currentValues = _.filter(currentValues, function(test) {
+            for (var i = 0; i < current.tags.length; i++) {
+              if (_.indexOf(current.tags[i].values, test) !== -1) {
+                return false;
+              }
             }
-            if (evt.keyCode === 13) {
-              scope.optionSelected(scope.search.options[scope.highlightIndex], {});
+            return true;
+          });
+          // convert values to text
+          var currentTexts = _.map(currentValues, function(value) {
+            for (var i = 0; i < vm.variable.options.length; i++) {
+              var option = vm.variable.options[i];
+              if (option.value === value) {
+                return option.text;
+              }
             }
-          };
+            return value;
+          });
+          // join texts
+          vm.linkText = currentTexts.join(' + ');
+          if (vm.linkText.length > 0) {
+            vm.linkText += ' + ';
+          }
+        } else {
+          vm.linkText = vm.variable.current.text;
+        }
+      };
 
-          scope.moveHighlight = function(direction) {
-            scope.highlightIndex = (scope.highlightIndex + direction) % scope.search.options.length;
-          };
+      vm.clearSelections = function() {
+        _.each(vm.options, function(option) {
+          option.selected = false;
+        });
 
-          scope.optionSelected = function(option, event) {
-            option.selected = !option.selected;
+        vm.selectionsChanged(false);
+      };
 
-            var hideAfter = true;
-            var setAllExceptCurrentTo = function(newValue) {
-              _.each(scope.options, function(other) {
-                if (option !== other) { other.selected = newValue; }
-              });
-            };
+      vm.selectTag = function(tag) {
+        tag.selected = !tag.selected;
+        var tagValuesPromise;
+        if (!tag.values) {
+          tagValuesPromise = vm.getValuesForTag({tagKey: tag.text});
+        } else {
+          tagValuesPromise = $q.when(tag.values);
+        }
 
-            if (option.text === 'All') {
-              setAllExceptCurrentTo(false);
-            }
-            else if (!variable.multi) {
-              setAllExceptCurrentTo(false);
-            } else {
-              if (event.ctrlKey || event.metaKey || event.shiftKey) {
-                hideAfter = false;
-              }
-              else {
-                setAllExceptCurrentTo(false);
-              }
+        tagValuesPromise.then(function(values) {
+          tag.values = values;
+          tag.valuesText = values.join(' + ');
+          _.each(vm.options, function(option) {
+            if (_.indexOf(tag.values, option.value) !== -1) {
+              option.selected = tag.selected;
             }
+          });
+
+          vm.selectionsChanged(false);
+        });
+      };
 
-            var selected = _.filter(scope.options, {selected: true});
+      vm.keyDown = function (evt) {
+        if (evt.keyCode === 27) {
+          vm.hide();
+        }
+        if (evt.keyCode === 40) {
+          vm.moveHighlight(1);
+        }
+        if (evt.keyCode === 38) {
+          vm.moveHighlight(-1);
+        }
+        if (evt.keyCode === 13) {
+          vm.optionSelected(vm.search.options[vm.highlightIndex], {}, true, false);
+        }
+        if (evt.keyCode === 32) {
+          vm.optionSelected(vm.search.options[vm.highlightIndex], {}, false, false);
+        }
+      };
 
-            if (selected.length === 0) {
-              option.selected = true;
-              selected = [option];
-            }
+      vm.moveHighlight = function(direction) {
+        vm.highlightIndex = (vm.highlightIndex + direction) % vm.search.options.length;
+      };
+
+      vm.selectValue = function(option, event, commitChange, excludeOthers) {
+        if (!option) { return; }
+
+        option.selected = !option.selected;
+
+        commitChange = commitChange || false;
+        excludeOthers = excludeOthers || false;
+
+        var setAllExceptCurrentTo = function(newValue) {
+          _.each(vm.options, function(other) {
+            if (option !== other) { other.selected = newValue; }
+          });
+        };
 
-            if (selected.length > 1 && selected.length !== scope.options.length) {
-              if (selected[0].text === 'All') {
-                selected[0].selected = false;
-                selected = selected.slice(1, selected.length);
+        // commit action (enter key), should not deselect it
+        if (commitChange) {
+          option.selected = true;
+        }
+
+        if (option.text === 'All' || excludeOthers) {
+          setAllExceptCurrentTo(false);
+          commitChange = true;
+        }
+        else if (!vm.variable.multi) {
+          setAllExceptCurrentTo(false);
+          commitChange = true;
+        } else if (event.ctrlKey || event.metaKey || event.shiftKey) {
+          commitChange = true;
+          setAllExceptCurrentTo(false);
+        }
+
+        vm.selectionsChanged(commitChange);
+      };
+
+      vm.selectionsChanged = function(commitChange) {
+        vm.selectedValues = _.filter(vm.options, {selected: true});
+
+        if (vm.selectedValues.length > 1 && vm.selectedValues.length !== vm.options.length) {
+          if (vm.selectedValues[0].text === 'All') {
+            vm.selectedValues[0].selected = false;
+            vm.selectedValues = vm.selectedValues.slice(1, vm.selectedValues.length);
+          }
+        }
+
+        // validate selected tags
+        _.each(vm.tags, function(tag) {
+          if (tag.selected)  {
+            _.each(tag.values, function(value) {
+              if (!_.findWhere(vm.selectedValues, {value: value})) {
+                tag.selected = false;
               }
-            }
+            });
+          }
+        });
 
-            variable.current = {
-              text: _.pluck(selected, 'text').join(', '),
-              value: _.pluck(selected, 'value'),
-            };
+        vm.selectedTags = _.filter(vm.tags, {selected: true});
+        vm.variable.current.value = _.pluck(vm.selectedValues, 'value');
+        vm.variable.current.text = _.pluck(vm.selectedValues, 'text').join(' + ');
+        vm.variable.current.tags = vm.selectedTags;
 
-            // only single value
-            if (variable.current.value.length === 1) {
-              variable.current.value = selected[0].value;
-            }
+        // only single value
+        if (vm.selectedValues.length === 1) {
+          vm.variable.current.value = vm.selectedValues[0].value;
+        }
 
-            scope.updateLinkText();
-            scope.onUpdated();
+        if (commitChange) {
+          vm.commitChanges();
+        }
+      };
 
-            if (hideAfter) {
-              scope.hide();
-            }
-          };
+      vm.commitChanges = function() {
+        // make sure one option is selected
+        if (vm.selectedValues.length === 0) {
+          vm.options[0].selected = true;
+          vm.selectionsChanged(false);
+        }
 
-          scope.hide = function() {
-            scope.selectorOpen = false;
-            bodyEl.off('click', scope.bodyOnClick);
-          };
+        vm.dropdownVisible = false;
+        vm.updateLinkText();
 
-          scope.bodyOnClick = function(e) {
-            var dropdown = elem.find('.variable-value-dropdown');
-            if (dropdown.has(e.target).length === 0) {
-              scope.$apply(scope.hide);
-            }
-          };
+        if (vm.variable.current.text !== vm.oldVariableText) {
+          vm.onUpdated();
+        }
+      };
 
-          scope.updateLinkText = function() {
-            scope.labelText = variable.label || '$' + variable.name;
-            scope.linkText = variable.current.text;
-          };
+      vm.queryChanged = function() {
+        vm.highlightIndex = -1;
+        vm.search.options = _.filter(vm.options, function(option) {
+          return option.text.toLowerCase().indexOf(vm.search.query.toLowerCase()) !== -1;
+        });
+      };
 
-          scope.$watchGroup(['variable.hideLabel', 'variable.name', 'variable.label', 'variable.current.text'], function() {
-            scope.updateLinkText();
+      vm.init = function() {
+        vm.selectedTags = vm.variable.current.tags || [];
+        vm.updateLinkText();
+      };
+
+    });
+
+  angular
+    .module('grafana.directives')
+    .directive('variableValueSelect', function($compile, $window, $timeout) {
+
+      return {
+        scope: { variable: "=", onUpdated: "&", getValuesForTag: "&" },
+        templateUrl: 'app/features/dashboard/partials/variableValueSelect.html',
+        controller: 'SelectDropdownCtrl',
+        controllerAs: 'vm',
+        bindToController: true,
+        link: function(scope, elem) {
+          var bodyEl = angular.element($window.document.body);
+          var linkEl = elem.find('.variable-value-link');
+          var inputEl = elem.find('input');
+
+          function openDropdown() {
+            inputEl.css('width', Math.max(linkEl.width(), 30) + 'px');
+
+            inputEl.show();
+            linkEl.hide();
+
+            inputEl.focus();
+            $timeout(function() { bodyEl.on('click', bodyOnClick); }, 0, false);
+          }
+
+          function switchToLink() {
+            inputEl.hide();
+            linkEl.show();
+            bodyEl.off('click', bodyOnClick);
+          }
+
+          function bodyOnClick (e) {
+            if (elem.has(e.target).length === 0) {
+              scope.$apply(function() {
+                scope.vm.commitChanges();
+              });
+            }
+          }
+
+          scope.$watch('vm.dropdownVisible', function(newValue) {
+            if (newValue) {
+              openDropdown();
+            } else {
+              switchToLink();
+            }
           });
+
+          scope.vm.init();
         },
       };
     });

+ 50 - 3
public/app/features/admin/adminEditUserCtrl.js

@@ -1,23 +1,26 @@
 define([
   'angular',
+  'lodash',
 ],
-function (angular) {
+function (angular, _) {
   'use strict';
 
   var module = angular.module('grafana.controllers');
 
   module.controller('AdminEditUserCtrl', function($scope, $routeParams, backendSrv, $location) {
     $scope.user = {};
+    $scope.newOrg = { name: '', role: 'Editor' };
     $scope.permissions = {};
 
     $scope.init = function() {
       if ($routeParams.id) {
         $scope.getUser($routeParams.id);
+        $scope.getUserOrgs($routeParams.id);
       }
     };
 
     $scope.getUser = function(id) {
-      backendSrv.get('/api/admin/users/' + id).then(function(user) {
+      backendSrv.get('/api/users/' + id).then(function(user) {
         $scope.user = user;
         $scope.user_id = id;
         $scope.permissions.isGrafanaAdmin = user.isGrafanaAdmin;
@@ -49,14 +52,58 @@ function (angular) {
       });
     };
 
+    $scope.getUserOrgs = function(id) {
+      backendSrv.get('/api/users/' + id + '/orgs').then(function(orgs) {
+        $scope.orgs = orgs;
+      });
+    };
+
     $scope.update = function() {
       if (!$scope.userForm.$valid) { return; }
 
-      backendSrv.put('/api/admin/users/' + $scope.user_id + '/details', $scope.user).then(function() {
+      backendSrv.put('/api/users/' + $scope.user_id, $scope.user).then(function() {
         $location.path('/admin/users');
       });
     };
 
+    $scope.updateOrgUser= function(orgUser) {
+      backendSrv.patch('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id, orgUser).then(function() {
+      });
+    };
+
+    $scope.removeOrgUser = function(orgUser) {
+      backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id).then(function() {
+        $scope.getUserOrgs($scope.user_id);
+      });
+    };
+
+    $scope.orgsSearchCache = [];
+
+    $scope.searchOrgs = function(queryStr, callback) {
+      if ($scope.orgsSearchCache.length > 0) {
+        callback(_.pluck($scope.orgsSearchCache, "name"));
+        return;
+      }
+
+      backendSrv.get('/api/orgs', {query: ''}).then(function(result) {
+        $scope.orgsSearchCache = result;
+        callback(_.pluck(result, "name"));
+      });
+    };
+
+    $scope.addOrgUser = function() {
+      if (!$scope.addOrgForm.$valid) { return; }
+
+      var orgInfo = _.findWhere($scope.orgsSearchCache, {name: $scope.newOrg.name});
+      if (!orgInfo) { return; }
+
+      $scope.newOrg.loginOrEmail = $scope.user.login;
+
+      backendSrv.post('/api/orgs/' + orgInfo.id + '/users/', $scope.newOrg).then(function() {
+        $scope.getUserOrgs($scope.user_id);
+      });
+    };
+
     $scope.init();
 
   });

+ 1 - 1
public/app/features/admin/adminUsersCtrl.js

@@ -13,7 +13,7 @@ function (angular) {
     };
 
     $scope.getUsers = function() {
-      backendSrv.get('/api/admin/users').then(function(users) {
+      backendSrv.get('/api/users').then(function(users) {
         $scope.users = users;
       });
     };

+ 68 - 14
public/app/features/admin/partials/edit_user.html

@@ -25,7 +25,7 @@
 					</ul>
 					<div class="clearfix"></div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Email</strong>
@@ -36,7 +36,7 @@
 					</ul>
 					<div class="clearfix"></div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Username</strong>
@@ -80,19 +80,73 @@
 			Permissions
 		</h2>
 
-		<div class="tight-form last">
-			<ul class="tight-form-list">
-				<li class="tight-form-item last">
-					Grafana Admin&nbsp;
-					<input class="cr1" id="permissions.isGrafanaAdmin" type="checkbox"
-					ng-model="permissions.isGrafanaAdmin" ng-checked="permissions.isGrafanaAdmin">
-					<label for="permissions.isGrafanaAdmin" class="cr1"></label>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
+		<div>
+			<div class="tight-form last">
+				<ul class="tight-form-list">
+					<li class="tight-form-item last">
+						Grafana Admin&nbsp;
+						<input class="cr1" id="permissions.isGrafanaAdmin" type="checkbox"
+						ng-model="permissions.isGrafanaAdmin" ng-checked="permissions.isGrafanaAdmin">
+						<label for="permissions.isGrafanaAdmin" class="cr1"></label>
+					</li>
+				</ul>
+				<div class="clearfix"></div>
+			</div>
+			<br>
+			<button type="submit" class="pull-right btn btn-success" ng-click="updatePermissions()">Update</button>
+			<br>
 		</div>
-		<br>
-		<button type="submit" class="pull-right btn btn-success" ng-click="updatePermissions()">Update</button>
+
+		<h2>
+			Organizations
+		</h2>
+
+		<form name="addOrgForm">
+			<div class="tight-form">
+				<ul class="tight-form-list">
+					<li class="tight-form-item" style="width: 160px">
+						Add organization
+					</li>
+					<li>
+						<input type="text" ng-model="newOrg.name" bs-typeahead="searchOrgs"
+									required class="input-xlarge tight-form-input" placeholder="organization name">
+					</li>
+					<li class="tight-form-item">
+						Role
+					</li>
+					<li>
+						<select type="text" ng-model="newOrg.role" class="input-small tight-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']">
+						</select>
+					</li>
+					<li>
+						<button class="btn btn-success tight-form-btn" ng-click="addOrgUser()">Add</button>
+					</li>
+					<div class="clearfix"></div>
+				</ul>
+			</div>
+		</form>
+
+		<table class="grafana-options-table form-inline">
+			<tr>
+				<th>Name</th>
+				<th>Role</th>
+				<th></th>
+			</tr>
+			<tr ng-repeat="org in orgs">
+				<td>
+					{{org.name}} <span class="label label-info" ng-show="org.orgId === user.orgId">Current</span>
+				</td>
+				<td>
+					<select type="text" ng-model="org.role" class="input-small" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']" ng-change="updateOrgUser(org)">
+					</select>
+				</td>
+				<td style="width: 1%">
+					<a ng-click="removeOrgUser(org)" class="btn btn-danger btn-mini">
+						<i class="fa fa-remove"></i>
+					</a>
+				</td>
+			</tr>
+		</table>
 
 	</div>
 </div>

+ 3 - 3
public/app/features/admin/partials/new_user.html

@@ -24,7 +24,7 @@
 					</ul>
 					<div class="clearfix"></div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Email</strong>
@@ -35,7 +35,7 @@
 					</ul>
 					<div class="clearfix"></div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Username</strong>
@@ -46,7 +46,7 @@
 					</ul>
 					<div class="clearfix"></div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Password</strong>

+ 1 - 1
public/app/features/admin/partials/users.html

@@ -1,4 +1,4 @@
-<topnav icon="fa fa-fw fa-user" title="Global users" subnav="true">
+<topnav icon="fa fa-fw fa-user" title="Global Users" subnav="true">
 	<ul class="nav">
 		<li class="active"><a href="admin/users">Overview</a></li>
 		<li><a href="admin/users/create">Create user</a></li>

+ 14 - 36
public/app/features/annotations/annotationsSrv.js

@@ -1,17 +1,15 @@
 define([
   'angular',
   'lodash',
-  'moment',
   './editorCtrl'
-], function (angular, _, moment) {
+], function (angular, _) {
   'use strict';
 
   var module = angular.module('grafana.services');
 
-  module.service('annotationsSrv', function(datasourceSrv, $q, alertSrv, $rootScope, $sanitize) {
+  module.service('annotationsSrv', function(datasourceSrv, $q, alertSrv, $rootScope) {
     var promiseCached;
     var list = [];
-    var timezone;
     var self = this;
 
     this.init = function() {
@@ -33,7 +31,7 @@ define([
         return promiseCached;
       }
 
-      timezone = dashboard.timezone;
+      self.dashboard = dashboard;
       var annotations = _.where(dashboard.annotations.list, {enable: true});
 
       var promises  = _.map(annotations, function(annotation) {
@@ -54,47 +52,27 @@ define([
 
     this.receiveAnnotationResults = function(results) {
       for (var i = 0; i < results.length; i++) {
-        addAnnotation(results[i]);
+        self.addAnnotation(results[i]);
       }
     };
 
-    function errorHandler(err) {
-      console.log('Annotation error: ', err);
-      var message = err.message || "Annotation query failed";
-      alertSrv.set('Annotations error', message,'error');
-    }
-
-    function addAnnotation(options) {
-      var title = $sanitize(options.title);
-      var tooltip = "<small><b>" + title + "</b><br/>";
-      if (options.tags) {
-        var tags = $sanitize(options.tags);
-        tooltip += '<span class="tag label label-tag">' + (tags || '') + '</span><br/>';
-      }
-
-      if (timezone === 'browser') {
-        tooltip += '<i>' + moment(options.time).format('YYYY-MM-DD HH:mm:ss') + '</i><br/>';
-      }
-      else {
-        tooltip += '<i>' + moment.utc(options.time).format('YYYY-MM-DD HH:mm:ss') + '</i><br/>';
-      }
-
-      if (options.text) {
-        var text = $sanitize(options.text);
-        tooltip += text.replace(/\n/g, '<br/>');
-      }
-
-      tooltip += "</small>";
-
+    this.addAnnotation = function(options) {
       list.push({
         annotation: options.annotation,
         min: options.time,
         max: options.time,
         eventType: options.annotation.name,
-        title: null,
-        description: tooltip,
+        title: options.title,
+        tags: options.tags,
+        text: options.text,
         score: 1
       });
+    };
+
+    function errorHandler(err) {
+      console.log('Annotation error: ', err);
+      var message = err.message || "Annotation query failed";
+      alertSrv.set('Annotations error', message,'error');
     }
 
     // Now init

+ 31 - 19
public/app/features/dashboard/partials/variableValueSelect.html

@@ -1,26 +1,38 @@
-<span class="template-variable" ng-show="!variable.hideLabel" style="padding-right: 5px">
-	{{labelText}}:
-</span>
-
-<div style="position: relative; display: inline-block">
-	<a ng-click="show()" class="variable-value-link">
-		{{linkText}}
+<div class="variable-link-wrapper">
+	<a ng-click="vm.show()" class="variable-value-link tight-form-item">
+		{{vm.linkText}}
+		<span ng-repeat="tag in vm.selectedTags" bs-tooltip='tag.valuesText' data-placement="bottom">
+			<span class="label-tag"tag-color-from-name="tag.text">
+				&nbsp;&nbsp;<i class="fa fa-tag"></i>&nbsp;
+				{{tag.text}}
+			</span>
+		</span>
 		<i class="fa fa-caret-down"></i>
 	</a>
 
-	<div ng-if="selectorOpen" class="variable-value-dropdown">
-		<div class="variable-search-wrapper">
-			<span style="position: relative;">
-				<input type="text" placeholder="Search values..." ng-keydown="keyDown($event)" give-focus="giveFocus" tabindex="1" ng-model="search.query" spellcheck='false' ng-change="queryChanged()" />
-			</span>
-		</div>
+	<input type="text" class="tight-form-clear-input input-small" style="display: none" ng-keydown="vm.keyDown($event)" ng-model="vm.search.query" ng-change="vm.queryChanged()" ></input>
 
-		<div class="variable-options-container" ng-if="!query.tagcloud">
-			<a class="variable-option pointer" bindonce ng-repeat="option in search.options"
-				ng-class="{'selected': option.selected, 'highlighted': $index === highlightIndex}" ng-click="optionSelected(option, $event)">
-				<span >{{option.text}}</label>
-				<span class="fa fa-fw variable-option-icon"></span>
-			</a>
+	<div class="variable-value-dropdown" ng-if="vm.dropdownVisible" ng-class="{'multi': vm.variable.multi, 'single': !vm.variable.multi}">
+		<div class="variable-options-wrapper">
+			<div class="variable-options-column">
+				<a class="variable-options-column-header" ng-if="vm.variable.multi" ng-class="{'many-selected': vm.selectedValuesCount > 1}" bs-tooltip="'Clear selections'" data-placement="top" ng-click="vm.clearSelections()">
+					<span class="variable-option-icon"></span>
+					Selected ({{vm.selectedValues.length}})
+				</a>
+				<a class="variable-option pointer" bindonce ng-repeat="option in vm.search.options" ng-class="{'selected': option.selected, 'highlighted': $index === vm.highlightIndex}" ng-click="vm.selectValue(option, $event)">
+					<span class="variable-option-icon"></span>
+					<span>{{option.text}}</span>
+				</a>
+			</div>
+			<div class="variable-options-column" ng-if="vm.tags.length">
+				<div class="variable-options-column-header text-center">
+					Tags
+				</div>
+				<a class="variable-option-tag pointer" ng-repeat="tag in vm.tags" ng-click="vm.selectTag(tag, $event)" ng-class="{'selected': tag.selected}">
+					<span class="fa fa-fw variable-option-icon"></span>
+					<span class="label-tag" tag-color-from-name="tag.text">{{tag.text}}&nbsp;&nbsp;<i class="fa fa-tag"></i>&nbsp;</span>
+				</a>
+			</div>
 		</div>
 	</div>
 </div>

+ 3 - 3
public/app/features/dashboard/shareModalCtrl.js

@@ -43,9 +43,9 @@ function (angular, _, require, config) {
 
       var params = angular.copy($location.search());
 
-      var range = timeSrv.timeRangeForUrl();
-      params.from = range.from;
-      params.to = range.to;
+      var range = timeSrv.timeRange();
+      params.from = range.from.getTime();
+      params.to = range.to.getTime();
 
       if ($scope.options.includeTemplateVars) {
         templateSrv.fillVariableValuesForUrl(params);

+ 5 - 1
public/app/features/dashboard/submenuCtrl.js

@@ -17,8 +17,8 @@ function (angular, _) {
     $scope.init = function() {
       $scope.panel = $scope.pulldown;
       $scope.row = $scope.pulldown;
-      $scope.variables = $scope.dashboard.templating.list;
       $scope.annotations = $scope.dashboard.templating.list;
+      $scope.variables = $scope.dashboard.templating.list;
     };
 
     $scope.disableAnnotation = function (annotation) {
@@ -26,6 +26,10 @@ function (angular, _) {
       $rootScope.$broadcast('refresh');
     };
 
+    $scope.getValuesForTag = function(variable, tagKey) {
+      return templateValuesSrv.getValuesForTag(variable, tagKey);
+    };
+
     $scope.variableUpdated = function(variable) {
       templateValuesSrv.variableUpdated(variable).then(function() {
         dynamicDashboardSrv.update($scope.dashboard);

+ 1 - 1
public/app/features/dashboard/timeSrv.js

@@ -93,7 +93,7 @@ define([
       _.extend(this.time, time);
 
       // disable refresh if we have an absolute time
-      if (time.to !== 'now') {
+      if (_.isString(time.to) && time.to.indexOf('now') === -1) {
         this.old_refresh = this.dashboard.refresh || this.old_refresh;
         this.set_interval(false);
       }

+ 4 - 3
public/app/features/dashboard/viewStateSrv.js

@@ -130,10 +130,11 @@ function (angular, _, $) {
       var docHeight = $(window).height();
       var editHeight = Math.floor(docHeight * 0.3);
       var fullscreenHeight = Math.floor(docHeight * 0.7);
-      this.oldTimeRange = panelScope.range;
 
-      panelScope.height = this.state.edit ? editHeight : fullscreenHeight;
-      panelScope.editMode = this.state.edit;
+      panelScope.editMode = this.state.edit && this.$scope.dashboardMeta.canEdit;
+      panelScope.height = panelScope.editMode ? editHeight : fullscreenHeight;
+
+      this.oldTimeRange = panelScope.range;
       this.fullscreenPanel = panelScope;
 
       $(window).scrollTop(0);

+ 3 - 2
public/app/features/dashlinks/editor.html

@@ -23,9 +23,10 @@
 					<select class="input-medium tight-form-input" style="width: 150px;" ng-model="link.type" ng-options="f for f in ['dashboards','link']" ng-change="updated()"></select>
 				</li>
 
-				<li class="tight-form-item" ng-show="link.type === 'dashboards'">With tag</li>
+				<li class="tight-form-item" ng-show="link.type === 'dashboards'">With tags</li>
 				<li ng-show="link.type === 'dashboards'">
-					<input type="text" ng-model="link.tag" class="input-small tight-form-input" style="width: 151px" ng-model-onblur ng-change="updated()">
+					<bootstrap-tagsinput ng-model="link.tags" tagclass="label label-tag" placeholder="add tags">
+					</bootstrap-tagsinput>
 				</li>
 				<li class="tight-form-item" ng-show="link.type === 'dashboards'">
 					<editor-checkbox text="As dropdown" model="link.asDropdown" change="updated()"></editor-checkbox>

+ 3 - 3
public/app/features/dashlinks/module.js

@@ -89,7 +89,7 @@ function (angular, _) {
 
     function buildLinks(linkDef) {
       if (linkDef.type === 'dashboards') {
-        if (!linkDef.tag) {
+        if (!linkDef.tags) {
           console.log('Dashboard link missing tag');
           return $q.when([]);
         }
@@ -97,7 +97,7 @@ function (angular, _) {
         if (linkDef.asDropdown) {
           return $q.when([{
             title: linkDef.title,
-            tag: linkDef.tag,
+            tags: linkDef.tags,
             keepTime: linkDef.keepTime,
             includeVars: linkDef.includeVars,
             icon: "fa fa-bars",
@@ -132,7 +132,7 @@ function (angular, _) {
     }
 
     $scope.searchDashboards = function(link) {
-      return backendSrv.search({tag: link.tag}).then(function(results) {
+      return backendSrv.search({tag: link.tags}).then(function(results) {
         return _.reduce(results, function(memo, dash) {
           // do not add current dashboard
           if (dash.id !== currentDashId) {

+ 42 - 13
public/app/features/org/datasourceEditCtrl.js

@@ -25,7 +25,6 @@ function (angular, config) {
 
       $scope.loadDatasourceTypes().then(function() {
         if ($routeParams.id) {
-          $scope.isNew = false;
           $scope.getDatasourceById($routeParams.id);
         } else {
           $scope.current = angular.copy(defaults);
@@ -48,6 +47,7 @@ function (angular, config) {
 
     $scope.getDatasourceById = function(id) {
       backendSrv.get('/api/datasources/' + id).then(function(ds) {
+        $scope.isNew = false;
         $scope.current = ds;
         $scope.typeChanged();
       });
@@ -65,26 +65,55 @@ function (angular, config) {
       });
     };
 
-    $scope.update = function() {
-      if (!$scope.editForm.$valid) {
-        return;
-      }
+    $scope.testDatasource = function() {
+      $scope.testing = { done: false };
 
-      backendSrv.post('/api/datasources', $scope.current).then(function() {
-        $scope.updateFrontendSettings();
-        $location.path("datasources");
+      datasourceSrv.get($scope.current.name).then(function(datasource) {
+        if (!datasource.testDatasource) {
+          $scope.testing.message = 'Data source does not support test connection feature.';
+          $scope.testing.status = 'warning';
+          $scope.testing.title = 'Unknown';
+          return;
+        }
+
+        return datasource.testDatasource().then(function(result) {
+          $scope.testing.message = result.message;
+          $scope.testing.status = result.status;
+          $scope.testing.title = result.title;
+        }, function(err) {
+          if (err.statusText) {
+            $scope.testing.message = err.statusText;
+            $scope.testing.title = "HTTP Error";
+          } else {
+            $scope.testing.message = err.message;
+            $scope.testing.title = "Unknown error";
+          }
+        });
+      }).finally(function() {
+        $scope.testing.done = true;
       });
     };
 
-    $scope.add = function() {
+    $scope.saveChanges = function(test) {
       if (!$scope.editForm.$valid) {
         return;
       }
 
-      backendSrv.put('/api/datasources', $scope.current).then(function() {
-        $scope.updateFrontendSettings();
-        $location.path("datasources");
-      });
+      if ($scope.current.id) {
+        return backendSrv.put('/api/datasources/' + $scope.current.id, $scope.current).then(function() {
+          $scope.updateFrontendSettings();
+          if (test) {
+            $scope.testDatasource();
+          } else {
+            $location.path('datasources');
+          }
+        });
+      } else {
+        return backendSrv.post('/api/datasources', $scope.current).then(function(result) {
+          $scope.updateFrontendSettings();
+          $location.path('datasources/edit/' + result.id);
+        });
+      }
     };
 
     $scope.init();

+ 1 - 1
public/app/features/org/newOrgCtrl.js

@@ -11,7 +11,7 @@ function (angular) {
     $scope.newOrg = {name: ''};
 
     $scope.createOrg = function() {
-      backendSrv.post('/api/org/', $scope.newOrg).then($scope.getUserOrgs);
+      backendSrv.post('/api/orgs/', $scope.newOrg).then($scope.getUserOrgs);
     };
 
   });

+ 2 - 0
public/app/features/org/orgApiKeysCtrl.js

@@ -35,6 +35,8 @@ function (angular) {
           src: './app/features/org/partials/apikeyModal.html',
           scope: modalScope
         });
+
+        $scope.getTokens();
       });
     };
 

+ 16 - 5
public/app/features/org/partials/datasourceEdit.html

@@ -43,11 +43,22 @@
 			</div>
 
 			<div ng-include="datasourceMeta.partials.config" ng-if="datasourceMeta.partials.config"></div>
-			<br>
-			<br>
-			<div class="pull-right">
-				<button type="submit" class="btn btn-success" ng-show="isNew" ng-click="add()">Add</button>
-				<button type="submit" class="btn btn-success" ng-show="!isNew" ng-click="update()">Update</button>
+
+			<div ng-if="testing" style="margin-top: 25px">
+				<h5 ng-show="!testing.done">Testing.... <i class="fa fa-spiner fa-spin"></i></h5>
+				<h5 ng-show="testing.done">Test results</h5>
+				<div class="alert-{{testing.status}} alert">
+					<div class="alert-title">{{testing.title}}</div>
+					<div ng-bind='testing.message'></div>
+				</div>
+			</div>
+
+			<div class="pull-right" style="margin-top: 35px">
+				<button type="submit" class="btn btn-success" ng-show="isNew" ng-click="saveChanges()">Add</button>
+				<button type="submit" class="btn btn-success" ng-show="!isNew" ng-click="saveChanges()">Save</button>
+				<button type="submit" class="btn btn-inverse" ng-show="!isNew" ng-click="saveChanges(true)">
+					Test Connection
+				</button>
 				<a class="btn btn-inverse" ng-show="!isNew" href="datasources">Cancel</a>
 			</div>
 			<br>

+ 1 - 1
public/app/features/org/partials/datasourceHttpConfig.html

@@ -6,7 +6,7 @@
 			Url
 		</li>
 		<li>
-			<input type="text" class="tight-form-input input-xlarge" ng-model='current.url' placeholder="http://my.server.com:8080" required></input>
+			<input type="text" class="tight-form-input input-xlarge" ng-model='current.url' placeholder="http://my.server.com:8080" ng-pattern="/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/" required></input>
 		</li>
 		<li class="tight-form-item">
 			Access <tip>Direct = url is used directly from browser, Proxy = Grafana backend will proxy the request</label>

+ 4 - 4
public/app/features/org/partials/orgUsers.html

@@ -7,12 +7,12 @@
 <div class="page-container">
 	<div class="page">
 
-		<h2>Account users</h2>
+		<h2>Organization users</h2>
 
 		<form name="form">
 			<div class="tight-form">
 				<ul class="tight-form-list">
-					<li class="tight-form-item" style="width: 160px">
+					<li class="tight-form-item" style="width: 127px">
 						<strong>Username or Email</strong>
 					</li>
 					<li>
@@ -22,7 +22,7 @@
 						role
 					</li>
 					<li>
-						<select type="text" ng-model="user.role" class="input-small tight-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Admin']">
+						<select type="text" ng-model="user.role" class="input-medium tight-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']">
 						</select>
 					</li>
 					<li>
@@ -46,7 +46,7 @@
 				<td>{{user.login}}</td>
 				<td>{{user.email}}</td>
 				<td>
-					<select type="text" ng-model="user.role" class="input-small" ng-options="f for f in ['Viewer', 'Editor', 'Admin']" ng-change="updateOrgUser(user)">
+					<select type="text" ng-model="user.role" class="input-medium" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']" ng-change="updateOrgUser(user)">
 					</select>
 				</td>
 				<td style="width: 1%">

+ 38 - 24
public/app/features/panel/panelMenu.js

@@ -18,18 +18,26 @@ function (angular, $, _) {
 
       function createMenuTemplate($scope) {
         var template = '<div class="panel-menu small">';
-        template += '<div class="panel-menu-inner">';
-        template += '<div class="panel-menu-row">';
-        template += '<a class="panel-menu-icon pull-left" ng-click="updateColumnSpan(-1)"><i class="fa fa-minus"></i></a>';
-        template += '<a class="panel-menu-icon pull-left" ng-click="updateColumnSpan(1)"><i class="fa fa-plus"></i></a>';
-        template += '<a class="panel-menu-icon pull-right" ng-click="removePanel(panel)"><i class="fa fa-remove"></i></a>';
-        template += '<div class="clearfix"></div>';
-        template += '</div>';
+
+        if ($scope.dashboardMeta.canEdit) {
+          template += '<div class="panel-menu-inner">';
+          template += '<div class="panel-menu-row">';
+          template += '<a class="panel-menu-icon pull-left" ng-click="updateColumnSpan(-1)"><i class="fa fa-minus"></i></a>';
+          template += '<a class="panel-menu-icon pull-left" ng-click="updateColumnSpan(1)"><i class="fa fa-plus"></i></a>';
+          template += '<a class="panel-menu-icon pull-right" ng-click="removePanel(panel)"><i class="fa fa-remove"></i></a>';
+          template += '<div class="clearfix"></div>';
+          template += '</div>';
+        }
 
         template += '<div class="panel-menu-row">';
         template += '<a class="panel-menu-link" gf-dropdown="extendedMenu"><i class="fa fa-bars"></i></a>';
 
         _.each($scope.panelMeta.menu, function(item) {
+          // skip edit actions if not editor
+          if (item.role === 'Editor' && !$scope.dashboardMeta.canEdit) {
+            return;
+          }
+
           template += '<a class="panel-menu-link" ';
           if (item.click) { template += ' ng-click="' + item.click + '"'; }
           if (item.editorLink) { template += ' dash-editor-link="' + item.editorLink + '"'; }
@@ -61,7 +69,6 @@ function (angular, $, _) {
         link: function($scope, elem) {
           var $link = $(linkTemplate);
           var $panelContainer = elem.parents(".panel-container");
-          var menuWidth = $scope.panelMeta.menu.length === 4 ? 236 : 191;
           var menuScope = null;
           var timeout = null;
           var $menu = null;
@@ -111,21 +118,8 @@ function (angular, $, _) {
               return;
             }
 
-            var windowWidth = $(window).width();
-            var panelLeftPos = $(elem).offset().left;
-            var panelWidth = $(elem).width();
-            var menuLeftPos = (panelWidth / 2) - (menuWidth/2);
-            var stickingOut = panelLeftPos + menuLeftPos + menuWidth - windowWidth;
-            if (stickingOut > 0) {
-              menuLeftPos -= stickingOut + 10;
-            }
-            if (panelLeftPos + menuLeftPos < 0) {
-              menuLeftPos = 0;
-            }
-
             var menuTemplate = createMenuTemplate($scope);
             $menu = $(menuTemplate);
-            $menu.css('left', menuLeftPos);
             $menu.mouseleave(function() {
               dismiss(1000);
             });
@@ -136,14 +130,34 @@ function (angular, $, _) {
               dismiss(null, true);
             };
 
+            $(".panel-container").removeClass('panel-highlight');
+            $panelContainer.toggleClass('panel-highlight');
+
             $('.panel-menu').remove();
+
             elem.append($menu);
+
             $scope.$apply(function() {
               $compile($menu.contents())(menuScope);
-            });
 
-            $(".panel-container").removeClass('panel-highlight');
-            $panelContainer.toggleClass('panel-highlight');
+              var menuWidth =  $menu[0].offsetWidth;
+              var menuHeight =  $menu[0].offsetHeight;
+
+              var windowWidth = $(window).width();
+              var panelLeftPos = $(elem).offset().left;
+              var panelWidth = $(elem).width();
+
+              var menuLeftPos = (panelWidth / 2) - (menuWidth/2);
+              var stickingOut = panelLeftPos + menuLeftPos + menuWidth - windowWidth;
+              if (stickingOut > 0) {
+                menuLeftPos -= stickingOut + 10;
+              }
+              if (panelLeftPos + menuLeftPos < 0) {
+                menuLeftPos = 0;
+              }
+
+              $menu.css({'left': menuLeftPos, top: -menuHeight});
+            });
 
             dismiss(2200);
           };

+ 0 - 8
public/app/features/panel/panelSrv.js

@@ -71,14 +71,6 @@ function (angular, _, config) {
       };
 
       $scope.toggleFullscreen = function(edit) {
-        if (edit && $scope.dashboardMeta.canEdit === false) {
-          $scope.appEvent('alert-warning', [
-            'Dashboard not editable',
-            'Use Save As.. feature to create an editable copy of this dashboard.'
-          ]);
-          return;
-        }
-
         $scope.dashboardViewState.update({ fullscreen: true, edit: edit, panelId: $scope.panel.id });
       };
 

+ 2 - 2
public/app/features/profile/partials/profile.html

@@ -71,10 +71,10 @@
 				<td style="width: 98%"><strong>Name: </strong> {{org.name}}</td>
 				<td><strong>Role: </strong> {{org.role}}</td>
 				<td class="nobg max-width-btns">
-					<span class="btn btn-primary btn-mini" ng-show="org.isUsing">
+					<span class="btn btn-primary btn-mini" ng-show="org.orgId === contextSrv.user.orgId">
 						Current
 					</span>
-					<a ng-click="setUsingOrg(org)" class="btn btn-inverse btn-mini" ng-show="!org.isUsing">
+					<a ng-click="setUsingOrg(org)" class="btn btn-inverse btn-mini" ng-show="org.orgId !== contextSrv.user.orgId">
 						Select
 					</a>
 				</td>

+ 40 - 4
public/app/features/templating/partials/editor.html

@@ -65,7 +65,7 @@
 								Name
 							</li>
 							<li>
-								<input type="text" class="input-xlarge tight-form-input" placeholder="apps.servers.*" ng-model='current.name'></input>
+								<input type="text" class="input-large tight-form-input" placeholder="name" ng-model='current.name'></input>
 							</li>
 							<li class="tight-form-item">
 								Type
@@ -139,7 +139,7 @@
 									Query
 								</li>
 								<li>
-									<input type="text" style="width: 646px" class="input-xxlarge tight-form-input last" placeholder="name" ng-model='current.query' placeholder="apps.servers.*" ng-model-onblur ng-change="runQuery()"></input>
+									<input type="text" style="width: 588px" class="input-xxlarge tight-form-input last" ng-model='current.query' placeholder="metric name or tags query" ng-model-onblur ng-change="runQuery()"></input>
 								</li>
 							</ul>
 							<div class="clearfix"></div>
@@ -151,7 +151,7 @@
 									<tip>Optional, if you want to extract part of a series name or metric node segment</tip>
 								</li>
 								<li>
-									<input type="text" style="width: 646px" class="input tight-form-input last" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur ng-change="runQuery()"></input>
+									<input type="text" style="width: 588px" class="input tight-form-input last" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur ng-change="runQuery()"></input>
 								</li>
 							</ul>
 							<div class="clearfix"></div>
@@ -163,7 +163,7 @@
 									<editor-checkbox text="All value" model="current.includeAll" change="runQuery()"></editor-checkbox>
 								</li>
 								<li ng-show="current.includeAll">
-									<input type="text" class="input-xlarge tight-form-input" style="width:422px" ng-model='current.options[0].value'></input>
+									<input type="text" class="input-xlarge tight-form-input" style="width:364px" ng-model='current.options[0].value'></input>
 								</li>
 								<li class="tight-form-item" ng-show="current.includeAll">
 									All format
@@ -226,6 +226,42 @@
 				</div>
 			</div>
 
+			<div class="editor-row" ng-if="current.type === 'query'">
+				<div class="tight-form-section">
+					<h5>Value groups/tags (Experimental feature)</h5>
+					<div class="tight-form" ng-if="current.useTags">
+						<ul class="tight-form-list">
+							<li class="tight-form-item" style="width: 135px">
+								Tags query
+							</li>
+							<li>
+								<input type="text" style="width: 588px" class="input-xxlarge tight-form-input last" ng-model='current.tagsQuery' placeholder="metric name or tags query" ng-model-onblur></input>
+							</li>
+						</ul>
+						<div class="clearfix"></div>
+					</div>
+					<div class="tight-form" ng-if="current.useTags">
+						<ul class="tight-form-list">
+							<li class="tight-form-item" style="width: 135px;">
+								Tag values query
+							</li>
+							<li>
+								<input type="text" style="width: 588px" class="input tight-form-input last" ng-model='current.tagValuesQuery' placeholder="apps.$__tag.*" ng-model-onblur></input>
+							</li>
+						</ul>
+						<div class="clearfix"></div>
+					</div>
+					<div class="tight-form">
+						<ul class="tight-form-list">
+							<li class="tight-form-item last">
+								<editor-checkbox text="Enable" model="current.useTags" change="runQuery()"></editor-checkbox>
+							</li>
+						</ul>
+						<div class="clearfix"></div>
+					</div>
+				</div>
+			</div>
+
 			<div class="editor-row">
 				<div class="tight-form-section">
 					<h5>Preview of values (shows max 20)</h5>

+ 31 - 2
public/app/features/templating/templateValuesSrv.js

@@ -78,7 +78,7 @@ function (angular, _, kbn) {
     };
 
     this.setVariableValue = function(variable, option) {
-      variable.current = option;
+      variable.current = angular.copy(option);
       templateSrv.updateTemplateData();
       return this.updateOptionsInChildVariables(variable);
     };
@@ -120,7 +120,7 @@ function (angular, _, kbn) {
       }
 
       return datasourceSrv.get(variable.datasource).then(function(datasource) {
-        return datasource.metricFindQuery(variable.query).then(function (results) {
+        var queryPromise = datasource.metricFindQuery(variable.query).then(function (results) {
           variable.options = self.metricNamesToVariableValues(variable, results);
 
           if (variable.includeAll) {
@@ -130,6 +130,10 @@ function (angular, _, kbn) {
           // if parameter has current value
           // if it exists in options array keep value
           if (variable.current) {
+            // if current value is an array do not do anything
+            if (_.isArray(variable.current.value)) {
+              return $q.when([]);
+            }
             var currentOption = _.findWhere(variable.options, { text: variable.current.text });
             if (currentOption) {
               return self.setVariableValue(variable, currentOption);
@@ -138,6 +142,31 @@ function (angular, _, kbn) {
 
           return self.setVariableValue(variable, variable.options[0]);
         });
+
+        if (variable.useTags) {
+          return queryPromise.then(function() {
+            datasource.metricFindQuery(variable.tagsQuery).then(function (results) {
+              variable.tags = [];
+              for (var i = 0; i < results.length; i++) {
+                variable.tags.push(results[i].text);
+              }
+            });
+          });
+        } else {
+          delete variable.tags;
+          return queryPromise;
+        }
+      });
+    };
+
+    this.getValuesForTag = function(variable, tagKey) {
+      return datasourceSrv.get(variable.datasource).then(function(datasource) {
+        var query = variable.tagValuesQuery.replace('$tag', tagKey);
+        return datasource.metricFindQuery(query).then(function (results) {
+          return _.map(results, function(value) {
+            return value.text;
+          });
+        });
       });
     };
 

+ 4 - 4
public/app/panels/dashlist/editor.html

@@ -23,15 +23,15 @@
 					Query
 				</li>
 				<li>
-					<input type="text" class="input-small tight-form-input" placeholder="title query"
+					<input type="text" class="input-medium tight-form-input" placeholder="title query"
 					ng-model="panel.query" ng-change="get_data()" ng-model-onblur>
 				</li>
 				<li class="tight-form-item">
-					Tag
+					Tags
 				</li>
 				<li>
-					<input type="text" class="input-small tight-form-input" placeholder="full tag name"
-					ng-model="panel.tag" ng-change="get_data()" ng-model-onblur>
+					<bootstrap-tagsinput ng-model="panel.tags" tagclass="label label-tag" placeholder="add tags" on-tags-updated="get_data()">
+					</bootstrap-tagsinput>
 				</li>
 			</ul>
 			<div class="clearfix"></div>

+ 5 - 2
public/app/panels/dashlist/module.js

@@ -32,7 +32,7 @@ function (angular, app, _, config, PanelMeta) {
       mode: 'starred',
       query: '',
       limit: 10,
-      tag: '',
+      tags: []
     };
 
     $scope.modes = ['starred', 'search'];
@@ -43,6 +43,9 @@ function (angular, app, _, config, PanelMeta) {
 
     $scope.init = function() {
       panelSrv.init($scope);
+      if ($scope.panel.tag) {
+        $scope.panel.tags = [$scope.panel.tag];
+      }
 
       if ($scope.isNewPanel()) {
         $scope.panel.title = "Starred Dashboards";
@@ -58,7 +61,7 @@ function (angular, app, _, config, PanelMeta) {
         params.starred = "true";
       } else {
         params.query = $scope.panel.query;
-        params.tag = $scope.panel.tag;
+        params.tag = $scope.panel.tags;
       }
 
       return backendSrv.search(params).then(function(result) {

+ 3 - 0
public/app/panels/graph/graph.js

@@ -480,6 +480,9 @@ function (angular, $, kbn, moment, _, GraphTooltip) {
             case 'bps':
               url += '&yUnitSystem=si';
               break;
+            case 'pps':
+              url += '&yUnitSystem=si';
+              break;
             case 'Bps':
               url += '&yUnitSystem=si';
               break;

+ 7 - 6
public/app/panels/singlestat/module.js

@@ -190,7 +190,12 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) {
         data.flotpairs = $scope.series[0].flotpairs;
       }
 
-      // first check value to text mappings
+      var decimalInfo = $scope.getDecimalsForValue(data.value);
+      var formatFunc = kbn.valueFormats[$scope.panel.format];
+      data.valueFormated = formatFunc(data.value, decimalInfo.decimals, decimalInfo.scaledDecimals);
+      data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals);
+
+      // check value to text mappings
       for(var i = 0; i < $scope.panel.valueMaps.length; i++) {
         var map = $scope.panel.valueMaps[i];
         // special null case
@@ -201,6 +206,7 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) {
           }
           continue;
         }
+
         // value/number to text mapping
         var value = parseFloat(map.value);
         if (value === data.value) {
@@ -212,11 +218,6 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) {
       if (data.value === null || data.value === void 0) {
         data.valueFormated = "no value";
       }
-
-      var decimalInfo = $scope.getDecimalsForValue(data.value);
-      var formatFunc = kbn.valueFormats[$scope.panel.format];
-      data.valueFormated = formatFunc(data.value, decimalInfo.decimals, decimalInfo.scaledDecimals);
-      data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals);
     };
 
     $scope.removeValueMap = function(map) {

+ 2 - 2
public/app/partials/login.html

@@ -19,7 +19,7 @@
       <form name="loginForm" class="login-form">
         <div class="tight-form" ng-if="loginMode">
           <ul class="tight-form-list">
-            <li class="tight-form-item" style="width: 80px">
+            <li class="tight-form-item" style="width: 78px">
 							<strong>User</strong>
             </li>
             <li>
@@ -30,7 +30,7 @@
         </div>
         <div class="tight-form" ng-if="loginMode">
           <ul class="tight-form-list">
-            <li class="tight-form-item" style="width: 80px">
+            <li class="tight-form-item" style="width: 78px">
 							<strong>Password</strong>
             </li>
             <li>

+ 10 - 7
public/app/partials/search.html

@@ -15,11 +15,14 @@
 				<i class="fa fa-remove" ng-show="tagsMode"></i>
 				tags
 			</a>
-			<span ng-show="query.tag">
-				| <a ng-click="filterByTag('')" tag-color-from-name tag="query.tag"  class="label label-tag" ng-if="query.tag">
-					<i class="fa fa-remove"></i>
-					{{query.tag}}
-				</a>
+			<span ng-if="query.tag.length">
+				|
+				<span ng-repeat="tagName in query.tag">
+					<a ng-click="removeTag(tagName, $event)" tag-color-from-name="tagName" class="label label-tag">
+						<i class="fa fa-remove"></i>
+						{{tagName}}
+					</a>
+				</span>
 			</span>
 		</div>
 	</div>
@@ -30,7 +33,7 @@
 				<div ng-repeat="tag in results" class="pointer" style="width: 180px; float: left;"
 					ng-class="{'selected': $index === selectedIndex }"
 					ng-click="filterByTag(tag.term, $event)">
-					<a class="search-result-tag label label-tag" tag-color-from-name tag="tag.term">
+					<a class="search-result-tag label label-tag" tag-color-from-name="tag.term">
 						<i class="fa fa-tag"></i>
 						<span>{{tag.term}} &nbsp;({{tag.count}})</span>
 					</a>
@@ -46,7 +49,7 @@
 			ng-class="{'selected': $index == selectedIndex}" ng-href="{{row.url}}">
 
 			<span class="search-result-tags">
-				<span ng-click="filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name tag="tag"  class="label label-tag">
+				<span ng-click="filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag"  class="label label-tag">
 					{{tag}}
 				</span>
 				<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>

+ 4 - 1
public/app/partials/submenu.html

@@ -3,7 +3,10 @@
 
 		<ul class="tight-form-list" ng-if="dashboard.templating.list.length > 0">
 			<li ng-repeat="variable in variables" class="submenu-item">
-				<variable-value-select variable="variable" on-updated="variableUpdated(variable)"></variable-value-select>
+				<span class="template-variable tight-form-item" ng-show="!variable.hideLabel" style="padding-right: 5px">
+					{{variable.label || variable.name}}:
+				</span>
+				<variable-value-select variable="variable" on-updated="variableUpdated(variable)" get-values-for-tag="getValuesForTag(variable, tagKey)"></variable-value-select>
 			</li>
 		</ul>
 

+ 7 - 0
public/app/plugins/datasource/graphite/datasource.js

@@ -111,6 +111,7 @@ function (angular, _, $, config, kbn, moment) {
             var list = [];
             for (var i = 0; i < results.data.length; i++) {
               var e = results.data[i];
+
               list.push({
                 annotation: annotation,
                 time: e.when * 1000,
@@ -195,6 +196,12 @@ function (angular, _, $, config, kbn, moment) {
         });
     };
 
+    GraphiteDatasource.prototype.testDatasource = function() {
+      return this.metricFindQuery('*').then(function () {
+        return { status: "success", message: "Data source is working", title: "Success" };
+      });
+    };
+
     GraphiteDatasource.prototype.listDashboards = function(query) {
       return this.doGraphiteRequest({ method: 'GET',  url: '/dashboard/find/', params: {query: query || ''} })
         .then(function(results) {

+ 3 - 1
public/app/plugins/datasource/graphite/partials/query.editor.html

@@ -74,7 +74,9 @@
               ng-show="showTextEditor" />
 
       <ul class="tight-form-list" role="menu" ng-hide="showTextEditor">
-        <li ng-repeat="segment in segments" role="menuitem" graphite-segment></li>
+				<li ng-repeat="segment in segments" role="menuitem">
+					<metric-segment segment="segment" get-alt-segments="getAltSegments($index)" on-value-changed="segmentValueChanged(segment, $index)"></metric-segment>
+				</li>
 				<li ng-repeat="func in functions">
           <span graphite-func-editor class="tight-form-item tight-form-func">
           </span>

+ 7 - 10
public/app/plugins/datasource/graphite/queryCtrl.js

@@ -152,23 +152,18 @@ function (angular, _, config, gfunc, Parser) {
     }
 
     $scope.getAltSegments = function (index) {
-      $scope.altSegments = [];
-
       var query = index === 0 ?  '*' : getSegmentPathUpTo(index) + '.*';
 
-      return $scope.datasource.metricFindQuery(query)
-        .then(function(segments) {
-          $scope.altSegments = _.map(segments, function(segment) {
+      return $scope.datasource.metricFindQuery(query).then(function(segments) {
+          var altSegments = _.map(segments, function(segment) {
             return new MetricSegment({ value: segment.text, expandable: segment.expandable });
           });
 
-          if ($scope.altSegments.length === 0) {
-            return;
-          }
+          if (altSegments.length === 0) { return altSegments; }
 
           // add template variables
           _.each(templateSrv.variables, function(variable) {
-            $scope.altSegments.unshift(new MetricSegment({
+            altSegments.unshift(new MetricSegment({
               type: 'template',
               value: '$' + variable.name,
               expandable: true,
@@ -176,10 +171,12 @@ function (angular, _, config, gfunc, Parser) {
           });
 
           // add wildcard option
-          $scope.altSegments.unshift(new MetricSegment('*'));
+          altSegments.unshift(new MetricSegment('*'));
+          return altSegments;
         })
         .then(null, function(err) {
           $scope.parserError = err.message || 'Failed to issue metric query';
+          return [];
         });
     };
 

+ 28 - 24
public/app/plugins/datasource/influxdb/datasource.js

@@ -43,7 +43,6 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
         // build query
         var queryBuilder = new InfluxQueryBuilder(target);
         var query = queryBuilder.build();
-        console.log('query builder result:' + query);
 
         // replace grafana variables
         query = query.replace('$timeFilter', timeFilter);
@@ -69,12 +68,15 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
       var query = annotation.query.replace('$timeFilter', timeFilter);
       query = templateSrv.replace(query);
 
-      return this._seriesQuery(query).then(function(results) {
-        return new InfluxSeries({ seriesList: results, annotation: annotation }).getAnnotations();
+      return this._seriesQuery(query).then(function(data) {
+        if (!data || !data.results || !data.results[0]) {
+          throw { message: 'No results in response from InfluxDB' };
+        }
+        return new InfluxSeries({ series: data.results[0].series, annotation: annotation }).getAnnotations();
       });
     };
 
-    InfluxDatasource.prototype.metricFindQuery = function (query, queryType) {
+    InfluxDatasource.prototype.metricFindQuery = function (query) {
       var interpolated;
       try {
         interpolated = templateSrv.replace(query);
@@ -83,39 +85,33 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
         return $q.reject(err);
       }
 
-      console.log('metricFindQuery called with: ' + [query, queryType].join(', '));
-
-      return this._seriesQuery(interpolated, queryType).then(function (results) {
+      return this._seriesQuery(interpolated).then(function (results) {
         if (!results || results.results.length === 0) { return []; }
 
         var influxResults = results.results[0];
         if (!influxResults.series) {
           return [];
         }
-
-        console.log('metric find query response', results);
         var series = influxResults.series[0];
 
-        switch (queryType) {
-        case 'MEASUREMENTS':
+        if (query.indexOf('SHOW MEASUREMENTS') === 0) {
           return _.map(series.values, function(value) { return { text: value[0], expandable: true }; });
-        case 'TAG_KEYS':
-          var tagKeys = _.flatten(series.values);
-          return _.map(tagKeys, function(tagKey) { return { text: tagKey, expandable: true }; });
-        case 'TAG_VALUES':
-          var tagValues = _.flatten(series.values);
-          return _.map(tagValues, function(tagValue) { return { text: tagValue, expandable: true }; });
-        default: // template values service does not pass in a a query type
-          var flattenedValues = _.flatten(series.values);
-          return _.map(flattenedValues, function(value) { return { text: value, expandable: true }; });
         }
+
+        var flattenedValues = _.flatten(series.values);
+        return _.map(flattenedValues, function(value) { return { text: value, expandable: true }; });
       });
     };
 
     function retry(deferred, callback, delay) {
       return callback().then(undefined, function(reason) {
         if (reason.status !== 0 || reason.status >= 300) {
-          reason.message = 'InfluxDB Error: <br/>' + reason.data;
+          if (reason.data && reason.data.error) {
+            reason.message = 'InfluxDB Error Response: ' + reason.data.error;
+          }
+          else {
+            reason.message = 'InfluxDB Error: ' + reason.message;
+          }
           deferred.reject(reason);
         }
         else {
@@ -130,6 +126,12 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
       return this._influxRequest('GET', '/query', {q: query});
     };
 
+    InfluxDatasource.prototype.testDatasource = function() {
+      return this.metricFindQuery('SHOW MEASUREMENTS LIMIT 1').then(function () {
+        return { status: "success", message: "Data source is working", title: "Success" };
+      });
+    };
+
     InfluxDatasource.prototype._influxRequest = function(method, url, data) {
       var self = this;
       var deferred = $q.defer();
@@ -174,9 +176,11 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
       return deferred.promise;
     };
 
-    function handleInfluxQueryResponse(alias, seriesList) {
-      var influxSeries = new InfluxSeries({ seriesList: seriesList, alias: alias });
-      return influxSeries.getTimeSeries();
+    function handleInfluxQueryResponse(alias, data) {
+      if (!data || !data.results || !data.results[0].series) {
+        return [];
+      }
+      return new InfluxSeries({ series: data.results[0].series, alias: alias }).getTimeSeries();
     }
 
     function getTimeFilter(options) {

+ 1 - 1
public/app/plugins/datasource/influxdb/funcEditor.js

@@ -108,7 +108,7 @@ function (angular, _, $) {
           function addElementsAndCompile() {
             $funcLink.appendTo(elem);
 
-            var $paramLink = $('<a ng-click="" class="graphite-func-param-link">' + $scope.target.column + '</a>');
+            var $paramLink = $('<a ng-click="" class="graphite-func-param-link">value</a>');
             var $input = $(paramTemplate);
 
             $paramLink.appendTo(elem);

+ 26 - 10
public/app/plugins/datasource/influxdb/influxSeries.js

@@ -5,8 +5,7 @@ function (_) {
   'use strict';
 
   function InfluxSeries(options) {
-    this.seriesList = options.seriesList && options.seriesList.results && options.seriesList.results.length > 0
-      ? options.seriesList.results[0].series || [] : [];
+    this.series = options.series;
     this.alias = options.alias;
     this.annotation = options.annotation;
   }
@@ -17,23 +16,25 @@ function (_) {
     var output = [];
     var self = this;
 
-    console.log(self.seriesList);
-    if (self.seriesList.length === 0) {
+    if (self.series.length === 0) {
       return output;
     }
 
-    _.each(self.seriesList, function(series) {
+    _.each(self.series, function(series) {
       var datapoints = [];
       for (var i = 0; i < series.values.length; i++) {
         datapoints[i] = [series.values[i][1], new Date(series.values[i][0]).getTime()];
       }
 
       var seriesName = series.name;
-      var tags = _.map(series.tags, function(value, key) {
-        return key + ': ' + value;
-      });
 
-      if (tags.length > 0) {
+      if (self.alias) {
+        seriesName = self._getSeriesName(series);
+      } else if (series.tags) {
+        var tags = _.map(series.tags, function(value, key) {
+          return key + ': ' + value;
+        });
+
         seriesName = seriesName + ' {' + tags.join(', ') + '}';
       }
 
@@ -43,11 +44,26 @@ function (_) {
     return output;
   };
 
+  p._getSeriesName = function(series) {
+    var regex = /\$(\w+)|\[\[([\s\S]+?)\]\]/g;
+
+    return this.alias.replace(regex, function(match, g1, g2) {
+      var group = g1 || g2;
+
+      if (group === 'm' || group === 'measurement') { return series.name; }
+      if (group.indexOf('tag_') !== 0) { return match; }
+
+      var tag = group.replace('tag_', '');
+      if (!series.tags) { return match; }
+      return series.tags[tag];
+    });
+  };
+
   p.getAnnotations = function () {
     var list = [];
     var self = this;
 
-    _.each(this.seriesList, function (series) {
+    _.each(this.series, function (series) {
       var titleCol = null;
       var timeCol = null;
       var tagsCol = null;

Some files were not shown because too many files changed in this diff