diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf01ab78c8..5988579123a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,26 @@ - [Issue #1888](https://github.com/grafana/grafana/issues/1144). Templating: Repeat panel or row for each selected template variable value - [Issue #1888](https://github.com/grafana/grafana/issues/1944). Dashboard: Custom Navigation links & dynamic links to related dashboards - [Issue #590](https://github.com/grafana/grafana/issues/590). Graph: Define series color using regex rule +- [Issue #2096](https://github.com/grafana/grafana/issues/2096). Dashboard list panel: Now supports search by multiple tags **User or Organization admin** - [Issue #1899](https://github.com/grafana/grafana/issues/1899). Organization: You can now update the organization user role directly (without removing and readding the organization user). +- [Issue #2088](https://github.com/grafana/grafana/issues/2088). Roles: New user role `Read Only Editor` that replaces the old `Viewer` role behavior **Backend** +- [Issue #2095](https://github.com/grafana/grafana/issues/2095). Search: Search now supports filtering by multiple dashboard tags - [Issue #1905](https://github.com/grafana/grafana/issues/1905). Github OAuth: You can now configure a Github team membership requirement, thx @dewski +- [Issue #2052](https://github.com/grafana/grafana/issues/2052). Github OAuth: You can now configure a Github organization requirement, thx @indrekj - [Issue #1891](https://github.com/grafana/grafana/issues/1891). Security: New config option to disable the use of gravatar for profile images - [Issue #1921](https://github.com/grafana/grafana/issues/1921). Auth: Support for user authentication via reverse proxy header (like X-Authenticated-User, or X-WEBAUTH-USER) - [Issue #960](https://github.com/grafana/grafana/issues/960). Search: Backend can now index a folder with json files, will be available in search (saving back to folder is not supported, this feature is meant for static generated json dashboards) **Breaking changes** +- [Issue #1826](https://github.com/grafana/grafana/issues/1826). User role 'Viewer' are now prohibited from entering edit mode (and doing other transient dashboard edits). A new role `Read Only Editor` will replace the old Viewer behavior - [Issue #1928](https://github.com/grafana/grafana/issues/1928). HTTP API: GET /api/dashboards/db/:slug response changed property `model` to `dashboard` to match the POST request nameing - Backend render URL changed from `/render/dashboard/solo` `render/dashboard-solo/` (in order to have consistent dashboard url `/dashboard/:type/:slug`) - Search HTTP API response has changed (simplified), tags list moved to seperate HTTP resource URI +- Datasource HTTP api breaking change, ADD datasource is now POST /api/datasources/, update is now PUT /api/datasources/:id # 2.0.3 (unreleased - 2.0.x branch) diff --git a/README.md b/README.md index 5f0fb88b951..0a7db318338 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ go get github.com/grafana/grafana ``` cd $GOPATH/src/github.com/grafana/grafana go run build.go setup (only needed once to install godep) -godep restore (will pull down all golang lib dependecies in your current GOPATH) +godep restore (will pull down all golang lib dependencies in your current GOPATH) go build . ``` diff --git a/conf/defaults.ini b/conf/defaults.ini index 628df5360e4..258a0198155 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -153,6 +153,7 @@ token_url = https://github.com/login/oauth/access_token api_url = https://api.github.com/user team_ids = allowed_domains = +allowed_organizations = #################################### Google Auth ########################## [auth.google] diff --git a/conf/sample.ini b/conf/sample.ini index df204a7f45d..3c2773fa674 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -146,12 +146,13 @@ ;allow_sign_up = false ;client_id = some_id ;client_secret = some_secret -;scopes = user:email +;scopes = user:email,read:org ;auth_url = https://github.com/login/oauth/authorize ;token_url = https://github.com/login/oauth/access_token ;api_url = https://api.github.com/user ;team_ids = ;allowed_domains = +;allowed_organizations = #################################### Google Auth ########################## [auth.google] diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2ff90577f07..5024fd33afe 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -61,7 +61,7 @@ pages: - ['datasources/influxdb.md', 'Data Sources', 'InfluxDB'] - ['datasources/opentsdb.md', 'Data Sources', 'OpenTSDB'] -- ['project/building_from_source.md', 'Project', 'Building from souce'] +- ['project/building_from_source.md', 'Project', 'Building from source'] - ['project/cla.md', 'Project', 'Contributor License Agreement'] - ['jsearch.md', '**HIDDEN**'] diff --git a/docs/sources/datasources/opentsdb.md b/docs/sources/datasources/opentsdb.md index 09b9f647a5f..4e85bdc9555 100644 --- a/docs/sources/datasources/opentsdb.md +++ b/docs/sources/datasources/opentsdb.md @@ -27,7 +27,7 @@ Open a graph in edit mode by click the title. ![](/img/v2/opentsdb_query_editor.png) -For details on opentsdb metric queries checkout the offical [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html) +For details on opentsdb metric queries checkout the official [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html) diff --git a/docs/sources/guides/gettingstarted.md b/docs/sources/guides/gettingstarted.md index b0037668f56..641dc9516f5 100644 --- a/docs/sources/guides/gettingstarted.md +++ b/docs/sources/guides/gettingstarted.md @@ -19,7 +19,7 @@ The image above shows you the top header for a dashboard. 1. Side menubar toggle: This toggles the side menu, allowing you to focus on the data presented in the dashboard. The side menu provides access to features unrelated to a Dashboard such as Users, Organizations, and Data Sources. 2. Dashboard dropdown: This dropdown shows you which Dashboard you are currently viewing, and allows you to easily switch to a new Dashboard. From here you can also create a new Dashboard, Import existing Dashboards, and manage Dashboard playlists. -3. Star Dashboard: Star (or unstar) the current Dashboar. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. +3. Star Dashboard: Star (or unstar) the current Dashboard. Starred Dashboards will show up on your own Home Dashboard by default, and are a convenient way to mark Dashboards that you're interested in. 4. Share Dashboard: Share the current dashboard by creating a link or create a static Snapshot of it. Make sure the Dashboard is saved before sharing. 5. Save dashboard: The current Dashboard will be saved with the current Dashboard name. 6. Settings: Manage Dashboard settings and features such as Templating and Annotations. @@ -28,7 +28,7 @@ The image above shows you the top header for a dashboard. Dashboards are at the core of what Grafana is all about. Dashboards are composed of individual Panels arranged on a number of Rows. By adjusting the display properties of Panels and Rows, you can customize the perfect Dashboard for your exact needs. Each panel can interact with data from any configured Grafana Data Source (currently InfluxDB, Graphite, OpenTSDB, and KairosDB). -This allows you to create a single dashboard that unifies the data across your organization. Panels use the time range specificed +This allows you to create a single dashboard that unifies the data across your organization. Panels use the time range specified in the main Time Picker in the upper right, but they can also have relative time overrides. diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 4665803960a..80ad5909a34 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -296,12 +296,12 @@ Secret. Specify these in the Grafana configuration file. For example: scopes = https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email auth_url = https://accounts.google.com/o/oauth2/auth token_url = https://accounts.google.com/o/oauth2/token - allowed_domains = mycompany.com + allowed_domains = mycompany.com mycompany.org allow_sign_up = false Restart the Grafana back-end. You should now see a Google login button on the login page. You can now login or sign up with your Google -accounts. The `allowed_domains` option is optional. +accounts. The `allowed_domains` option is optional, and domains were separated by space. You may allow users to sign-up via Google authentication by setting the `allow_sign_up` option to `true`. When this option is set to `true`, any diff --git a/docs/sources/installation/mac.md b/docs/sources/installation/mac.md index a585b748630..48f8ca075a3 100644 --- a/docs/sources/installation/mac.md +++ b/docs/sources/installation/mac.md @@ -7,7 +7,7 @@ page_keywords: grafana, installation, mac, osx, guide # Installing on Mac There is currently no binary build for Mac. But read the [build from -source](../project/building_from_source) page for instructions on how to +source](/project/building_from_source) page for instructions on how to build it yourself. diff --git a/docs/sources/project/building_from_source.md b/docs/sources/project/building_from_source.md index 8466dbd2736..8401bf543de 100644 --- a/docs/sources/project/building_from_source.md +++ b/docs/sources/project/building_from_source.md @@ -72,4 +72,4 @@ You only need to add the options you want to override. Config files are applied ## Create a pull requests -Before or after your create a pull requests, sign the [contributor license aggrement](/docs/contributing/cla.html). +Before or after your create a pull requests, sign the [contributor license agreement](/docs/contributing/cla.html). diff --git a/docs/sources/reference/annotations.md b/docs/sources/reference/annotations.md index 41d7bf411a3..51c9ac1ba32 100644 --- a/docs/sources/reference/annotations.md +++ b/docs/sources/reference/annotations.md @@ -18,9 +18,9 @@ dropdown. This will open the `Annotations` edit view. Click the `Add` tab to add Graphite supports two ways to query annotations. - A regular metric query, use the `Graphite target expression` text input for this -- Graphite events query, use the `Graphite event tags` text input, especify an tag or wildcard (leave empty should also work) +- Graphite events query, use the `Graphite event tags` text input, specify an tag or wildcard (leave empty should also work) -## Elasticsearch annoations +## Elasticsearch annotations ![](/img/v2/annotations_es.png) Grafana can query any Elasticsearch index for annotation events. The index name can be the name of an alias or an index wildcard pattern. diff --git a/docs/sources/reference/graph.md b/docs/sources/reference/graph.md index 61bebd6b37b..9de23332a99 100644 --- a/docs/sources/reference/graph.md +++ b/docs/sources/reference/graph.md @@ -62,7 +62,7 @@ The ``Left Y`` and ``Right Y`` can be customized using: - ``Unit`` - The display unit for the Y value - ``Grid Max`` - The maximum Y value. (default auto) -- ``Grid Min`` - The minium Y value. (default auto) +- ``Grid Min`` - The minimum Y value. (default auto) - ``Label`` - The Y axis label (default "") Axes can also be hidden by unchecking the appropriate box from `Show Axis`. diff --git a/docs/sources/reference/http_api.md b/docs/sources/reference/http_api.md index c0b63050199..9e24ccb39d7 100644 --- a/docs/sources/reference/http_api.md +++ b/docs/sources/reference/http_api.md @@ -84,8 +84,8 @@ Status Codes: - **401** – Unauthorized - **412** – Precondition failed -The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the verison that was sent). The -same status code is also used if another dashboar exists with the same title. The response body will look like this: +The **412** status code is used when a newer dashboard already exists (newer, its version is greater than the version that was sent). The +same status code is also used if another dashboard exists with the same title. The response body will look like this: HTTP/1.1 412 Precondition Failed Content-Type: application/json; charset=UTF-8 @@ -141,12 +141,236 @@ Will return the dashboard given the dashboard slug. Slug is the url friendly ver The above will delete the dashboard with the specified slug. The slug is the url friendly (unique) version of the dashboard title. +### Gets the home dashboard + +`GET /api/dashboards/home` + +### Tags for Dashboard + +`GET /api/dashboards/tags` + +### Dashboard from JSON file + +`GET /file/:file` + +### Search Dashboards + +`GET /api/search/` + +Status Codes: + +- **query** – Search Query +- **tags** – Tags to use +- **starred** – Flag indicating if only starred Dashboards should be returned +- **tagcloud** - Flag indicating if a tagcloud should be returned + +**Example Request**: + + GET /api/search?query=MyDashboard&starred=true&tag=prod HTTP/1.1 + Accept: application/json + Content-Type: application/json + Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + ## Data sources +### Get all datasources + +`GET /api/datasources` + +### Get a single data sources by Id + +`GET /api/datasources/:datasourceId` + ### Create data source -## Organizations +`PUT /api/datasources` + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + {"message":"Datasource added"} + +### Edit an existing data source + +`POST /api/datasources` + +### Delete an existing data source + +`DELETE /api/datasources/:datasourceId` + +**Example Response**: + + HTTP/1.1 200 + Content-Type: application/json + + {"message":"Data source deleted"} + +### Available data source types + +`GET /api/datasources/plugins` + +## Data source proxy calls + +`GET /api/datasources/proxy/:datasourceId/*` + +Proxies all calls to the actual datasource. + +## Organisation + +### Get current Organisation + +`GET /api/org` + +### Get all users within the actual organisation + +`GET /api/org/users` + +### Add a new user to the actual organisation + +`POST /api/org/users` + +Adds a global user to the actual organisation. + +### Updates the given user + +`PATCH /api/org/users/:userId` + +### Delete user in actual organisation + +`DELETE /api/org/users/:userId` + +### Get all Users + +`GET /api/org/users` + +## Organisations + +### Search all Organisations + +`GET /api/orgs` + +### Update Organisation + +`PUT /api/orgs/:orgId` + +### Get Users in Organisation + +`GET /api/orgs/:orgId/users` + +### Add User in Organisation + +`POST /api/orgs/:orgId/users` + +### Update Users in Organisation + +`PATCH /api/orgs/:orgId/users/:userId` + +### Delete User in Organisation + +`DELETE /api/orgs/:orgId/users/:userId` ## Users +### Search Users +`GET /api/users` + +### Get single user by Id + +`GET /api/users/:id` + +### User Update + +`PUT /api/users/:id` + +### Get Organisations for user + +`GET /api/users/:id/orgs` + +## User + +### Change Password + +`PUT /api/user/password` + +Changes the password for the user + +### Actual User + +`GET /api/user` + +The above will return the current user. + +### Switch user context + +`POST /api/user/using/:organisationId` + +Switch user context to the given organisation. + +### Organisations of the actual User + +`GET /api/user/orgs` + +The above will return a list of all organisations of the current user. + +### Star a dashboard + +`POST /api/user/stars/dashboard/:dashboardId` + +Stars the given Dashboard for the actual user. + +### Unstar a dashboard + +`DELETE /api/user/stars/dashboard/:dashboardId` + +Deletes the staring of the given Dashboard for the actual user. + +## Snapshots + +### Create new snapshot + +`POST /api/snapshots` + +### Get Snapshot by Id + +`GET /api/snapshots/:key` + +### Delete Snapshot by Id + +`DELETE /api/snapshots-delete/:key` + +## Frontend Settings + +### Get Settings + +`GET /api/frontend/settings` + +## Login + +### Renew session based on remember cookie + +`GET /api/login/ping` + +## Admin + +### Settings + +`GET /api/admin/settings` + +### Global Users + +`POST /api/admin/users` + +### Password for User + +`PUT /api/admin/users/:id/password` + +### Permissions + +`PUT /api/admin/users/:id/permissions` + +### Delete global User + +`DELETE /api/admin/users/:id` diff --git a/docs/sources/reference/scripting.md b/docs/sources/reference/scripting.md index 45158eeadcd..d896a2c7650 100644 --- a/docs/sources/reference/scripting.md +++ b/docs/sources/reference/scripting.md @@ -12,7 +12,7 @@ With scripted dashboards you can dynamically create your dashboards using javasc under `public/dashboards/` there is a file named `scripted.js`. This file contains an example of a scripted dashboard. You can access it by using the url: `http://grafana_url/dashboard/script/scripted.js?rows=3&name=myName` -If you open scripted.js you can see how it reads url paramters from ARGS variable and then adds rows and panels. +If you open scripted.js you can see how it reads url parameters from ARGS variable and then adds rows and panels. ## Example diff --git a/docs/sources/reference/timerange.md b/docs/sources/reference/timerange.md index 47c9186e119..d2f05f5a9d6 100644 --- a/docs/sources/reference/timerange.md +++ b/docs/sources/reference/timerange.md @@ -24,7 +24,7 @@ All of this applies to all Panels in the Dashboard (except those with Panel Time It's possible to customize the options displayed for relative time and the auto-refresh options. -From Dashboard setttings, click the Timepicker tab. From here you can specify the relative and auto refresh intervals. The Timepicker tab settings are saved on a per Dashboard basis. Entries are comma seperated and accept a number followed by one of the following units: s (seconds), m (minutes), h (hours), d (days), w (weeks), M (months), y (years). +From Dashboard settings, click the Timepicker tab. From here you can specify the relative and auto refresh intervals. The Timepicker tab settings are saved on a per Dashboard basis. Entries are comma separated and accept a number followed by one of the following units: s (seconds), m (minutes), h (hours), d (days), w (weeks), M (months), y (years). ![](/img/v1/timepicker_editor.png) diff --git a/latest.json b/latest.json index a6613c11634..a85d79df539 100644 --- a/latest.json +++ b/latest.json @@ -1,3 +1,3 @@ { - "version": "2.0.1", + "version": "2.0.2" } diff --git a/pkg/api/admin_settings.go b/pkg/api/admin_settings.go index 21615219acd..06413d6a0b1 100644 --- a/pkg/api/admin_settings.go +++ b/pkg/api/admin_settings.go @@ -17,7 +17,7 @@ func AdminGetSettings(c *middleware.Context) { for _, key := range section.Keys() { keyName := key.Name() value := key.Value() - if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") { + if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") || (strings.Contains(keyName, "provider_config") && strings.Contains(value, "@")) { value = "************" } diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index f7e8fca2b5e..a293032c200 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -9,36 +9,6 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func AdminSearchUsers(c *middleware.Context) { - query := m.SearchUsersQuery{Query: "", Page: 0, Limit: 1000} - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to fetch users", err) - return - } - - c.JSON(200, query.Result) -} - -func AdminGetUser(c *middleware.Context) { - userId := c.ParamsInt64(":id") - - query := m.GetUserByIdQuery{Id: userId} - - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to fetch user", err) - return - } - - result := dtos.AdminUserListItem{ - Name: query.Result.Name, - Email: query.Result.Email, - Login: query.Result.Login, - IsGrafanaAdmin: query.Result.IsAdmin, - } - - c.JSON(200, result) -} - func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) { cmd := m.CreateUserCommand{ Login: form.Login, @@ -70,32 +40,6 @@ func AdminCreateUser(c *middleware.Context, form dtos.AdminCreateUserForm) { c.JsonOK("User created") } -func AdminUpdateUser(c *middleware.Context, form dtos.AdminUpdateUserForm) { - userId := c.ParamsInt64(":id") - - cmd := m.UpdateUserCommand{ - UserId: userId, - Login: form.Login, - Email: form.Email, - Name: form.Name, - } - - if len(cmd.Login) == 0 { - cmd.Login = cmd.Email - if len(cmd.Login) == 0 { - c.JsonApiErr(400, "Validation error, need specify either username or email", nil) - return - } - } - - if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "failed to update user", err) - return - } - - c.JsonOK("User updated") -} - func AdminUpdateUserPassword(c *middleware.Context, form dtos.AdminUpdateUserPasswordForm) { userId := c.ParamsInt64(":id") diff --git a/pkg/api/api.go b/pkg/api/api.go index 14e693752b9..6ecaa51652e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,7 +13,7 @@ func Register(r *macaron.Macaron) { reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true}) reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) - reqAccountAdmin := middleware.RoleAuth(m.ROLE_ADMIN) + regOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN) bind := binding.Bind // not logged in views @@ -53,48 +53,71 @@ func Register(r *macaron.Macaron) { // authed api r.Group("/api", func() { - // user + + // user (signed in) r.Group("/user", func() { - r.Get("/", GetUser) - r.Put("/", bind(m.UpdateUserCommand{}), UpdateUser) - r.Post("/using/:id", UserSetUsingOrg) - r.Get("/orgs", GetUserOrgList) - r.Post("/stars/dashboard/:id", StarDashboard) - r.Delete("/stars/dashboard/:id", UnstarDashboard) - r.Put("/password", bind(m.ChangeUserPasswordCommand{}), ChangeUserPassword) + r.Get("/", wrap(GetSignedInUser)) + r.Put("/", bind(m.UpdateUserCommand{}), wrap(UpdateSignedInUser)) + r.Post("/using/:id", wrap(UserSetUsingOrg)) + r.Get("/orgs", wrap(GetSignedInUserOrgList)) + r.Post("/stars/dashboard/:id", wrap(StarDashboard)) + r.Delete("/stars/dashboard/:id", wrap(UnstarDashboard)) + r.Put("/password", bind(m.ChangeUserPasswordCommand{}), wrap(ChangeUserPassword)) }) - // account + // users (admin permission required) + r.Group("/users", func() { + r.Get("/", wrap(SearchUsers)) + r.Get("/:id", wrap(GetUserById)) + r.Get("/:id/orgs", wrap(GetUserOrgList)) + r.Put("/:id", bind(m.UpdateUserCommand{}), wrap(UpdateUser)) + }, reqGrafanaAdmin) + + // current org r.Group("/org", func() { - r.Get("/", GetOrg) - r.Post("/", bind(m.CreateOrgCommand{}), CreateOrg) - r.Put("/", bind(m.UpdateOrgCommand{}), UpdateOrg) - r.Post("/users", bind(m.AddOrgUserCommand{}), AddOrgUser) - r.Get("/users", GetOrgUsers) - r.Patch("/users/:id", bind(m.UpdateOrgUserCommand{}), UpdateOrgUser) - r.Delete("/users/:id", RemoveOrgUser) - }, reqAccountAdmin) + r.Get("/", wrap(GetOrgCurrent)) + r.Put("/", bind(m.UpdateOrgCommand{}), wrap(UpdateOrgCurrent)) + r.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUserToCurrentOrg)) + r.Get("/users", wrap(GetOrgUsersForCurrentOrg)) + r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg)) + r.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg)) + }, regOrgAdmin) + + // create new org + r.Post("/orgs", bind(m.CreateOrgCommand{}), wrap(CreateOrg)) + + // search all orgs + r.Get("/orgs", reqGrafanaAdmin, wrap(SearchOrgs)) + + // orgs (admin routes) + r.Group("/orgs/:orgId", func() { + r.Put("/", bind(m.UpdateOrgCommand{}), wrap(UpdateOrg)) + r.Get("/users", wrap(GetOrgUsers)) + r.Post("/users", bind(m.AddOrgUserCommand{}), wrap(AddOrgUser)) + r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUser)) + r.Delete("/users/:userId", wrap(RemoveOrgUser)) + }, reqGrafanaAdmin) // auth api keys r.Group("/auth/keys", func() { - r.Get("/", GetApiKeys) - r.Post("/", bind(m.AddApiKeyCommand{}), AddApiKey) - r.Delete("/:id", DeleteApiKey) - }, reqAccountAdmin) + r.Get("/", wrap(GetApiKeys)) + r.Post("/", bind(m.AddApiKeyCommand{}), wrap(AddApiKey)) + r.Delete("/:id", wrap(DeleteApiKey)) + }, regOrgAdmin) // Data sources r.Group("/datasources", func() { - r.Combo("/"). - Get(GetDataSources). - Put(bind(m.AddDataSourceCommand{}), AddDataSource). - Post(bind(m.UpdateDataSourceCommand{}), UpdateDataSource) + r.Get("/", GetDataSources) + r.Post("/", bind(m.AddDataSourceCommand{}), AddDataSource) + r.Put("/:id", bind(m.UpdateDataSourceCommand{}), UpdateDataSource) r.Delete("/:id", DeleteDataSource) r.Get("/:id", GetDataSourceById) r.Get("/plugins", GetDataSourcePlugins) - }, reqAccountAdmin) + }, regOrgAdmin) r.Get("/frontend/settings/", GetFrontendSettings) r.Any("/datasources/proxy/:id/*", reqSignedIn, ProxyDataSourceRequest) + r.Any("/datasources/proxy/:id", reqSignedIn, ProxyDataSourceRequest) // Dashboard r.Group("/dashboards", func() { @@ -115,10 +138,7 @@ func Register(r *macaron.Macaron) { // admin api r.Group("/api/admin", func() { r.Get("/settings", AdminGetSettings) - r.Get("/users", AdminSearchUsers) - r.Get("/users/:id", AdminGetUser) r.Post("/users", bind(dtos.AdminCreateUserForm{}), AdminCreateUser) - r.Put("/users/:id/details", bind(dtos.AdminUpdateUserForm{}), AdminUpdateUser) r.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword) r.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions) r.Delete("/users/:id", AdminDeleteUser) @@ -127,5 +147,5 @@ func Register(r *macaron.Macaron) { // rendering r.Get("/render/*", reqSignedIn, RenderToPng) - r.NotFound(NotFound) + r.NotFound(NotFoundHandler) } diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 237fdc48ab0..b2097104aba 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -8,12 +8,11 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func GetApiKeys(c *middleware.Context) { +func GetApiKeys(c *middleware.Context) Response { query := m.GetApiKeysQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to list api keys", err) - return + return ApiError(500, "Failed to list api keys", err) } result := make([]*m.ApiKeyDTO, len(query.Result)) @@ -24,27 +23,26 @@ func GetApiKeys(c *middleware.Context) { Role: t.Role, } } - c.JSON(200, result) + + return Json(200, result) } -func DeleteApiKey(c *middleware.Context) { +func DeleteApiKey(c *middleware.Context) Response { id := c.ParamsInt64(":id") cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId} err := bus.Dispatch(cmd) if err != nil { - c.JsonApiErr(500, "Failed to delete API key", err) - return + return ApiError(500, "Failed to delete API key", err) } - c.JsonOK("API key deleted") + return ApiSuccess("API key deleted") } -func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) { +func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) Response { if !cmd.Role.IsValid() { - c.JsonApiErr(400, "Invalid role specified", nil) - return + return ApiError(400, "Invalid role specified", nil) } cmd.OrgId = c.OrgId @@ -53,14 +51,12 @@ func AddApiKey(c *middleware.Context, cmd m.AddApiKeyCommand) { cmd.Key = newKeyInfo.HashedKey if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to add API key", err) - return + return ApiError(500, "Failed to add API key", err) } result := &dtos.NewApiKeyResult{ Name: cmd.Result.Name, - Key: newKeyInfo.ClientSecret, - } + Key: newKeyInfo.ClientSecret} - c.JSON(200, result) + return Json(200, result) } diff --git a/pkg/api/common.go b/pkg/api/common.go new file mode 100644 index 00000000000..4d8b3c28032 --- /dev/null +++ b/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), + } +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index b439bd67ac7..b010f32cdcb 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -55,6 +55,7 @@ func GetDashboard(c *middleware.Context) { Type: m.DashTypeDB, CanStar: c.IsSignedIn, CanSave: c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR, + CanEdit: c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_READ_ONLY_EDITOR, }, } diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go index 81318cdc536..11075294b66 100644 --- a/pkg/api/dataproxy.go +++ b/pkg/api/dataproxy.go @@ -1,9 +1,12 @@ package api import ( + "crypto/tls" + "net" "net/http" "net/http/httputil" "net/url" + "time" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" @@ -11,6 +14,16 @@ import ( "github.com/grafana/grafana/pkg/util" ) +var dataProxyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, +} + func NewReverseProxy(ds *m.DataSource, proxyPath string) *httputil.ReverseProxy { target, _ := url.Parse(ds.Url) @@ -56,5 +69,6 @@ func ProxyDataSourceRequest(c *middleware.Context) { proxyPath := c.Params("*") proxy := NewReverseProxy(&query.Result, proxyPath) + proxy.Transport = dataProxyTransport proxy.ServeHTTP(c.RW(), c.Req.Request) } diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 76a9bddd253..e0253df3cdb 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/util" ) func GetDataSources(c *middleware.Context) { @@ -94,11 +95,12 @@ func AddDataSource(c *middleware.Context, cmd m.AddDataSourceCommand) { return } - c.JsonOK("Datasource added") + c.JSON(200, util.DynMap{"message": "Datasource added", "id": cmd.Result.Id}) } func UpdateDataSource(c *middleware.Context, cmd m.UpdateDataSourceCommand) { cmd.OrgId = c.OrgId + cmd.Id = c.ParamsInt64(":id") err := bus.Dispatch(&cmd) if err != nil { diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index d5e294cc983..3e1826f56fb 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -34,6 +34,7 @@ type DashboardMeta struct { IsSnapshot bool `json:"isSnapshot,omitempty"` Type string `json:"type,omitempty"` CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` CanStar bool `json:"canStar"` Slug string `json:"slug"` Expires time.Time `json:"expires"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 3af191a7af7..4dd6ba06819 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -54,6 +54,10 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro defaultDatasource = ds.Name } + if len(ds.JsonData) > 0 { + dsMap["jsonData"] = ds.JsonData + } + if ds.Access == m.DS_ACCESS_DIRECT { if ds.BasicAuth { dsMap["basicAuth"] = util.GetBasicAuthHeader(ds.BasicAuthUser, ds.BasicAuthPassword) @@ -95,6 +99,7 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro "defaultDatasource": defaultDatasource, "datasources": datasources, "appSubUrl": setting.AppSubUrl, + "viewerRoleMode": setting.ViewerRoleMode, "buildInfo": map[string]interface{}{ "version": setting.BuildVersion, "commit": setting.BuildCommit, diff --git a/pkg/api/index.go b/pkg/api/index.go index 386bb3351df..8f486c4b785 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -59,7 +59,7 @@ func Index(c *middleware.Context) { c.HTML(200, "index") } -func NotFound(c *middleware.Context) { +func NotFoundHandler(c *middleware.Context) { if c.IsApiRequest() { c.JsonApiErr(404, "Not found", nil) return diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 505c17ddde8..796599df864 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -48,6 +48,8 @@ func OAuthLogin(ctx *middleware.Context) { if err != nil { if err == social.ErrMissingTeamMembership { ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github team membership not fulfilled")) + } else if err == social.ErrMissingOrganizationMembership { + ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required Github organization membership not fulfilled")) } else { ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err) } diff --git a/pkg/api/org.go b/pkg/api/org.go index ac8727c9e49..75499f33f61 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -8,17 +8,25 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func GetOrg(c *middleware.Context) { - query := m.GetOrgByIdQuery{Id: c.OrgId} +// GET /api/org +func GetOrgCurrent(c *middleware.Context) Response { + return getOrgHelper(c.OrgId) +} + +// GET /api/orgs/:orgId +func GetOrgById(c *middleware.Context) Response { + return getOrgHelper(c.ParamsInt64(":orgId")) +} + +func getOrgHelper(orgId int64) Response { + query := m.GetOrgByIdQuery{Id: orgId} if err := bus.Dispatch(&query); err != nil { if err == m.ErrOrgNotFound { - c.JsonApiErr(404, "Organization not found", err) - return + return ApiError(404, "Organization not found", err) } - c.JsonApiErr(500, "Failed to get organization", err) - return + return ApiError(500, "Failed to get organization", err) } org := m.OrgDTO{ @@ -26,33 +34,56 @@ func GetOrg(c *middleware.Context) { Name: query.Result.Name, } - c.JSON(200, &org) + return Json(200, &org) } -func CreateOrg(c *middleware.Context, cmd m.CreateOrgCommand) { +// POST /api/orgs +func CreateOrg(c *middleware.Context, cmd m.CreateOrgCommand) Response { if !setting.AllowUserOrgCreate && !c.IsGrafanaAdmin { - c.JsonApiErr(401, "Access denied", nil) - return + return ApiError(401, "Access denied", nil) } cmd.UserId = c.UserId if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to create organization", err) - return + return ApiError(500, "Failed to create organization", err) } metrics.M_Api_Org_Create.Inc(1) - c.JsonOK("Organization created") + return ApiSuccess("Organization created") } -func UpdateOrg(c *middleware.Context, cmd m.UpdateOrgCommand) { +// PUT /api/org +func UpdateOrgCurrent(c *middleware.Context, cmd m.UpdateOrgCommand) Response { cmd.OrgId = c.OrgId + return updateOrgHelper(cmd) +} +// PUT /api/orgs/:orgId +func UpdateOrg(c *middleware.Context, cmd m.UpdateOrgCommand) Response { + cmd.OrgId = c.ParamsInt64(":orgId") + return updateOrgHelper(cmd) +} + +func updateOrgHelper(cmd m.UpdateOrgCommand) Response { if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to update organization", err) - return + return ApiError(500, "Failed to update organization", err) } - c.JsonOK("Organization updated") + 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) + } + + return Json(200, query.Result) } diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 8a372c791b9..03570619e6b 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -6,77 +6,115 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func AddOrgUser(c *middleware.Context, cmd m.AddOrgUserCommand) { +// POST /api/org/users +func AddOrgUserToCurrentOrg(c *middleware.Context, cmd m.AddOrgUserCommand) Response { + cmd.OrgId = c.OrgId + return addOrgUserHelper(cmd) +} + +// POST /api/orgs/:orgId/users +func AddOrgUser(c *middleware.Context, cmd m.AddOrgUserCommand) Response { + cmd.OrgId = c.ParamsInt64(":orgId") + return addOrgUserHelper(cmd) +} + +func addOrgUserHelper(cmd m.AddOrgUserCommand) Response { if !cmd.Role.IsValid() { - c.JsonApiErr(400, "Invalid role specified", nil) - return + return ApiError(400, "Invalid role specified", nil) } userQuery := m.GetUserByLoginQuery{LoginOrEmail: cmd.LoginOrEmail} err := bus.Dispatch(&userQuery) if err != nil { - c.JsonApiErr(404, "User not found", nil) - return + return ApiError(404, "User not found", nil) } userToAdd := userQuery.Result - if userToAdd.Id == c.UserId { - c.JsonApiErr(400, "Cannot add yourself as user", nil) - return - } + // if userToAdd.Id == c.UserId { + // return ApiError(400, "Cannot add yourself as user", nil) + // } - cmd.OrgId = c.OrgId cmd.UserId = userToAdd.Id if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Could not add user to organization", err) - return + return ApiError(500, "Could not add user to organization", err) } - c.JsonOK("User added to organization") + return ApiSuccess("User added to organization") } -func GetOrgUsers(c *middleware.Context) { - query := m.GetOrgUsersQuery{OrgId: c.OrgId} +// GET /api/org/users +func GetOrgUsersForCurrentOrg(c *middleware.Context) Response { + return getOrgUsersHelper(c.OrgId) +} + +// GET /api/orgs/:orgId/users +func GetOrgUsers(c *middleware.Context) Response { + return getOrgUsersHelper(c.ParamsInt64(":orgId")) +} + +func getOrgUsersHelper(orgId int64) Response { + query := m.GetOrgUsersQuery{OrgId: orgId} if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to get account user", err) - return + return ApiError(500, "Failed to get account user", err) } - c.JSON(200, query.Result) + return Json(200, query.Result) } -func UpdateOrgUser(c *middleware.Context, cmd m.UpdateOrgUserCommand) { - if !cmd.Role.IsValid() { - c.JsonApiErr(400, "Invalid role specified", nil) - return - } - - cmd.UserId = c.ParamsInt64(":id") +// PATCH /api/org/users/:userId +func UpdateOrgUserForCurrentOrg(c *middleware.Context, cmd m.UpdateOrgUserCommand) Response { cmd.OrgId = c.OrgId - - if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed update org user", err) - return - } - - c.JsonOK("Organization user updated") + cmd.UserId = c.ParamsInt64(":userId") + return updateOrgUserHelper(cmd) } -func RemoveOrgUser(c *middleware.Context) { - userId := c.ParamsInt64(":id") +// 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) +} - cmd := m.RemoveOrgUserCommand{OrgId: c.OrgId, UserId: userId} +func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response { + if !cmd.Role.IsValid() { + return ApiError(400, "Invalid role specified", nil) + } if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrLastOrgAdmin { - c.JsonApiErr(400, "Cannot remove last organization admin", nil) - return + return ApiError(400, "Cannot change role so that there is no organization admin left", nil) } - c.JsonApiErr(500, "Failed to remove user from organization", err) + return ApiError(500, "Failed update org user", err) } - c.JsonOK("User removed from organization") + return ApiSuccess("Organization user updated") +} + +// 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) +} + +func removeOrgUserHelper(orgId int64, userId int64) Response { + cmd := m.RemoveOrgUserCommand{OrgId: orgId, UserId: userId} + + if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrLastOrgAdmin { + return ApiError(400, "Cannot remove last organization admin", nil) + } + return ApiError(500, "Failed to remove user from organization", err) + } + + return ApiSuccess("User removed from organization") } diff --git a/pkg/api/search.go b/pkg/api/search.go index 10329f445bd..e451ac398d6 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -8,17 +8,17 @@ import ( func Search(c *middleware.Context) { query := c.Query("query") - tag := c.Query("tag") + tags := c.QueryStrings("tag") starred := c.Query("starred") limit := c.QueryInt("limit") if limit == 0 { - limit = 200 + limit = 1000 } searchQuery := search.Query{ Title: query, - Tag: tag, + Tags: tags, UserId: c.UserId, Limit: limit, IsStarred: starred == "true", diff --git a/pkg/api/stars.go b/pkg/api/stars.go index 42a096ff4fb..c6f9d037eba 100644 --- a/pkg/api/stars.go +++ b/pkg/api/stars.go @@ -6,45 +6,35 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func StarDashboard(c *middleware.Context) { +func StarDashboard(c *middleware.Context) Response { if !c.IsSignedIn { - c.JsonApiErr(412, "You need to sign in to star dashboards", nil) - return + return ApiError(412, "You need to sign in to star dashboards", nil) } - var cmd = m.StarDashboardCommand{ - UserId: c.UserId, - DashboardId: c.ParamsInt64(":id"), - } + cmd := m.StarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")} if cmd.DashboardId <= 0 { - c.JsonApiErr(400, "Missing dashboard id", nil) - return + return ApiError(400, "Missing dashboard id", nil) } if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to star dashboard", err) - return + return ApiError(500, "Failed to star dashboard", err) } - c.JsonOK("Dashboard starred!") + return ApiSuccess("Dashboard starred!") } -func UnstarDashboard(c *middleware.Context) { - var cmd = m.UnstarDashboardCommand{ - UserId: c.UserId, - DashboardId: c.ParamsInt64(":id"), - } +func UnstarDashboard(c *middleware.Context) Response { + + cmd := m.UnstarDashboardCommand{UserId: c.UserId, DashboardId: c.ParamsInt64(":id")} if cmd.DashboardId <= 0 { - c.JsonApiErr(400, "Missing dashboard id", nil) - return + return ApiError(400, "Missing dashboard id", nil) } if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to unstar dashboard", err) - return + return ApiError(500, "Failed to unstar dashboard", err) } - c.JsonOK("Dashboard unstarred") + return ApiSuccess("Dashboard unstarred") } diff --git a/pkg/api/user.go b/pkg/api/user.go index 9d870a10a1b..5af243eeb22 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -7,44 +7,71 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func GetUser(c *middleware.Context) { - query := m.GetUserProfileQuery{UserId: c.UserId} - - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to get user", err) - return - } - - c.JSON(200, query.Result) +// GET /api/user (current authenticated user) +func GetSignedInUser(c *middleware.Context) Response { + return getUserUserProfile(c.UserId) } -func UpdateUser(c *middleware.Context, cmd m.UpdateUserCommand) { +// 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 { + return ApiError(500, "Failed to get user", err) + } + + return Json(200, query.Result) +} + +// POST /api/user +func UpdateSignedInUser(c *middleware.Context, cmd m.UpdateUserCommand) Response { cmd.UserId = c.UserId - - if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(400, "Failed to update user", err) - return - } - - c.JsonOK("User updated") + return handleUpdateUser(cmd) } -func GetUserOrgList(c *middleware.Context) { - query := m.GetUserOrgListQuery{UserId: c.UserId} +// POST /api/users/:id +func UpdateUser(c *middleware.Context, cmd m.UpdateUserCommand) Response { + cmd.UserId = c.ParamsInt64(":id") + return handleUpdateUser(cmd) +} - if err := bus.Dispatch(&query); err != nil { - c.JsonApiErr(500, "Failed to get user organizations", err) - return - } - - for _, ac := range query.Result { - if ac.OrgId == c.OrgId { - ac.IsUsing = true - break +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) } } - c.JSON(200, query.Result) + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "failed to update user", err) + } + + return ApiSuccess("User updated") +} + +// GET /api/user/orgs +func GetSignedInUserOrgList(c *middleware.Context) Response { + return getUserOrgList(c.UserId) +} + +// GET /api/user/:id/orgs +func GetUserOrgList(c *middleware.Context) Response { + return getUserOrgList(c.ParamsInt64(":id")) +} + +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) + } + + return Json(200, query.Result) } func validateUsingOrg(userId int64, orgId int64) bool { @@ -65,53 +92,55 @@ func validateUsingOrg(userId int64, orgId int64) bool { return valid } -func UserSetUsingOrg(c *middleware.Context) { +// POST /api/user/using/:id +func UserSetUsingOrg(c *middleware.Context) Response { orgId := c.ParamsInt64(":id") if !validateUsingOrg(c.UserId, orgId) { - c.JsonApiErr(401, "Not a valid organization", nil) - return + return ApiError(401, "Not a valid organization", nil) } - cmd := m.SetUsingOrgCommand{ - UserId: c.UserId, - OrgId: orgId, - } + cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgId} if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed change active organization", err) - return + return ApiError(500, "Failed change active organization", err) } - c.JsonOK("Active organization changed") + return ApiSuccess("Active organization changed") } -func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) { +func ChangeUserPassword(c *middleware.Context, cmd m.ChangeUserPasswordCommand) Response { userQuery := m.GetUserByIdQuery{Id: c.UserId} if err := bus.Dispatch(&userQuery); err != nil { - c.JsonApiErr(500, "Could not read user from database", err) - return + return ApiError(500, "Could not read user from database", err) } passwordHashed := util.EncodePassword(cmd.OldPassword, userQuery.Result.Salt) if passwordHashed != userQuery.Result.Password { - c.JsonApiErr(401, "Invalid old password", nil) - return + return ApiError(401, "Invalid old password", nil) } if len(cmd.NewPassword) < 4 { - c.JsonApiErr(400, "New password too short", nil) - return + return ApiError(400, "New password too short", nil) } cmd.UserId = c.UserId cmd.NewPassword = util.EncodePassword(cmd.NewPassword, userQuery.Result.Salt) if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to change user password", err) - return + return ApiError(500, "Failed to change user password", err) } - c.JsonOK("User password changed") + 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) + } + + return Json(200, query.Result) } diff --git a/pkg/components/renderer/renderer.go b/pkg/components/renderer/renderer.go index aa9e0c92525..9d5ddd00d73 100644 --- a/pkg/components/renderer/renderer.go +++ b/pkg/components/renderer/renderer.go @@ -26,7 +26,7 @@ func RenderToPng(params *RenderOpts) (string, error) { pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20))) pngPath = pngPath + ".png" - cmd := exec.Command(binPath, "--ignore-ssl-errors=true", scriptPath, "url="+params.Url, "width="+params.Width, + cmd := exec.Command(binPath, "--ignore-ssl-errors=true", "--ssl-protocol=any", scriptPath, "url="+params.Url, "width="+params.Width, "height="+params.Height, "png="+pngPath, "cookiename="+setting.SessionOptions.CookieName, "domain="+setting.Domain, "sessionid="+params.SessionId) stdout, err := cmd.StdoutPipe() diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 2ba236cd56b..c756faaba59 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -69,7 +69,6 @@ type AddDataSourceCommand struct { // Also acts as api DTO type UpdateDataSourceCommand struct { - Id int64 `json:"id" binding:"Required"` Name string `json:"name" binding:"Required"` Type string `json:"type" binding:"Required"` Access DsAccess `json:"access" binding:"Required"` @@ -84,6 +83,7 @@ type UpdateDataSourceCommand struct { JsonData map[string]interface{} `json:"jsonData"` OrgId int64 `json:"-"` + Id int64 `json:"-"` } type DeleteDataSourceCommand struct { diff --git a/pkg/models/org.go b/pkg/models/org.go index ab6d97b9ae8..b2d18be9537 100644 --- a/pkg/models/org.go +++ b/pkg/models/org.go @@ -48,8 +48,13 @@ type GetOrgByNameQuery struct { Result *Org } -type GetOrgListQuery struct { - Result []*Org +type SearchOrgsQuery struct { + Query string + Name string + Limit int + Page int + + Result []*OrgDTO } type OrgDTO struct { @@ -58,8 +63,7 @@ type OrgDTO struct { } type UserOrgDTO struct { - OrgId int64 `json:"orgId"` - Name string `json:"name"` - Role RoleType `json:"role"` - IsUsing bool `json:"isUsing"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + Role RoleType `json:"role"` } diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index 3e40fd24b68..afbb10386c8 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -15,13 +15,14 @@ var ( type RoleType string const ( - ROLE_VIEWER RoleType = "Viewer" - ROLE_EDITOR RoleType = "Editor" - ROLE_ADMIN RoleType = "Admin" + ROLE_VIEWER RoleType = "Viewer" + ROLE_EDITOR RoleType = "Editor" + ROLE_READ_ONLY_EDITOR RoleType = "Read Only Editor" + ROLE_ADMIN RoleType = "Admin" ) func (r RoleType) IsValid() bool { - return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR + return r == ROLE_VIEWER || r == ROLE_ADMIN || r == ROLE_EDITOR || r == ROLE_READ_ONLY_EDITOR } type OrgUser struct { diff --git a/pkg/models/user.go b/pkg/models/user.go index 68e0001b99c..5efecc8deef 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -133,6 +133,7 @@ type UserProfileDTO struct { Name string `json:"name"` Login string `json:"login"` Theme string `json:"theme"` + OrgId int64 `json:"orgId"` IsGrafanaAdmin bool `json:"isGrafanaAdmin"` } diff --git a/pkg/search/handlers.go b/pkg/search/handlers.go index 874b85994ca..326924f05f4 100644 --- a/pkg/search/handlers.go +++ b/pkg/search/handlers.go @@ -33,9 +33,7 @@ func searchHandler(query *Query) error { dashQuery := FindPersistedDashboardsQuery{ Title: query.Title, - Tag: query.Tag, UserId: query.UserId, - Limit: query.Limit, IsStarred: query.IsStarred, OrgId: query.OrgId, } @@ -55,8 +53,30 @@ func searchHandler(query *Query) error { hits = append(hits, jsonHits...) } + // filter out results with tag filter + if len(query.Tags) > 0 { + filtered := HitList{} + for _, hit := range hits { + if hasRequiredTags(query.Tags, hit.Tags) { + filtered = append(filtered, hit) + } + } + hits = filtered + } + + // sort main result array sort.Sort(hits) + if len(hits) > query.Limit { + hits = hits[0:query.Limit] + } + + // sort tags + for _, hit := range hits { + sort.Strings(hit.Tags) + } + + // add isStarred info if err := setIsStarredFlagOnSearchResults(query.UserId, hits); err != nil { return err } @@ -65,6 +85,25 @@ func searchHandler(query *Query) error { return nil } +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +func hasRequiredTags(queryTags, hitTags []string) bool { + for _, queryTag := range queryTags { + if !stringInSlice(queryTag, hitTags) { + return false + } + } + + return true +} + func setIsStarredFlagOnSearchResults(userId int64, hits []*Hit) error { query := m.GetUserStarsQuery{UserId: userId} if err := bus.Dispatch(&query); err != nil { diff --git a/pkg/search/handlers_test.go b/pkg/search/handlers_test.go new file mode 100644 index 00000000000..dc9835caa44 --- /dev/null +++ b/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") + }) + + }) + }) +} diff --git a/pkg/search/json_index.go b/pkg/search/json_index.go index 0b4c335e703..a0fc02343e2 100644 --- a/pkg/search/json_index.go +++ b/pkg/search/json_index.go @@ -47,18 +47,15 @@ func (index *JsonDashIndex) updateLoop() { func (index *JsonDashIndex) Search(query *Query) ([]*Hit, error) { results := make([]*Hit, 0) + if query.IsStarred { + return results, nil + } + for _, item := range index.items { if len(results) > query.Limit { break } - // filter out results with tag filter - if query.Tag != "" { - if !strings.Contains(item.TagsCsv, query.Tag) { - continue - } - } - // add results with matchig title filter if strings.Contains(item.TitleLower, query.Title) { results = append(results, &Hit{ diff --git a/pkg/search/json_index_test.go b/pkg/search/json_index_test.go index 38fbf7207d9..afd584fffbd 100644 --- a/pkg/search/json_index_test.go +++ b/pkg/search/json_index_test.go @@ -17,19 +17,26 @@ func TestJsonDashIndex(t *testing.T) { }) Convey("Should be able to search index", func() { - res, err := index.Search(&Query{Title: "", Tag: "", Limit: 20}) + res, err := index.Search(&Query{Title: "", Limit: 20}) So(err, ShouldBeNil) So(len(res), ShouldEqual, 3) }) Convey("Should be able to search index by title", func() { - res, err := index.Search(&Query{Title: "home", Tag: "", Limit: 20}) + res, err := index.Search(&Query{Title: "home", Limit: 20}) So(err, ShouldBeNil) So(len(res), ShouldEqual, 1) So(res[0].Title, ShouldEqual, "Home") }) + Convey("Should not return when starred is filtered", func() { + res, err := index.Search(&Query{Title: "", IsStarred: true}) + So(err, ShouldBeNil) + + So(len(res), ShouldEqual, 0) + }) + }) } diff --git a/pkg/search/models.go b/pkg/search/models.go index 157d9a292e3..9b8c7627f89 100644 --- a/pkg/search/models.go +++ b/pkg/search/models.go @@ -26,7 +26,7 @@ func (s HitList) Less(i, j int) bool { return s[i].Title < s[j].Title } type Query struct { Title string - Tag string + Tags []string OrgId int64 UserId int64 Limit int @@ -37,10 +37,8 @@ type Query struct { type FindPersistedDashboardsQuery struct { Title string - Tag string OrgId int64 UserId int64 - Limit int IsStarred bool Result HitList diff --git a/pkg/services/eventpublisher/eventpublisher.go b/pkg/services/eventpublisher/eventpublisher.go index 14e527b2cc7..2854b63a9a5 100644 --- a/pkg/services/eventpublisher/eventpublisher.go +++ b/pkg/services/eventpublisher/eventpublisher.go @@ -109,25 +109,26 @@ func Setup() error { } func publish(routingKey string, msgString []byte) { - err := channel.Publish( - exchange, //exchange - routingKey, // routing key - false, // mandatory - false, // immediate - amqp.Publishing{ - ContentType: "application/json", - Body: msgString, - }, - ) - if err != nil { + for { + err := channel.Publish( + exchange, //exchange + routingKey, // routing key + false, // mandatory + false, // immediate + amqp.Publishing{ + ContentType: "application/json", + Body: msgString, + }, + ) + if err == nil { + return + } // failures are most likely because the connection was lost. // the connection will be re-established, so just keep // retrying every 2seconds until we successfully publish. time.Sleep(2 * time.Second) fmt.Println("publish failed, retrying.") - publish(routingKey, msgString) } - return } func eventListener(event interface{}) error { diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 027f2cd2fac..01eacb8436a 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -150,16 +150,7 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error { params = append(params, "%"+query.Title+"%") } - if len(query.Tag) > 0 { - sql.WriteString(" AND dashboard_tag.term=?") - params = append(params, query.Tag) - } - - if query.Limit == 0 || query.Limit > 10000 { - query.Limit = 300 - } - - sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT %d", query.Limit)) + sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000")) var res []DashboardSearchProjection err := x.Sql(sql.String(), params...).Find(&res) diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/sqlstore/dashboard_test.go index 70675f9c42b..0ddd0db0df6 100644 --- a/pkg/services/sqlstore/dashboard_test.go +++ b/pkg/services/sqlstore/dashboard_test.go @@ -99,18 +99,6 @@ func TestDashboardDataAccess(t *testing.T) { So(len(hit.Tags), ShouldEqual, 2) }) - Convey("Should be able to search for dashboards using tags", func() { - query1 := search.FindPersistedDashboardsQuery{Tag: "webapp", OrgId: 1} - query2 := search.FindPersistedDashboardsQuery{Tag: "tagdoesnotexist", OrgId: 1} - - err := SearchDashboards(&query1) - err = SearchDashboards(&query2) - So(err, ShouldBeNil) - - So(len(query1.Result), ShouldEqual, 1) - So(len(query2.Result), ShouldEqual, 0) - }) - Convey("Should not be able to save dashboard with same name", func() { cmd := m.SaveDashboardCommand{ OrgId: 1, diff --git a/pkg/services/sqlstore/org.go b/pkg/services/sqlstore/org.go index 18284feccac..725a21d7fad 100644 --- a/pkg/services/sqlstore/org.go +++ b/pkg/services/sqlstore/org.go @@ -14,12 +14,23 @@ func init() { bus.AddHandler("sql", CreateOrg) bus.AddHandler("sql", UpdateOrg) bus.AddHandler("sql", GetOrgByName) - bus.AddHandler("sql", GetOrgList) + bus.AddHandler("sql", SearchOrgs) bus.AddHandler("sql", DeleteOrg) } -func GetOrgList(query *m.GetOrgListQuery) error { - return x.Find(&query.Result) +func SearchOrgs(query *m.SearchOrgsQuery) error { + query.Result = make([]*m.OrgDTO, 0) + sess := x.Table("org") + if query.Query != "" { + sess.Where("name LIKE ?", query.Query+"%") + } + if query.Name != "" { + sess.Where("name=?", query.Name) + } + sess.Limit(query.Limit, query.Limit*query.Page) + sess.Cols("id", "name") + err := sess.Find(&query.Result) + return err } func GetOrgById(query *m.GetOrgByIdQuery) error { diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 1b230e349ff..f52175c2e5c 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -142,11 +142,18 @@ func TestAccountDataAccess(t *testing.T) { }) }) - Convey("Cannot delete last admin account user", func() { + Convey("Cannot delete last admin org user", func() { cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id} err := RemoveOrgUser(&cmd) So(err, ShouldEqual, m.ErrLastOrgAdmin) }) + + Convey("Cannot update role so no one is admin user", func() { + cmd := m.UpdateOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id, Role: m.ROLE_VIEWER} + err := UpdateOrgUser(&cmd) + So(err, ShouldEqual, m.ErrLastOrgAdmin) + }) + }) }) }) diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 3502cd97d60..2e8fc40cb7c 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -48,7 +48,11 @@ func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error { orgUser.Role = cmd.Role orgUser.Updated = time.Now() _, err = sess.Id(orgUser.Id).Update(&orgUser) - return err + if err != nil { + return err + } + + return validateOneAdminLeftInOrg(cmd.OrgId, sess) }) } @@ -72,16 +76,20 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error { return err } - // validate that there is an admin user left - res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and role='Admin'", cmd.OrgId) - if err != nil { - return err - } - - if len(res) == 0 { - return m.ErrLastOrgAdmin - } - - return err + return validateOneAdminLeftInOrg(cmd.OrgId, sess) }) } + +func validateOneAdminLeftInOrg(orgId int64, sess *xorm.Session) error { + // validate that there is an admin user left + res, err := sess.Query("SELECT 1 from org_user WHERE org_id=? and role='Admin'", orgId) + if err != nil { + return err + } + + if len(res) == 0 { + return m.ErrLastOrgAdmin + } + + return err +} diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 663496427ba..f5df6f9ff1f 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -231,10 +231,12 @@ func GetUserProfile(query *m.GetUserProfileQuery) error { } query.Result = m.UserProfileDTO{ - Name: user.Name, - Email: user.Email, - Login: user.Login, - Theme: user.Theme, + Name: user.Name, + Email: user.Email, + Login: user.Login, + Theme: user.Theme, + IsGrafanaAdmin: user.IsAdmin, + OrgId: user.OrgId, } return err @@ -282,6 +284,11 @@ func GetSignedInUser(query *m.GetSignedInUserQuery) error { return m.ErrUserNotFound } + if user.OrgRole == "" { + user.OrgId = -1 + user.OrgName = "Org missing" + } + query.Result = &user return err } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 77445dd6c4c..6768f9aabd9 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -79,6 +79,7 @@ var ( AllowUserOrgCreate bool AutoAssignOrg bool AutoAssignOrgRole string + ViewerRoleMode string // Http auth AdminUser string @@ -383,6 +384,7 @@ func NewConfigContext(args *CommandLineArgs) { AllowUserOrgCreate = users.Key("allow_org_create").MustBool(true) AutoAssignOrg = users.Key("auto_assign_org").MustBool(true) AutoAssignOrgRole = users.Key("auto_assign_org_role").In("Editor", []string{"Editor", "Admin", "Viewer"}) + ViewerRoleMode = users.Key("viewer_role_mode").In("default", []string{"default", "strinct"}) // anonymous access AnonymousEnabled = Cfg.Section("auth.anonymous").Key("enabled").MustBool(false) diff --git a/pkg/social/social.go b/pkg/social/social.go index 355f85b54b6..1a00934b937 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -78,12 +78,14 @@ func NewOAuthService() { if name == "github" { setting.OAuthService.GitHub = true teamIds := sec.Key("team_ids").Ints(",") + allowedOrganizations := sec.Key("allowed_organizations").Strings(" ") SocialMap["github"] = &SocialGithub{ - Config: &config, - allowedDomains: info.AllowedDomains, - apiUrl: info.ApiUrl, - allowSignup: info.AllowSignup, - teamIds: teamIds, + Config: &config, + allowedDomains: info.AllowedDomains, + apiUrl: info.ApiUrl, + allowSignup: info.AllowSignup, + teamIds: teamIds, + allowedOrganizations: allowedOrganizations, } } @@ -115,16 +117,21 @@ func isEmailAllowed(email string, allowedDomains []string) bool { type SocialGithub struct { *oauth2.Config - allowedDomains []string - apiUrl string - allowSignup bool - teamIds []int + allowedDomains []string + allowedOrganizations []string + apiUrl string + allowSignup bool + teamIds []int } var ( ErrMissingTeamMembership = errors.New("User not a member of one of the required teams") ) +var ( + ErrMissingOrganizationMembership = errors.New("User not a member of one of the required organizations") +) + func (s *SocialGithub) Type() int { return int(models.GITHUB) } @@ -137,26 +144,131 @@ func (s *SocialGithub) IsSignupAllowed() bool { return s.allowSignup } -func (s *SocialGithub) IsTeamMember(client *http.Client, username string, teamId int) bool { - var data struct { - Url string `json:"url"` - State string `json:"state"` +func (s *SocialGithub) IsTeamMember(client *http.Client) bool { + if len(s.teamIds) == 0 { + return true } - membershipUrl := fmt.Sprintf("https://api.github.com/teams/%d/memberships/%s", teamId, username) - r, err := client.Get(membershipUrl) + teamMemberships, err := s.FetchTeamMemberships(client) if err != nil { return false } - defer r.Body.Close() + for _, teamId := range s.teamIds { + for _, membershipId := range teamMemberships { + if teamId == membershipId { + return true + } + } + } - if err = json.NewDecoder(r.Body).Decode(&data); err != nil { + return false +} + +func (s *SocialGithub) IsOrganizationMember(client *http.Client) bool { + if len(s.allowedOrganizations) == 0 { + return true + } + + organizations, err := s.FetchOrganizations(client) + if err != nil { return false } - active := data.State == "active" - return active + for _, allowedOrganization := range s.allowedOrganizations { + for _, organization := range organizations { + if organization == allowedOrganization { + return true + } + } + } + + return false +} + +func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) { + type Record struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + + emailsUrl := fmt.Sprintf("https://api.github.com/user/emails") + r, err := client.Get(emailsUrl) + if err != nil { + return "", err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return "", err + } + + var email = "" + for _, record := range records { + if record.Primary { + email = record.Email + } + } + + return email, nil +} + +func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error) { + type Record struct { + Id int `json:"id"` + } + + membershipUrl := fmt.Sprintf("https://api.github.com/user/teams") + r, err := client.Get(membershipUrl) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var ids = make([]int, len(records)) + for i, record := range records { + ids[i] = record.Id + } + + return ids, nil +} + +func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error) { + type Record struct { + Login string `json:"login"` + } + + url := fmt.Sprintf("https://api.github.com/user/orgs") + r, err := client.Get(url) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var logins = make([]string, len(records)) + for i, record := range records { + logins[i] = record.Login + } + + return logins, nil } func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { @@ -185,17 +297,22 @@ func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { Email: data.Email, } - if len(s.teamIds) > 0 { - for _, teamId := range s.teamIds { - if s.IsTeamMember(client, data.Name, teamId) { - return userInfo, nil - } - } - + if !s.IsTeamMember(client) { return nil, ErrMissingTeamMembership - } else { - return userInfo, nil } + + if !s.IsOrganizationMember(client) { + return nil, ErrMissingOrganizationMembership + } + + if userInfo.Email == "" { + userInfo.Email, err = s.FetchPrivateEmail(client) + if err != nil { + return nil, err + } + } + + return userInfo, nil } // ________ .__ diff --git a/public/app/components/extend-jquery.js b/public/app/components/extend-jquery.js index ce5c8afb1a9..3e1f6b0c054 100644 --- a/public/app/components/extend-jquery.js +++ b/public/app/components/extend-jquery.js @@ -1,5 +1,5 @@ -define(['jquery'], -function ($) { +define(['jquery', 'angular', 'lodash'], +function ($, angular, _) { 'use strict'; /** @@ -14,6 +14,7 @@ function ($) { return function (x, y, opts) { opts = $.extend(true, {}, defaults, opts); + return this.each(function () { var $tooltip = $(this), width, height; @@ -22,6 +23,17 @@ function ($) { $("#tooltip").remove(); $tooltip.appendTo(document.body); + if (opts.compile) { + angular.element(document).injector().invoke(function($compile, $rootScope) { + var tmpScope = $rootScope.$new(true); + _.extend(tmpScope, opts.scopeData); + + $compile($tooltip)(tmpScope); + tmpScope.$digest(); + //tmpScope.$destroy(); + }); + } + width = $tooltip.outerWidth(true); height = $tooltip.outerHeight(true); diff --git a/public/app/components/kbn.js b/public/app/components/kbn.js index ef1e45c23d3..c9d2900f53b 100644 --- a/public/app/components/kbn.js +++ b/public/app/components/kbn.js @@ -383,8 +383,9 @@ function($, _, moment) { kbn.valueFormats.mbytes = kbn.formatFuncCreator(1024, [' MiB', ' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']); kbn.valueFormats.gbytes = kbn.formatFuncCreator(1024, [' GiB', ' TiB', ' PiB', ' EiB', ' ZiB', ' YiB']); kbn.valueFormats.bps = kbn.formatFuncCreator(1000, [' bps', ' Kbps', ' Mbps', ' Gbps', ' Tbps', ' Pbps', ' Ebps', ' Zbps', ' Ybps']); + kbn.valueFormats.pps = kbn.formatFuncCreator(1000, [' pps', ' Kpps', ' Mpps', ' Gpps', ' Tpps', ' Ppps', ' Epps', ' Zpps', ' Ypps']); kbn.valueFormats.Bps = kbn.formatFuncCreator(1000, [' Bps', ' KBps', ' MBps', ' GBps', ' TBps', ' PBps', ' EBps', ' ZBps', ' YBps']); - kbn.valueFormats.short = kbn.formatFuncCreator(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Qaudr', ' Quint', ' Sext', ' Sept']); + kbn.valueFormats.short = kbn.formatFuncCreator(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']); kbn.valueFormats.joule = kbn.formatFuncCreator(1000, [' J', ' kJ', ' MJ', ' GJ', ' TJ', ' PJ', ' EJ', ' ZJ', ' YJ']); kbn.valueFormats.amp = kbn.formatFuncCreator(1000, [' A', ' kA', ' MA', ' GA', ' TA', ' PA', ' EA', ' ZA', ' YA']); kbn.valueFormats.volt = kbn.formatFuncCreator(1000, [' V', ' kV', ' MV', ' GV', ' TV', ' PV', ' EV', ' ZV', ' YV']); @@ -564,6 +565,7 @@ function($, _, moment) { { text: 'data rate', submenu: [ + {text: 'packets/sec', value: 'pps'}, {text: 'bits/sec', value: 'bps'}, {text: 'bytes/sec', value: 'Bps'}, ] diff --git a/public/app/components/panelmeta.js b/public/app/components/panelmeta.js index 4ee4a9b9b55..013919c174a 100644 --- a/public/app/components/panelmeta.js +++ b/public/app/components/panelmeta.js @@ -16,8 +16,8 @@ function () { this.addMenuItem('view', 'icon-eye-open', 'toggleFullscreen(false); dismiss();'); } - this.addMenuItem('edit', 'icon-cog', 'editPanel(); dismiss();'); - this.addMenuItem('duplicate', 'icon-copy', 'duplicatePanel()'); + this.addMenuItem('edit', 'icon-cog', 'editPanel(); dismiss();', 'Editor'); + this.addMenuItem('duplicate', 'icon-copy', 'duplicatePanel()', 'Editor'); this.addMenuItem('share', 'icon-share', 'sharePanel(); dismiss();'); this.addEditorTab('General', 'app/partials/panelgeneral.html'); @@ -29,12 +29,12 @@ function () { this.addExtendedMenuItem('Panel JSON', '', 'editPanelJson(); dismiss();'); } - PanelMeta.prototype.addMenuItem = function(text, icon, click) { - this.menu.push({text: text, icon: icon, click: click}); + PanelMeta.prototype.addMenuItem = function(text, icon, click, role) { + this.menu.push({text: text, icon: icon, click: click, role: role}); }; - PanelMeta.prototype.addExtendedMenuItem = function(text, icon, click) { - this.extendedMenu.push({text: text, icon: icon, click: click}); + PanelMeta.prototype.addExtendedMenuItem = function(text, icon, click, role) { + this.extendedMenu.push({text: text, icon: icon, click: click, role: role}); }; PanelMeta.prototype.addEditorTab = function(title, src) { diff --git a/public/app/controllers/search.js b/public/app/controllers/search.js index 9083ef74705..a762af0f887 100644 --- a/public/app/controllers/search.js +++ b/public/app/controllers/search.js @@ -13,8 +13,8 @@ function (angular, _, config) { $scope.init = function() { $scope.giveSearchFocus = 0; $scope.selectedIndex = -1; - $scope.results = {dashboards: [], tags: [], metrics: []}; - $scope.query = { query: '', tag: '', starred: false }; + $scope.results = []; + $scope.query = { query: '', tag: [], starred: false }; $scope.currentSearchId = 0; if ($scope.dashboardViewState.fullscreen) { @@ -26,7 +26,6 @@ function (angular, _, config) { $scope.query.query = ''; $scope.search(); }, 100); - }; $scope.keyDown = function (evt) { @@ -83,12 +82,11 @@ function (angular, _, config) { $scope.queryHasNoFilters = function() { var query = $scope.query; - return query.query === '' && query.starred === false && query.tag === ''; + return query.query === '' && query.starred === false && query.tag.length === 0; }; $scope.filterByTag = function(tag, evt) { - $scope.query.tag = tag; - $scope.query.tagcloud = false; + $scope.query.tag.push(tag); $scope.search(); $scope.giveSearchFocus = $scope.giveSearchFocus + 1; if (evt) { @@ -97,6 +95,14 @@ function (angular, _, config) { } }; + $scope.removeTag = function(tag, evt) { + $scope.query.tag = _.without($scope.query.tag, tag); + $scope.search(); + $scope.giveSearchFocus = $scope.giveSearchFocus + 1; + evt.stopPropagation(); + evt.preventDefault(); + }; + $scope.getTags = function() { return backendSrv.get('/api/dashboards/tags').then(function(results) { $scope.tagsMode = true; @@ -123,32 +129,4 @@ function (angular, _, config) { }); - module.directive('tagColorFromName', function() { - - function djb2(str) { - var hash = 5381; - for (var i = 0; i < str.length; i++) { - hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */ - } - return hash; - } - - return { - scope: { tag: "=" }, - link: function (scope, element) { - var name = scope.tag; - var hash = djb2(name.toLowerCase()); - var colors = [ - "#E24D42","#1F78C1","#BA43A9","#705DA0","#466803", - "#508642","#447EBC","#C15C17","#890F02","#757575", - "#0A437C","#6D1F62","#584477","#629E51","#2F4F4F", - "#BF1B00","#806EB7","#8a2eb8", "#699e00","#000000", - "#3F6833","#2F575E","#99440A","#E0752D","#0E4AB4", - "#58140C","#052B51","#511749","#3F2B5B", - ]; - var color = colors[Math.abs(hash % colors.length)]; - element.css("background-color", color); - } - }; - }); }); diff --git a/public/app/directives/all.js b/public/app/directives/all.js index 4ab92d111d6..b92bc59ca41 100644 --- a/public/app/directives/all.js +++ b/public/app/directives/all.js @@ -5,16 +5,17 @@ define([ './ngBlur', './dashEditLink', './ngModelOnBlur', - './tip', + './misc', './confirmClick', './configModal', './spectrumPicker', - './bootstrap-tagsinput', + './tags', './bodyClass', './variableValueSelect', - './graphiteSegment', + './metric.segment', './grafanaVersionCheck', './dropdown.typeahead', './topnav', './giveFocus', + './annotationTooltip', ], function () {}); diff --git a/public/app/directives/annotationTooltip.js b/public/app/directives/annotationTooltip.js new file mode 100644 index 00000000000..25059d08274 --- /dev/null +++ b/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 = '' + dashboard.formatDate(event.min) + ''; + + var tooltip = '
' + title + ' ' + time + '
' ; + + if (event.text) { + var text = $sanitize(event.text); + tooltip += text.replace(/\n/g, '
') + '
'; + } + + 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 += '{{tag}}
'; + } + + tooltip += "
"; + + var $tooltip = $(tooltip); + $tooltip.appendTo(element); + + $compile(element.contents())(scope); + } + }; + }); + +}); diff --git a/public/app/directives/bootstrap-tagsinput.js b/public/app/directives/bootstrap-tagsinput.js deleted file mode 100644 index a8b7eb6a7ad..00000000000 --- a/public/app/directives/bootstrap-tagsinput.js +++ /dev/null @@ -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: '', - 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 = [ - '' - ]; - - angular.forEach(items, function (item, index) { - if (item.divider) { - return ul.splice(index + 1, 0, '
  • '); - } - - var li = '' + - '' + (item.text || '') + ''; - - if (item.submenu && item.submenu.length) { - li += buildTemplate(item.submenu).join('\n'); - } - - 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'); - } - }; - }); -}); diff --git a/public/app/directives/graphiteSegment.js b/public/app/directives/metric.segment.js similarity index 87% rename from public/app/directives/graphiteSegment.js rename to public/app/directives/metric.segment.js index c8ad131e6c7..4f5677ca3ed 100644 --- a/public/app/directives/graphiteSegment.js +++ b/public/app/directives/metric.segment.js @@ -9,14 +9,21 @@ function (angular, app, _, $) { angular .module('grafana.directives') - .directive('graphiteSegment', function($compile, $sce) { + .directive('metricSegment', function($compile, $sce) { var inputTemplate = ''; - var buttonTemplate = ''; + var buttonTemplate = ''; return { + scope: { + segment: "=", + getAltSegments: "&", + onValueChanged: "&" + }, + link: function($scope, elem) { var $input = $(inputTemplate); var $button = $(buttonTemplate); @@ -46,7 +53,7 @@ function (angular, app, _, $) { segment.expandable = true; segment.fake = false; } - $scope.segmentValueChanged(segment, $scope.$index); + $scope.onValueChanged(); }); }; @@ -61,7 +68,7 @@ function (angular, app, _, $) { else { // need to have long delay because the blur // happens long before the click event on the typeahead options - cancelBlur = setTimeout($scope.switchToLink, 350); + cancelBlur = setTimeout($scope.switchToLink, 50); } }; @@ -69,7 +76,8 @@ function (angular, app, _, $) { if (options) { return options; } $scope.$apply(function() { - $scope.getAltSegments($scope.$index).then(function() { + $scope.getAltSegments().then(function(altSegments) { + $scope.altSegments = altSegments; options = _.map($scope.altSegments, function(alt) { return alt.value; }); // add custom values diff --git a/public/app/directives/tip.js b/public/app/directives/misc.js similarity index 61% rename from public/app/directives/tip.js rename to public/app/directives/misc.js index 8553faad279..11c1334d553 100644 --- a/public/app/directives/tip.js +++ b/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 = [ + '' + ]; + + angular.forEach(items, function (item, index) { + if (item.divider) { + return ul.splice(index + 1, 0, '
  • '); + } + + var li = '' + + '' + (item.text || '') + ''; + + if (item.submenu && item.submenu.length) { + li += buildTemplate(item.submenu).join('\n'); + } + + 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'); + } + }; + }); + }); diff --git a/public/app/directives/tags.js b/public/app/directives/tags.js new file mode 100644 index 00000000000..f408a5e3864 --- /dev/null +++ b/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: '', + 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); + } + }; + }); + +}); diff --git a/public/app/directives/variableValueSelect.js b/public/app/directives/variableValueSelect.js index fa0e56acebc..ae08ad55ee4 100644 --- a/public/app/directives/variableValueSelect.js +++ b/public/app/directives/variableValueSelect.js @@ -7,151 +7,270 @@ define([ function (angular, app, _) { 'use strict'; + angular + .module('grafana.controllers') + .controller('SelectDropdownCtrl', function($q) { + var vm = this; + + vm.show = function() { + vm.oldVariableText = vm.variable.current.text; + vm.highlightIndex = -1; + + var currentValues = vm.variable.current.value; + if (_.isString(currentValues)) { + currentValues = [currentValues]; + } + + vm.options = _.map(vm.variable.options, function(option) { + if (_.indexOf(currentValues, option.value) >= 0) { option.selected = true; } + return option; + }); + + _.sortBy(vm.options, 'text'); + + vm.selectedValues = _.filter(vm.options, {selected: true}); + + vm.tags = _.map(vm.variable.tags, function(value) { + return { text: value, selected: false }; + }); + + vm.search = {query: '', options: vm.options}; + vm.dropdownVisible = true; + }; + + vm.updateLinkText = function() { + var current = vm.variable.current; + var currentValues = current.value; + + 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; + } + } + 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; + } + }; + + vm.clearSelections = function() { + _.each(vm.options, function(option) { + option.selected = false; + }); + + vm.selectionsChanged(false); + }; + + 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); + } + + 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); + }); + }; + + 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); + } + }; + + 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; } + }); + }; + + // 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; + } + }); + } + }); + + 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 (vm.selectedValues.length === 1) { + vm.variable.current.value = vm.selectedValues[0].value; + } + + if (commitChange) { + vm.commitChanges(); + } + }; + + vm.commitChanges = function() { + // make sure one option is selected + if (vm.selectedValues.length === 0) { + vm.options[0].selected = true; + vm.selectionsChanged(false); + } + + vm.dropdownVisible = false; + vm.updateLinkText(); + + if (vm.variable.current.text !== vm.oldVariableText) { + vm.onUpdated(); + } + }; + + vm.queryChanged = function() { + vm.highlightIndex = -1; + vm.search.options = _.filter(vm.options, function(option) { + return option.text.toLowerCase().indexOf(vm.search.query.toLowerCase()) !== -1; + }); + }; + + 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: "&" - }, + 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 variable = scope.variable; + var linkEl = elem.find('.variable-value-link'); + var inputEl = elem.find('input'); - scope.show = function() { - if (scope.selectorOpen) { - return; - } + function openDropdown() { + inputEl.css('width', Math.max(linkEl.width(), 30) + 'px'); - scope.selectorOpen = true; - scope.giveFocus = 1; - scope.oldCurrentText = variable.current.text; - scope.highlightIndex = -1; + inputEl.show(); + linkEl.hide(); - var currentValues = variable.current.value; + inputEl.focus(); + $timeout(function() { bodyEl.on('click', bodyOnClick); }, 0, false); + } - if (_.isString(currentValues)) { - currentValues = [currentValues]; - } + function switchToLink() { + inputEl.hide(); + linkEl.show(); + bodyEl.off('click', bodyOnClick); + } - scope.options = _.map(variable.options, function(option) { - if (_.indexOf(currentValues, option.value) >= 0) { - option.selected = true; - } - return option; - }); - - scope.search = {query: '', options: scope.options}; - - $timeout(function() { - bodyEl.on('click', scope.bodyOnClick); - }, 0, false); - }; - - scope.queryChanged = function() { - scope.highlightIndex = -1; - scope.search.options = _.filter(scope.options, function(option) { - return option.text.toLowerCase().indexOf(scope.search.query.toLowerCase()) !== -1; - }); - }; - - 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 (evt.keyCode === 13) { - scope.optionSelected(scope.search.options[scope.highlightIndex], {}); - } - }; - - scope.moveHighlight = function(direction) { - scope.highlightIndex = (scope.highlightIndex + direction) % scope.search.options.length; - }; - - scope.optionSelected = function(option, event) { - option.selected = !option.selected; - - var hideAfter = true; - var setAllExceptCurrentTo = function(newValue) { - _.each(scope.options, function(other) { - if (option !== other) { other.selected = newValue; } + function bodyOnClick (e) { + if (elem.has(e.target).length === 0) { + scope.$apply(function() { + scope.vm.commitChanges(); }); - }; - - if (option.text === 'All') { - setAllExceptCurrentTo(false); } - else if (!variable.multi) { - setAllExceptCurrentTo(false); + } + + scope.$watch('vm.dropdownVisible', function(newValue) { + if (newValue) { + openDropdown(); } else { - if (event.ctrlKey || event.metaKey || event.shiftKey) { - hideAfter = false; - } - else { - setAllExceptCurrentTo(false); - } + switchToLink(); } - - var selected = _.filter(scope.options, {selected: true}); - - if (selected.length === 0) { - option.selected = true; - selected = [option]; - } - - if (selected.length > 1 && selected.length !== scope.options.length) { - if (selected[0].text === 'All') { - selected[0].selected = false; - selected = selected.slice(1, selected.length); - } - } - - variable.current = { - text: _.pluck(selected, 'text').join(', '), - value: _.pluck(selected, 'value'), - }; - - // only single value - if (variable.current.value.length === 1) { - variable.current.value = selected[0].value; - } - - scope.updateLinkText(); - scope.onUpdated(); - - if (hideAfter) { - scope.hide(); - } - }; - - scope.hide = function() { - scope.selectorOpen = false; - bodyEl.off('click', scope.bodyOnClick); - }; - - scope.bodyOnClick = function(e) { - var dropdown = elem.find('.variable-value-dropdown'); - if (dropdown.has(e.target).length === 0) { - scope.$apply(scope.hide); - } - }; - - scope.updateLinkText = function() { - scope.labelText = variable.label || '$' + variable.name; - scope.linkText = variable.current.text; - }; - - scope.$watchGroup(['variable.hideLabel', 'variable.name', 'variable.label', 'variable.current.text'], function() { - scope.updateLinkText(); }); + + scope.vm.init(); }, }; }); diff --git a/public/app/features/admin/adminEditUserCtrl.js b/public/app/features/admin/adminEditUserCtrl.js index 19deac532ea..d8500a845d3 100644 --- a/public/app/features/admin/adminEditUserCtrl.js +++ b/public/app/features/admin/adminEditUserCtrl.js @@ -1,23 +1,26 @@ define([ 'angular', + 'lodash', ], -function (angular) { +function (angular, _) { 'use strict'; var module = angular.module('grafana.controllers'); module.controller('AdminEditUserCtrl', function($scope, $routeParams, backendSrv, $location) { $scope.user = {}; + $scope.newOrg = { name: '', role: 'Editor' }; $scope.permissions = {}; $scope.init = function() { if ($routeParams.id) { $scope.getUser($routeParams.id); + $scope.getUserOrgs($routeParams.id); } }; $scope.getUser = function(id) { - backendSrv.get('/api/admin/users/' + id).then(function(user) { + backendSrv.get('/api/users/' + id).then(function(user) { $scope.user = user; $scope.user_id = id; $scope.permissions.isGrafanaAdmin = user.isGrafanaAdmin; @@ -49,14 +52,58 @@ function (angular) { }); }; + $scope.getUserOrgs = function(id) { + backendSrv.get('/api/users/' + id + '/orgs').then(function(orgs) { + $scope.orgs = orgs; + }); + }; + $scope.update = function() { if (!$scope.userForm.$valid) { return; } - backendSrv.put('/api/admin/users/' + $scope.user_id + '/details', $scope.user).then(function() { + backendSrv.put('/api/users/' + $scope.user_id, $scope.user).then(function() { $location.path('/admin/users'); }); }; + $scope.updateOrgUser= function(orgUser) { + backendSrv.patch('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id, orgUser).then(function() { + }); + }; + + $scope.removeOrgUser = function(orgUser) { + backendSrv.delete('/api/orgs/' + orgUser.orgId + '/users/' + $scope.user_id).then(function() { + $scope.getUserOrgs($scope.user_id); + }); + }; + + $scope.orgsSearchCache = []; + + $scope.searchOrgs = function(queryStr, callback) { + if ($scope.orgsSearchCache.length > 0) { + callback(_.pluck($scope.orgsSearchCache, "name")); + return; + } + + backendSrv.get('/api/orgs', {query: ''}).then(function(result) { + $scope.orgsSearchCache = result; + callback(_.pluck(result, "name")); + }); + }; + + $scope.addOrgUser = function() { + if (!$scope.addOrgForm.$valid) { return; } + + var orgInfo = _.findWhere($scope.orgsSearchCache, {name: $scope.newOrg.name}); + if (!orgInfo) { return; } + + $scope.newOrg.loginOrEmail = $scope.user.login; + + backendSrv.post('/api/orgs/' + orgInfo.id + '/users/', $scope.newOrg).then(function() { + $scope.getUserOrgs($scope.user_id); + }); + }; + $scope.init(); }); diff --git a/public/app/features/admin/adminUsersCtrl.js b/public/app/features/admin/adminUsersCtrl.js index c9be0238fd4..737812c5474 100644 --- a/public/app/features/admin/adminUsersCtrl.js +++ b/public/app/features/admin/adminUsersCtrl.js @@ -13,7 +13,7 @@ function (angular) { }; $scope.getUsers = function() { - backendSrv.get('/api/admin/users').then(function(users) { + backendSrv.get('/api/users').then(function(users) { $scope.users = users; }); }; diff --git a/public/app/features/admin/partials/edit_user.html b/public/app/features/admin/partials/edit_user.html index 9b2a18fd010..a1a4cb989cd 100644 --- a/public/app/features/admin/partials/edit_user.html +++ b/public/app/features/admin/partials/edit_user.html @@ -25,7 +25,7 @@
    -
    +
    • Email @@ -36,7 +36,7 @@
    -
    +
    • Username @@ -80,19 +80,73 @@ Permissions -
      -
        -
      • - Grafana Admin  - - -
      • -
      -
      +
      +
      +
        +
      • + Grafana Admin  + + +
      • +
      +
      +
      +
      + +
      -
      - + +

      + Organizations +

      + +
      +
      +
        +
      • + Add organization +
      • +
      • + +
      • +
      • + Role +
      • +
      • + +
      • +
      • + +
      • +
        +
      +
      +
      + + + + + + + + + + + + +
      NameRole
      + {{org.name}} Current + + + + + + +
    diff --git a/public/app/features/admin/partials/new_user.html b/public/app/features/admin/partials/new_user.html index 48f78fb76b6..73877f9bda4 100644 --- a/public/app/features/admin/partials/new_user.html +++ b/public/app/features/admin/partials/new_user.html @@ -24,7 +24,7 @@
    -
    +
    • Email @@ -35,7 +35,7 @@
    -
    +
    • Username @@ -46,7 +46,7 @@
    -
    +
    • Password diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html index 14bcd922b5e..6d4f7a8671c 100644 --- a/public/app/features/admin/partials/users.html +++ b/public/app/features/admin/partials/users.html @@ -1,4 +1,4 @@ - +
      @@ -163,7 +163,7 @@
    • - +
    • All format @@ -226,6 +226,42 @@
    +
    +
    +
    Value groups/tags (Experimental feature)
    +
    +
      +
    • + Tags query +
    • +
    • + +
    • +
    +
    +
    +
    +
      +
    • + Tag values query +
    • +
    • + +
    • +
    +
    +
    +
    +
      +
    • + +
    • +
    +
    +
    +
    +
    +
    Preview of values (shows max 20)
    diff --git a/public/app/features/templating/templateValuesSrv.js b/public/app/features/templating/templateValuesSrv.js index 6db5ce595a2..267a465cd89 100644 --- a/public/app/features/templating/templateValuesSrv.js +++ b/public/app/features/templating/templateValuesSrv.js @@ -78,7 +78,7 @@ function (angular, _, kbn) { }; this.setVariableValue = function(variable, option) { - variable.current = option; + variable.current = angular.copy(option); templateSrv.updateTemplateData(); return this.updateOptionsInChildVariables(variable); }; @@ -120,7 +120,7 @@ function (angular, _, kbn) { } return datasourceSrv.get(variable.datasource).then(function(datasource) { - return datasource.metricFindQuery(variable.query).then(function (results) { + var queryPromise = datasource.metricFindQuery(variable.query).then(function (results) { variable.options = self.metricNamesToVariableValues(variable, results); if (variable.includeAll) { @@ -130,6 +130,10 @@ function (angular, _, kbn) { // if parameter has current value // if it exists in options array keep value if (variable.current) { + // if current value is an array do not do anything + if (_.isArray(variable.current.value)) { + return $q.when([]); + } var currentOption = _.findWhere(variable.options, { text: variable.current.text }); if (currentOption) { return self.setVariableValue(variable, currentOption); @@ -138,6 +142,31 @@ function (angular, _, kbn) { return self.setVariableValue(variable, variable.options[0]); }); + + if (variable.useTags) { + return queryPromise.then(function() { + datasource.metricFindQuery(variable.tagsQuery).then(function (results) { + variable.tags = []; + for (var i = 0; i < results.length; i++) { + variable.tags.push(results[i].text); + } + }); + }); + } else { + delete variable.tags; + return queryPromise; + } + }); + }; + + this.getValuesForTag = function(variable, tagKey) { + return datasourceSrv.get(variable.datasource).then(function(datasource) { + var query = variable.tagValuesQuery.replace('$tag', tagKey); + return datasource.metricFindQuery(query).then(function (results) { + return _.map(results, function(value) { + return value.text; + }); + }); }); }; diff --git a/public/app/panels/dashlist/editor.html b/public/app/panels/dashlist/editor.html index 578d9e4b2d2..ff2e75fd95c 100644 --- a/public/app/panels/dashlist/editor.html +++ b/public/app/panels/dashlist/editor.html @@ -23,15 +23,15 @@ Query
  • -
  • - Tag + Tags
  • - + +
  • diff --git a/public/app/panels/dashlist/module.js b/public/app/panels/dashlist/module.js index da409fb3bde..9b4e1ebc045 100644 --- a/public/app/panels/dashlist/module.js +++ b/public/app/panels/dashlist/module.js @@ -32,7 +32,7 @@ function (angular, app, _, config, PanelMeta) { mode: 'starred', query: '', limit: 10, - tag: '', + tags: [] }; $scope.modes = ['starred', 'search']; @@ -43,6 +43,9 @@ function (angular, app, _, config, PanelMeta) { $scope.init = function() { panelSrv.init($scope); + if ($scope.panel.tag) { + $scope.panel.tags = [$scope.panel.tag]; + } if ($scope.isNewPanel()) { $scope.panel.title = "Starred Dashboards"; @@ -58,7 +61,7 @@ function (angular, app, _, config, PanelMeta) { params.starred = "true"; } else { params.query = $scope.panel.query; - params.tag = $scope.panel.tag; + params.tag = $scope.panel.tags; } return backendSrv.search(params).then(function(result) { diff --git a/public/app/panels/graph/graph.js b/public/app/panels/graph/graph.js index e090ce4e85e..c6ca8ec7227 100755 --- a/public/app/panels/graph/graph.js +++ b/public/app/panels/graph/graph.js @@ -480,6 +480,9 @@ function (angular, $, kbn, moment, _, GraphTooltip) { case 'bps': url += '&yUnitSystem=si'; break; + case 'pps': + url += '&yUnitSystem=si'; + break; case 'Bps': url += '&yUnitSystem=si'; break; diff --git a/public/app/panels/singlestat/module.js b/public/app/panels/singlestat/module.js index 3c698bfd553..24855634ba6 100644 --- a/public/app/panels/singlestat/module.js +++ b/public/app/panels/singlestat/module.js @@ -190,7 +190,12 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) { data.flotpairs = $scope.series[0].flotpairs; } - // first check value to text mappings + var decimalInfo = $scope.getDecimalsForValue(data.value); + var formatFunc = kbn.valueFormats[$scope.panel.format]; + data.valueFormated = formatFunc(data.value, decimalInfo.decimals, decimalInfo.scaledDecimals); + data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals); + + // check value to text mappings for(var i = 0; i < $scope.panel.valueMaps.length; i++) { var map = $scope.panel.valueMaps[i]; // special null case @@ -201,6 +206,7 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) { } continue; } + // value/number to text mapping var value = parseFloat(map.value); if (value === data.value) { @@ -212,11 +218,6 @@ function (angular, app, _, TimeSeries, kbn, PanelMeta) { if (data.value === null || data.value === void 0) { data.valueFormated = "no value"; } - - var decimalInfo = $scope.getDecimalsForValue(data.value); - var formatFunc = kbn.valueFormats[$scope.panel.format]; - data.valueFormated = formatFunc(data.value, decimalInfo.decimals, decimalInfo.scaledDecimals); - data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals); }; $scope.removeValueMap = function(map) { diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 8db858dac8d..437588dd652 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -19,7 +19,7 @@
      -
    • +
    • User
    • @@ -30,7 +30,7 @@
      -
    • +
    • Password
    • diff --git a/public/app/partials/search.html b/public/app/partials/search.html index 66e5fd3b7f4..d4fc65afa38 100644 --- a/public/app/partials/search.html +++ b/public/app/partials/search.html @@ -15,11 +15,14 @@ tags - - | - - {{query.tag}} - + + | + + + + {{tagName}} + +
    @@ -30,7 +33,7 @@
    - + {{tag.term}}  ({{tag.count}}) @@ -46,7 +49,7 @@ ng-class="{'selected': $index == selectedIndex}" ng-href="{{row.url}}"> - + {{tag}} diff --git a/public/app/partials/submenu.html b/public/app/partials/submenu.html index 0de2bb1adc4..5f5d29e87ba 100644 --- a/public/app/partials/submenu.html +++ b/public/app/partials/submenu.html @@ -3,7 +3,10 @@
    diff --git a/public/app/plugins/datasource/graphite/datasource.js b/public/app/plugins/datasource/graphite/datasource.js index dfd096cdb0a..a3255584879 100644 --- a/public/app/plugins/datasource/graphite/datasource.js +++ b/public/app/plugins/datasource/graphite/datasource.js @@ -111,6 +111,7 @@ function (angular, _, $, config, kbn, moment) { var list = []; for (var i = 0; i < results.data.length; i++) { var e = results.data[i]; + list.push({ annotation: annotation, time: e.when * 1000, @@ -195,6 +196,12 @@ function (angular, _, $, config, kbn, moment) { }); }; + GraphiteDatasource.prototype.testDatasource = function() { + return this.metricFindQuery('*').then(function () { + return { status: "success", message: "Data source is working", title: "Success" }; + }); + }; + GraphiteDatasource.prototype.listDashboards = function(query) { return this.doGraphiteRequest({ method: 'GET', url: '/dashboard/find/', params: {query: query || ''} }) .then(function(results) { diff --git a/public/app/plugins/datasource/graphite/partials/query.editor.html b/public/app/plugins/datasource/graphite/partials/query.editor.html index 48446049e65..dcbbdc69a8a 100755 --- a/public/app/plugins/datasource/graphite/partials/query.editor.html +++ b/public/app/plugins/datasource/graphite/partials/query.editor.html @@ -74,7 +74,9 @@ ng-show="showTextEditor" />