Преглед на файлове

Merge branch 'master' into ldap

Torkel Ödegaard преди 10 години
родител
ревизия
2c7d33cdfa
променени са 100 файла, в които са добавени 2009 реда и са изтрити 872 реда
  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/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 #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 #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**
 **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 #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**
 **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 #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 #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 #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)
 - [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**
 **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
 - [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`)
 - 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
 - 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)
 # 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
 cd $GOPATH/src/github.com/grafana/grafana
 go run build.go setup            (only needed once to install godep)
 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 .
 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
 api_url = https://api.github.com/user
 team_ids =
 team_ids =
 allowed_domains =
 allowed_domains =
+allowed_organizations =
 
 
 #################################### Google Auth ##########################
 #################################### Google Auth ##########################
 [auth.google]
 [auth.google]

+ 2 - 1
conf/sample.ini

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

+ 1 - 1
docs/mkdocs.yml

@@ -61,7 +61,7 @@ pages:
 - ['datasources/influxdb.md', 'Data Sources', 'InfluxDB']
 - ['datasources/influxdb.md', 'Data Sources', 'InfluxDB']
 - ['datasources/opentsdb.md', 'Data Sources', 'OpenTSDB']
 - ['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']
 - ['project/cla.md', 'Project', 'Contributor License Agreement']
 
 
 - ['jsearch.md', '**HIDDEN**']
 - ['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)
 ![](/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.
 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.
 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.
 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.
 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.
 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.
 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.
 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).
 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.
 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">
 <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
     scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email
     auth_url = https://accounts.google.com/o/oauth2/auth
     auth_url = https://accounts.google.com/o/oauth2/auth
     token_url = https://accounts.google.com/o/oauth2/token
     token_url = https://accounts.google.com/o/oauth2/token
-    allowed_domains = mycompany.com
+    allowed_domains = mycompany.com mycompany.org
     allow_sign_up = false
     allow_sign_up = false
 
 
 Restart the Grafana back-end. You should now see a Google login button
 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
 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
 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
 `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
 # Installing on Mac
 
 
 There is currently no binary build for Mac. But read the [build from
 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.
 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
 ## 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.
 Graphite supports two ways to query annotations.
 
 
 - A regular metric query, use the `Graphite target expression` text input for this
 - 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)
 ![](/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.
 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
 - ``Unit`` - The display unit for the Y value
 - ``Grid Max`` - The maximum Y value. (default auto)
 - ``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 "")
 - ``Label`` - The Y axis label (default "")
 
 
 Axes can also be hidden by unchecking the appropriate box from `Show Axis`.
 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
 - **401** – Unauthorized
 - **412** – Precondition failed
 - **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
     HTTP/1.1 412 Precondition Failed
     Content-Type: application/json; charset=UTF-8
     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.
 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
 ## Data sources
 
 
+### Get all datasources
+
+`GET /api/datasources`
+
+### Get a single data sources by Id
+
+`GET /api/datasources/:datasourceId`
+
 ### Create data source
 ### 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
 ## 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:
 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`
 `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
 ## 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. 
 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)
 ![](/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() {
 		for _, key := range section.Keys() {
 			keyName := key.Name()
 			keyName := key.Name()
 			value := key.Value()
 			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 = "************"
 				value = "************"
 			}
 			}
 
 

+ 0 - 56
pkg/api/admin_users.go

@@ -9,36 +9,6 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 	"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) {
 func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) {
 	cmd := m.CreateUserCommand{
 	cmd := m.CreateUserCommand{
 		Login:    form.Login,
 		Login:    form.Login,
@@ -70,32 +40,6 @@ func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) {
 	c.JsonOK("User created")
 	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) {
 func AdminUpdateUserPassword(c *middleware.Context, form dtos.AdminUpdateUserPasswordForm) {
 	userId := c.ParamsInt64(":id")
 	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})
 	reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
-	reqAccountAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
+	regOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
 	bind := binding.Bind
 	bind := binding.Bind
 
 
 	// not logged in views
 	// not logged in views
@@ -53,48 +53,71 @@ func Register(r *macaron.Macaron) {
 
 
 	// authed api
 	// authed api
 	r.Group("/api", func() {
 	r.Group("/api", func() {
-		// user
+
+		// user (signed in)
 		r.Group("/user", func() {
 		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.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
 		// auth api keys
 		r.Group("/auth/keys", func() {
 		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
 		// Data sources
 		r.Group("/datasources", func() {
 		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.Delete("/:id", DeleteDataSource)
 			r.Get("/:id", GetDataSourceById)
 			r.Get("/:id", GetDataSourceById)
 			r.Get("/plugins", GetDataSourcePlugins)
 			r.Get("/plugins", GetDataSourcePlugins)
-		}, reqAccountAdmin)
+		}, regOrgAdmin)
 
 
 		r.Get("/frontend/settings/", GetFrontendSettings)
 		r.Get("/frontend/settings/", GetFrontendSettings)
 		r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest)
 		r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest)
+		r.Any("/datasources/proxy/:id", reqSignedIn, ProxyDataSourceRequest)
 
 
 		// Dashboard
 		// Dashboard
 		r.Group("/dashboards", func() {
 		r.Group("/dashboards", func() {
@@ -115,10 +138,7 @@ func Register(r *macaron.Macaron) {
 	// admin api
 	// admin api
 	r.Group("/api/admin", func() {
 	r.Group("/api/admin", func() {
 		r.Get("/settings", AdminGetSettings)
 		r.Get("/settings", AdminGetSettings)
-		r.Get("/users", AdminSearchUsers)
-		r.Get("/users/:id", AdminGetUser)
 		r.Post("/users", bind(dtos.AdminCreateUserForm{}), AdminCreateUser)
 		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/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword)
 		r.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions)
 		r.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions)
 		r.Delete("/users/:id", AdminDeleteUser)
 		r.Delete("/users/:id", AdminDeleteUser)
@@ -127,5 +147,5 @@ func Register(r *macaron.Macaron) {
 	// rendering
 	// rendering
 	r.Get("/render/*", reqSignedIn, RenderToPng)
 	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"
 	m "github.com/grafana/grafana/pkg/models"
 )
 )
 
 
-func GetApiKeys(c *middleware.Context) {
+func GetApiKeys(c *middleware.Context) Response {
 	query := m.GetApiKeysQuery{OrgId: c.OrgId}
 	query := m.GetApiKeysQuery{OrgId: c.OrgId}
 
 
 	if err := bus.Dispatch(&query); err != nil {
 	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))
 	result := make([]*m.ApiKeyDTO, len(query.Result))
@@ -24,27 +23,26 @@ func GetApiKeys(c *middleware.Context) {
 			Role: t.Role,
 			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")
 	id := c.ParamsInt64(":id")
 
 
 	cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId}
 	cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId}
 
 
 	err := bus.Dispatch(cmd)
 	err := bus.Dispatch(cmd)
 	if err != nil {
 	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() {
 	if !cmd.Role.IsValid() {
-		c.JsonApiErr(400, "Invalid role specified", nil)
-		return
+		return ApiError(400, "Invalid role specified", nil)
 	}
 	}
 
 
 	cmd.OrgId = c.OrgId
 	cmd.OrgId = c.OrgId
@@ -53,14 +51,12 @@ func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) {
 	cmd.Key = newKeyInfo.HashedKey
 	cmd.Key = newKeyInfo.HashedKey
 
 
 	if err := bus.Dispatch(&cmd); err != nil {
 	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{
 	result := &dtos.NewApiKeyResult{
 		Name: cmd.Result.Name,
 		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,
 			Type:      m.DashTypeDB,
 			CanStar:   c.IsSignedIn,
 			CanStar:   c.IsSignedIn,
 			CanSave:   c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR,
 			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
 package api
 
 
 import (
 import (
+	"crypto/tls"
+	"net"
 	"net/http"
 	"net/http"
 	"net/http/httputil"
 	"net/http/httputil"
 	"net/url"
 	"net/url"
+	"time"
 
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
@@ -11,6 +14,16 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 	"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 {
 func NewReverseProxy(ds *m.DataSource, proxyPath string) *httputil.ReverseProxy {
 	target, _ := url.Parse(ds.Url)
 	target, _ := url.Parse(ds.Url)
 
 
@@ -56,5 +69,6 @@ func ProxyDataSourceRequest(c *middleware.Context) {
 
 
 	proxyPath := c.Params("*")
 	proxyPath := c.Params("*")
 	proxy := NewReverseProxy(&query.Result, proxyPath)
 	proxy := NewReverseProxy(&query.Result, proxyPath)
+	proxy.Transport = dataProxyTransport
 	proxy.ServeHTTP(c.RW(), c.Req.Request)
 	proxy.ServeHTTP(c.RW(), c.Req.Request)
 }
 }

+ 3 - 1
pkg/api/datasources.go

@@ -6,6 +6,7 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
 func GetDataSources(c *middleware.Context) {
 func GetDataSources(c *middleware.Context) {
@@ -94,11 +95,12 @@ func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) {
 		return
 		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) {
 func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) {
 	cmd.OrgId = c.OrgId
 	cmd.OrgId = c.OrgId
+	cmd.Id = c.ParamsInt64(":id")
 
 
 	err := bus.Dispatch(&cmd)
 	err := bus.Dispatch(&cmd)
 	if err != nil {
 	if err != nil {

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

@@ -34,6 +34,7 @@ type DashboardMeta struct {
 	IsSnapshot bool      `json:"isSnapshot,omitempty"`
 	IsSnapshot bool      `json:"isSnapshot,omitempty"`
 	Type       string    `json:"type,omitempty"`
 	Type       string    `json:"type,omitempty"`
 	CanSave    bool      `json:"canSave"`
 	CanSave    bool      `json:"canSave"`
+	CanEdit    bool      `json:"canEdit"`
 	CanStar    bool      `json:"canStar"`
 	CanStar    bool      `json:"canStar"`
 	Slug       string    `json:"slug"`
 	Slug       string    `json:"slug"`
 	Expires    time.Time `json:"expires"`
 	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
 			defaultDatasource = ds.Name
 		}
 		}
 
 
+		if len(ds.JsonData) > 0 {
+			dsMap["jsonData"] = ds.JsonData
+		}
+
 		if ds.Access == m.DS_ACCESS_DIRECT {
 		if ds.Access == m.DS_ACCESS_DIRECT {
 			if ds.BasicAuth {
 			if ds.BasicAuth {
 				dsMap["basicAuth"] = util.GetBasicAuthHeader(ds.BasicAuthUser, ds.BasicAuthPassword)
 				dsMap["basicAuth"] = util.GetBasicAuthHeader(ds.BasicAuthUser, ds.BasicAuthPassword)
@@ -95,6 +99,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
 		"defaultDatasource": defaultDatasource,
 		"defaultDatasource": defaultDatasource,
 		"datasources":       datasources,
 		"datasources":       datasources,
 		"appSubUrl":         setting.AppSubUrl,
 		"appSubUrl":         setting.AppSubUrl,
+		"viewerRoleMode":    setting.ViewerRoleMode,
 		"buildInfo": map[string]interface{}{
 		"buildInfo": map[string]interface{}{
 			"version":    setting.BuildVersion,
 			"version":    setting.BuildVersion,
 			"commit":     setting.BuildCommit,
 			"commit":     setting.BuildCommit,

+ 1 - 1
pkg/api/index.go

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

+ 2 - 0
pkg/api/login_oauth.go

@@ -48,6 +48,8 @@ func OAuthLogin(ctx *middleware.Context) {
 	if err != nil {
 	if err != nil {
 		if err == social.ErrMissingTeamMembership {
 		if err == social.ErrMissingTeamMembership {
 			ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github team membership not fulfilled"))
 			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 {
 		} else {
 			ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
 			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"
 	"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 := bus.Dispatch(&query); err != nil {
 		if err == m.ErrOrgNotFound {
 		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{
 	org := m.OrgDTO{
@@ -26,33 +34,56 @@ func GetOrg(c *middleware.Context) {
 		Name: query.Result.Name,
 		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 {
 	if !setting.AllowUserOrgCreate && !c.IsGrafanaAdmin {
-		c.JsonApiErr(401, "Access denied", nil)
-		return
+		return ApiError(401, "Access denied", nil)
 	}
 	}
 
 
 	cmd.UserId = c.UserId
 	cmd.UserId = c.UserId
 	if err := bus.Dispatch(&cmd); err != nil {
 	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)
 	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
 	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 {
 	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"
 	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() {
 	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}
 	userQuery := m.GetUserByLoginQuery{LoginOrEmail: cmd.LoginOrEmail}
 	err := bus.Dispatch(&userQuery)
 	err := bus.Dispatch(&userQuery)
 	if err != nil {
 	if err != nil {
-		c.JsonApiErr(404, "User not found", nil)
-		return
+		return ApiError(404, "User not found", nil)
 	}
 	}
 
 
 	userToAdd := userQuery.Result
 	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
 	cmd.UserId = userToAdd.Id
 
 
 	if err := bus.Dispatch(&cmd); err != nil {
 	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 {
 	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() {
 	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 {
 	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 := bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrLastOrgAdmin {
 		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) {
 func Search(c *middleware.Context) {
 	query := c.Query("query")
 	query := c.Query("query")
-	tag := c.Query("tag")
+	tags := c.QueryStrings("tag")
 	starred := c.Query("starred")
 	starred := c.Query("starred")
 	limit := c.QueryInt("limit")
 	limit := c.QueryInt("limit")
 
 
 	if limit == 0 {
 	if limit == 0 {
-		limit = 200
+		limit = 1000
 	}
 	}
 
 
 	searchQuery := search.Query{
 	searchQuery := search.Query{
 		Title:     query,
 		Title:     query,
-		Tag:       tag,
+		Tags:      tags,
 		UserId:    c.UserId,
 		UserId:    c.UserId,
 		Limit:     limit,
 		Limit:     limit,
 		IsStarred: starred == "true",
 		IsStarred: starred == "true",

+ 12 - 22
pkg/api/stars.go

@@ -6,45 +6,35 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 )
 )
 
 
-func StarDashboard(c *middleware.Context) {
+func StarDashboard(c *middleware.Context) Response {
 	if !c.IsSignedIn {
 	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 {
 	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 {
 	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 {
 	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 {
 	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"
 	"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 {
 	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
 	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 {
 	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 {
 func validateUsingOrg(userId int64, orgId int64) bool {
@@ -65,53 +92,55 @@ func validateUsingOrg(userId int64, orgId int64) bool {
 	return valid
 	return valid
 }
 }
 
 
-func UserSetUsingOrg(c *middleware.Context) {
+// POST /api/user/using/:id
+func UserSetUsingOrg(c *middleware.Context) Response {
 	orgId := c.ParamsInt64(":id")
 	orgId := c.ParamsInt64(":id")
 
 
 	if !validateUsingOrg(c.UserId, orgId) {
 	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 {
 	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}
 	userQuery := m.GetUserByIdQuery{Id: c.UserId}
 
 
 	if err := bus.Dispatch(&userQuery); err != nil {
 	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)
 	passwordHashed := util.EncodePassword(cmd.OldPassword, userQuery.Result.Salt)
 	if passwordHashed != userQuery.Result.Password {
 	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 {
 	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.UserId = c.UserId
 	cmd.NewPassword = util.EncodePassword(cmd.NewPassword, userQuery.Result.Salt)
 	cmd.NewPassword = util.EncodePassword(cmd.NewPassword, userQuery.Result.Salt)
 
 
 	if err := bus.Dispatch(&cmd); err != nil {
 	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, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20)))
 	pngPath = pngPath + ".png"
 	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,
 		"height="+params.Height, "png="+pngPath, "cookiename="+setting.SessionOptions.CookieName,
 		"domain="+setting.Domain, "sessionid="+params.SessionId)
 		"domain="+setting.Domain, "sessionid="+params.SessionId)
 	stdout, err := cmd.StdoutPipe()
 	stdout, err := cmd.StdoutPipe()

+ 1 - 1
pkg/models/datasource.go

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

+ 10 - 6
pkg/models/org.go

@@ -48,8 +48,13 @@ type GetOrgByNameQuery struct {
 	Result *Org
 	Result *Org
 }
 }
 
 
-type GetOrgListQuery struct {
-	Result []*Org
+type SearchOrgsQuery struct {
+	Query string
+	Name  string
+	Limit int
+	Page  int
+
+	Result []*OrgDTO
 }
 }
 
 
 type OrgDTO struct {
 type OrgDTO struct {
@@ -58,8 +63,7 @@ type OrgDTO struct {
 }
 }
 
 
 type UserOrgDTO 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
 type RoleType string
 
 
 const (
 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 {
 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 {
 type OrgUser struct {

+ 1 - 0
pkg/models/user.go

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

+ 41 - 2
pkg/search/handlers.go

@@ -33,9 +33,7 @@ func searchHandler(query *Query) error {
 
 
 	dashQuery := FindPersistedDashboardsQuery{
 	dashQuery := FindPersistedDashboardsQuery{
 		Title:     query.Title,
 		Title:     query.Title,
-		Tag:       query.Tag,
 		UserId:    query.UserId,
 		UserId:    query.UserId,
-		Limit:     query.Limit,
 		IsStarred: query.IsStarred,
 		IsStarred: query.IsStarred,
 		OrgId:     query.OrgId,
 		OrgId:     query.OrgId,
 	}
 	}
@@ -55,8 +53,30 @@ func searchHandler(query *Query) error {
 		hits = append(hits, jsonHits...)
 		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)
 	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 {
 	if err := setIsStarredFlagOnSearchResults(query.UserId, hits); err != nil {
 		return err
 		return err
 	}
 	}
@@ -65,6 +85,25 @@ func searchHandler(query *Query) error {
 	return nil
 	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 {
 func setIsStarredFlagOnSearchResults(userId int64, hits []*Hit) error {
 	query := m.GetUserStarsQuery{UserId: userId}
 	query := m.GetUserStarsQuery{UserId: userId}
 	if err := bus.Dispatch(&query); err != nil {
 	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) {
 func (index *JsonDashIndex) Search(query *Query) ([]*Hit, error) {
 	results := make([]*Hit, 0)
 	results := make([]*Hit, 0)
 
 
+	if query.IsStarred {
+		return results, nil
+	}
+
 	for _, item := range index.items {
 	for _, item := range index.items {
 		if len(results) > query.Limit {
 		if len(results) > query.Limit {
 			break
 			break
 		}
 		}
 
 
-		// filter out results with tag filter
-		if query.Tag != "" {
-			if !strings.Contains(item.TagsCsv, query.Tag) {
-				continue
-			}
-		}
-
 		// add results with matchig title filter
 		// add results with matchig title filter
 		if strings.Contains(item.TitleLower, query.Title) {
 		if strings.Contains(item.TitleLower, query.Title) {
 			results = append(results, &Hit{
 			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() {
 		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(err, ShouldBeNil)
 
 
 			So(len(res), ShouldEqual, 3)
 			So(len(res), ShouldEqual, 3)
 		})
 		})
 
 
 		Convey("Should be able to search index by title", func() {
 		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(err, ShouldBeNil)
 
 
 			So(len(res), ShouldEqual, 1)
 			So(len(res), ShouldEqual, 1)
 			So(res[0].Title, ShouldEqual, "Home")
 			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 {
 type Query struct {
 	Title     string
 	Title     string
-	Tag       string
+	Tags      []string
 	OrgId     int64
 	OrgId     int64
 	UserId    int64
 	UserId    int64
 	Limit     int
 	Limit     int
@@ -37,10 +37,8 @@ type Query struct {
 
 
 type FindPersistedDashboardsQuery struct {
 type FindPersistedDashboardsQuery struct {
 	Title     string
 	Title     string
-	Tag       string
 	OrgId     int64
 	OrgId     int64
 	UserId    int64
 	UserId    int64
-	Limit     int
 	IsStarred bool
 	IsStarred bool
 
 
 	Result HitList
 	Result HitList

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

@@ -109,25 +109,26 @@ func Setup() error {
 }
 }
 
 
 func publish(routingKey string, msgString []byte) {
 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.
 		// failures are most likely because the connection was lost.
 		// the connection will be re-established, so just keep
 		// the connection will be re-established, so just keep
 		// retrying every 2seconds until we successfully publish.
 		// retrying every 2seconds until we successfully publish.
 		time.Sleep(2 * time.Second)
 		time.Sleep(2 * time.Second)
 		fmt.Println("publish failed, retrying.")
 		fmt.Println("publish failed, retrying.")
-		publish(routingKey, msgString)
 	}
 	}
-	return
 }
 }
 
 
 func eventListener(event interface{}) error {
 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+"%")
 		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
 	var res []DashboardSearchProjection
 	err := x.Sql(sql.String(), params...).Find(&res)
 	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)
 				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() {
 			Convey("Should not be able to save dashboard with same name", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,

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

@@ -14,12 +14,23 @@ func init() {
 	bus.AddHandler("sql", CreateOrg)
 	bus.AddHandler("sql", CreateOrg)
 	bus.AddHandler("sql", UpdateOrg)
 	bus.AddHandler("sql", UpdateOrg)
 	bus.AddHandler("sql", GetOrgByName)
 	bus.AddHandler("sql", GetOrgByName)
-	bus.AddHandler("sql", GetOrgList)
+	bus.AddHandler("sql", SearchOrgs)
 	bus.AddHandler("sql", DeleteOrg)
 	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 {
 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}
 					cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id}
 					err := RemoveOrgUser(&cmd)
 					err := RemoveOrgUser(&cmd)
 					So(err, ShouldEqual, m.ErrLastOrgAdmin)
 					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.Role = cmd.Role
 		orgUser.Updated = time.Now()
 		orgUser.Updated = time.Now()
 		_, err = sess.Id(orgUser.Id).Update(&orgUser)
 		_, 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
 			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
 		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{
 	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
 	return err
@@ -282,6 +284,11 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error {
 		return m.ErrUserNotFound
 		return m.ErrUserNotFound
 	}
 	}
 
 
+	if user.OrgRole == "" {
+		user.OrgId = -1
+		user.OrgName = "Org missing"
+	}
+
 	query.Result = &user
 	query.Result = &user
 	return err
 	return err
 }
 }

+ 2 - 0
pkg/setting/setting.go

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

+ 145 - 28
pkg/social/social.go

@@ -78,12 +78,14 @@ func NewOAuthService() {
 		if name == "github" {
 		if name == "github" {
 			setting.OAuthService.GitHub = true
 			setting.OAuthService.GitHub = true
 			teamIds := sec.Key("team_ids").Ints(",")
 			teamIds := sec.Key("team_ids").Ints(",")
+			allowedOrganizations := sec.Key("allowed_organizations").Strings(" ")
 			SocialMap["github"] = &SocialGithub{
 			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 {
 type SocialGithub struct {
 	*oauth2.Config
 	*oauth2.Config
-	allowedDomains []string
-	apiUrl         string
-	allowSignup    bool
-	teamIds        []int
+	allowedDomains       []string
+	allowedOrganizations []string
+	apiUrl               string
+	allowSignup          bool
+	teamIds              []int
 }
 }
 
 
 var (
 var (
 	ErrMissingTeamMembership = errors.New("User not a member of one of the required teams")
 	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 {
 func (s *SocialGithub) Type() int {
 	return int(models.GITHUB)
 	return int(models.GITHUB)
 }
 }
@@ -137,26 +144,131 @@ func (s *SocialGithub) IsSignupAllowed() bool {
 	return s.allowSignup
 	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 {
 	if err != nil {
 		return false
 		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
 		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) {
 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,
 		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
 		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';
   'use strict';
 
 
   /**
   /**
@@ -14,6 +14,7 @@ function ($) {
 
 
     return function (x, y, opts) {
     return function (x, y, opts) {
       opts = $.extend(true, {}, defaults, opts);
       opts = $.extend(true, {}, defaults, opts);
+
       return this.each(function () {
       return this.each(function () {
         var $tooltip = $(this), width, height;
         var $tooltip = $(this), width, height;
 
 
@@ -22,6 +23,17 @@ function ($) {
         $("#tooltip").remove();
         $("#tooltip").remove();
         $tooltip.appendTo(document.body);
         $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);
         width = $tooltip.outerWidth(true);
         height = $tooltip.outerHeight(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.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.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.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.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.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.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']);
   kbn.valueFormats.volt = kbn.formatFuncCreator(1000, [' V', ' kV', ' MV', ' GV', ' TV', ' PV', ' EV', ' ZV', ' YV']);
@@ -564,6 +565,7 @@ function($, _, moment) {
       {
       {
         text: 'data rate',
         text: 'data rate',
         submenu: [
         submenu: [
+          {text: 'packets/sec', value: 'pps'},
           {text: 'bits/sec', value: 'bps'},
           {text: 'bits/sec', value: 'bps'},
           {text: 'bytes/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('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.addMenuItem('share', 'icon-share', 'sharePanel(); dismiss();');
 
 
     this.addEditorTab('General', 'app/partials/panelgeneral.html');
     this.addEditorTab('General', 'app/partials/panelgeneral.html');
@@ -29,12 +29,12 @@ function () {
     this.addExtendedMenuItem('Panel JSON', '', 'editPanelJson(); dismiss();');
     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) {
   PanelMeta.prototype.addEditorTab = function(title, src) {

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

@@ -13,8 +13,8 @@ function (angular, _, config) {
     $scope.init = function() {
     $scope.init = function() {
       $scope.giveSearchFocus = 0;
       $scope.giveSearchFocus = 0;
       $scope.selectedIndex = -1;
       $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;
       $scope.currentSearchId = 0;
 
 
       if ($scope.dashboardViewState.fullscreen) {
       if ($scope.dashboardViewState.fullscreen) {
@@ -26,7 +26,6 @@ function (angular, _, config) {
         $scope.query.query = '';
         $scope.query.query = '';
         $scope.search();
         $scope.search();
       }, 100);
       }, 100);
-
     };
     };
 
 
     $scope.keyDown = function (evt) {
     $scope.keyDown = function (evt) {
@@ -83,12 +82,11 @@ function (angular, _, config) {
 
 
     $scope.queryHasNoFilters = function() {
     $scope.queryHasNoFilters = function() {
       var query = $scope.query;
       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.filterByTag = function(tag, evt) {
-      $scope.query.tag = tag;
-      $scope.query.tagcloud = false;
+      $scope.query.tag.push(tag);
       $scope.search();
       $scope.search();
       $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
       $scope.giveSearchFocus = $scope.giveSearchFocus + 1;
       if (evt) {
       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() {
     $scope.getTags = function() {
       return backendSrv.get('/api/dashboards/tags').then(function(results) {
       return backendSrv.get('/api/dashboards/tags').then(function(results) {
         $scope.tagsMode = true;
         $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',
   './ngBlur',
   './dashEditLink',
   './dashEditLink',
   './ngModelOnBlur',
   './ngModelOnBlur',
-  './tip',
+  './misc',
   './confirmClick',
   './confirmClick',
   './configModal',
   './configModal',
   './spectrumPicker',
   './spectrumPicker',
-  './bootstrap-tagsinput',
+  './tags',
   './bodyClass',
   './bodyClass',
   './variableValueSelect',
   './variableValueSelect',
-  './graphiteSegment',
+  './metric.segment',
   './grafanaVersionCheck',
   './grafanaVersionCheck',
   './dropdown.typeahead',
   './dropdown.typeahead',
   './topnav',
   './topnav',
   './giveFocus',
   './giveFocus',
+  './annotationTooltip',
 ], function () {});
 ], 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
   angular
     .module('grafana.directives')
     .module('grafana.directives')
-    .directive('graphiteSegment', function($compile, $sce) {
+    .directive('metricSegment', function($compile, $sce) {
       var inputTemplate = '<input type="text" data-provide="typeahead" ' +
       var inputTemplate = '<input type="text" data-provide="typeahead" ' +
                             ' class="tight-form-clear-input input-medium"' +
                             ' class="tight-form-clear-input input-medium"' +
                             ' spellcheck="false" style="display:none"></input>';
                             ' 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 {
       return {
+        scope: {
+          segment: "=",
+          getAltSegments: "&",
+          onValueChanged: "&"
+        },
+
         link: function($scope, elem) {
         link: function($scope, elem) {
           var $input = $(inputTemplate);
           var $input = $(inputTemplate);
           var $button = $(buttonTemplate);
           var $button = $(buttonTemplate);
@@ -46,7 +53,7 @@ function (angular, app, _, $) {
                 segment.expandable = true;
                 segment.expandable = true;
                 segment.fake = false;
                 segment.fake = false;
               }
               }
-              $scope.segmentValueChanged(segment, $scope.$index);
+              $scope.onValueChanged();
             });
             });
           };
           };
 
 
@@ -61,7 +68,7 @@ function (angular, app, _, $) {
             else {
             else {
               // need to have long delay because the blur
               // need to have long delay because the blur
               // happens long before the click event on the typeahead options
               // 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; }
             if (options) { return options; }
 
 
             $scope.$apply(function() {
             $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; });
                 options = _.map($scope.altSegments, function(alt) { return alt.value; });
 
 
                 // add custom values
                 // 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';
   'use strict';
 
 
   angular
   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([
 define([
   'angular',
   'angular',
+  'lodash',
 ],
 ],
-function (angular) {
+function (angular, _) {
   'use strict';
   'use strict';
 
 
   var module = angular.module('grafana.controllers');
   var module = angular.module('grafana.controllers');
 
 
   module.controller('AdminEditUserCtrl', function($scope, $routeParams, backendSrv, $location) {
   module.controller('AdminEditUserCtrl', function($scope, $routeParams, backendSrv, $location) {
     $scope.user = {};
     $scope.user = {};
+    $scope.newOrg = { name: '', role: 'Editor' };
     $scope.permissions = {};
     $scope.permissions = {};
 
 
     $scope.init = function() {
     $scope.init = function() {
       if ($routeParams.id) {
       if ($routeParams.id) {
         $scope.getUser($routeParams.id);
         $scope.getUser($routeParams.id);
+        $scope.getUserOrgs($routeParams.id);
       }
       }
     };
     };
 
 
     $scope.getUser = function(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 = user;
         $scope.user_id = id;
         $scope.user_id = id;
         $scope.permissions.isGrafanaAdmin = user.isGrafanaAdmin;
         $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() {
     $scope.update = function() {
       if (!$scope.userForm.$valid) { return; }
       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');
         $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();
     $scope.init();
 
 
   });
   });

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

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

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

@@ -25,7 +25,7 @@
 					</ul>
 					</ul>
 					<div class="clearfix"></div>
 					<div class="clearfix"></div>
 				</div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Email</strong>
 							<strong>Email</strong>
@@ -36,7 +36,7 @@
 					</ul>
 					</ul>
 					<div class="clearfix"></div>
 					<div class="clearfix"></div>
 				</div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Username</strong>
 							<strong>Username</strong>
@@ -80,19 +80,73 @@
 			Permissions
 			Permissions
 		</h2>
 		</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>
 		</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>
 </div>
 </div>

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

@@ -24,7 +24,7 @@
 					</ul>
 					</ul>
 					<div class="clearfix"></div>
 					<div class="clearfix"></div>
 				</div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Email</strong>
 							<strong>Email</strong>
@@ -35,7 +35,7 @@
 					</ul>
 					</ul>
 					<div class="clearfix"></div>
 					<div class="clearfix"></div>
 				</div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Username</strong>
 							<strong>Username</strong>
@@ -46,7 +46,7 @@
 					</ul>
 					</ul>
 					<div class="clearfix"></div>
 					<div class="clearfix"></div>
 				</div>
 				</div>
-				<div class="tight-form" style="margin-top: 5px">
+				<div class="tight-form">
 					<ul class="tight-form-list">
 					<ul class="tight-form-list">
 						<li class="tight-form-item" style="width: 100px">
 						<li class="tight-form-item" style="width: 100px">
 							<strong>Password</strong>
 							<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">
 	<ul class="nav">
 		<li class="active"><a href="admin/users">Overview</a></li>
 		<li class="active"><a href="admin/users">Overview</a></li>
 		<li><a href="admin/users/create">Create user</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([
 define([
   'angular',
   'angular',
   'lodash',
   'lodash',
-  'moment',
   './editorCtrl'
   './editorCtrl'
-], function (angular, _, moment) {
+], function (angular, _) {
   'use strict';
   'use strict';
 
 
   var module = angular.module('grafana.services');
   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 promiseCached;
     var list = [];
     var list = [];
-    var timezone;
     var self = this;
     var self = this;
 
 
     this.init = function() {
     this.init = function() {
@@ -33,7 +31,7 @@ define([
         return promiseCached;
         return promiseCached;
       }
       }
 
 
-      timezone = dashboard.timezone;
+      self.dashboard = dashboard;
       var annotations = _.where(dashboard.annotations.list, {enable: true});
       var annotations = _.where(dashboard.annotations.list, {enable: true});
 
 
       var promises  = _.map(annotations, function(annotation) {
       var promises  = _.map(annotations, function(annotation) {
@@ -54,47 +52,27 @@ define([
 
 
     this.receiveAnnotationResults = function(results) {
     this.receiveAnnotationResults = function(results) {
       for (var i = 0; i < results.length; i++) {
       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({
       list.push({
         annotation: options.annotation,
         annotation: options.annotation,
         min: options.time,
         min: options.time,
         max: options.time,
         max: options.time,
         eventType: options.annotation.name,
         eventType: options.annotation.name,
-        title: null,
-        description: tooltip,
+        title: options.title,
+        tags: options.tags,
+        text: options.text,
         score: 1
         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
     // 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>
 		<i class="fa fa-caret-down"></i>
 	</a>
 	</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>
 	</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 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) {
       if ($scope.options.includeTemplateVars) {
         templateSrv.fillVariableValuesForUrl(params);
         templateSrv.fillVariableValuesForUrl(params);

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

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

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

@@ -93,7 +93,7 @@ define([
       _.extend(this.time, time);
       _.extend(this.time, time);
 
 
       // disable refresh if we have an absolute 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.old_refresh = this.dashboard.refresh || this.old_refresh;
         this.set_interval(false);
         this.set_interval(false);
       }
       }

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

@@ -130,10 +130,11 @@ function (angular, _, $) {
       var docHeight = $(window).height();
       var docHeight = $(window).height();
       var editHeight = Math.floor(docHeight * 0.3);
       var editHeight = Math.floor(docHeight * 0.3);
       var fullscreenHeight = Math.floor(docHeight * 0.7);
       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;
       this.fullscreenPanel = panelScope;
 
 
       $(window).scrollTop(0);
       $(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>
 					<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>
 
 
-				<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'">
 				<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>
 				<li class="tight-form-item" ng-show="link.type === 'dashboards'">
 				<li class="tight-form-item" ng-show="link.type === 'dashboards'">
 					<editor-checkbox text="As dropdown" model="link.asDropdown" change="updated()"></editor-checkbox>
 					<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) {
     function buildLinks(linkDef) {
       if (linkDef.type === 'dashboards') {
       if (linkDef.type === 'dashboards') {
-        if (!linkDef.tag) {
+        if (!linkDef.tags) {
           console.log('Dashboard link missing tag');
           console.log('Dashboard link missing tag');
           return $q.when([]);
           return $q.when([]);
         }
         }
@@ -97,7 +97,7 @@ function (angular, _) {
         if (linkDef.asDropdown) {
         if (linkDef.asDropdown) {
           return $q.when([{
           return $q.when([{
             title: linkDef.title,
             title: linkDef.title,
-            tag: linkDef.tag,
+            tags: linkDef.tags,
             keepTime: linkDef.keepTime,
             keepTime: linkDef.keepTime,
             includeVars: linkDef.includeVars,
             includeVars: linkDef.includeVars,
             icon: "fa fa-bars",
             icon: "fa fa-bars",
@@ -132,7 +132,7 @@ function (angular, _) {
     }
     }
 
 
     $scope.searchDashboards = function(link) {
     $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) {
         return _.reduce(results, function(memo, dash) {
           // do not add current dashboard
           // do not add current dashboard
           if (dash.id !== currentDashId) {
           if (dash.id !== currentDashId) {

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

@@ -25,7 +25,6 @@ function (angular, config) {
 
 
       $scope.loadDatasourceTypes().then(function() {
       $scope.loadDatasourceTypes().then(function() {
         if ($routeParams.id) {
         if ($routeParams.id) {
-          $scope.isNew = false;
           $scope.getDatasourceById($routeParams.id);
           $scope.getDatasourceById($routeParams.id);
         } else {
         } else {
           $scope.current = angular.copy(defaults);
           $scope.current = angular.copy(defaults);
@@ -48,6 +47,7 @@ function (angular, config) {
 
 
     $scope.getDatasourceById = function(id) {
     $scope.getDatasourceById = function(id) {
       backendSrv.get('/api/datasources/' + id).then(function(ds) {
       backendSrv.get('/api/datasources/' + id).then(function(ds) {
+        $scope.isNew = false;
         $scope.current = ds;
         $scope.current = ds;
         $scope.typeChanged();
         $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) {
       if (!$scope.editForm.$valid) {
         return;
         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();
     $scope.init();

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

@@ -11,7 +11,7 @@ function (angular) {
     $scope.newOrg = {name: ''};
     $scope.newOrg = {name: ''};
 
 
     $scope.createOrg = function() {
     $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',
           src: './app/features/org/partials/apikeyModal.html',
           scope: modalScope
           scope: modalScope
         });
         });
+
+        $scope.getTokens();
       });
       });
     };
     };
 
 

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

@@ -43,11 +43,22 @@
 			</div>
 			</div>
 
 
 			<div ng-include="datasourceMeta.partials.config" ng-if="datasourceMeta.partials.config"></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>
 				<a class="btn btn-inverse" ng-show="!isNew" href="datasources">Cancel</a>
 			</div>
 			</div>
 			<br>
 			<br>

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

@@ -6,7 +6,7 @@
 			Url
 			Url
 		</li>
 		</li>
 		<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>
 		<li class="tight-form-item">
 		<li class="tight-form-item">
 			Access <tip>Direct = url is used directly from browser, Proxy = Grafana backend will proxy the request</label>
 			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-container">
 	<div class="page">
 	<div class="page">
 
 
-		<h2>Account users</h2>
+		<h2>Organization users</h2>
 
 
 		<form name="form">
 		<form name="form">
 			<div class="tight-form">
 			<div class="tight-form">
 				<ul class="tight-form-list">
 				<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>
 						<strong>Username or Email</strong>
 					</li>
 					</li>
 					<li>
 					<li>
@@ -22,7 +22,7 @@
 						role
 						role
 					</li>
 					</li>
 					<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>
 						</select>
 					</li>
 					</li>
 					<li>
 					<li>
@@ -46,7 +46,7 @@
 				<td>{{user.login}}</td>
 				<td>{{user.login}}</td>
 				<td>{{user.email}}</td>
 				<td>{{user.email}}</td>
 				<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>
 					</select>
 				</td>
 				</td>
 				<td style="width: 1%">
 				<td style="width: 1%">

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

@@ -18,18 +18,26 @@ function (angular, $, _) {
 
 
       function createMenuTemplate($scope) {
       function createMenuTemplate($scope) {
         var template = '<div class="panel-menu small">';
         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 += '<div class="panel-menu-row">';
         template += '<a class="panel-menu-link" gf-dropdown="extendedMenu"><i class="fa fa-bars"></i></a>';
         template += '<a class="panel-menu-link" gf-dropdown="extendedMenu"><i class="fa fa-bars"></i></a>';
 
 
         _.each($scope.panelMeta.menu, function(item) {
         _.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" ';
           template += '<a class="panel-menu-link" ';
           if (item.click) { template += ' ng-click="' + item.click + '"'; }
           if (item.click) { template += ' ng-click="' + item.click + '"'; }
           if (item.editorLink) { template += ' dash-editor-link="' + item.editorLink + '"'; }
           if (item.editorLink) { template += ' dash-editor-link="' + item.editorLink + '"'; }
@@ -61,7 +69,6 @@ function (angular, $, _) {
         link: function($scope, elem) {
         link: function($scope, elem) {
           var $link = $(linkTemplate);
           var $link = $(linkTemplate);
           var $panelContainer = elem.parents(".panel-container");
           var $panelContainer = elem.parents(".panel-container");
-          var menuWidth = $scope.panelMeta.menu.length === 4 ? 236 : 191;
           var menuScope = null;
           var menuScope = null;
           var timeout = null;
           var timeout = null;
           var $menu = null;
           var $menu = null;
@@ -111,21 +118,8 @@ function (angular, $, _) {
               return;
               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);
             var menuTemplate = createMenuTemplate($scope);
             $menu = $(menuTemplate);
             $menu = $(menuTemplate);
-            $menu.css('left', menuLeftPos);
             $menu.mouseleave(function() {
             $menu.mouseleave(function() {
               dismiss(1000);
               dismiss(1000);
             });
             });
@@ -136,14 +130,34 @@ function (angular, $, _) {
               dismiss(null, true);
               dismiss(null, true);
             };
             };
 
 
+            $(".panel-container").removeClass('panel-highlight');
+            $panelContainer.toggleClass('panel-highlight');
+
             $('.panel-menu').remove();
             $('.panel-menu').remove();
+
             elem.append($menu);
             elem.append($menu);
+
             $scope.$apply(function() {
             $scope.$apply(function() {
               $compile($menu.contents())(menuScope);
               $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);
             dismiss(2200);
           };
           };

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

@@ -71,14 +71,6 @@ function (angular, _, config) {
       };
       };
 
 
       $scope.toggleFullscreen = function(edit) {
       $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 });
         $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 style="width: 98%"><strong>Name: </strong> {{org.name}}</td>
 				<td><strong>Role: </strong> {{org.role}}</td>
 				<td><strong>Role: </strong> {{org.role}}</td>
 				<td class="nobg max-width-btns">
 				<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
 						Current
 					</span>
 					</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
 						Select
 					</a>
 					</a>
 				</td>
 				</td>

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

@@ -65,7 +65,7 @@
 								Name
 								Name
 							</li>
 							</li>
 							<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>
 							<li class="tight-form-item">
 							<li class="tight-form-item">
 								Type
 								Type
@@ -139,7 +139,7 @@
 									Query
 									Query
 								</li>
 								</li>
 								<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>
 								</li>
 							</ul>
 							</ul>
 							<div class="clearfix"></div>
 							<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>
 									<tip>Optional, if you want to extract part of a series name or metric node segment</tip>
 								</li>
 								</li>
 								<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>
 								</li>
 							</ul>
 							</ul>
 							<div class="clearfix"></div>
 							<div class="clearfix"></div>
@@ -163,7 +163,7 @@
 									<editor-checkbox text="All value" model="current.includeAll" change="runQuery()"></editor-checkbox>
 									<editor-checkbox text="All value" model="current.includeAll" change="runQuery()"></editor-checkbox>
 								</li>
 								</li>
 								<li ng-show="current.includeAll">
 								<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>
 								<li class="tight-form-item" ng-show="current.includeAll">
 								<li class="tight-form-item" ng-show="current.includeAll">
 									All format
 									All format
@@ -226,6 +226,42 @@
 				</div>
 				</div>
 			</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="editor-row">
 				<div class="tight-form-section">
 				<div class="tight-form-section">
 					<h5>Preview of values (shows max 20)</h5>
 					<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) {
     this.setVariableValue = function(variable, option) {
-      variable.current = option;
+      variable.current = angular.copy(option);
       templateSrv.updateTemplateData();
       templateSrv.updateTemplateData();
       return this.updateOptionsInChildVariables(variable);
       return this.updateOptionsInChildVariables(variable);
     };
     };
@@ -120,7 +120,7 @@ function (angular, _, kbn) {
       }
       }
 
 
       return datasourceSrv.get(variable.datasource).then(function(datasource) {
       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);
           variable.options = self.metricNamesToVariableValues(variable, results);
 
 
           if (variable.includeAll) {
           if (variable.includeAll) {
@@ -130,6 +130,10 @@ function (angular, _, kbn) {
           // if parameter has current value
           // if parameter has current value
           // if it exists in options array keep value
           // if it exists in options array keep value
           if (variable.current) {
           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 });
             var currentOption = _.findWhere(variable.options, { text: variable.current.text });
             if (currentOption) {
             if (currentOption) {
               return self.setVariableValue(variable, currentOption);
               return self.setVariableValue(variable, currentOption);
@@ -138,6 +142,31 @@ function (angular, _, kbn) {
 
 
           return self.setVariableValue(variable, variable.options[0]);
           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
 					Query
 				</li>
 				</li>
 				<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>
 					ng-model="panel.query" ng-change="get_data()" ng-model-onblur>
 				</li>
 				</li>
 				<li class="tight-form-item">
 				<li class="tight-form-item">
-					Tag
+					Tags
 				</li>
 				</li>
 				<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>
 				</li>
 			</ul>
 			</ul>
 			<div class="clearfix"></div>
 			<div class="clearfix"></div>

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

@@ -32,7 +32,7 @@ function (angular, app, _, config, PanelMeta) {
       mode: 'starred',
       mode: 'starred',
       query: '',
       query: '',
       limit: 10,
       limit: 10,
-      tag: '',
+      tags: []
     };
     };
 
 
     $scope.modes = ['starred', 'search'];
     $scope.modes = ['starred', 'search'];
@@ -43,6 +43,9 @@ function (angular, app, _, config, PanelMeta) {
 
 
     $scope.init = function() {
     $scope.init = function() {
       panelSrv.init($scope);
       panelSrv.init($scope);
+      if ($scope.panel.tag) {
+        $scope.panel.tags = [$scope.panel.tag];
+      }
 
 
       if ($scope.isNewPanel()) {
       if ($scope.isNewPanel()) {
         $scope.panel.title = "Starred Dashboards";
         $scope.panel.title = "Starred Dashboards";
@@ -58,7 +61,7 @@ function (angular, app, _, config, PanelMeta) {
         params.starred = "true";
         params.starred = "true";
       } else {
       } else {
         params.query = $scope.panel.query;
         params.query = $scope.panel.query;
-        params.tag = $scope.panel.tag;
+        params.tag = $scope.panel.tags;
       }
       }
 
 
       return backendSrv.search(params).then(function(result) {
       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':
             case 'bps':
               url += '&yUnitSystem=si';
               url += '&yUnitSystem=si';
               break;
               break;
+            case 'pps':
+              url += '&yUnitSystem=si';
+              break;
             case 'Bps':
             case 'Bps':
               url += '&yUnitSystem=si';
               url += '&yUnitSystem=si';
               break;
               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;
         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++) {
       for(var i = 0; i < $scope.panel.valueMaps.length; i++) {
         var map = $scope.panel.valueMaps[i];
         var map = $scope.panel.valueMaps[i];
         // special null case
         // special null case
@@ -201,6 +206,7 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) {
           }
           }
           continue;
           continue;
         }
         }
+
         // value/number to text mapping
         // value/number to text mapping
         var value = parseFloat(map.value);
         var value = parseFloat(map.value);
         if (value === data.value) {
         if (value === data.value) {
@@ -212,11 +218,6 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) {
       if (data.value === null || data.value === void 0) {
       if (data.value === null || data.value === void 0) {
         data.valueFormated = "no value";
         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) {
     $scope.removeValueMap = function(map) {

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

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

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

@@ -15,11 +15,14 @@
 				<i class="fa fa-remove" ng-show="tagsMode"></i>
 				<i class="fa fa-remove" ng-show="tagsMode"></i>
 				tags
 				tags
 			</a>
 			</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>
 			</span>
 		</div>
 		</div>
 	</div>
 	</div>
@@ -30,7 +33,7 @@
 				<div ng-repeat="tag in results" class="pointer" style="width: 180px; float: left;"
 				<div ng-repeat="tag in results" class="pointer" style="width: 180px; float: left;"
 					ng-class="{'selected': $index === selectedIndex }"
 					ng-class="{'selected': $index === selectedIndex }"
 					ng-click="filterByTag(tag.term, $event)">
 					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>
 						<i class="fa fa-tag"></i>
 						<span>{{tag.term}} &nbsp;({{tag.count}})</span>
 						<span>{{tag.term}} &nbsp;({{tag.count}})</span>
 					</a>
 					</a>
@@ -46,7 +49,7 @@
 			ng-class="{'selected': $index == selectedIndex}" ng-href="{{row.url}}">
 			ng-class="{'selected': $index == selectedIndex}" ng-href="{{row.url}}">
 
 
 			<span class="search-result-tags">
 			<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}}
 					{{tag}}
 				</span>
 				</span>
 				<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
 				<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">
 		<ul class="tight-form-list" ng-if="dashboard.templating.list.length > 0">
 			<li ng-repeat="variable in variables" class="submenu-item">
 			<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>
 			</li>
 		</ul>
 		</ul>
 
 

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

@@ -111,6 +111,7 @@ function (angular, _, $, config, kbn, moment) {
             var list = [];
             var list = [];
             for (var i = 0; i < results.data.length; i++) {
             for (var i = 0; i < results.data.length; i++) {
               var e = results.data[i];
               var e = results.data[i];
+
               list.push({
               list.push({
                 annotation: annotation,
                 annotation: annotation,
                 time: e.when * 1000,
                 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) {
     GraphiteDatasource.prototype.listDashboards = function(query) {
       return this.doGraphiteRequest({ method: 'GET',  url: '/dashboard/find/', params: {query: query || ''} })
       return this.doGraphiteRequest({ method: 'GET',  url: '/dashboard/find/', params: {query: query || ''} })
         .then(function(results) {
         .then(function(results) {

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

@@ -74,7 +74,9 @@
               ng-show="showTextEditor" />
               ng-show="showTextEditor" />
 
 
       <ul class="tight-form-list" role="menu" ng-hide="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">
 				<li ng-repeat="func in functions">
           <span graphite-func-editor class="tight-form-item tight-form-func">
           <span graphite-func-editor class="tight-form-item tight-form-func">
           </span>
           </span>

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

@@ -152,23 +152,18 @@ function (angular, _, config, gfunc, Parser) {
     }
     }
 
 
     $scope.getAltSegments = function (index) {
     $scope.getAltSegments = function (index) {
-      $scope.altSegments = [];
-
       var query = index === 0 ?  '*' : getSegmentPathUpTo(index) + '.*';
       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 });
             return new MetricSegment({ value: segment.text, expandable: segment.expandable });
           });
           });
 
 
-          if ($scope.altSegments.length === 0) {
-            return;
-          }
+          if (altSegments.length === 0) { return altSegments; }
 
 
           // add template variables
           // add template variables
           _.each(templateSrv.variables, function(variable) {
           _.each(templateSrv.variables, function(variable) {
-            $scope.altSegments.unshift(new MetricSegment({
+            altSegments.unshift(new MetricSegment({
               type: 'template',
               type: 'template',
               value: '$' + variable.name,
               value: '$' + variable.name,
               expandable: true,
               expandable: true,
@@ -176,10 +171,12 @@ function (angular, _, config, gfunc, Parser) {
           });
           });
 
 
           // add wildcard option
           // add wildcard option
-          $scope.altSegments.unshift(new MetricSegment('*'));
+          altSegments.unshift(new MetricSegment('*'));
+          return altSegments;
         })
         })
         .then(null, function(err) {
         .then(null, function(err) {
           $scope.parserError = err.message || 'Failed to issue metric query';
           $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
         // build query
         var queryBuilder = new InfluxQueryBuilder(target);
         var queryBuilder = new InfluxQueryBuilder(target);
         var query = queryBuilder.build();
         var query = queryBuilder.build();
-        console.log('query builder result:' + query);
 
 
         // replace grafana variables
         // replace grafana variables
         query = query.replace('$timeFilter', timeFilter);
         query = query.replace('$timeFilter', timeFilter);
@@ -69,12 +68,15 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
       var query = annotation.query.replace('$timeFilter', timeFilter);
       var query = annotation.query.replace('$timeFilter', timeFilter);
       query = templateSrv.replace(query);
       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;
       var interpolated;
       try {
       try {
         interpolated = templateSrv.replace(query);
         interpolated = templateSrv.replace(query);
@@ -83,39 +85,33 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
         return $q.reject(err);
         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 []; }
         if (!results || results.results.length === 0) { return []; }
 
 
         var influxResults = results.results[0];
         var influxResults = results.results[0];
         if (!influxResults.series) {
         if (!influxResults.series) {
           return [];
           return [];
         }
         }
-
-        console.log('metric find query response', results);
         var series = influxResults.series[0];
         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 }; });
           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) {
     function retry(deferred, callback, delay) {
       return callback().then(undefined, function(reason) {
       return callback().then(undefined, function(reason) {
         if (reason.status !== 0 || reason.status >= 300) {
         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);
           deferred.reject(reason);
         }
         }
         else {
         else {
@@ -130,6 +126,12 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
       return this._influxRequest('GET', '/query', {q: query});
       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) {
     InfluxDatasource.prototype._influxRequest = function(method, url, data) {
       var self = this;
       var self = this;
       var deferred = $q.defer();
       var deferred = $q.defer();
@@ -174,9 +176,11 @@ function (angular, _, kbn, InfluxSeries, InfluxQueryBuilder) {
       return deferred.promise;
       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) {
     function getTimeFilter(options) {

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

@@ -108,7 +108,7 @@ function (angular, _, $) {
           function addElementsAndCompile() {
           function addElementsAndCompile() {
             $funcLink.appendTo(elem);
             $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);
             var $input = $(paramTemplate);
 
 
             $paramLink.appendTo(elem);
             $paramLink.appendTo(elem);

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

@@ -5,8 +5,7 @@ function (_) {
   'use strict';
   'use strict';
 
 
   function InfluxSeries(options) {
   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.alias = options.alias;
     this.annotation = options.annotation;
     this.annotation = options.annotation;
   }
   }
@@ -17,23 +16,25 @@ function (_) {
     var output = [];
     var output = [];
     var self = this;
     var self = this;
 
 
-    console.log(self.seriesList);
-    if (self.seriesList.length === 0) {
+    if (self.series.length === 0) {
       return output;
       return output;
     }
     }
 
 
-    _.each(self.seriesList, function(series) {
+    _.each(self.series, function(series) {
       var datapoints = [];
       var datapoints = [];
       for (var i = 0; i < series.values.length; i++) {
       for (var i = 0; i < series.values.length; i++) {
         datapoints[i] = [series.values[i][1], new Date(series.values[i][0]).getTime()];
         datapoints[i] = [series.values[i][1], new Date(series.values[i][0]).getTime()];
       }
       }
 
 
       var seriesName = series.name;
       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(', ') + '}';
         seriesName = seriesName + ' {' + tags.join(', ') + '}';
       }
       }
 
 
@@ -43,11 +44,26 @@ function (_) {
     return output;
     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 () {
   p.getAnnotations = function () {
     var list = [];
     var list = [];
     var self = this;
     var self = this;
 
 
-    _.each(this.seriesList, function (series) {
+    _.each(this.series, function (series) {
       var titleCol = null;
       var titleCol = null;
       var timeCol = null;
       var timeCol = null;
       var tagsCol = null;
       var tagsCol = null;

Някои файлове не бяха показани, защото твърде много файлове са промени