From 654f1f2eb66c134f07f68dd42529752fc2b148e6 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed Date: Wed, 26 Jun 2019 07:35:58 +0200 Subject: [PATCH 01/12] Docs: Update release guide (#17759) and add comment about Rollup namedExports --- packages/grafana-ui/README.md | 18 +++++++++++------- packages/grafana-ui/rollup.config.ts | 2 ++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/grafana-ui/README.md b/packages/grafana-ui/README.md index 935124e99ba04..f897127b6605a 100644 --- a/packages/grafana-ui/README.md +++ b/packages/grafana-ui/README.md @@ -17,31 +17,35 @@ See [package source](https://github.com/grafana/grafana/tree/master/packages/gra For development purposes we suggest using `yarn link` that will create symlink to @grafana/ui lib. To do so navigate to `packages/grafana-ui` and run `yarn link`. Then, navigate to your project and run `yarn link @grafana/ui` to use the linked version of the lib. To unlink follow the same procedure, but use `yarn unlink` instead. ## Building @grafana/ui -To build @grafana/ui run `npm run gui:build` script *from Grafana repository root*. The build will be created in `packages/grafana-ui/dist` directory. Following steps from [Development](#development) you can test built package. + +To build @grafana/ui run `npm run gui:build` script _from Grafana repository root_. The build will be created in `packages/grafana-ui/dist` directory. Following steps from [Development](#development) you can test built package. ## Releasing new version -To release new version run `npm run gui:release` script *from Grafana repository root*. The script will prepare the distribution package as well as prompt you to bump library version and publish it to the NPM registry. + +To release new version run `npm run gui:release` script _from Grafana repository root_. This has to be done on the master branch. The script will prepare the distribution package as well as prompt you to bump library version and publish it to the NPM registry. When the new package is published, create a PR with the bumped version in package.json. ### Automatic version bump + When running `npm run gui:release` package.json file will be automatically updated. Also, package.json file will be commited and pushed to upstream branch. ### Manual version bump -To use `package.json` defined version run `npm run gui:release --usePackageJsonVersion` *from Grafana repository root*. + +Manually update the version in `package.json` and then run `npm run gui:release --usePackageJsonVersion` _from Grafana repository root_. ### Preparing release package without publishing to NPM registry + For testing purposes there is `npm run gui:releasePrepare` task that prepares distribution package without publishing it to the NPM registry. ### V1 release process overview + 1. Package is compiled with TSC. Typings are created in `/dist` directory, and the compiled js lands in `/compiled` dir 2. Rollup creates a CommonJS package based on compiled sources, and outputs it to `/dist` directory 3. Readme, changelog and index.js files are moved to `/dist` directory 4. Package version is bumped in both `@grafana/ui` package dir and in dist directory. 5. Version commit is created and pushed to master branch -5. Package is published to npm - +6. Package is published to npm ## Versioning + To limit the confusion related to @grafana/ui and Grafana versioning we decided to keep the major version in sync between those two. This means, that first version of @grafana/ui is taged with 6.0.0-alpha.0 to keep version in sync with Grafana 6.0 release. - - diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 32a41d2409161..85564fa54e02d 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -27,6 +27,8 @@ const buildCjsPackage = ({ env }) => { plugins: [ commonjs({ include: /node_modules/, + // When 'rollup-plugin-commonjs' fails to properly convert the CommonJS modules to ES6 one has to manually name the exports + // https://github.com/rollup/rollup-plugin-commonjs#custom-named-exports namedExports: { '../../node_modules/lodash/lodash.js': [ 'flatten', From 19185bd0af186c9341acb19f45350acadb6abd00 Mon Sep 17 00:00:00 2001 From: fxmiii <51002364+fxmiii@users.noreply.github.com> Date: Wed, 26 Jun 2019 01:55:36 -0400 Subject: [PATCH 02/12] 17278 prometheus step align utc (#17477) * Update datasource.ts * Update datasource.test.ts * utcOffset reverse from moment docs, utcOffset "function returns the real offset from UTC, not the reverse offset" * add utcOffset() to DateTime interface method returns the UTC offset as a number of minutes * Fixed test --- .../grafana-ui/src/utils/moment_wrapper.ts | 1 + .../datasource/prometheus/datasource.ts | 14 ++++++++--- .../prometheus/specs/datasource.test.ts | 25 +++++++++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/grafana-ui/src/utils/moment_wrapper.ts b/packages/grafana-ui/src/utils/moment_wrapper.ts index 014a6f06e616e..b9e51d1826b9e 100644 --- a/packages/grafana-ui/src/utils/moment_wrapper.ts +++ b/packages/grafana-ui/src/utils/moment_wrapper.ts @@ -68,6 +68,7 @@ export interface DateTime extends Object { valueOf: () => number; unix: () => number; utc: () => DateTime; + utcOffset: () => number; hour?: () => number; } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 7f62b7e689703..de85be08ebb3f 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -367,7 +367,7 @@ export class PrometheusDatasource extends DataSourceApi // Align query interval with step to allow query caching and to ensure // that about-same-time query results look the same. - const adjusted = alignRange(start, end, query.step); + const adjusted = alignRange(start, end, query.step, this.timeSrv.timeRange().to.utcOffset() * 60); query.start = adjusted.start; query.end = adjusted.end; this._addTracingHeaders(query, options); @@ -694,10 +694,16 @@ export class PrometheusDatasource extends DataSourceApi * @param start Timestamp marking the beginning of the range. * @param end Timestamp marking the end of the range. * @param step Interval to align start and end with. + * @param utcOffsetSec Number of seconds current timezone is offset from UTC */ -export function alignRange(start: number, end: number, step: number): { end: number; start: number } { - const alignedEnd = Math.floor(end / step) * step; - const alignedStart = Math.floor(start / step) * step; +export function alignRange( + start: number, + end: number, + step: number, + utcOffsetSec: number +): { end: number; start: number } { + const alignedEnd = Math.floor((end + utcOffsetSec) / step) * step - utcOffsetSec; + const alignedStart = Math.floor((start + utcOffsetSec) / step) * step - utcOffsetSec; return { end: alignedEnd, start: alignedStart, diff --git a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts index a70cdf9080383..433b6520bdd1b 100644 --- a/public/app/plugins/datasource/prometheus/specs/datasource.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/datasource.test.ts @@ -219,25 +219,37 @@ describe('PrometheusDatasource', () => { describe('alignRange', () => { it('does not modify already aligned intervals with perfect step', () => { - const range = alignRange(0, 3, 3); + const range = alignRange(0, 3, 3, 0); expect(range.start).toEqual(0); expect(range.end).toEqual(3); }); it('does modify end-aligned intervals to reflect number of steps possible', () => { - const range = alignRange(1, 6, 3); + const range = alignRange(1, 6, 3, 0); expect(range.start).toEqual(0); expect(range.end).toEqual(6); }); it('does align intervals that are a multiple of steps', () => { - const range = alignRange(1, 4, 3); + const range = alignRange(1, 4, 3, 0); expect(range.start).toEqual(0); expect(range.end).toEqual(3); }); it('does align intervals that are not a multiple of steps', () => { - const range = alignRange(1, 5, 3); + const range = alignRange(1, 5, 3, 0); expect(range.start).toEqual(0); expect(range.end).toEqual(3); }); + it('does align intervals with local midnight -UTC offset', () => { + //week range, location 4+ hours UTC offset, 24h step time + const range = alignRange(4 * 60 * 60, (7 * 24 + 4) * 60 * 60, 24 * 60 * 60, -4 * 60 * 60); //04:00 UTC, 7 day range + expect(range.start).toEqual(4 * 60 * 60); + expect(range.end).toEqual((7 * 24 + 4) * 60 * 60); + }); + it('does align intervals with local midnight +UTC offset', () => { + //week range, location 4- hours UTC offset, 24h step time + const range = alignRange(20 * 60 * 60, (8 * 24 - 4) * 60 * 60, 24 * 60 * 60, 4 * 60 * 60); //20:00 UTC on day1, 7 days later is 20:00 on day8 + expect(range.start).toEqual(20 * 60 * 60); + expect(range.end).toEqual((8 * 24 - 4) * 60 * 60); + }); }); describe('extractRuleMappingFromGroups()', () => { @@ -419,7 +431,10 @@ const templateSrv = ({ const timeSrv = ({ timeRange: () => { - return { to: { diff: () => 2000 }, from: '' }; + return { + from: dateTime(1531468681), + to: dateTime(1531468681 + 2000), + }; }, } as unknown) as TimeSrv; From dc9ec7dc9106d3abf6ea78143360d6d15c6b6692 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki Date: Wed, 26 Jun 2019 09:47:03 +0300 Subject: [PATCH 03/12] Auth: Allow expiration of API keys (#17678) * Modify backend to allow expiration of API Keys * Add middleware test for expired api keys * Modify frontend to enable expiration of API Keys * Fix frontend tests * Fix migration and add index for `expires` field * Add api key tests for database access * Substitude time.Now() by a mock for test usage * Front-end modifications * Change input label to `Time to live` * Change input behavior to comply with the other similar * Add tooltip * Modify AddApiKey api call response Expiration should be *time.Time instead of string * Present expiration date in the selected timezone * Use kbn for transforming intervals to seconds * Use `assert` library for tests * Frontend fixes Add checks for empty/undefined/null values * Change expires column from datetime to integer * Restrict api key duration input It should be interval not number * AddApiKey must complain if SecondsToLive is negative * Declare ErrInvalidApiKeyExpiration * Move configuration to auth section * Update docs * Eliminate alias for models in modified files * Omit expiration from api response if empty * Eliminate Goconvey from test file * Fix test Do not sleep, use mocked timeNow() instead * Remove index for expires from api_key table The index should be anyway on both org_id and expires fields. However this commit eliminates completely the index for now since not many rows are expected to be in this table. * Use getTimeZone function * Minor change in api key listing The frontend should display a message instead of empty string if the key does not expire. --- conf/defaults.ini | 3 + docs/sources/auth/overview.md | 3 + docs/sources/http_api/auth.md | 12 +- .../grafana-ui/src/utils/moment_wrapper.ts | 1 + pkg/api/api.go | 62 ++++---- pkg/api/apikey.go | 40 +++-- pkg/middleware/middleware.go | 50 ++++--- pkg/middleware/middleware_test.go | 140 +++++++++++------- pkg/models/apikey.go | 23 +-- pkg/services/sqlstore/apikey.go | 43 ++++-- pkg/services/sqlstore/apikey_test.go | 110 ++++++++++++-- .../sqlstore/migrations/apikey_mig.go | 4 + pkg/setting/setting.go | 3 + public/app/features/api-keys/ApiKeysPage.tsx | 59 +++++++- .../api-keys/__mocks__/apiKeysMock.ts | 4 + .../__snapshots__/ApiKeysPage.test.tsx.snap | 26 ++++ public/app/types/apiKeys.ts | 3 + 17 files changed, 432 insertions(+), 154 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index e37fe54f148c1..2d7bfd7275060 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -287,6 +287,9 @@ signout_redirect_url = # This setting is ignored if multiple OAuth providers are configured. oauth_auto_login = false +# limit of api_key seconds to live before expiration +api_key_max_seconds_to_live = -1 + #################################### Anonymous Auth ###################### [auth.anonymous] # enable anonymous access diff --git a/docs/sources/auth/overview.md b/docs/sources/auth/overview.md index fc22d6b2d7fb7..f00438e1c4ab1 100644 --- a/docs/sources/auth/overview.md +++ b/docs/sources/auth/overview.md @@ -63,6 +63,9 @@ login_maximum_lifetime_days = 30 # How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes. token_rotation_interval_minutes = 10 + +# The maximum lifetime (seconds) an api key can be used. If it is set all the api keys should have limited lifetime that is lower than this value. +api_key_max_seconds_to_live = -1 ``` ### Anonymous authentication diff --git a/docs/sources/http_api/auth.md b/docs/sources/http_api/auth.md index e87d3571322ce..fd5007f8af716 100644 --- a/docs/sources/http_api/auth.md +++ b/docs/sources/http_api/auth.md @@ -82,7 +82,8 @@ Content-Type: application/json { "id": 1, "name": "TestAdmin", - "role": "Admin" + "role": "Admin", + "expiration": "2019-06-26T10:52:03+03:00" } ] ``` @@ -101,7 +102,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk { "name": "mykey", - "role": "Admin" + "role": "Admin", + "secondsToLive": 86400 } ``` @@ -109,6 +111,12 @@ JSON Body schema: - **name** – The key name - **role** – Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor` or `Admin`. +- **secondsToLive** – Sets the key expiration in seconds. It is optional. If it is a positive number an expiration date for the key is set. If it is null, zero or is omitted completely (unless `api_key_max_seconds_to_live` configuration option is set) the key will never expire. + +Error statuses: + +- **400** – `api_key_max_seconds_to_live` is set but no `secondsToLive` is specified or `secondsToLive` is greater than this value. +- **500** – The key was unable to be stored in the database. **Example Response**: diff --git a/packages/grafana-ui/src/utils/moment_wrapper.ts b/packages/grafana-ui/src/utils/moment_wrapper.ts index b9e51d1826b9e..e77c2f41196fd 100644 --- a/packages/grafana-ui/src/utils/moment_wrapper.ts +++ b/packages/grafana-ui/src/utils/moment_wrapper.ts @@ -46,6 +46,7 @@ export interface DateTimeDuration { hours: () => number; minutes: () => number; seconds: () => number; + asSeconds: () => number; } export interface DateTime extends Object { diff --git a/pkg/api/api.go b/pkg/api/api.go index 9f80f1cb4fbaa..27f68f90f0100 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" ) func (hs *HTTPServer) registerRoutes() { @@ -105,7 +105,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index) // api for dashboard snapshots - r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) + r.Post("/api/snapshots/", bind(models.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", GetSharingOptions) r.Get("/api/snapshots/:key", GetDashboardSnapshot) r.Get("/api/snapshots-delete/:deleteKey", Wrap(DeleteDashboardSnapshotByDeleteKey)) @@ -120,7 +120,7 @@ func (hs *HTTPServer) registerRoutes() { // user (signed in) apiRoute.Group("/user", func(userRoute routing.RouteRegister) { userRoute.Get("/", Wrap(GetSignedInUser)) - userRoute.Put("/", bind(m.UpdateUserCommand{}), Wrap(UpdateSignedInUser)) + userRoute.Put("/", bind(models.UpdateUserCommand{}), Wrap(UpdateSignedInUser)) userRoute.Post("/using/:id", Wrap(UserSetUsingOrg)) userRoute.Get("/orgs", Wrap(GetSignedInUserOrgList)) userRoute.Get("/teams", Wrap(GetSignedInUserTeamList)) @@ -128,7 +128,7 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Post("/stars/dashboard/:id", Wrap(StarDashboard)) userRoute.Delete("/stars/dashboard/:id", Wrap(UnstarDashboard)) - userRoute.Put("/password", bind(m.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword)) + userRoute.Put("/password", bind(models.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword)) userRoute.Get("/quotas", Wrap(GetUserQuotas)) userRoute.Put("/helpflags/:id", Wrap(SetHelpFlag)) // For dev purpose @@ -138,7 +138,7 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences)) userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens)) - userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken)) + userRoute.Post("/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken)) }) // users (admin permission required) @@ -150,18 +150,18 @@ func (hs *HTTPServer) registerRoutes() { usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList)) // query parameters /users/lookup?loginOrEmail=admin@example.com usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail)) - usersRoute.Put("/:id", bind(m.UpdateUserCommand{}), Wrap(UpdateUser)) + usersRoute.Put("/:id", bind(models.UpdateUserCommand{}), Wrap(UpdateUser)) usersRoute.Post("/:id/using/:orgId", Wrap(UpdateUserActiveOrg)) }, reqGrafanaAdmin) // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { - teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) + teamsRoute.Post("/", bind(models.CreateTeamCommand{}), Wrap(hs.CreateTeam)) + teamsRoute.Put("/:teamId", bind(models.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID)) teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember)) - teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember)) + teamsRoute.Post("/:teamId/members", bind(models.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember)) + teamsRoute.Put("/:teamId/members/:userId", bind(models.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember)) teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember)) teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences)) teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences)) @@ -183,8 +183,8 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), Wrap(UpdateOrgCurrent)) orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddressCurrent)) - orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg)) - orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg)) + orgRoute.Post("/users", quota("user"), bind(models.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg)) + orgRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg)) orgRoute.Delete("/users/:userId", Wrap(RemoveOrgUserForCurrentOrg)) // invites @@ -203,7 +203,7 @@ func (hs *HTTPServer) registerRoutes() { }) // create new org - apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), Wrap(CreateOrg)) + apiRoute.Post("/orgs", quota("org"), bind(models.CreateOrgCommand{}), Wrap(CreateOrg)) // search all orgs apiRoute.Get("/orgs", reqGrafanaAdmin, Wrap(SearchOrgs)) @@ -215,11 +215,11 @@ func (hs *HTTPServer) registerRoutes() { orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddress)) orgsRoute.Delete("/", Wrap(DeleteOrgByID)) orgsRoute.Get("/users", Wrap(GetOrgUsers)) - orgsRoute.Post("/users", bind(m.AddOrgUserCommand{}), Wrap(AddOrgUser)) - orgsRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser)) + orgsRoute.Post("/users", bind(models.AddOrgUserCommand{}), Wrap(AddOrgUser)) + orgsRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser)) orgsRoute.Delete("/users/:userId", Wrap(RemoveOrgUser)) orgsRoute.Get("/quotas", Wrap(GetOrgQuotas)) - orgsRoute.Put("/quotas/:target", bind(m.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota)) + orgsRoute.Put("/quotas/:target", bind(models.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota)) }, reqGrafanaAdmin) // orgs (admin routes) @@ -230,20 +230,20 @@ func (hs *HTTPServer) registerRoutes() { // auth api keys apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { keysRoute.Get("/", Wrap(GetAPIKeys)) - keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), Wrap(AddAPIKey)) + keysRoute.Post("/", quota("api_key"), bind(models.AddApiKeyCommand{}), Wrap(hs.AddAPIKey)) keysRoute.Delete("/:id", Wrap(DeleteAPIKey)) }, reqOrgAdmin) // Preferences apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) { - prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), Wrap(SetHomeDashboard)) + prefRoute.Post("/set-home-dash", bind(models.SavePreferencesCommand{}), Wrap(SetHomeDashboard)) }) // Data sources apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) { datasourceRoute.Get("/", Wrap(GetDataSources)) - datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource)) - datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource)) + datasourceRoute.Post("/", quota("data_source"), bind(models.AddDataSourceCommand{}), Wrap(AddDataSource)) + datasourceRoute.Put("/:id", bind(models.UpdateDataSourceCommand{}), Wrap(UpdateDataSource)) datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById)) datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName)) datasourceRoute.Get("/:id", Wrap(GetDataSourceById)) @@ -258,7 +258,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards)) - pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) + pluginRoute.Post("/:pluginId/settings", bind(models.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting)) }, reqOrgAdmin) apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings) @@ -269,11 +269,11 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { folderRoute.Get("/", Wrap(GetFolders)) folderRoute.Get("/id/:id", Wrap(GetFolderByID)) - folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder)) + folderRoute.Post("/", bind(models.CreateFolderCommand{}), Wrap(hs.CreateFolder)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { folderUidRoute.Get("/", Wrap(GetFolderByUID)) - folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), Wrap(UpdateFolder)) + folderUidRoute.Put("/", bind(models.UpdateFolderCommand{}), Wrap(UpdateFolder)) folderUidRoute.Delete("/", Wrap(DeleteFolder)) folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { @@ -293,7 +293,7 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), Wrap(CalculateDashboardDiff)) - dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), Wrap(hs.PostDashboard)) + dashboardRoute.Post("/db", bind(models.SaveDashboardCommand{}), Wrap(hs.PostDashboard)) dashboardRoute.Get("/home", Wrap(GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), Wrap(ImportDashboard)) @@ -322,8 +322,8 @@ func (hs *HTTPServer) registerRoutes() { playlistRoute.Get("/:id/items", ValidateOrgPlaylist, Wrap(GetPlaylistItems)) playlistRoute.Get("/:id/dashboards", ValidateOrgPlaylist, Wrap(GetPlaylistDashboards)) playlistRoute.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, Wrap(DeletePlaylist)) - playlistRoute.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist)) - playlistRoute.Post("/", reqEditorRole, bind(m.CreatePlaylistCommand{}), Wrap(CreatePlaylist)) + playlistRoute.Put("/:id", reqEditorRole, bind(models.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist)) + playlistRoute.Post("/", reqEditorRole, bind(models.CreatePlaylistCommand{}), Wrap(CreatePlaylist)) }) // Search @@ -348,12 +348,12 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) { alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), Wrap(NotificationTest)) - alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification)) - alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification)) + alertNotifications.Post("/", bind(models.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification)) + alertNotifications.Put("/:notificationId", bind(models.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification)) alertNotifications.Get("/:notificationId", Wrap(GetAlertNotificationByID)) alertNotifications.Delete("/:notificationId", Wrap(DeleteAlertNotification)) alertNotifications.Get("/uid/:uid", Wrap(GetAlertNotificationByUID)) - alertNotifications.Put("/uid/:uid", bind(m.UpdateAlertNotificationWithUidCommand{}), Wrap(UpdateAlertNotificationByUID)) + alertNotifications.Put("/uid/:uid", bind(models.UpdateAlertNotificationWithUidCommand{}), Wrap(UpdateAlertNotificationByUID)) alertNotifications.Delete("/uid/:uid", Wrap(DeleteAlertNotificationByUID)) }, reqEditorRole) @@ -384,13 +384,13 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Post("/users/:id/disable", Wrap(hs.AdminDisableUser)) adminRoute.Post("/users/:id/enable", Wrap(AdminEnableUser)) adminRoute.Get("/users/:id/quotas", Wrap(GetUserQuotas)) - adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) + adminRoute.Put("/users/:id/quotas/:target", bind(models.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) adminRoute.Get("/stats", AdminGetStats) adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts)) adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser)) adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens)) - adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) + adminRoute.Post("/users/:id/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) adminRoute.Post("/provisioning/dashboards/reload", Wrap(hs.AdminProvisioningReloadDasboards)) adminRoute.Post("/provisioning/datasources/reload", Wrap(hs.AdminProvisioningReloadDatasources)) diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 7fda738f1cd51..d194429906f60 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -4,32 +4,39 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" + "time" ) -func GetAPIKeys(c *m.ReqContext) Response { - query := m.GetApiKeysQuery{OrgId: c.OrgId} +func GetAPIKeys(c *models.ReqContext) Response { + query := models.GetApiKeysQuery{OrgId: c.OrgId} if err := bus.Dispatch(&query); err != nil { return Error(500, "Failed to list api keys", err) } - result := make([]*m.ApiKeyDTO, len(query.Result)) + result := make([]*models.ApiKeyDTO, len(query.Result)) for i, t := range query.Result { - result[i] = &m.ApiKeyDTO{ - Id: t.Id, - Name: t.Name, - Role: t.Role, + var expiration *time.Time = nil + if t.Expires != nil { + v := time.Unix(*t.Expires, 0) + expiration = &v + } + result[i] = &models.ApiKeyDTO{ + Id: t.Id, + Name: t.Name, + Role: t.Role, + Expiration: expiration, } } return JSON(200, result) } -func DeleteAPIKey(c *m.ReqContext) Response { +func DeleteAPIKey(c *models.ReqContext) Response { id := c.ParamsInt64(":id") - cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId} + cmd := &models.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId} err := bus.Dispatch(cmd) if err != nil { @@ -39,17 +46,28 @@ func DeleteAPIKey(c *m.ReqContext) Response { return Success("API key deleted") } -func AddAPIKey(c *m.ReqContext, cmd m.AddApiKeyCommand) Response { +func (hs *HTTPServer) AddAPIKey(c *models.ReqContext, cmd models.AddApiKeyCommand) Response { if !cmd.Role.IsValid() { return Error(400, "Invalid role specified", nil) } + if hs.Cfg.ApiKeyMaxSecondsToLive != -1 { + if cmd.SecondsToLive == 0 { + return Error(400, "Number of seconds before expiration should be set", nil) + } + if cmd.SecondsToLive > hs.Cfg.ApiKeyMaxSecondsToLive { + return Error(400, "Number of seconds before expiration is greater than the global limit", nil) + } + } cmd.OrgId = c.OrgId newKeyInfo := apikeygen.New(cmd.OrgId, cmd.Name) cmd.Key = newKeyInfo.HashedKey if err := bus.Dispatch(&cmd); err != nil { + if err == models.ErrInvalidApiKeyExpiration { + return Error(400, err.Error(), nil) + } return Error(500, "Failed to add API key", err) } diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 6b71d75f6e035..49ec9f54b2ade 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -14,26 +14,28 @@ import ( "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/remotecache" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) +var getTime = time.Now + var ( ReqGrafanaAdmin = Auth(&AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true}) ReqSignedIn = Auth(&AuthOptions{ReqSignedIn: true}) - ReqEditorRole = RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN) - ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN) + ReqEditorRole = RoleAuth(models.ROLE_EDITOR, models.ROLE_ADMIN) + ReqOrgAdmin = RoleAuth(models.ROLE_ADMIN) ) func GetContextHandler( - ats m.UserTokenService, + ats models.UserTokenService, remoteCache *remotecache.RemoteCache, ) macaron.Handler { return func(c *macaron.Context) { - ctx := &m.ReqContext{ + ctx := &models.ReqContext{ Context: c, - SignedInUser: &m.SignedInUser{}, + SignedInUser: &models.SignedInUser{}, IsSignedIn: false, AllowAnonymous: false, SkipCache: false, @@ -68,19 +70,19 @@ func GetContextHandler( // update last seen every 5min if ctx.ShouldUpdateLastSeenAt() { ctx.Logger.Debug("Updating last user_seen_at", "user_id", ctx.UserId) - if err := bus.Dispatch(&m.UpdateUserLastSeenAtCommand{UserId: ctx.UserId}); err != nil { + if err := bus.Dispatch(&models.UpdateUserLastSeenAtCommand{UserId: ctx.UserId}); err != nil { ctx.Logger.Error("Failed to update last_seen_at", "error", err) } } } } -func initContextWithAnonymousUser(ctx *m.ReqContext) bool { +func initContextWithAnonymousUser(ctx *models.ReqContext) bool { if !setting.AnonymousEnabled { return false } - orgQuery := m.GetOrgByNameQuery{Name: setting.AnonymousOrgName} + orgQuery := models.GetOrgByNameQuery{Name: setting.AnonymousOrgName} if err := bus.Dispatch(&orgQuery); err != nil { log.Error(3, "Anonymous access organization error: '%s': %s", setting.AnonymousOrgName, err) return false @@ -88,14 +90,14 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool { ctx.IsSignedIn = false ctx.AllowAnonymous = true - ctx.SignedInUser = &m.SignedInUser{IsAnonymous: true} - ctx.OrgRole = m.RoleType(setting.AnonymousOrgRole) + ctx.SignedInUser = &models.SignedInUser{IsAnonymous: true} + ctx.OrgRole = models.RoleType(setting.AnonymousOrgRole) ctx.OrgId = orgQuery.Result.Id ctx.OrgName = orgQuery.Result.Name return true } -func initContextWithApiKey(ctx *m.ReqContext) bool { +func initContextWithApiKey(ctx *models.ReqContext) bool { var keyString string if keyString = getApiKey(ctx); keyString == "" { return false @@ -109,7 +111,7 @@ func initContextWithApiKey(ctx *m.ReqContext) bool { } // fetch key - keyQuery := m.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId} + keyQuery := models.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId} if err := bus.Dispatch(&keyQuery); err != nil { ctx.JsonApiErr(401, "Invalid API key", err) return true @@ -123,15 +125,21 @@ func initContextWithApiKey(ctx *m.ReqContext) bool { return true } + // check for expiration + if apikey.Expires != nil && *apikey.Expires <= getTime().Unix() { + ctx.JsonApiErr(401, "Expired API key", err) + return true + } + ctx.IsSignedIn = true - ctx.SignedInUser = &m.SignedInUser{} + ctx.SignedInUser = &models.SignedInUser{} ctx.OrgRole = apikey.Role ctx.ApiKeyId = apikey.Id ctx.OrgId = apikey.OrgId return true } -func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { +func initContextWithBasicAuth(ctx *models.ReqContext, orgId int64) bool { if !setting.BasicAuthEnabled { return false @@ -148,7 +156,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } - loginQuery := m.GetUserByLoginQuery{LoginOrEmail: username} + loginQuery := models.GetUserByLoginQuery{LoginOrEmail: username} if err := bus.Dispatch(&loginQuery); err != nil { ctx.JsonApiErr(401, "Basic auth failed", err) return true @@ -156,13 +164,13 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { user := loginQuery.Result - loginUserQuery := m.LoginUserQuery{Username: username, Password: password, User: user} + loginUserQuery := models.LoginUserQuery{Username: username, Password: password, User: user} if err := bus.Dispatch(&loginUserQuery); err != nil { ctx.JsonApiErr(401, "Invalid username or password", err) return true } - query := m.GetSignedInUserQuery{UserId: user.Id, OrgId: orgId} + query := models.GetSignedInUserQuery{UserId: user.Id, OrgId: orgId} if err := bus.Dispatch(&query); err != nil { ctx.JsonApiErr(401, "Authentication error", err) return true @@ -173,7 +181,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool { return true } -func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext, orgID int64) bool { +func initContextWithToken(authTokenService models.UserTokenService, ctx *models.ReqContext, orgID int64) bool { rawToken := ctx.GetCookie(setting.LoginCookieName) if rawToken == "" { return false @@ -186,7 +194,7 @@ func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext return false } - query := m.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID} + query := models.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID} if err := bus.Dispatch(&query); err != nil { ctx.Logger.Error("failed to get user with id", "userId", token.UserId, "error", err) return false @@ -209,7 +217,7 @@ func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext return true } -func WriteSessionCookie(ctx *m.ReqContext, value string, maxLifetimeDays int) { +func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetimeDays int) { if setting.Env == setting.DEV { ctx.Logger.Info("new token", "unhashed token", value) } diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index e3687f6057d8a..ee725ef62876b 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/remotecache" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -21,6 +21,19 @@ import ( "gopkg.in/macaron.v1" ) +func mockGetTime() { + var timeSeed int64 + getTime = func() time.Time { + fakeNow := time.Unix(timeSeed, 0) + timeSeed++ + return fakeNow + } +} + +func resetGetTime() { + getTime = time.Now +} + func TestMiddleWareSecurityHeaders(t *testing.T) { setting.ERR_TEMPLATE_NAME = "error-template" @@ -83,7 +96,7 @@ func TestMiddlewareContext(t *testing.T) { }) middlewareScenario(t, "middleware should add Cache-Control header for requests with html response", func(sc *scenarioContext) { - sc.handler(func(c *m.ReqContext) { + sc.handler(func(c *models.ReqContext) { data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, Settings: map[string]interface{}{}, @@ -125,20 +138,20 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Using basic auth", func(sc *scenarioContext) { - bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { - query.Result = &m.User{ + bus.AddHandler("test", func(query *models.GetUserByLoginQuery) error { + query.Result = &models.User{ Password: util.EncodePassword("myPass", "salt"), Salt: "salt", } return nil }) - bus.AddHandler("test", func(loginUserQuery *m.LoginUserQuery) error { + bus.AddHandler("test", func(loginUserQuery *models.LoginUserQuery) error { return nil }) - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) @@ -156,8 +169,8 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Valid api key", func(sc *scenarioContext) { keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") - bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { - query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} return nil }) @@ -170,15 +183,15 @@ func TestMiddlewareContext(t *testing.T) { Convey("Should init middleware context", func() { So(sc.context.IsSignedIn, ShouldEqual, true) So(sc.context.OrgId, ShouldEqual, 12) - So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) }) }) middlewareScenario(t, "Valid api key, but does not match db hash", func(sc *scenarioContext) { keyhash := "something_not_matching" - bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { - query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} return nil }) @@ -190,11 +203,34 @@ func TestMiddlewareContext(t *testing.T) { }) }) + middlewareScenario(t, "Valid api key, but expired", func(sc *scenarioContext) { + mockGetTime() + defer resetGetTime() + + keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") + + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + + // api key expired one second before + expires := getTime().Add(-1 * time.Second).Unix() + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash, + Expires: &expires} + return nil + }) + + sc.fakeReq("GET", "/").withValidApiKey().exec() + + Convey("Should return 401", func() { + So(sc.resp.Code, ShouldEqual, 401) + So(sc.respJson["message"], ShouldEqual, "Expired API key") + }) + }) + middlewareScenario(t, "Valid api key via Basic auth", func(sc *scenarioContext) { keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd") - bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error { - query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash} + bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error { + query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash} return nil }) @@ -208,20 +244,20 @@ func TestMiddlewareContext(t *testing.T) { Convey("Should init middleware context", func() { So(sc.context.IsSignedIn, ShouldEqual, true) So(sc.context.OrgId, ShouldEqual, 12) - So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) }) }) middlewareScenario(t, "Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) { - return &m.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ UserId: 12, UnhashedToken: unhashedToken, }, nil @@ -244,19 +280,19 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) { - return &m.UserToken{ + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ UserId: 12, UnhashedToken: "", }, nil } - sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *m.UserToken, clientIP, userAgent string) (bool, error) { + sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *models.UserToken, clientIP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil } @@ -291,8 +327,8 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "Invalid/expired auth token in cookie", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) { - return nil, m.ErrUserTokenNotFound + sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) { + return nil, models.ErrUserTokenNotFound } sc.fakeReq("GET", "/").exec() @@ -307,12 +343,12 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "When anonymous access is enabled", func(sc *scenarioContext) { setting.AnonymousEnabled = true setting.AnonymousOrgName = "test" - setting.AnonymousOrgRole = string(m.ROLE_EDITOR) + setting.AnonymousOrgRole = string(models.ROLE_EDITOR) - bus.AddHandler("test", func(query *m.GetOrgByNameQuery) error { + bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error { So(query.Name, ShouldEqual, "test") - query.Result = &m.Org{Id: 2, Name: "test"} + query.Result = &models.Org{Id: 2, Name: "test"} return nil }) @@ -321,7 +357,7 @@ func TestMiddlewareContext(t *testing.T) { Convey("should init context with org info", func() { So(sc.context.UserId, ShouldEqual, 0) So(sc.context.OrgId, ShouldEqual, 2) - So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR) + So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR) }) Convey("context signed in should be false", func() { @@ -339,8 +375,8 @@ func TestMiddlewareContext(t *testing.T) { name := "markelog" middlewareScenario(t, "should not sync the user if it's in the cache", func(sc *scenarioContext) { - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 4, UserId: query.UserId} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 4, UserId: query.UserId} return nil }) @@ -362,16 +398,16 @@ func TestMiddlewareContext(t *testing.T) { setting.LDAPEnabled = false setting.AuthProxyAutoSignUp = true - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { if query.UserId > 0 { - query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} return nil } - return m.ErrUserNotFound + return models.ErrUserNotFound }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 33} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 33} return nil }) @@ -389,13 +425,13 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario(t, "should get an existing user from header", func(sc *scenarioContext) { setting.LDAPEnabled = false - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 2, UserId: 12} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 2, UserId: 12} return nil }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 12} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 12} return nil }) @@ -414,13 +450,13 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120" setting.LDAPEnabled = false - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} return nil }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 33} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 33} return nil }) @@ -440,13 +476,13 @@ func TestMiddlewareContext(t *testing.T) { setting.AuthProxyWhitelist = "8.8.8.8" setting.LDAPEnabled = false - bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error { - query.Result = &m.SignedInUser{OrgId: 4, UserId: 33} + bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error { + query.Result = &models.SignedInUser{OrgId: 4, UserId: 33} return nil }) - bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error { - cmd.Result = &m.User{Id: 33} + bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error { + cmd.Result = &models.User{Id: 33} return nil }) @@ -489,7 +525,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) { sc.m.Use(OrgRedirect()) - sc.defaultHandler = func(c *m.ReqContext) { + sc.defaultHandler = func(c *models.ReqContext) { sc.context = c if sc.handlerFunc != nil { sc.handlerFunc(sc.context) @@ -504,7 +540,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) { type scenarioContext struct { m *macaron.Macaron - context *m.ReqContext + context *models.ReqContext resp *httptest.ResponseRecorder apiKey string authHeader string @@ -587,4 +623,4 @@ func (sc *scenarioContext) exec() { } type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *m.ReqContext) +type handlerFunc func(c *models.ReqContext) diff --git a/pkg/models/apikey.go b/pkg/models/apikey.go index a666cb30c6147..1edc8379d6478 100644 --- a/pkg/models/apikey.go +++ b/pkg/models/apikey.go @@ -6,6 +6,7 @@ import ( ) var ErrInvalidApiKey = errors.New("Invalid API Key") +var ErrInvalidApiKeyExpiration = errors.New("Negative value for SecondsToLive") type ApiKey struct { Id int64 @@ -15,15 +16,17 @@ type ApiKey struct { Role RoleType Created time.Time Updated time.Time + Expires *int64 } // --------------------- // COMMANDS type AddApiKeyCommand struct { - Name string `json:"name" binding:"Required"` - Role RoleType `json:"role" binding:"Required"` - OrgId int64 `json:"-"` - Key string `json:"-"` + Name string `json:"name" binding:"Required"` + Role RoleType `json:"role" binding:"Required"` + OrgId int64 `json:"-"` + Key string `json:"-"` + SecondsToLive int64 `json:"secondsToLive"` Result *ApiKey `json:"-"` } @@ -45,8 +48,9 @@ type DeleteApiKeyCommand struct { // QUERIES type GetApiKeysQuery struct { - OrgId int64 - Result []*ApiKey + OrgId int64 + IncludeInvalid bool + Result []*ApiKey } type GetApiKeyByNameQuery struct { @@ -64,7 +68,8 @@ type GetApiKeyByIdQuery struct { // DTO & Projections type ApiKeyDTO struct { - Id int64 `json:"id"` - Name string `json:"name"` - Role RoleType `json:"role"` + Id int64 `json:"id"` + Name string `json:"name"` + Role RoleType `json:"role"` + Expiration *time.Time `json:"expiration,omitempty"` } diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go index 775d4cf644737..13ea1feb7daf5 100644 --- a/pkg/services/sqlstore/apikey.go +++ b/pkg/services/sqlstore/apikey.go @@ -5,7 +5,7 @@ import ( "time" "github.com/grafana/grafana/pkg/bus" - m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" ) func init() { @@ -16,14 +16,18 @@ func init() { bus.AddHandler("sql", AddApiKey) } -func GetApiKeys(query *m.GetApiKeysQuery) error { - sess := x.Limit(100, 0).Where("org_id=?", query.OrgId).Asc("name") +func GetApiKeys(query *models.GetApiKeysQuery) error { + sess := x.Limit(100, 0).Where("org_id=? and ( expires IS NULL or expires >= ?)", + query.OrgId, timeNow().Unix()).Asc("name") + if query.IncludeInvalid { + sess = x.Limit(100, 0).Where("org_id=?", query.OrgId).Asc("name") + } - query.Result = make([]*m.ApiKey, 0) + query.Result = make([]*models.ApiKey, 0) return sess.Find(&query.Result) } -func DeleteApiKeyCtx(ctx context.Context, cmd *m.DeleteApiKeyCommand) error { +func DeleteApiKeyCtx(ctx context.Context, cmd *models.DeleteApiKeyCommand) error { return withDbSession(ctx, func(sess *DBSession) error { var rawSql = "DELETE FROM api_key WHERE id=? and org_id=?" _, err := sess.Exec(rawSql, cmd.Id, cmd.OrgId) @@ -31,15 +35,24 @@ func DeleteApiKeyCtx(ctx context.Context, cmd *m.DeleteApiKeyCommand) error { }) } -func AddApiKey(cmd *m.AddApiKeyCommand) error { +func AddApiKey(cmd *models.AddApiKeyCommand) error { return inTransaction(func(sess *DBSession) error { - t := m.ApiKey{ + updated := timeNow() + var expires *int64 = nil + if cmd.SecondsToLive > 0 { + v := updated.Add(time.Second * time.Duration(cmd.SecondsToLive)).Unix() + expires = &v + } else if cmd.SecondsToLive < 0 { + return models.ErrInvalidApiKeyExpiration + } + t := models.ApiKey{ OrgId: cmd.OrgId, Name: cmd.Name, Role: cmd.Role, Key: cmd.Key, - Created: time.Now(), - Updated: time.Now(), + Created: updated, + Updated: updated, + Expires: expires, } if _, err := sess.Insert(&t); err != nil { @@ -50,28 +63,28 @@ func AddApiKey(cmd *m.AddApiKeyCommand) error { }) } -func GetApiKeyById(query *m.GetApiKeyByIdQuery) error { - var apikey m.ApiKey +func GetApiKeyById(query *models.GetApiKeyByIdQuery) error { + var apikey models.ApiKey has, err := x.Id(query.ApiKeyId).Get(&apikey) if err != nil { return err } else if !has { - return m.ErrInvalidApiKey + return models.ErrInvalidApiKey } query.Result = &apikey return nil } -func GetApiKeyByName(query *m.GetApiKeyByNameQuery) error { - var apikey m.ApiKey +func GetApiKeyByName(query *models.GetApiKeyByNameQuery) error { + var apikey models.ApiKey has, err := x.Where("org_id=? AND name=?", query.OrgId, query.KeyName).Get(&apikey) if err != nil { return err } else if !has { - return m.ErrInvalidApiKey + return models.ErrInvalidApiKey } query.Result = &apikey diff --git a/pkg/services/sqlstore/apikey_test.go b/pkg/services/sqlstore/apikey_test.go index 790c8837def83..a1b06db0f9cde 100644 --- a/pkg/services/sqlstore/apikey_test.go +++ b/pkg/services/sqlstore/apikey_test.go @@ -1,31 +1,117 @@ package sqlstore import ( + "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/assert" "testing" - - . "github.com/smartystreets/goconvey/convey" - - m "github.com/grafana/grafana/pkg/models" + "time" ) func TestApiKeyDataAccess(t *testing.T) { + mockTimeNow() + defer resetTimeNow() - Convey("Testing API Key data access", t, func() { + t.Run("Testing API Key data access", func(t *testing.T) { InitTestDB(t) - Convey("Given saved api key", func() { - cmd := m.AddApiKeyCommand{OrgId: 1, Name: "hello", Key: "asd"} + t.Run("Given saved api key", func(t *testing.T) { + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "hello", Key: "asd"} err := AddApiKey(&cmd) - So(err, ShouldBeNil) + assert.Nil(t, err) - Convey("Should be able to get key by name", func() { - query := m.GetApiKeyByNameQuery{KeyName: "hello", OrgId: 1} + t.Run("Should be able to get key by name", func(t *testing.T) { + query := models.GetApiKeyByNameQuery{KeyName: "hello", OrgId: 1} err = GetApiKeyByName(&query) - So(err, ShouldBeNil) - So(query.Result, ShouldNotBeNil) + assert.Nil(t, err) + assert.NotNil(t, query.Result) }) }) + + t.Run("Add non expiring key", func(t *testing.T) { + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring", Key: "asd1", SecondsToLive: 0} + err := AddApiKey(&cmd) + assert.Nil(t, err) + + query := models.GetApiKeyByNameQuery{KeyName: "non-expiring", OrgId: 1} + err = GetApiKeyByName(&query) + assert.Nil(t, err) + + assert.Nil(t, query.Result.Expires) + }) + + t.Run("Add an expiring key", func(t *testing.T) { + //expires in one hour + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "expiring-in-an-hour", Key: "asd2", SecondsToLive: 3600} + err := AddApiKey(&cmd) + assert.Nil(t, err) + + query := models.GetApiKeyByNameQuery{KeyName: "expiring-in-an-hour", OrgId: 1} + err = GetApiKeyByName(&query) + assert.Nil(t, err) + + assert.True(t, *query.Result.Expires >= timeNow().Unix()) + + // timeNow() has been called twice since creation; once by AddApiKey and once by GetApiKeyByName + // therefore two seconds should be subtracted by next value retuned by timeNow() + // that equals the number by which timeSeed has been advanced + then := timeNow().Add(-2 * time.Second) + expected := then.Add(1 * time.Hour).UTC().Unix() + assert.Equal(t, *query.Result.Expires, expected) + }) + + t.Run("Add a key with negative lifespan", func(t *testing.T) { + //expires in one day + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "key-with-negative-lifespan", Key: "asd3", SecondsToLive: -3600} + err := AddApiKey(&cmd) + assert.EqualError(t, err, models.ErrInvalidApiKeyExpiration.Error()) + + query := models.GetApiKeyByNameQuery{KeyName: "key-with-negative-lifespan", OrgId: 1} + err = GetApiKeyByName(&query) + assert.EqualError(t, err, "Invalid API Key") + }) + + t.Run("Add keys", func(t *testing.T) { + //never expires + cmd := models.AddApiKeyCommand{OrgId: 1, Name: "key1", Key: "key1", SecondsToLive: 0} + err := AddApiKey(&cmd) + assert.Nil(t, err) + + //expires in 1s + cmd = models.AddApiKeyCommand{OrgId: 1, Name: "key2", Key: "key2", SecondsToLive: 1} + err = AddApiKey(&cmd) + assert.Nil(t, err) + + //expires in one hour + cmd = models.AddApiKeyCommand{OrgId: 1, Name: "key3", Key: "key3", SecondsToLive: 3600} + err = AddApiKey(&cmd) + assert.Nil(t, err) + + // advance mocked getTime by 1s + timeNow() + + query := models.GetApiKeysQuery{OrgId: 1, IncludeInvalid: false} + err = GetApiKeys(&query) + assert.Nil(t, err) + + for _, k := range query.Result { + if k.Name == "key2" { + t.Fatalf("key2 should not be there") + } + } + + query = models.GetApiKeysQuery{OrgId: 1, IncludeInvalid: true} + err = GetApiKeys(&query) + assert.Nil(t, err) + + found := false + for _, k := range query.Result { + if k.Name == "key2" { + found = true + } + } + assert.True(t, found) + }) }) } diff --git a/pkg/services/sqlstore/migrations/apikey_mig.go b/pkg/services/sqlstore/migrations/apikey_mig.go index 928f84c4fb026..bc3de8ef4c437 100644 --- a/pkg/services/sqlstore/migrations/apikey_mig.go +++ b/pkg/services/sqlstore/migrations/apikey_mig.go @@ -78,4 +78,8 @@ func addApiKeyMigrations(mg *Migrator) { {Name: "key", Type: DB_Varchar, Length: 190, Nullable: false}, {Name: "role", Type: DB_NVarchar, Length: 255, Nullable: false}, })) + + mg.AddMigration("Add expires to api_key table", NewAddColumnMigration(apiKeyV2, &Column{ + Name: "expires", Type: DB_BigInt, Nullable: true, + })) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 05d42f1000e1c..f2f7a2957434e 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -259,6 +259,8 @@ type Cfg struct { RemoteCacheOptions *RemoteCacheOptions EditorsCanAdmin bool + + ApiKeyMaxSecondsToLive int64 } type CommandLineArgs struct { @@ -795,6 +797,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30) cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays + cfg.ApiKeyMaxSecondsToLive = auth.Key("api_key_max_seconds_to_live").MustInt64(-1) cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10) if cfg.TokenRotationIntervalMinutes < 2 { diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 817d932980caf..511ba57c275a9 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -12,9 +12,34 @@ import ApiKeysAddedModal from './ApiKeysAddedModal'; import config from 'app/core/config'; import appEvents from 'app/core/app_events'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { DeleteButton, Input } from '@grafana/ui'; +import { DeleteButton, EventsWithValidation, FormLabel, Input, ValidationEvents } from '@grafana/ui'; import { NavModel } from '@grafana/data'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; +import { store } from 'app/store/store'; +import kbn from 'app/core/utils/kbn'; + +// Utils +import { dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper'; +import { getTimeZone } from 'app/features/profile/state/selectors'; + +const timeRangeValidationEvents: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: value => { + if (!value) { + return true; + } + try { + kbn.interval_to_seconds(value); + return true; + } catch { + return false; + } + }, + errorMessage: 'Not a valid duration', + }, + ], +}; export interface Props { navModel: NavModel; @@ -36,13 +61,18 @@ export interface State { enum ApiKeyStateProps { Name = 'name', Role = 'role', + SecondsToLive = 'secondsToLive', } const initialApiKeyState = { name: '', role: OrgRole.Viewer, + secondsToLive: '', }; +const tooltipText = + 'The api key life duration. For example 1d if your key is going to last for one day. All the supported units are: s,m,h,d,w,M,y'; + export class ApiKeysPage extends PureComponent { constructor(props) { super(props); @@ -81,6 +111,9 @@ export class ApiKeysPage extends PureComponent { }); }; + // make sure that secondsToLive is number or null + const secondsToLive = this.state.newApiKey['secondsToLive']; + this.state.newApiKey['secondsToLive'] = secondsToLive ? kbn.interval_to_seconds(secondsToLive) : null; this.props.addApiKey(this.state.newApiKey, openModal); this.setState((prevState: State) => { return { @@ -130,6 +163,17 @@ export class ApiKeysPage extends PureComponent { ); } + formatDate(date, format?) { + if (!date) { + return 'No expiration date'; + } + date = isDateTime(date) ? date : dateTime(date); + format = format || 'YYYY-MM-DD HH:mm:ss'; + const timezone = getTimeZone(store.getState().user); + + return timezone.isUtc ? date.utc().format(format) : date.format(format); + } + renderAddApiKeyForm() { const { newApiKey, isAdding } = this.state; @@ -170,6 +214,17 @@ export class ApiKeysPage extends PureComponent { +
+ Time to live + this.onApiKeyStateUpdate(evt, ApiKeyStateProps.SecondsToLive)} + /> +
@@ -211,6 +266,7 @@ export class ApiKeysPage extends PureComponent { Name Role + Expires @@ -221,6 +277,7 @@ export class ApiKeysPage extends PureComponent { {key.name} {key.role} + {this.formatDate(key.expiration)} this.onDeleteApiKey(key)} /> diff --git a/public/app/features/api-keys/__mocks__/apiKeysMock.ts b/public/app/features/api-keys/__mocks__/apiKeysMock.ts index 117f0d6d0c647..099b4c92ec496 100644 --- a/public/app/features/api-keys/__mocks__/apiKeysMock.ts +++ b/public/app/features/api-keys/__mocks__/apiKeysMock.ts @@ -7,6 +7,8 @@ export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => { id: i, name: `test-${i}`, role: OrgRole.Viewer, + secondsToLive: null, + expiration: '2019-06-04', }); } @@ -18,5 +20,7 @@ export const getMockKey = (): ApiKey => { id: 1, name: 'test', role: OrgRole.Admin, + secondsToLive: null, + expiration: '2019-06-04', }; }; diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap index 1ece45d86ef21..634dd9738d157 100644 --- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -130,6 +130,32 @@ exports[`Render should render CTA if there are no API keys 1`] = ` +
+ + Time to live + + +
diff --git a/public/app/types/apiKeys.ts b/public/app/types/apiKeys.ts index 6cf92011c69d0..4df2ebd41e81b 100644 --- a/public/app/types/apiKeys.ts +++ b/public/app/types/apiKeys.ts @@ -4,11 +4,14 @@ export interface ApiKey { id: number; name: string; role: OrgRole; + secondsToLive: number; + expiration: string; } export interface NewApiKey { name: string; role: OrgRole; + secondsToLive: number; } export interface ApiKeysState { From 7acbb94bb8cc38b95890d9d32e0f6cd685f50c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 26 Jun 2019 09:44:19 +0200 Subject: [PATCH 04/12] Docs: fixed notifications table --- docs/sources/alerting/notifications.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index b3a7e10b2a4fa..40b830c8d54b2 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -167,8 +167,8 @@ Notifications can be sent by setting up an incoming webhook in Google Hangouts c ### All supported notifiers -Name | Type | Supports images |Support alert rule tags ------|------------ | ------ +Name | Type | Supports images | Support alert rule tags +-----|------|---------------- | ----------------------- DingDing | `dingding` | yes, external only | no Discord | `discord` | yes | no Email | `email` | yes | no From 40e5c4f8ba85f9a2ec5c7378bcd1ac3fe780af4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 26 Jun 2019 09:56:11 +0200 Subject: [PATCH 05/12] Prometheus: Minor style fix (#17773) * Prometheus: Minor style fix * Updated snapshot --- .../prometheus/components/PromQueryEditor.tsx | 22 +++++------ .../PromQueryEditor.test.tsx.snap | 37 +++++++------------ 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx index fc5d71ff85853..6196a10028293 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx @@ -98,18 +98,16 @@ export class PromQueryEditor extends PureComponent { return (
-
- -
+
diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap index a705b312ea470..184648c1cc881 100644 --- a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromQueryEditor.test.tsx.snap @@ -2,33 +2,24 @@ exports[`Render PromQueryEditor with basic options should render 1`] = `
-
- -
+ } + />
From 6fb36e705f4f50a62d2a0cd23fdd37a2063a40c9 Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki Date: Wed, 26 Jun 2019 11:26:16 +0300 Subject: [PATCH 06/12] ApiKeys: Fix check for UTC timezone (#17776) getTimeZone() no longer returns an object, but a string --- public/app/features/api-keys/ApiKeysPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 511ba57c275a9..cb932334ec8ce 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -171,7 +171,7 @@ export class ApiKeysPage extends PureComponent { format = format || 'YYYY-MM-DD HH:mm:ss'; const timezone = getTimeZone(store.getState().user); - return timezone.isUtc ? date.utc().format(format) : date.format(format); + return timezone === 'utc' ? date.utc().format(format) : date.format(format); } renderAddApiKeyForm() { From 8541214c9e3b64e97469d032eaaa03993949dca4 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed Date: Wed, 26 Jun 2019 13:15:45 +0200 Subject: [PATCH 07/12] Markdown: Replace rendering library (#17686) * Replace remarkable with marked * Add wrapper and options for marked --- package.json | 4 +- packages/grafana-data/src/utils/index.ts | 1 + packages/grafana-data/src/utils/markdown.ts | 20 +++++++++ public/app/app.ts | 3 ++ .../core/components/PluginHelp/PluginHelp.tsx | 6 +-- .../PanelHeader/PanelHeaderCorner.tsx | 10 +++-- public/app/features/panel/panel_ctrl.ts | 6 +-- public/app/features/users/UsersListPage.tsx | 5 +-- public/app/plugins/panel/text/module.ts | 10 ++--- public/app/plugins/panel/text2/TextPanel.tsx | 9 +--- yarn.lock | 41 +++++-------------- 11 files changed, 54 insertions(+), 61 deletions(-) create mode 100644 packages/grafana-data/src/utils/markdown.ts diff --git a/package.json b/package.json index 9d80b5d39a22c..bfd01de690fa2 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "@types/react-transition-group": "2.0.16", "@types/react-virtualized": "9.18.12", "@types/react-window": "1.7.0", - "@types/remarkable": "1.7.4", "angular-mocks": "1.6.6", "autoprefixer": "9.5.0", "axios": "0.19.0", @@ -192,6 +191,7 @@ "@types/angular-route": "1.7.0", "@types/d3-scale-chromatic": "1.3.1", "@types/enzyme-adapter-react-16": "1.0.5", + "@types/marked": "0.6.5", "@types/react-redux": "^7.0.8", "@types/redux-logger": "3.0.7", "@types/reselect": "2.2.0", @@ -214,6 +214,7 @@ "immutable": "3.8.2", "jquery": "3.4.1", "lodash": "4.17.11", + "marked": "0.6.2", "moment": "2.24.0", "mousetrap": "1.6.3", "mousetrap-global-bind": "1.1.0", @@ -238,7 +239,6 @@ "redux-logger": "3.0.6", "redux-observable": "1.1.0", "redux-thunk": "2.3.0", - "remarkable": "1.7.1", "reselect": "4.0.0", "rst2html": "github:thoward/rst2html#990cb89", "rxjs": "6.4.0", diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 57f9f48d8bdec..0174d82b35b0e 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -1 +1,2 @@ export * from './string'; +export * from './markdown'; diff --git a/packages/grafana-data/src/utils/markdown.ts b/packages/grafana-data/src/utils/markdown.ts new file mode 100644 index 0000000000000..8a0ce6c839f73 --- /dev/null +++ b/packages/grafana-data/src/utils/markdown.ts @@ -0,0 +1,20 @@ +import marked, { MarkedOptions } from 'marked'; + +const defaultMarkedOptions: MarkedOptions = { + renderer: new marked.Renderer(), + pedantic: false, + gfm: true, + tables: true, + sanitize: true, + smartLists: true, + smartypants: false, + xhtml: false, +}; + +export function setMarkdownOptions(optionsOverride?: MarkedOptions) { + marked.setOptions({ ...defaultMarkedOptions, ...optionsOverride }); +} + +export function renderMarkdown(str: string): string { + return marked(str); +} diff --git a/public/app/app.ts b/public/app/app.ts index 9f95634330d57..bd819a7a43090 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -36,6 +36,7 @@ import { setupAngularRoutes } from 'app/routes/routes'; import 'app/routes/GrafanaCtrl'; import 'app/features/all'; import { setLocale } from '@grafana/ui/src/utils/moment_wrapper'; +import { setMarkdownOptions } from '@grafana/data'; // import symlinked extensions const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); @@ -70,6 +71,8 @@ export class GrafanaApp { setLocale(config.bootData.user.locale); + setMarkdownOptions({ sanitize: !config.disableSanitizeHtml }); + app.config( ( $locationProvider: angular.ILocationProvider, diff --git a/public/app/core/components/PluginHelp/PluginHelp.tsx b/public/app/core/components/PluginHelp/PluginHelp.tsx index 40aed4a6c0c88..67364ea9366f0 100644 --- a/public/app/core/components/PluginHelp/PluginHelp.tsx +++ b/public/app/core/components/PluginHelp/PluginHelp.tsx @@ -1,6 +1,5 @@ import React, { PureComponent } from 'react'; -// @ts-ignore -import Remarkable from 'remarkable'; +import { renderMarkdown } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; interface Props { @@ -39,8 +38,7 @@ export class PluginHelp extends PureComponent { getBackendSrv() .get(`/api/plugins/${plugin.id}/markdown/${type}`) .then((response: string) => { - const markdown = new Remarkable(); - const helpHtml = markdown.render(response); + const helpHtml = renderMarkdown(response); if (response === '' && type === 'help') { this.setState({ diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx index d3bd38b93d1a4..8aaba8bdd4a9d 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; -import Remarkable from 'remarkable'; + +import { renderMarkdown } from '@grafana/data'; import { Tooltip, ScopedVars, DataLink } from '@grafana/ui'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -45,11 +46,12 @@ export class PanelHeaderCorner extends Component { const markdown = panel.description; const linkSrv = new LinkSrv(templateSrv, this.timeSrv); const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars); - const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown); + const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown); return ( -
-

+

+
+ {panel.links && panel.links.length > 0 && (
    {panel.links.map((link, idx) => { diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 52ef34b581010..473ccdbb99b81 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import Remarkable from 'remarkable'; import { sanitize, escapeHtml } from 'app/core/utils/text'; +import { renderMarkdown } from '@grafana/data'; import config from 'app/core/config'; import { profiler } from 'app/core/core'; @@ -259,8 +259,8 @@ export class PanelCtrl { const interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars); let html = '
    '; - const md = new Remarkable().render(interpolatedMarkdown); - html += sanitize(md); + const md = renderMarkdown(interpolatedMarkdown); + html += config.disableSanitizeHtml ? md : sanitize(md); if (this.panel.links && this.panel.links.length > 0) { html += '
    ); diff --git a/public/app/features/explore/LogRow.tsx b/public/app/features/explore/LogRow.tsx index e626782f1deb9..e1bdaa09b0737 100644 --- a/public/app/features/explore/LogRow.tsx +++ b/public/app/features/explore/LogRow.tsx @@ -23,6 +23,7 @@ import { LogRowModel, LogLabelStatsModel, LogsParser, + TimeZone, } from '@grafana/ui'; import { LogRowContext } from './LogRowContext'; import tinycolor from 'tinycolor2'; @@ -32,8 +33,8 @@ interface Props { row: LogRowModel; showDuplicates: boolean; showLabels: boolean; - showLocalTime: boolean; - showUtc: boolean; + showTime: boolean; + timeZone: TimeZone; getRows: () => LogRowModel[]; onClickLabel?: (label: string, value: string) => void; onContextClick?: () => void; @@ -209,8 +210,8 @@ export class LogRow extends PureComponent { row, showDuplicates, showLabels, - showLocalTime, - showUtc, + timeZone, + showTime, } = this.props; const { fieldCount, @@ -229,6 +230,7 @@ export class LogRow extends PureComponent { const highlightClassName = classnames('logs-row__match-highlight', { 'logs-row__match-highlight--preview': previewHighlights, }); + const showUtc = timeZone === 'utc'; return ( @@ -242,13 +244,13 @@ export class LogRow extends PureComponent {
    {row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
    )}
    - {showUtc && ( -
    - {row.timestamp} + {showTime && showUtc && ( +
    + {row.timeUtc}
    )} - {showLocalTime && ( -
    + {showTime && !showUtc && ( +
    {row.timeLocal}
    )} diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 201eda44ece3e..1d6d6460ff83c 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -76,8 +76,7 @@ interface State { deferLogs: boolean; renderAll: boolean; showLabels: boolean; - showLocalTime: boolean; - showUtc: boolean; + showTime: boolean; } export default class Logs extends PureComponent { @@ -88,8 +87,7 @@ export default class Logs extends PureComponent { deferLogs: true, renderAll: false, showLabels: false, - showLocalTime: true, - showUtc: false, + showTime: true, }; componentDidMount() { @@ -130,17 +128,10 @@ export default class Logs extends PureComponent { }); }; - onChangeLocalTime = (event: React.SyntheticEvent) => { + onChangeTime = (event: React.SyntheticEvent) => { const target = event.target as HTMLInputElement; this.setState({ - showLocalTime: target.checked, - }); - }; - - onChangeUtc = (event: React.SyntheticEvent) => { - const target = event.target as HTMLInputElement; - this.setState({ - showUtc: target.checked, + showTime: target.checked, }); }; @@ -178,7 +169,7 @@ export default class Logs extends PureComponent { return null; } - const { deferLogs, renderAll, showLabels, showLocalTime, showUtc } = this.state; + const { deferLogs, renderAll, showLabels, showTime } = this.state; const { dedupStrategy } = this.props; const hasData = data && data.rows && data.rows.length > 0; const hasLabel = hasData && dedupedData.hasUniqueLabels; @@ -223,8 +214,7 @@ export default class Logs extends PureComponent {
    - - + {Object.keys(LogsDedupStrategy).map((dedupType, i) => ( @@ -265,8 +255,8 @@ export default class Logs extends PureComponent { row={row} showDuplicates={showDuplicates} showLabels={showLabels && hasLabel} - showLocalTime={showLocalTime} - showUtc={showUtc} + showTime={showTime} + timeZone={timeZone} onClickLabel={onClickLabel} /> ))} @@ -281,8 +271,8 @@ export default class Logs extends PureComponent { row={row} showDuplicates={showDuplicates} showLabels={showLabels && hasLabel} - showLocalTime={showLocalTime} - showUtc={showUtc} + showTime={showTime} + timeZone={timeZone} onClickLabel={onClickLabel} /> ))} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 88639aa6864c3..93fb547eb8715 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -116,7 +116,7 @@ export class LogsContainer extends Component { if (isLive) { return ( - + ); } diff --git a/public/app/features/explore/utils/ResultProcessor.test.ts b/public/app/features/explore/utils/ResultProcessor.test.ts index 4420450c2211b..50b1bd5cecf50 100644 --- a/public/app/features/explore/utils/ResultProcessor.test.ts +++ b/public/app/features/explore/utils/ResultProcessor.test.ts @@ -6,6 +6,11 @@ jest.mock('@grafana/ui/src/utils/moment_wrapper', () => ({ format: (fmt: string) => 'format() jest mocked', }; }, + toUtc: (ts: any) => { + return { + format: (fmt: string) => 'format() jest mocked', + }; + }, })); import { ResultProcessor } from './ResultProcessor'; @@ -178,6 +183,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038519831, uniqueLabels: {}, }, @@ -191,6 +197,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038518831, uniqueLabels: {}, }, @@ -321,6 +328,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038519831, uniqueLabels: {}, }, @@ -335,6 +343,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038518831, uniqueLabels: {}, }, @@ -375,6 +384,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038519831, uniqueLabels: {}, }, @@ -389,6 +399,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1558038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1558038518831, uniqueLabels: {}, }, @@ -403,6 +414,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038519831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038519831, uniqueLabels: {}, }, @@ -417,6 +429,7 @@ describe('ResultProcessor', () => { timeEpochMs: 1559038518831, timeFromNow: 'fromNow() jest mocked', timeLocal: 'format() jest mocked', + timeUtc: 'format() jest mocked', timestamp: 1559038518831, uniqueLabels: {}, }, diff --git a/public/sass/components/_panel_logs.scss b/public/sass/components/_panel_logs.scss index b0156ce9ceeea..62e9850b7c0a8 100644 --- a/public/sass/components/_panel_logs.scss +++ b/public/sass/components/_panel_logs.scss @@ -81,11 +81,6 @@ $column-horizontal-spacing: 10px; } } -.logs-row__time { - white-space: nowrap; - width: 19em; -} - .logs-row__localtime { white-space: nowrap; width: 12.5em; From 8493965d31d60b09ec30334a3bfc74589973356e Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 26 Jun 2019 17:10:45 +0200 Subject: [PATCH 09/12] Devenv: makes the grafana users default for saml. (#17782) --- devenv/docker/blocks/saml/users.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv/docker/blocks/saml/users.php b/devenv/docker/blocks/saml/users.php index 498d86f5734e1..8ffa693c3e740 100644 --- a/devenv/docker/blocks/saml/users.php +++ b/devenv/docker/blocks/saml/users.php @@ -3,7 +3,7 @@ 'admin' => array( 'core:AdminPassword', ), - 'grafana-userpass' => array( + 'example-userpass' => array( 'exampleauth:UserPass', 'saml-admin:grafana' => array( 'groups' => array('admins'), From f53f0c806316b340fe0eb63e30c5bd6bb04291a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 26 Jun 2019 19:40:38 +0200 Subject: [PATCH 10/12] Docs: Adds section on Querying Logs for InfluxDB (#17726) Fixes #17715 --- docs/sources/features/datasources/influxdb.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/sources/features/datasources/influxdb.md b/docs/sources/features/datasources/influxdb.md index 3d905f75713e9..63ca063d3c229 100644 --- a/docs/sources/features/datasources/influxdb.md +++ b/docs/sources/features/datasources/influxdb.md @@ -117,6 +117,26 @@ You can switch to raw query mode by clicking hamburger icon and then `Switch edi You can remove the group by time by clicking on the `time` part and then the `x` icon. You can change the option `Format As` to `Table` if you want to show raw data in the `Table` panel. +## Querying Logs (BETA) + +> Only available in Grafana v6.3+. + +Querying and displaying log data from InfluxDB is available via [Explore](/features/explore). + +![](/img/docs/v63/influxdb_explore_logs.png) + +Select the InfluxDB data source, change to Logs using the Metrics/Logs switcher, +and then use the `Measurements/Fields` button to display your logs. + +### Log Queries + +The Logs Explorer (the `Measurements/Fields` button) next to the query field shows a list of measurements and fields. Choose the desired measurement that contains your log data and then choose which field Explore should use to display the log message. + +Once the result is returned, the log panel shows a list of log rows and a bar chart where the x-axis shows the time and the y-axis shows the frequency/count. + +### Filter search + +To add a filter click the plus icon to the right of the `Measurements/Fields` button or a condition. You can remove tag filters by clicking on the first select and choosing `--remove filter--`. ## Templating From 3af7eeb331b8bb62918d114dda5a9d39a0469914 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Wed, 26 Jun 2019 19:06:32 +0100 Subject: [PATCH 11/12] Docs: Adds section on Querying Logs for Elasticsearch (#17730) Closes #17713 --- docs/sources/administration/provisioning.md | 2 + .../features/datasources/elasticsearch.md | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index df1c909dc0c74..1299b7577522a 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -149,6 +149,8 @@ Since not all datasources have the same configuration settings we only have the | esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56/60/70) | | timeField | string | Elasticsearch | Which field that should be used as timestamp | | interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' | +| logMessageField | string | Elasticsearch | Which field should be used as the log message | +| logLevelField | string | Elasticsearch | Which field should be used to indicate the priority of the log message | | authType | string | Cloudwatch | Auth provider. keys/credentials/arn | | assumeRoleArn | string | Cloudwatch | ARN of Assume Role | | defaultRegion | string | Cloudwatch | AWS region | diff --git a/docs/sources/features/datasources/elasticsearch.md b/docs/sources/features/datasources/elasticsearch.md index 9d4d65e3699d8..8c07a187a5e8c 100644 --- a/docs/sources/features/datasources/elasticsearch.md +++ b/docs/sources/features/datasources/elasticsearch.md @@ -79,6 +79,18 @@ Identifier | Description `s` | second `ms` | millisecond +### Logs (BETA) + +> Only available in Grafana v6.3+. + +There are two parameters, `Message field name` and `Level field name`, that can optionally be configured from the data source settings page that determine +which fields will be used for log messages and log levels when visualizing logs in [Explore](/features/explore). + +For example, if you're using a default setup of Filebeat for shipping logs to Elasticsearch the following configuration should work: + +- **Message field name:** message +- **Level field name:** fields.level + ## Metric Query editor ![Elasticsearch Query Editor](/img/docs/elasticsearch/query_editor.png) @@ -162,6 +174,28 @@ Time | The name of the time field, needs to be date field. Text | Event description field. Tags | Optional field name to use for event tags (can be an array or a CSV string). +## Querying Logs (BETA) + +> Only available in Grafana v6.3+. + +Querying and displaying log data from Elasticsearch is available via [Explore](/features/explore). + +![](/img/docs/v63/elasticsearch_explore_logs.png) + +Select the Elasticsearch data source, change to Logs using the Metrics/Logs switcher, and then optionally enter a lucene query into the query field to filter the log messages. + +Finally, press the `Enter` key or the `Run Query` button to display your logs. + +### Log Queries + +Once the result is returned, the log panel shows a list of log rows and a bar chart where the x-axis shows the time and the y-axis shows the frequency/count. + +Note that the fields used for log message and level is based on an [optional datasource configuration](#logs-beta). + +### Filter Log Messages + +Optionally enter a lucene query into the query field to filter the log messages. For example, using a default Filebeat setup you should be able to use `fields.level:error` to only show error log messages. + ## Configure the Datasource with Provisioning It's now possible to configure datasources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for datasources on the [provisioning docs page](/administration/provisioning/#datasources) @@ -181,3 +215,22 @@ datasources: interval: Daily timeField: "@timestamp" ``` + +or, for logs: + +```yaml +apiVersion: 1 + +datasources: + - name: elasticsearch-v7-filebeat + type: elasticsearch + access: proxy + database: "[filebeat-]YYYY.MM.DD" + url: http://localhost:9200 + jsonData: + interval: Daily + timeField: "@timestamp" + esVersion: 70 + logMessageField: message + logLevelField: fields.level +``` From 0a3f977ea2c0ea368bf7ca1d9064a9eb0830be04 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Wed, 26 Jun 2019 21:15:45 +0200 Subject: [PATCH 12/12] Usage Stats: Update known datasource plugins (#17787) --- pkg/models/datasource.go | 67 +++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 10d6b38cc7a42..6df4dcb34573f 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -24,6 +24,7 @@ const ( DS_ACCESS_PROXY = "proxy" DS_STACKDRIVER = "stackdriver" DS_AZURE_MONITOR = "grafana-azure-monitor-datasource" + DS_LOKI = "loki" ) var ( @@ -82,37 +83,41 @@ func (ds *DataSource) decryptedValue(field string, fallback string) string { } var knownDatasourcePlugins = map[string]bool{ - DS_ES: true, - DS_GRAPHITE: true, - DS_INFLUXDB: true, - DS_INFLUXDB_08: true, - DS_KAIROSDB: true, - DS_CLOUDWATCH: true, - DS_PROMETHEUS: true, - DS_OPENTSDB: true, - DS_POSTGRES: true, - DS_MYSQL: true, - DS_MSSQL: true, - DS_STACKDRIVER: true, - DS_AZURE_MONITOR: true, - "opennms": true, - "abhisant-druid-datasource": true, - "dalmatinerdb-datasource": true, - "gnocci": true, - "zabbix": true, - "newrelic-app": true, - "grafana-datadog-datasource": true, - "grafana-simple-json": true, - "grafana-splunk-datasource": true, - "udoprog-heroic-datasource": true, - "grafana-openfalcon-datasource": true, - "opennms-datasource": true, - "rackerlabs-blueflood-datasource": true, - "crate-datasource": true, - "ayoungprogrammer-finance-datasource": true, - "monasca-datasource": true, - "vertamedia-clickhouse-datasource": true, - "alexanderzobnin-zabbix-datasource": true, + DS_ES: true, + DS_GRAPHITE: true, + DS_INFLUXDB: true, + DS_INFLUXDB_08: true, + DS_KAIROSDB: true, + DS_CLOUDWATCH: true, + DS_PROMETHEUS: true, + DS_OPENTSDB: true, + DS_POSTGRES: true, + DS_MYSQL: true, + DS_MSSQL: true, + DS_STACKDRIVER: true, + DS_AZURE_MONITOR: true, + DS_LOKI: true, + "opennms": true, + "abhisant-druid-datasource": true, + "dalmatinerdb-datasource": true, + "gnocci": true, + "zabbix": true, + "newrelic-app": true, + "grafana-datadog-datasource": true, + "grafana-simple-json": true, + "grafana-splunk-datasource": true, + "udoprog-heroic-datasource": true, + "grafana-openfalcon-datasource": true, + "opennms-datasource": true, + "rackerlabs-blueflood-datasource": true, + "crate-datasource": true, + "ayoungprogrammer-finance-datasource": true, + "monasca-datasource": true, + "vertamedia-clickhouse-datasource": true, + "alexanderzobnin-zabbix-datasource": true, + "grafana-influxdb-flux-datasource": true, + "doitintl-bigquery-datasource": true, + "grafana-azure-data-explorer-datasource": true, } func IsKnownDataSourcePlugin(dsType string) bool {