diff --git a/docs/sources/_index.md b/docs/sources/_index.md index ee0e2d1a0cf..999cb817612 100644 --- a/docs/sources/_index.md +++ b/docs/sources/_index.md @@ -60,9 +60,9 @@ aliases = ["/v1.1", "/guides/reference/admin", "/v3.1"]

Provisioning

A guide to help you automate your Grafana setup & configuration.

- }}" class="nav-cards__item nav-cards__item--guide"> -

What's new in v6.2

-

Article on all the new cool features and enhancements in v6.2

+
}}" class="nav-cards__item nav-cards__item--guide"> +

What's new in v6.3

+

Article on all the new cool features and enhancements in v6.3

}}" class="nav-cards__item nav-cards__item--guide">

Screencasts

diff --git a/docs/sources/auth/github.md b/docs/sources/auth/github.md index 22a10044d93..ec1eda00232 100644 --- a/docs/sources/auth/github.md +++ b/docs/sources/auth/github.md @@ -99,3 +99,18 @@ allow_sign_up = true allowed_organizations = github google ``` +### Team Sync (Enterprise only) + +> Only available in Grafana Enterprise v6.3+ + +With Team Sync you can map your GitHub org teams to teams in Grafana so that your users will automatically be added to +the correct teams. + +Your GitHub teams can be referenced in two ways: + +- `https://github.com/orgs//teams/` +- `@/` + +Example: `@grafana/developers` + +[Learn more about Team Sync]({{< relref "auth/enhanced_ldap.md" >}}) diff --git a/docs/sources/guides/whats-new-in-v6-3.md b/docs/sources/guides/whats-new-in-v6-3.md new file mode 100644 index 00000000000..bfb2642cbaf --- /dev/null +++ b/docs/sources/guides/whats-new-in-v6-3.md @@ -0,0 +1,144 @@ ++++ +title = "What's New in Grafana v6.3" +description = "Feature & improvement highlights for Grafana v6.3" +keywords = ["grafana", "new", "documentation", "6.3"] +type = "docs" +[menu.docs] +name = "Version 6.3" +identifier = "v6.3" +parent = "whatsnew" +weight = -14 ++++ + +# What's New in Grafana v6.3 + +For all details please read the full [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) + +## Highlights + +- New Explore features + - [Loki Live Streaming]({{< relref "#loki-live-streaming" >}}) + - [Loki Context Queries]({{< relref "#loki-context-queries" >}}) + - [Elasticsearch Logs Support]({{< relref "#elasticsearch-logs-support" >}}) + - [InfluxDB Logs Support]({{< relref "#influxdb-logs-support" >}}) +- [Data links]({{< relref "#data-links" >}}) +- [New Time Picker]({{< relref "#new-time-picker" >}}) +- [Graph Area Gradients]({{< relref "#graph-gradients" >}}) - A new graph display option! +- Grafana Enterprise + - [LDAP Active Sync]({{< relref "#ldap-active-sync" >}}) - LDAP Active Sync + - [SAML Authentication]({{< relref "#saml-authentication" >}}) - SAML Authentication + +## Explore improvements + +This release adds a ton of enhancements to Explore. Both in terms of new general enhancements but also in +new data source specific features. + +### Loki live streaming + +For log queries using the Loki data source you can now stream logs live directly to the Explore UI. + +### Loki context queries + +After finding a log line through the heavy use of query filters it can then be useful to +see the log lines surrounding the line your searched for. The `show context` feature +allows you to view lines before and after the line of interest. + +### Elasticsearch logs support + +This release adds support for searching & visualizing logs stored in Elasticsearch in the Explore mode. With a special +simplified query interface specifically designed for logs search. + +{{< docs-imagebox img="/img/docs/v63/elasticsearch_explore_logs.png" max-width="600px" caption="New Time Picker" >}} + +Please read [Using Elasticsearch in Grafana](/features/datasources/elasticsearch/#querying-logs-beta) for more detailed information on how to get started and use it. + +### InfluxDB logs support + +This release adds support for searching & visualizing logs stored in InfluxDB in the Explore mode. With a special +simplified query interface specifically designed for logs search. + +{{< docs-imagebox img="/img/docs/v63/influxdb_explore_logs.png" max-width="600px" caption="New Time Picker" >}} + +Please read [Using InfluxDB in Grafana](/features/datasources/influxdb/#querying-logs-beta) for more detailed information on how to get started and use it. + +## Data Links + +We have simplified the UI for defining panel drilldown links (and renamed them to Panel links). We have also added a +new type of link named `Data link`. The reason to have two different types is to make it clear how they are used +and what variables you can use in the link. Panel links are only shown in the top left corner of +the panel and you cannot reference series name or any data field. + +While `Data links` are used by the actual visualization and can reference data fields. + +Example: +```url +http://my-grafana.com/d/bPCI6VSZz/other-dashboard?var-server=${__series_name} +``` + +You have access to these variables: + +Name | Description +------------ | ------------- +*${__series_name}* | The name of the time series (or table) +*${__value_time}* | The time of the point your clicking on (in millisecond epoch) +*${__url_time_range}* | Interpolates as the full time range (i.e. from=21312323412&to=21312312312) +*${__all_variables}* | Adds all current variables (and current values) to the url + +You can then click on point in the Graph. + +{{< docs-imagebox img="/img/docs/v63/graph_datalink.png" max-width="400px" caption="New Time Picker" >}} + +For now only the Graph panel supports `Data links` but we hope to add these to many visualizations. + +## New Time Picker + +The time picker has been re-designed and with a more basic design that makes accessing quick ranges more easy. + +{{< docs-imagebox img="/img/docs/v63/time_picker.png" max-width="400px" caption="New Time Picker" >}} + +## Graph Gradients + +Want more eye candy in your graphs? Then the fill gradient option might be for you! Works really well for +graphs with only a single series. + +{{< docs-imagebox img="/img/docs/v63/graph_gradient_area.jpeg" max-width="800px" caption="Graph Gradient Area" >}} + +Looks really nice in light theme as well. + +{{< docs-imagebox img="/img/docs/v63/graph_gradients_white.png" max-width="800px" caption="Graph Gradient Area" >}} + +## Grafana Enterprise + +Substantial refactoring and improvements to the external auth systems has gone in to this release making the features +listed below possible as well as laying a foundation for future enhancements. + +### LDAP Active Sync + +This is a new Enterprise feature that enables background syncing of user information, org role and teams memberships. +This syncing is otherwise only done at login time. With this feature you can schedule how often this user synchronization should +occur. + +For example, lets say a user is removed from an LDAP group. In previous versions of Grafana an admin would have to +wait for the user to logout or the session to expire for the Grafana permissions to update, a process that can take days. + +With active sync the user would be automatically removed from the corresponding team in Grafana or even logged out and disabled if no longer +belonging to an LDAP group that gives them access to Grafana. + +[Read more](/auth/enhanced_ldap/#active-ldap-synchronization) + +### SAML Authentication + +Built-in support for SAML is now available in Grafana Enterprise. + +### Team Sync for GitHub OAuth + +When setting up OAuth with GitHub it's now possible to sync GitHub teams with Teams in Grafana. + +[See docs]({{< relref "auth/github.md" >}}) + +### Team Sync for Auth Proxy + +We've added support for enriching the Auth Proxy headers with Teams information, which makes it possible +to use Team Sync with Auth Proxy. + +[See docs](/auth/auth-proxy/#auth-proxy-authentication) diff --git a/lerna.json b/lerna.json new file mode 100644 index 00000000000..83a941b5539 --- /dev/null +++ b/lerna.json @@ -0,0 +1,6 @@ +{ + "npmClient": "yarn", + "useWorkspaces": true, + "packages": ["packages/*"], + "version": "6.3.0-alpha.36" +} diff --git a/package.json b/package.json index 0f196c33afd..569bbfd6096 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "husky": "1.3.1", "jest": "24.8.0", "jest-date-mock": "1.0.7", + "lerna": "^3.15.0", "lint-staged": "8.1.5", "load-grunt-tasks": "3.5.2", "mini-css-extract-plugin": "0.5.0", @@ -143,13 +144,11 @@ "storybook:build": "cd packages/grafana-ui && yarn storybook:build", "prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"", "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss}\" --write", - "gui:tslint": "tslint -c ./packages/grafana-ui/tslint.json --project ./packages/grafana-ui/tsconfig.json", - "gui:build": "grafana-toolkit gui:build", - "gui:releasePrepare": "grafana-toolkit gui:release", - "gui:publish": "cd packages/grafana-ui/dist && npm publish --access public", - "gui:release": "grafana-toolkit gui:release -p --createVersionCommit", "precommit": "grafana-toolkit precommit", - "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts" + "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts", + "packages:prepare": "lerna run clean && npm run test && lerna version --tag-version-prefix=\"packages@\" -m \"Packages: publish %s\" --no-push", + "packages:build": "lerna run clean && lerna run build", + "packages:publish": "lerna publish from-package --contents dist --tag-version-prefix=\"packages@\" --dist-tag next" }, "husky": { "hooks": { diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 00000000000..992a72c4f51 --- /dev/null +++ b/packages/README.md @@ -0,0 +1,15 @@ +## Grafana frontend packages + +## Releasing new version +We use [Lerna](https://github.com/lerna/lerna) for packages versioning and releases + +### Manual release +1. Run `packages:prepare` script from root directory. This will perform cleanup, run all tests and bump version for all packages. Also, it will create `@packages@[version]` tag and version bump commit with `Packages: publish [version]` message. +2. Run `packages:build` script that will prepare distribution packages. +3. Run `packages:publish` to publish new versions + - add `--dist-tag next` to publish under `next` tag +4. Push version commit + +### Building individual packages +To build induvidual packages run `grafana-toolkit package:build --scope=` + diff --git a/packages/grafana-data/CHANGELOG.md b/packages/grafana-data/CHANGELOG.md new file mode 100644 index 00000000000..556d4241a68 --- /dev/null +++ b/packages/grafana-data/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 86f13dce934..34d6509bfde 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/data", - "version": "6.3.0-alpha.0", + "version": "6.3.0-alpha.36", "description": "Grafana Data Library", "keywords": [ "typescript" @@ -10,11 +10,12 @@ "tslint": "tslint -c tslint.json --project tsconfig.json", "typecheck": "tsc --noEmit", "clean": "rimraf ./dist ./compiled", - "build": "rollup -c rollup.config.ts" + "bundle": "rollup -c rollup.config.ts", + "build": "grafana-toolkit package:build --scope=data", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", - "dependencies": {}, "devDependencies": { "@types/jest": "23.3.14", "@types/jquery": "1.10.35", diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index d7a39d1db21..9d05768b679 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -97,6 +97,9 @@ export interface AnnotationEvent { dashboardId?: number; panelId?: number; userId?: number; + login?: string; + email?: string; + avatarUrl?: string; time?: number; timeEnd?: number; isRegion?: boolean; diff --git a/packages/grafana-data/src/utils/fieldReducer.test.ts b/packages/grafana-data/src/utils/fieldReducer.test.ts index abd29307ddf..7e4e205cb22 100644 --- a/packages/grafana-data/src/utils/fieldReducer.test.ts +++ b/packages/grafana-data/src/utils/fieldReducer.test.ts @@ -91,4 +91,58 @@ describe('Stats Calculators', () => { expect(stats.step).toEqual(100); expect(stats.delta).toEqual(300); }); + + it('consistent results for first/last value with null', () => { + const info = [ + { + rows: [[null], [200], [null]], // first/last value is null + result: 200, + }, + { + rows: [[null], [null], [null]], // All null + result: undefined, + }, + { + rows: [], // Empty row + result: undefined, + }, + ]; + const fields = [{ name: 'A' }]; + + const stats = reduceField({ + series: { rows: info[0].rows, fields }, + fieldIndex: 0, + reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull], // uses standard path + }); + expect(stats[ReducerID.first]).toEqual(null); + expect(stats[ReducerID.last]).toEqual(null); + expect(stats[ReducerID.firstNotNull]).toEqual(200); + expect(stats[ReducerID.lastNotNull]).toEqual(200); + + const reducers = [ReducerID.lastNotNull, ReducerID.firstNotNull]; + for (const input of info) { + for (const reducer of reducers) { + const v1 = reduceField({ + series: { rows: input.rows, fields }, + fieldIndex: 0, + reducers: [reducer, ReducerID.mean], // uses standard path + })[reducer]; + + const v2 = reduceField({ + series: { rows: input.rows, fields }, + fieldIndex: 0, + reducers: [reducer], // uses optimized path + })[reducer]; + + if (v1 !== v2 || v1 !== input.result) { + const msg = + `Invalid ${reducer} result for: ` + + input.rows.join(', ') + + ` Expected: ${input.result}` + // configured + ` Recieved: Multiple: ${v1}, Single: ${v2}`; + expect(msg).toEqual(null); + } + } + } + }); }); diff --git a/packages/grafana-data/src/utils/fieldReducer.ts b/packages/grafana-data/src/utils/fieldReducer.ts index dc0ef596e25..f0b9b789208 100644 --- a/packages/grafana-data/src/utils/fieldReducer.ts +++ b/packages/grafana-data/src/utils/fieldReducer.ts @@ -17,6 +17,9 @@ export enum ReducerID { delta = 'delta', step = 'step', + firstNotNull = 'firstNotNull', + lastNotNull = 'lastNotNull', + changeCount = 'changeCount', distinctCount = 'distinctCount', @@ -132,14 +135,28 @@ function getById(id: string): FieldReducerInfo | undefined { if (!hasBuiltIndex) { [ { - id: ReducerID.last, - name: 'Last', - description: 'Last Value (current)', + id: ReducerID.lastNotNull, + name: 'Last (not null)', + description: 'Last non-null value', standard: true, alias: 'current', + reduce: calculateLastNotNull, + }, + { + id: ReducerID.last, + name: 'Last', + description: 'Last Value', + standard: true, reduce: calculateLast, }, { id: ReducerID.first, name: 'First', description: 'First Value', standard: true, reduce: calculateFirst }, + { + id: ReducerID.firstNotNull, + name: 'First (not null)', + description: 'First non-null value', + standard: true, + reduce: calculateFirstNotNull, + }, { id: ReducerID.min, name: 'Min', description: 'Minimum Value', standard: true }, { id: ReducerID.max, name: 'Max', description: 'Maximum Value', standard: true }, { id: ReducerID.mean, name: 'Mean', description: 'Average Value', standard: true, alias: 'avg' }, @@ -231,6 +248,8 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole mean: null, last: null, first: null, + lastNotNull: undefined, + firstNotNull: undefined, count: 0, nonNullCount: 0, allIsNull: true, @@ -246,6 +265,10 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole for (let i = 0; i < data.rows.length; i++) { let currentValue = data.rows[i][fieldIndex]; + if (i === 0) { + calcs.first = currentValue; + } + calcs.last = currentValue; if (currentValue === null) { if (ignoreNulls) { @@ -257,9 +280,9 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole } if (currentValue !== null) { - const isFirst = calcs.first === null; + const isFirst = calcs.firstNotNull === undefined; if (isFirst) { - calcs.first = currentValue; + calcs.firstNotNull = currentValue; } if (isNumber(currentValue)) { @@ -268,12 +291,12 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole calcs.nonNullCount++; if (!isFirst) { - const step = currentValue - calcs.last!; + const step = currentValue - calcs.lastNotNull!; if (calcs.step > step) { calcs.step = step; // the minimum interval } - if (calcs.last! > currentValue) { + if (calcs.lastNotNull! > currentValue) { // counter reset calcs.previousDeltaUp = false; if (i === data.rows.length - 1) { @@ -307,7 +330,7 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole calcs.allIsZero = false; } - calcs.last = currentValue; + calcs.lastNotNull = currentValue; } } @@ -331,10 +354,8 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole calcs.range = calcs.max - calcs.min; } - if (calcs.first !== null && calcs.last !== null) { - if (isNumber(calcs.first) && isNumber(calcs.last)) { - calcs.diff = calcs.last - calcs.first; - } + if (isNumber(calcs.firstNotNull) && isNumber(calcs.lastNotNull)) { + calcs.diff = calcs.lastNotNull - calcs.firstNotNull; } return calcs; @@ -344,10 +365,41 @@ function calculateFirst(data: DataFrame, fieldIndex: number, ignoreNulls: boolea return { first: data.rows[0][fieldIndex] }; } +function calculateFirstNotNull( + data: DataFrame, + fieldIndex: number, + ignoreNulls: boolean, + nullAsZero: boolean +): FieldCalcs { + for (let idx = 0; idx < data.rows.length; idx++) { + const v = data.rows[idx][fieldIndex]; + if (v != null) { + return { firstNotNull: v }; + } + } + return { firstNotNull: undefined }; +} + function calculateLast(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs { return { last: data.rows[data.rows.length - 1][fieldIndex] }; } +function calculateLastNotNull( + data: DataFrame, + fieldIndex: number, + ignoreNulls: boolean, + nullAsZero: boolean +): FieldCalcs { + let idx = data.rows.length - 1; + while (idx >= 0) { + const v = data.rows[idx--][fieldIndex]; + if (v != null) { + return { lastNotNull: v }; + } + } + return { lastNotNull: undefined }; +} + function calculateChangeCount( data: DataFrame, fieldIndex: number, diff --git a/packages/grafana-data/tsconfig.json b/packages/grafana-data/tsconfig.json index dcc4fd97436..a7d3e870c45 100644 --- a/packages/grafana-data/tsconfig.json +++ b/packages/grafana-data/tsconfig.json @@ -1,19 +1,11 @@ { - "extends": "../../tsconfig.json", + "extends": "../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.tsx", "../../public/app/types/jquery/*.ts"], "exclude": ["dist", "node_modules"], "compilerOptions": { "rootDirs": ["."], - "module": "esnext", - "outDir": "compiled", - "declaration": true, - "declarationDir": "dist", - "strict": true, - "alwaysStrict": true, - "noImplicitAny": true, - "strictNullChecks": true, "typeRoots": ["./node_modules/@types", "types"], - "skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors, - "removeComments": false + "declarationDir": "dist", + "outDir": "compiled" } } diff --git a/packages/grafana-runtime/CHANGELOG.md b/packages/grafana-runtime/CHANGELOG.md new file mode 100644 index 00000000000..556d4241a68 --- /dev/null +++ b/packages/grafana-runtime/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2019-07-08) +First public release + diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 3ce6061298e..7798b549dac 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/runtime", - "version": "6.3.0-alpha.0", + "version": "6.3.0-alpha.36", "description": "Grafana Runtime Library", "keywords": [ "grafana" @@ -10,7 +10,9 @@ "tslint": "tslint -c tslint.json --project tsconfig.json", "typecheck": "tsc --noEmit", "clean": "rimraf ./dist ./compiled", - "build": "rollup -c rollup.config.ts" + "bundle": "rollup -c rollup.config.ts", + "build": "grafana-toolkit package:build --scope=runtime", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", diff --git a/packages/grafana-runtime/rollup.config.ts b/packages/grafana-runtime/rollup.config.ts index a2d6da109d9..64f5ec8a3c0 100644 --- a/packages/grafana-runtime/rollup.config.ts +++ b/packages/grafana-runtime/rollup.config.ts @@ -20,7 +20,7 @@ const buildCjsPackage = ({ env }) => { globals: {}, }, ], - external: ['lodash'], // Use Lodash from grafana + external: ['lodash', '@grafana/ui', '@grafana/data'], // Use Lodash from grafana plugins: [ commonjs({ include: /node_modules/, diff --git a/packages/grafana-runtime/tsconfig.json b/packages/grafana-runtime/tsconfig.json index dcc4fd97436..a7d3e870c45 100644 --- a/packages/grafana-runtime/tsconfig.json +++ b/packages/grafana-runtime/tsconfig.json @@ -1,19 +1,11 @@ { - "extends": "../../tsconfig.json", + "extends": "../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.tsx", "../../public/app/types/jquery/*.ts"], "exclude": ["dist", "node_modules"], "compilerOptions": { "rootDirs": ["."], - "module": "esnext", - "outDir": "compiled", - "declaration": true, - "declarationDir": "dist", - "strict": true, - "alwaysStrict": true, - "noImplicitAny": true, - "strictNullChecks": true, "typeRoots": ["./node_modules/@types", "types"], - "skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors, - "removeComments": false + "declarationDir": "dist", + "outDir": "compiled" } } diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md index 71ff7376ac9..58b94d101c1 100644 --- a/packages/grafana-toolkit/README.md +++ b/packages/grafana-toolkit/README.md @@ -84,7 +84,7 @@ Adidtionaly, you can also provide additional Jest config via package.json file. ## Working with CSS & static assets -We support pure css, SASS and CSS in JS approach (via Emotion). All static assets referenced in your code (i.e. images) should be placed under `src/static` directory and referenced using relative paths. +We support pure css, SASS and CSS in JS approach (via Emotion). 1. Single css/sass file Create your css/sass file and import it in your plugin entry point (typically module.ts): @@ -101,6 +101,8 @@ If you want to provide different stylesheets for dark/light theme, create `dark. TODO: add note about loadPluginCss +Note that static files (png, svg, json, html) are all copied to dist directory when the plugin is bundled. Relative paths to those files does not change. + 3. Emotion Starting from Grafana 6.2 our suggested way of styling plugins is by using [Emotion](https://emotion.sh). It's a css-in-js library that we use internaly at Grafana. The biggest advantage of using Emotion is that you will get access to Grafana Theme variables. diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index dbc667de0db..88713004773 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/toolkit", - "version": "6.3.0-alpha.2", + "version": "6.3.0-alpha.36", "description": "Grafana Toolkit", "keywords": [ "grafana", @@ -14,7 +14,9 @@ "tslint": "tslint -c tslint.json --project tsconfig.json", "typecheck": "tsc --noEmit", "precommit": "npm run tslint & npm run typecheck", - "clean": "rimraf ./dist ./compiled" + "clean": "rimraf ./dist ./compiled", + "build": "grafana-toolkit toolkit:build", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", @@ -31,6 +33,7 @@ "@types/webpack": "4.4.34", "axios": "0.19.0", "babel-loader": "8.0.6", + "babel-plugin-angularjs-annotate": "0.10.0", "chalk": "^2.4.2", "commander": "^2.20.0", "concurrently": "4.1.0", @@ -46,7 +49,6 @@ "jest-coverage-badges": "^1.1.2", "lodash": "4.17.11", "mini-css-extract-plugin": "^0.7.0", - "ng-annotate-webpack-plugin": "^0.3.0", "node-sass": "^4.12.0", "optimize-css-assets-webpack-plugin": "^5.0.3", "ora": "^3.4.0", diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index 423a14b4a58..17277a4b4bf 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -3,8 +3,6 @@ import program from 'commander'; import { execTask } from './utils/execTask'; import chalk from 'chalk'; import { startTask } from './tasks/core.start'; -import { buildTask } from './tasks/grafanaui.build'; -import { releaseTask } from './tasks/grafanaui.release'; import { changelogTask } from './tasks/changelog'; import { cherryPickTask } from './tasks/cherrypick'; import { precommitTask } from './tasks/precommit'; @@ -16,6 +14,7 @@ import { searchTestDataSetupTask } from './tasks/searchTestDataSetup'; import { closeMilestoneTask } from './tasks/closeMilestone'; import { pluginDevTask } from './tasks/plugin.dev'; import { pluginCITask } from './tasks/plugin.ci'; +import { buildPackageTask } from './tasks/package.build'; export const run = (includeInternalScripts = false) => { if (includeInternalScripts) { @@ -33,24 +32,12 @@ export const run = (includeInternalScripts = false) => { }); program - .command('gui:build') - .description('Builds @grafana/ui package to packages/grafana-ui/dist') + .command('package:build') + .option('-s, --scope ', 'packages=[data|runtime|ui|toolkit]') + .description('Builds @grafana/* package to packages/grafana-*/dist') .action(async cmd => { - // @ts-ignore - await execTask(buildTask)(); - }); - - program - .command('gui:release') - .description('Prepares @grafana/ui release (and publishes to npm on demand)') - .option('-p, --publish', 'Publish @grafana/ui to npm registry') - .option('-u, --usePackageJsonVersion', 'Use version specified in package.json') - .option('--createVersionCommit', 'Create and push version commit') - .action(async cmd => { - await execTask(releaseTask)({ - publishToNpm: !!cmd.publish, - usePackageJsonVersion: !!cmd.usePackageJsonVersion, - createVersionCommit: !!cmd.createVersionCommit, + await execTask(buildPackageTask)({ + scope: cmd.scope, }); }); diff --git a/packages/grafana-toolkit/src/cli/tasks/changelog.ts b/packages/grafana-toolkit/src/cli/tasks/changelog.ts index 004ea46603a..36649d8e718 100644 --- a/packages/grafana-toolkit/src/cli/tasks/changelog.ts +++ b/packages/grafana-toolkit/src/cli/tasks/changelog.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; // @ts-ignore import * as _ from 'lodash'; import { Task, TaskRunner } from './task'; diff --git a/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts b/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts deleted file mode 100644 index eb8da0da1c2..00000000000 --- a/packages/grafana-toolkit/src/cli/tasks/grafanaui.release.ts +++ /dev/null @@ -1,190 +0,0 @@ -import execa = require('execa'); -import { execTask } from '../utils/execTask'; -import { changeCwdToGrafanaUiDist, changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd'; -import { ReleaseType, inc } from 'semver'; -import { prompt } from 'inquirer'; -import chalk from 'chalk'; -import { useSpinner } from '../utils/useSpinner'; -import { savePackage, buildTask, clean } from './grafanaui.build'; -import { TaskRunner, Task } from './task'; - -type VersionBumpType = 'prerelease' | 'patch' | 'minor' | 'major'; - -interface ReleaseTaskOptions { - publishToNpm: boolean; - usePackageJsonVersion: boolean; - createVersionCommit: boolean; -} - -const promptBumpType = async () => { - return prompt<{ type: VersionBumpType }>([ - { - type: 'list', - message: 'Select version bump', - name: 'type', - choices: ['prerelease', 'patch', 'minor', 'major'], - }, - ]); -}; - -const promptPrereleaseId = async (message = 'Is this a prerelease?', allowNo = true) => { - return prompt<{ id: string }>([ - { - type: 'list', - message: message, - name: 'id', - choices: allowNo ? ['no', 'alpha', 'beta'] : ['alpha', 'beta'], - }, - ]); -}; - -const promptConfirm = async (message?: string) => { - return prompt<{ confirmed: boolean }>([ - { - type: 'confirm', - message: message || 'Is that correct?', - name: 'confirmed', - default: false, - }, - ]); -}; - -// Since Grafana core depends on @grafana/ui highly, we run full check before release -const runChecksAndTests = async () => - // @ts-ignore - useSpinner(`Running checks and tests`, async () => { - try { - await execa('npm', ['run', 'test']); - } catch (e) { - console.log(e); - throw e; - } - })(); - -const bumpVersion = (version: string) => - // @ts-ignore - useSpinner(`Saving version ${version} to package.json`, async () => { - changeCwdToGrafanaUi(); - await execa('npm', ['version', version]); - changeCwdToGrafanaUiDist(); - const pkg = require(`${process.cwd()}/package.json`); - pkg.version = version; - await savePackage({ path: `${process.cwd()}/package.json`, pkg }); - })(); - -const publishPackage = (name: string, version: string) => - // @ts-ignore - useSpinner(`Publishing ${name} @ ${version} to npm registry...`, async () => { - changeCwdToGrafanaUiDist(); - await execa('npm', ['publish', '--access', 'public']); - })(); - -const ensureMasterBranch = async () => { - const currentBranch = await execa.stdout('git', ['symbolic-ref', '--short', 'HEAD']); - const status = await execa.stdout('git', ['status', '--porcelain']); - - if (currentBranch !== 'master' && status !== '') { - console.error(chalk.red.bold('You need to be on clean master branch to release @grafana/ui')); - process.exit(1); - } -}; - -const prepareVersionCommitAndPush = async (version: string) => - // @ts-ignore - useSpinner('Commiting and pushing @grafana/ui version update', async () => { - await execa.stdout('git', ['commit', '-a', '-m', `Upgrade @grafana/ui version to v${version}`]); - await execa.stdout('git', ['push']); - })(); - -const releaseTaskRunner: TaskRunner = async ({ - publishToNpm, - usePackageJsonVersion, - createVersionCommit, -}) => { - changeCwdToGrafanaUi(); - // @ts-ignore - await clean(); // Clean previous build if exists - restoreCwd(); - - if (publishToNpm) { - // TODO: Ensure release branch - // When need to update this when we star keeping @grafana/ui releases in sync with core - await ensureMasterBranch(); - } - - await runChecksAndTests(); - - await execTask(buildTask)({} as any); - - let releaseConfirmed = false; - let nextVersion; - changeCwdToGrafanaUiDist(); - - const pkg = require(`${process.cwd()}/package.json`); - - console.log(`Current version: ${pkg.version}`); - - do { - if (!usePackageJsonVersion) { - const { type } = await promptBumpType(); - console.log(type); - if (type === 'prerelease') { - const { id } = await promptPrereleaseId('What kind of prerelease?', false); - nextVersion = inc(pkg.version, type, id as any); - } else { - const { id } = await promptPrereleaseId(); - if (id !== 'no') { - nextVersion = inc(pkg.version, `pre${type}` as ReleaseType, id as any); - } else { - nextVersion = inc(pkg.version, type as ReleaseType); - } - } - } else { - nextVersion = pkg.version; - } - - console.log(chalk.yellowBright.bold(`You are going to release a new version of ${pkg.name}`)); - - if (usePackageJsonVersion) { - console.log(chalk.green(`Version based on package.json: `), chalk.bold.yellowBright(`${nextVersion}`)); - } else { - console.log(chalk.green(`Version bump: ${pkg.version} ->`), chalk.bold.yellowBright(`${nextVersion}`)); - } - - const { confirmed } = await promptConfirm(); - - releaseConfirmed = confirmed; - } while (!releaseConfirmed); - - if (!usePackageJsonVersion) { - await bumpVersion(nextVersion); - } - - if (createVersionCommit) { - await prepareVersionCommitAndPush(nextVersion); - } - - if (publishToNpm) { - console.log(chalk.yellowBright.bold(`\nReview dist package.json before proceeding!\n`)); - const { confirmed } = await promptConfirm('Are you ready to publish to npm?'); - - if (!confirmed) { - process.exit(); - } - - await publishPackage(pkg.name, nextVersion); - console.log(chalk.green(`\nVersion ${nextVersion} of ${pkg.name} succesfully released!`)); - console.log(chalk.yellow(`\nUpdated @grafana/ui/package.json with version bump created.`)); - - process.exit(); - } else { - console.log( - chalk.green( - `\nVersion ${nextVersion} of ${pkg.name} succesfully prepared for release. See packages/grafana-ui/dist` - ) - ); - console.log(chalk.green(`\nTo publish to npm registry run`), chalk.bold.blue(`npm run gui:publish`)); - } -}; - -export const releaseTask = new Task('@grafana/ui release', releaseTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts b/packages/grafana-toolkit/src/cli/tasks/package.build.ts similarity index 61% rename from packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts rename to packages/grafana-toolkit/src/cli/tasks/package.build.ts index 6dbbc7b1d36..c52c098c32f 100644 --- a/packages/grafana-toolkit/src/cli/tasks/grafanaui.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/package.build.ts @@ -1,7 +1,8 @@ import execa = require('execa'); // @ts-ignore import * as fs from 'fs'; -import { changeCwdToGrafanaUi, restoreCwd } from '../utils/cwd'; +// @ts-ignore +import * as path from 'path'; import chalk from 'chalk'; import { useSpinner } from '../utils/useSpinner'; import { Task, TaskRunner } from './task'; @@ -15,7 +16,7 @@ export const clean = useSpinner('Cleaning', async () => await execa('npm', const compile = useSpinner('Compiling sources', () => execa('tsc', ['-p', './tsconfig.build.json'])); // @ts-ignore -const rollup = useSpinner('Bundling', () => execa('npm', ['run', 'build'])); +const rollup = useSpinner('Bundling', () => execa('npm', ['run', 'bundle'])); interface SavePackageOptions { path: string; @@ -68,19 +69,34 @@ const moveFiles = () => { })(); }; -const buildTaskRunner: TaskRunner = async () => { - cwd = changeCwdToGrafanaUi(); - distDir = `${cwd}/dist`; - const pkg = require(`${cwd}/package.json`); - console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); +interface PackageBuildOptions { + scope: string; +} - await clean(); - await compile(); - await rollup(); - await preparePackage(pkg); - await moveFiles(); +const buildTaskRunner: TaskRunner = async ({ scope }) => { + if (!scope) { + throw new Error('Provide packages with -s, --scope '); + } - restoreCwd(); + const scopes = scope.split(',').map(s => { + return async () => { + cwd = path.resolve(__dirname, `../../../../grafana-${s}`); + // Lerna executes this in package's dir context, but for testing purposes I want to be able to run from root: + // grafana-toolkit package:build --scope= + process.chdir(cwd); + distDir = `${cwd}/dist`; + const pkg = require(`${cwd}/package.json`); + console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); + + await clean(); + await compile(); + await rollup(); + await preparePackage(pkg); + await moveFiles(); + }; + }); + + await Promise.all(scopes.map(s => s())); }; -export const buildTask = new Task('@grafana/ui build', buildTaskRunner); +export const buildPackageTask = new Task('@grafana/ui build', buildTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts index 40b4e946e5a..0266b308a84 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.build.ts @@ -4,7 +4,6 @@ import execa = require('execa'); import path = require('path'); import fs = require('fs'); import glob = require('glob'); -import util = require('util'); import { Linter, Configuration, RuleFailure } from 'tslint'; import * as prettier from 'prettier'; @@ -17,7 +16,6 @@ interface PluginBuildOptions { export const bundlePlugin = useSpinner('Compiling...', async options => await bundleFn(options)); -const readFileAsync = util.promisify(fs.readFile); // @ts-ignore export const clean = useSpinner('Cleaning', async () => await execa('rimraf', [`${process.cwd()}/dist`])); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts index 9a5834de9dc..c9ce6db4e65 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -1,6 +1,5 @@ import { Task, TaskRunner } from './task'; import { pluginBuildRunner } from './plugin.build'; -import { useSpinner } from '../utils/useSpinner'; import { restoreCwd } from '../utils/cwd'; import { getPluginJson } from '../../config/utils/pluginValidation'; diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts index b6ba6d9bda0..a5fb1d37a74 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/bundle.ts @@ -1,5 +1,3 @@ -import path = require('path'); -import fs = require('fs'); import webpack = require('webpack'); import { getWebpackConfig } from '../../../config/webpack.plugin.config'; import formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts index eba45d6c24a..a5926b9f4b4 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/tests.ts @@ -1,4 +1,3 @@ -import path = require('path'); import * as jestCLI from 'jest-cli'; import { useSpinner } from '../../utils/useSpinner'; import { jestConfig } from '../../../config/jest.plugin.config'; diff --git a/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts b/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts index 915f431a957..0e051dc2dff 100644 --- a/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts +++ b/packages/grafana-toolkit/src/cli/tasks/searchTestDataSetup.ts @@ -46,7 +46,6 @@ export async function getTeam(team: any): Promise { } export async function addToTeam(team: any, user: any): Promise { - const members = await client.get(`/teams/${team.id}/members`); console.log(`Adding user ${user.name} to team ${team.name}`); await client.post(`/teams/${team.id}/members`, { userId: user.id }); } diff --git a/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts index 61fd6fcaee0..68f53cb8668 100644 --- a/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts +++ b/packages/grafana-toolkit/src/cli/tasks/toolkit.build.ts @@ -1,6 +1,5 @@ import execa = require('execa'); import * as fs from 'fs'; -import { changeCwdToGrafanaUi, restoreCwd, changeCwdToGrafanaToolkit } from '../utils/cwd'; import chalk from 'chalk'; import { useSpinner } from '../utils/useSpinner'; import { Task, TaskRunner } from './task'; @@ -102,7 +101,7 @@ const copySassFiles = () => { }; const toolkitBuildTaskRunner: TaskRunner = async () => { - cwd = changeCwdToGrafanaToolkit(); + cwd = path.resolve(__dirname, '../../../'); distDir = `${cwd}/dist`; const pkg = require(`${cwd}/package.json`); console.log(chalk.yellow(`Building ${pkg.name} (package.json version: ${pkg.version})`)); @@ -114,7 +113,6 @@ const toolkitBuildTaskRunner: TaskRunner = async () => { fs.mkdirSync('./dist/sass'); await moveFiles(); await copySassFiles(); - restoreCwd(); }; export const toolkitBuildTask = new Task('@grafana/toolkit build', toolkitBuildTaskRunner); diff --git a/packages/grafana-toolkit/src/cli/utils/cwd.ts b/packages/grafana-toolkit/src/cli/utils/cwd.ts index 48ca39e5da2..836ebd0b608 100644 --- a/packages/grafana-toolkit/src/cli/utils/cwd.ts +++ b/packages/grafana-toolkit/src/cli/utils/cwd.ts @@ -17,3 +17,15 @@ export const changeCwdToGrafanaUiDist = () => { export const restoreCwd = () => { process.chdir(cwd); }; + +type PackageId = 'ui' | 'data' | 'runtime' | 'toolkit'; + +export const changeCwdToPackage = (scope: PackageId) => { + try { + process.chdir(`${cwd}/packages/grafana-${scope}`); + } catch (e) { + throw e; + } + + return process.cwd(); +}; diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 79e461b2b4a..5e48b2d3a66 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -5,9 +5,9 @@ const ReplaceInFileWebpackPlugin = require('replace-in-file-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); -const ngAnnotatePlugin = require('ng-annotate-webpack-plugin'); + import * as webpack from 'webpack'; -import { hasThemeStylesheets, getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack/loaders'; +import { getStyleLoaders, getStylesheetEntries, getFileLoaders } from './webpack/loaders'; interface WebpackConfigurationOptions { watch?: boolean; @@ -51,6 +51,7 @@ const getManualChunk = (id: string) => { }; } } + return null; }; const getEntries = () => { @@ -114,7 +115,6 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { const optimization: { [key: string]: any } = {}; if (options.production) { - plugins.push(new ngAnnotatePlugin()); optimization.minimizer = [new TerserPlugin(), new OptimizeCssAssetsPlugin()]; } @@ -177,8 +177,12 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => { loaders: [ { loader: 'babel-loader', - options: { presets: ['@babel/preset-env'] }, + options: { + presets: ['@babel/preset-env'], + plugins: ['angularjs-annotate'], + }, }, + 'ts-loader', ], exclude: /(node_modules)/, diff --git a/packages/grafana-toolkit/src/config/webpack/loaders.test.ts b/packages/grafana-toolkit/src/config/webpack/loaders.test.ts index af30c990ffe..58913d20c1a 100644 --- a/packages/grafana-toolkit/src/config/webpack/loaders.test.ts +++ b/packages/grafana-toolkit/src/config/webpack/loaders.test.ts @@ -3,7 +3,6 @@ import { getStylesheetEntries, hasThemeStylesheets } from './loaders'; describe('Loaders', () => { describe('stylesheet helpers', () => { const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); afterAll(() => { logSpy.mockRestore(); diff --git a/packages/grafana-toolkit/src/config/webpack/loaders.ts b/packages/grafana-toolkit/src/config/webpack/loaders.ts index 3cdcfeee486..aab0846b45d 100644 --- a/packages/grafana-toolkit/src/config/webpack/loaders.ts +++ b/packages/grafana-toolkit/src/config/webpack/loaders.ts @@ -1,6 +1,3 @@ -import { getPluginJson } from '../utils/pluginValidation'; - -const path = require('path'); const fs = require('fs'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); @@ -122,8 +119,8 @@ export const getFileLoaders = () => { ? { loader: 'file-loader', options: { - outputPath: 'static', - name: '[name].[hash:8].[ext]', + outputPath: '/', + name: '[path][name].[ext]', }, } : // When using single css import images are inlined as base64 URIs in the result bundle diff --git a/packages/grafana-toolkit/tsconfig.json b/packages/grafana-toolkit/tsconfig.json index 28b734aa9cd..d7c48a886b9 100644 --- a/packages/grafana-toolkit/tsconfig.json +++ b/packages/grafana-toolkit/tsconfig.json @@ -1,17 +1,13 @@ { + "extends": "../tsconfig.json", "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules"], "compilerOptions": { "module": "commonjs", "rootDirs": ["."], "outDir": "dist/src", - "strict": true, - "alwaysStrict": true, - "noImplicitAny": true, - "strictNullChecks": true, + "declaration": false, "typeRoots": ["./node_modules/@types"], - "skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors, - "removeComments": false, "esModuleInterop": true, "lib": ["es2015", "es2017.string"] } diff --git a/packages/grafana-ui/README.md b/packages/grafana-ui/README.md index f897127b660..bc45a78e576 100644 --- a/packages/grafana-ui/README.md +++ b/packages/grafana-ui/README.md @@ -15,37 +15,3 @@ See [package source](https://github.com/grafana/grafana/tree/master/packages/gra ## Development 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. - -## Releasing new version - -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 - -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 -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/package.json b/packages/grafana-ui/package.json index 030377923a5..de0cb7f3eb1 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -1,6 +1,6 @@ { "name": "@grafana/ui", - "version": "6.3.0-alpha.1", + "version": "6.3.0-alpha.36", "description": "Grafana Components Library", "keywords": [ "grafana", @@ -14,7 +14,9 @@ "storybook": "start-storybook -p 9001 -c .storybook", "storybook:build": "build-storybook -o ./dist/storybook -c .storybook", "clean": "rimraf ./dist ./compiled", - "build": "rollup -c rollup.config.ts" + "bundle": "rollup -c rollup.config.ts", + "build": "grafana-toolkit package:build --scope=ui", + "postpublish": "npm run clean" }, "author": "Grafana Labs", "license": "Apache-2.0", diff --git a/packages/grafana-ui/src/components/TimePicker/time.ts b/packages/grafana-ui/src/components/TimePicker/time.ts index eb9bb0b0b23..7d90e04db6b 100644 --- a/packages/grafana-ui/src/components/TimePicker/time.ts +++ b/packages/grafana-ui/src/components/TimePicker/time.ts @@ -1,7 +1,15 @@ -import { TimeRange, TIME_FORMAT, RawTimeRange, TimeZone } from '@grafana/data'; -import { describeTimeRange } from '@grafana/data/src/utils/rangeutil'; -import { dateMath } from '@grafana/data'; -import { isDateTime, dateTime, DateTime, toUtc } from '@grafana/data'; +import { + TimeRange, + TIME_FORMAT, + RawTimeRange, + TimeZone, + rangeUtil, + dateMath, + isDateTime, + dateTime, + DateTime, + toUtc, +} from '@grafana/data'; export const rawToTimeRange = (raw: RawTimeRange, timeZone?: TimeZone): TimeRange => { const from = stringToDateTimeType(raw.from, false, timeZone); @@ -32,7 +40,7 @@ export const stringToDateTimeType = (value: string | DateTime, roundUp?: boolean }; export const mapTimeRangeToRangeString = (timeRange: RawTimeRange): string => { - return describeTimeRange(timeRange); + return rangeUtil.describeTimeRange(timeRange); }; export const isValidTimeString = (text: string) => dateMath.isValid(text); diff --git a/public/app/features/explore/utils/dom.ts b/packages/grafana-ui/src/utils/dom.ts similarity index 81% rename from public/app/features/explore/utils/dom.ts rename to packages/grafana-ui/src/utils/dom.ts index 381c150e3f4..39582d493f7 100644 --- a/public/app/features/explore/utils/dom.ts +++ b/packages/grafana-ui/src/utils/dom.ts @@ -1,6 +1,6 @@ // Node.closest() polyfill if ('Element' in window && !Element.prototype.closest) { - Element.prototype.closest = function(this: any, s) { + Element.prototype.closest = function(this: any, s: string) { const matches = (this.document || this.ownerDocument).querySelectorAll(s); let el = this; let i; @@ -15,7 +15,7 @@ if ('Element' in window && !Element.prototype.closest) { }; } -export function getPreviousCousin(node, selector) { +export function getPreviousCousin(node: any, selector: string) { let sibling = node.parentElement.previousSibling; let el; while (sibling) { @@ -30,12 +30,12 @@ export function getPreviousCousin(node, selector) { export function getNextCharacter(global = window) { const selection = global.getSelection(); - if (!selection.anchorNode) { + if (!selection || !selection.anchorNode) { return null; } const range = selection.getRangeAt(0); const text = selection.anchorNode.textContent; const offset = range.startOffset; - return text.substr(offset, 1); + return text!.substr(offset, 1); } diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 6ccf5a71107..0c973b08486 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -7,3 +7,7 @@ export * from './deprecationWarning'; export * from './validate'; export { getFlotPairs } from './flotPairs'; export * from './slate'; + +// Export with a namespace +import * as DOMUtil from './dom'; // includes Element.closest polyfil +export { DOMUtil }; diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json index 0089f89d194..d6dbfc1e0b7 100644 --- a/packages/grafana-ui/tsconfig.json +++ b/packages/grafana-ui/tsconfig.json @@ -1,19 +1,11 @@ { - "extends": "../../tsconfig.json", + "extends": "../tsconfig.json", "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["dist", "node_modules"], "compilerOptions": { "rootDirs": [".", "stories"], - "module": "esnext", - "outDir": "compiled", - "declaration": true, - "declarationDir": "dist", - "strict": true, - "alwaysStrict": true, - "noImplicitAny": true, - "strictNullChecks": true, "typeRoots": ["./node_modules/@types", "types"], - "skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors, - "removeComments": false + "declarationDir": "dist", + "outDir": "compiled" } } diff --git a/packages/tsconfig.json b/packages/tsconfig.json new file mode 100644 index 00000000000..a82659b1e37 --- /dev/null +++ b/packages/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "esnext", + "declaration": true, + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "skipLibCheck": true, // Temp workaround for Duplicate identifier tsc errors, + "removeComments": false + } +} diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 0764fb0bfd6..590074f7193 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -93,6 +93,26 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map return sc } +func (sc *scenarioContext) fakeReqNoAssertions(method, url string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, _ := http.NewRequest(method, url, nil) + sc.req = req + + return sc +} + +func (sc *scenarioContext) fakeReqNoAssertionsWithCookie(method, url string, cookie http.Cookie) *scenarioContext { + sc.resp = httptest.NewRecorder() + http.SetCookie(sc.resp, &cookie) + + req, _ := http.NewRequest(method, url, nil) + req.Header = http.Header{"Cookie": sc.resp.Header()["Set-Cookie"]} + + sc.req = req + + return sc +} + type scenarioContext struct { m *macaron.Macaron context *m.ReqContext diff --git a/pkg/api/login.go b/pkg/api/login.go index 37df4613212..61a6299b935 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -21,8 +21,14 @@ const ( LoginErrorCookieName = "login_error" ) +var setIndexViewData = (*HTTPServer).setIndexViewData + +var getViewIndex = func() string { + return ViewIndex +} + func (hs *HTTPServer) LoginView(c *models.ReqContext) { - viewData, err := hs.setIndexViewData(c) + viewData, err := setIndexViewData(hs, c) if err != nil { c.Handle(500, "Failed to get settings", err) return @@ -41,8 +47,14 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) { viewData.Settings["samlEnabled"] = hs.Cfg.SAMLEnabled if loginError, ok := tryGetEncryptedCookie(c, LoginErrorCookieName); ok { + //this cookie is only set whenever an OAuth login fails + //therefore the loginError should be passed to the view data + //and the view should return immediately before attempting + //to login again via OAuth and enter to a redirect loop deleteCookie(c, LoginErrorCookieName) viewData.Settings["loginError"] = loginError + c.HTML(200, getViewIndex(), viewData) + return } if tryOAuthAutoLogin(c) { diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go new file mode 100644 index 00000000000..ab28848a43d --- /dev/null +++ b/pkg/api/login_test.go @@ -0,0 +1,135 @@ +package api + +import ( + "encoding/hex" + "errors" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "github.com/stretchr/testify/assert" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func mockSetIndexViewData() { + setIndexViewData = func(*HTTPServer, *models.ReqContext) (*dtos.IndexViewData, error) { + data := &dtos.IndexViewData{ + User: &dtos.CurrentUser{}, + Settings: map[string]interface{}{}, + NavTree: []*dtos.NavLink{}, + } + return data, nil + } +} + +func resetSetIndexViewData() { + setIndexViewData = (*HTTPServer).setIndexViewData +} + +func mockViewIndex() { + getViewIndex = func() string { + return "index-template" + } +} + +func resetViewIndex() { + getViewIndex = func() string { + return ViewIndex + } +} + +func getBody(resp *httptest.ResponseRecorder) (string, error) { + responseData, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(responseData), nil +} + +func TestLoginErrorCookieApiEndpoint(t *testing.T) { + mockSetIndexViewData() + defer resetSetIndexViewData() + + mockViewIndex() + defer resetViewIndex() + + sc := setupScenarioContext("/login") + hs := &HTTPServer{ + Cfg: setting.NewCfg(), + } + + sc.defaultHandler = Wrap(func(w http.ResponseWriter, c *models.ReqContext) { + hs.LoginView(c) + }) + + setting.OAuthService = &setting.OAuther{} + setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) + setting.LoginCookieName = "grafana_session" + setting.SecretKey = "login_testing" + + setting.OAuthService = &setting.OAuther{} + setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) + setting.OAuthService.OAuthInfos["github"] = &setting.OAuthInfo{ + ClientId: "fake", + ClientSecret: "fakefake", + Enabled: true, + AllowSignup: true, + Name: "github", + } + setting.OAuthAutoLogin = true + + oauthError := errors.New("User not a member of one of the required organizations") + encryptedError, _ := util.Encrypt([]byte(oauthError.Error()), setting.SecretKey) + cookie := http.Cookie{ + Name: LoginErrorCookieName, + MaxAge: 60, + Value: hex.EncodeToString(encryptedError), + HttpOnly: true, + Path: setting.AppSubUrl + "/", + Secure: hs.Cfg.CookieSecure, + SameSite: hs.Cfg.CookieSameSite, + } + sc.m.Get(sc.url, sc.defaultHandler) + sc.fakeReqNoAssertionsWithCookie("GET", sc.url, cookie).exec() + assert.Equal(t, sc.resp.Code, 200) + + responseString, err := getBody(sc.resp) + assert.Nil(t, err) + assert.True(t, strings.Contains(responseString, oauthError.Error())) +} + +func TestLoginOAuthRedirect(t *testing.T) { + mockSetIndexViewData() + defer resetSetIndexViewData() + + sc := setupScenarioContext("/login") + hs := &HTTPServer{ + Cfg: setting.NewCfg(), + } + + sc.defaultHandler = Wrap(func(c *models.ReqContext) { + hs.LoginView(c) + }) + + setting.OAuthService = &setting.OAuther{} + setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) + setting.OAuthService.OAuthInfos["github"] = &setting.OAuthInfo{ + ClientId: "fake", + ClientSecret: "fakefake", + Enabled: true, + AllowSignup: true, + Name: "github", + } + setting.OAuthAutoLogin = true + sc.m.Get(sc.url, sc.defaultHandler) + sc.fakeReqNoAssertions("GET", sc.url).exec() + + assert.Equal(t, sc.resp.Code, 307) + location, ok := sc.resp.Header()["Location"] + assert.True(t, ok) + assert.Equal(t, location[0], "/login/github") +} diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 5d34323c55f..0d8139e4e5c 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -30,23 +30,6 @@ func GetTeamMembers(c *m.ReqContext) Response { return JSON(200, query.Result) } -func GetAuthProviderLabel(authModule string) string { - switch authModule { - case "oauth_github": - return "GitHub" - case "oauth_google": - return "Google" - case "oauth_gitlab": - return "GitLab" - case "oauth_grafana_com", "oauth_grafananet": - return "grafana.com" - case "ldap", "": - return "LDAP" - default: - return "OAuth" - } -} - // POST /api/teams/:teamId/members func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { cmd.OrgId = c.OrgId diff --git a/pkg/api/user.go b/pkg/api/user.go index 2d82b60ba33..528e7431ffd 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -29,8 +29,11 @@ func getUserUserProfile(userID int64) Response { } getAuthQuery := m.GetAuthInfoQuery{UserId: userID} + query.Result.AuthLabels = []string{} if err := bus.Dispatch(&getAuthQuery); err == nil { - query.Result.AuthModule = []string{getAuthQuery.Result.AuthModule} + authLabel := GetAuthProviderLabel(getAuthQuery.Result.AuthModule) + query.Result.AuthLabels = append(query.Result.AuthLabels, authLabel) + query.Result.IsExternal = true } return JSON(200, query.Result) @@ -277,6 +280,12 @@ func searchUser(c *m.ReqContext) (*m.SearchUsersQuery, error) { for _, user := range query.Result.Users { user.AvatarUrl = dtos.GetGravatarUrl(user.Email) + user.AuthLabels = make([]string, 0) + if user.AuthModule != nil && len(user.AuthModule) > 0 { + for _, authModule := range user.AuthModule { + user.AuthLabels = append(user.AuthLabels, GetAuthProviderLabel(authModule)) + } + } } query.Result.Page = page @@ -315,3 +324,20 @@ func ClearHelpFlags(c *m.ReqContext) Response { return JSON(200, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) } + +func GetAuthProviderLabel(authModule string) string { + switch authModule { + case "oauth_github": + return "GitHub" + case "oauth_google": + return "Google" + case "oauth_gitlab": + return "GitLab" + case "oauth_grafana_com", "oauth_grafananet": + return "grafana.com" + case "ldap", "": + return "LDAP" + default: + return "OAuth" + } +} diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go index e55fa2d70b8..26418714a0d 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords.go @@ -62,7 +62,7 @@ func EncryptDatasourcePaswords(c utils.CommandLine, sqlStore *sqlstore.SqlStore) } func migrateColumn(session *sqlstore.DBSession, column string) (int, error) { - var rows []map[string]string + var rows []map[string][]byte session.Cols("id", column, "secure_json_data") session.Table("data_source") @@ -78,7 +78,7 @@ func migrateColumn(session *sqlstore.DBSession, column string) (int, error) { return rowsUpdated, errutil.Wrapf(err, "failed to update column: %s", column) } -func updateRows(session *sqlstore.DBSession, rows []map[string]string, passwordFieldName string) (int, error) { +func updateRows(session *sqlstore.DBSession, rows []map[string][]byte, passwordFieldName string) (int, error) { var rowsUpdated int for _, row := range rows { @@ -94,7 +94,7 @@ func updateRows(session *sqlstore.DBSession, rows []map[string]string, passwordF newRow := map[string]interface{}{"secure_json_data": data, passwordFieldName: ""} session.Table("data_source") - session.Where("id = ?", row["id"]) + session.Where("id = ?", string(row["id"])) // Setting both columns while having value only for secure_json_data should clear the [passwordFieldName] column session.Cols("secure_json_data", passwordFieldName) @@ -108,16 +108,20 @@ func updateRows(session *sqlstore.DBSession, rows []map[string]string, passwordF return rowsUpdated, nil } -func getUpdatedSecureJSONData(row map[string]string, passwordFieldName string) (map[string]interface{}, error) { - encryptedPassword, err := util.Encrypt([]byte(row[passwordFieldName]), setting.SecretKey) +func getUpdatedSecureJSONData(row map[string][]byte, passwordFieldName string) (map[string]interface{}, error) { + encryptedPassword, err := util.Encrypt(row[passwordFieldName], setting.SecretKey) if err != nil { return nil, err } var secureJSONData map[string]interface{} - if err := json.Unmarshal([]byte(row["secure_json_data"]), &secureJSONData); err != nil { - return nil, err + if len(row["secure_json_data"]) > 0 { + if err := json.Unmarshal(row["secure_json_data"], &secureJSONData); err != nil { + return nil, err + } + } else { + secureJSONData = map[string]interface{}{} } jsonFieldName := util.ToCamelCase(passwordFieldName) diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go index 64987423dec..2d9a04d3775 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go @@ -20,19 +20,30 @@ func TestPasswordMigrationCommand(t *testing.T) { datasources := []*models.DataSource{ {Type: "influxdb", Name: "influxdb", Password: "foobar"}, {Type: "graphite", Name: "graphite", BasicAuthPassword: "foobar"}, - {Type: "prometheus", Name: "prometheus", SecureJsonData: securejsondata.GetEncryptedJsonData(map[string]string{})}, + {Type: "prometheus", Name: "prometheus"}, + {Type: "elasticsearch", Name: "elasticsearch", Password: "pwd"}, } // set required default values for _, ds := range datasources { ds.Created = time.Now() ds.Updated = time.Now() - ds.SecureJsonData = securejsondata.GetEncryptedJsonData(map[string]string{}) + if ds.Name == "elasticsearch" { + ds.SecureJsonData = securejsondata.GetEncryptedJsonData(map[string]string{ + "key": "value", + }) + } else { + ds.SecureJsonData = securejsondata.GetEncryptedJsonData(map[string]string{}) + } } _, err := session.Insert(&datasources) assert.Nil(t, err) + // force secure_json_data to be null to verify that migration can handle that + _, err = session.Exec("update data_source set secure_json_data = null where name = 'influxdb'") + assert.Nil(t, err) + //run migration err = EncryptDatasourcePaswords(&commandstest.FakeCommandLine{}, sqlstore) assert.Nil(t, err) @@ -41,7 +52,7 @@ func TestPasswordMigrationCommand(t *testing.T) { var dss []*models.DataSource err = session.SQL("select * from data_source").Find(&dss) assert.Nil(t, err) - assert.Equal(t, len(dss), 3) + assert.Equal(t, len(dss), 4) for _, ds := range dss { sj := ds.SecureJsonData.Decrypt() @@ -63,5 +74,15 @@ func TestPasswordMigrationCommand(t *testing.T) { if ds.Name == "prometheus" { assert.Equal(t, len(sj), 0) } + + if ds.Name == "elasticsearch" { + assert.Equal(t, ds.Password, "") + key, exist := sj["key"] + assert.True(t, exist) + password, exist := sj["password"] + assert.True(t, exist) + assert.Equal(t, password, "pwd", "expected password to be moved to securejson") + assert.Equal(t, key, "value", "expected existing key to be kept intact in securejson") + } } } diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index db390768263..ad77c39ba87 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -85,7 +85,7 @@ func InstallPlugin(pluginName, version string, c utils.CommandLine) error { } logger.Infof("installing %v @ %v\n", pluginName, version) - logger.Infof("from url: %v\n", downloadURL) + logger.Infof("from: %v\n", downloadURL) logger.Infof("into: %v\n", pluginFolder) logger.Info("\n") @@ -145,18 +145,27 @@ func downloadFile(pluginName, filePath, url string) (err error) { } }() - resp, err := http.Get(url) // #nosec - if err != nil { - return err - } - defer resp.Body.Close() + var bytes []byte - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err + if _, err := os.Stat(url); err == nil { + bytes, err = ioutil.ReadFile(url) + if err != nil { + return err + } + } else { + resp, err := http.Get(url) // #nosec + if err != nil { + return err + } + defer resp.Body.Close() + + bytes, err = ioutil.ReadAll(resp.Body) + if err != nil { + return err + } } - return extractFiles(body, pluginName, filePath) + return extractFiles(bytes, pluginName, filePath) } func extractFiles(body []byte, pluginName string, filePath string) error { diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 49ec9f54b2a..d4a0b2da2aa 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -182,6 +182,10 @@ func initContextWithBasicAuth(ctx *models.ReqContext, orgId int64) bool { } func initContextWithToken(authTokenService models.UserTokenService, ctx *models.ReqContext, orgID int64) bool { + if setting.LoginCookieName == "" { + return false + } + rawToken := ctx.GetCookie(setting.LoginCookieName) if rawToken == "" { return false diff --git a/pkg/models/user.go b/pkg/models/user.go index a9032f1a8d8..bf52aed6160 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -216,7 +216,8 @@ type UserProfileDTO struct { OrgId int64 `json:"orgId"` IsGrafanaAdmin bool `json:"isGrafanaAdmin"` IsDisabled bool `json:"isDisabled"` - AuthModule []string `json:"authModule"` + IsExternal bool `json:"isExternal"` + AuthLabels []string `json:"authLabels"` } type UserSearchHitDTO struct { @@ -229,7 +230,8 @@ type UserSearchHitDTO struct { IsDisabled bool `json:"isDisabled"` LastSeenAt time.Time `json:"lastSeenAt"` LastSeenAtAge string `json:"lastSeenAtAge"` - AuthModule AuthModuleConversion `json:"authModule"` + AuthLabels []string `json:"authLabels"` + AuthModule AuthModuleConversion `json:"-"` } type UserIdDTO struct { diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go index 0e4404340b5..d93fdbdabcb 100644 --- a/pkg/services/ldap/ldap.go +++ b/pkg/services/ldap/ldap.go @@ -31,7 +31,8 @@ type IConnection interface { type IServer interface { Login(*models.LoginUserQuery) (*models.ExternalUserInfo, error) Users([]string) ([]*models.ExternalUserInfo, error) - Auth(string, string) error + Bind() error + UserBind(string, string) error Dial() error Close() } @@ -43,6 +44,23 @@ type Server struct { log log.Logger } +// Bind authenticates the connection with the LDAP server +// - with the username and password setup in the config +// - or, anonymously +func (server *Server) Bind() error { + if server.shouldAuthAdmin() { + if err := server.AuthAdmin(); err != nil { + return err + } + } else { + err := server.Connection.UnauthenticatedBind(server.Config.BindDN) + if err != nil { + return err + } + } + return nil +} + // UsersMaxRequest is a max amount of users we can request via Users(). // Since many LDAP servers has limitations // on how much items can we return in one request @@ -140,15 +158,19 @@ func (server *Server) Login(query *models.LoginUserQuery) ( *models.ExternalUserInfo, error, ) { var err error + var authAndBind bool - // Do we need to authenticate the "admin" user first? - // Admin user should have access for the user search in LDAP server + // Check if we can use a search user if server.shouldAuthAdmin() { if err := server.AuthAdmin(); err != nil { return nil, err } - - // Or if anyone can perform the search in LDAP? + } else if server.shouldSingleBind() { + authAndBind = true + err = server.UserBind(server.singleBindDN(query.Username), query.Password) + if err != nil { + return nil, err + } } else { err := server.Connection.UnauthenticatedBind(server.Config.BindDN) if err != nil { @@ -173,15 +195,25 @@ func (server *Server) Login(query *models.LoginUserQuery) ( return nil, err } - // Authenticate user - err = server.Auth(user.AuthId, query.Password) - if err != nil { - return nil, err + if !authAndBind { + // Authenticate user + err = server.UserBind(user.AuthId, query.Password) + if err != nil { + return nil, err + } } return user, nil } +func (server *Server) singleBindDN(username string) string { + return fmt.Sprintf(server.Config.BindDN, username) +} + +func (server *Server) shouldSingleBind() bool { + return strings.Contains(server.Config.BindDN, "%s") +} + // getUsersIteration is a helper function for Users() method. // It divides the users by equal parts for the anticipated requests func getUsersIteration(logins []string, fn func(int, int) error) error { @@ -366,9 +398,9 @@ func (server *Server) shouldAuthAdmin() bool { return server.Config.BindPassword != "" } -// Auth authentificates user in LDAP -func (server *Server) Auth(username, password string) error { - err := server.auth(username, password) +// UserBind authenticates the connection with the LDAP server +func (server *Server) UserBind(username, password string) error { + err := server.userBind(username, password) if err != nil { server.log.Error( fmt.Sprintf("Cannot authentificate user %s in LDAP", username), @@ -383,7 +415,7 @@ func (server *Server) Auth(username, password string) error { // AuthAdmin authentificates LDAP admin user func (server *Server) AuthAdmin() error { - err := server.auth(server.Config.BindDN, server.Config.BindPassword) + err := server.userBind(server.Config.BindDN, server.Config.BindPassword) if err != nil { server.log.Error( "Cannot authentificate admin user in LDAP", @@ -396,8 +428,8 @@ func (server *Server) AuthAdmin() error { return nil } -// auth is helper for several types of LDAP authentification -func (server *Server) auth(path, password string) error { +// userBind authenticates the connection with the LDAP server +func (server *Server) userBind(path, password string) error { err := server.Connection.Bind(path, password) if err != nil { if ldapErr, ok := err.(*ldap.Error); ok { diff --git a/pkg/services/ldap/ldap_login_test.go b/pkg/services/ldap/ldap_login_test.go index 1828eeb9d7b..abdf6c2c3a4 100644 --- a/pkg/services/ldap/ldap_login_test.go +++ b/pkg/services/ldap/ldap_login_test.go @@ -19,7 +19,7 @@ func TestLDAPLogin(t *testing.T) { } Convey("Login()", t, func() { - Convey("Should get invalid credentials when auth fails", func() { + Convey("Should get invalid credentials when userBind fails", func() { connection := &MockConnection{} entry := ldap.Entry{} result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}} @@ -198,5 +198,37 @@ func TestLDAPLogin(t *testing.T) { So(username, ShouldEqual, "test") So(password, ShouldEqual, "pwd") }) + Convey("Should bind with user if %s exists in the bind_dn", func() { + connection := &MockConnection{} + entry := ldap.Entry{ + DN: "test", + } + connection.setSearchResult(&ldap.SearchResult{Entries: []*ldap.Entry{&entry}}) + + authBindUser := "" + authBindPassword := "" + + connection.BindProvider = func(name, pass string) error { + authBindUser = name + authBindPassword = pass + return nil + } + server := &Server{ + Config: &ServerConfig{ + BindDN: "cn=%s,ou=users,dc=grafana,dc=org", + SearchBaseDNs: []string{"BaseDNHere"}, + }, + Connection: connection, + log: log.New("test-logger"), + } + + _, err := server.Login(defaultLogin) + + So(err, ShouldBeNil) + + So(authBindUser, ShouldEqual, "cn=user,ou=users,dc=grafana,dc=org") + So(authBindPassword, ShouldEqual, "pwd") + So(connection.BindCalled, ShouldBeTrue) + }) }) } diff --git a/pkg/services/ldap/ldap_private_test.go b/pkg/services/ldap/ldap_private_test.go index f1ec97c176b..1a43087399f 100644 --- a/pkg/services/ldap/ldap_private_test.go +++ b/pkg/services/ldap/ldap_private_test.go @@ -145,7 +145,7 @@ func TestLDAPPrivateMethods(t *testing.T) { }) Convey("shouldAuthAdmin()", t, func() { - Convey("it should require admin auth", func() { + Convey("it should require admin userBind", func() { server := &Server{ Config: &ServerConfig{ BindPassword: "test", @@ -156,7 +156,7 @@ func TestLDAPPrivateMethods(t *testing.T) { So(result, ShouldBeTrue) }) - Convey("it should not require admin auth", func() { + Convey("it should not require admin userBind", func() { server := &Server{ Config: &ServerConfig{ BindPassword: "", diff --git a/pkg/services/ldap/ldap_test.go b/pkg/services/ldap/ldap_test.go index 8207e0d3187..5a87d89bdaa 100644 --- a/pkg/services/ldap/ldap_test.go +++ b/pkg/services/ldap/ldap_test.go @@ -102,7 +102,7 @@ func TestPublicAPI(t *testing.T) { }) }) - Convey("Auth()", t, func() { + Convey("UserBind()", t, func() { Convey("Should use provided DN and password", func() { connection := &MockConnection{} var actualUsername, actualPassword string @@ -119,7 +119,7 @@ func TestPublicAPI(t *testing.T) { } dn := "cn=user,ou=users,dc=grafana,dc=org" - err := server.Auth(dn, "pwd") + err := server.UserBind(dn, "pwd") So(err, ShouldBeNil) So(actualUsername, ShouldEqual, dn) @@ -141,7 +141,7 @@ func TestPublicAPI(t *testing.T) { }, log: log.New("test-logger"), } - err := server.Auth("user", "pwd") + err := server.UserBind("user", "pwd") So(err, ShouldEqual, expected) }) }) diff --git a/pkg/services/multildap/multildap.go b/pkg/services/multildap/multildap.go index 742cf3c3942..d2560d07e93 100644 --- a/pkg/services/multildap/multildap.go +++ b/pkg/services/multildap/multildap.go @@ -109,6 +109,10 @@ func (multiples *MultiLDAP) User(login string) ( defer server.Close() + if err := server.Bind(); err != nil { + return nil, err + } + users, err := server.Users(search) if err != nil { return nil, err @@ -142,6 +146,10 @@ func (multiples *MultiLDAP) Users(logins []string) ( defer server.Close() + if err := server.Bind(); err != nil { + return nil, err + } + users, err := server.Users(logins) if err != nil { return nil, err diff --git a/pkg/services/multildap/testing.go b/pkg/services/multildap/testing.go index a5d5caefadd..9d3623ad8d3 100644 --- a/pkg/services/multildap/testing.go +++ b/pkg/services/multildap/testing.go @@ -11,12 +11,15 @@ type MockLDAP struct { loginCalledTimes int closeCalledTimes int usersCalledTimes int + bindCalledTimes int dialErrReturn error loginErrReturn error loginReturn *models.ExternalUserInfo + bindErrReturn error + usersErrReturn error usersFirstReturn []*models.ExternalUserInfo usersRestReturn []*models.ExternalUserInfo @@ -40,8 +43,8 @@ func (mock *MockLDAP) Users([]string) ([]*models.ExternalUserInfo, error) { return mock.usersRestReturn, mock.usersErrReturn } -// Auth test fn -func (mock *MockLDAP) Auth(string, string) error { +// UserBind test fn +func (mock *MockLDAP) UserBind(string, string) error { return nil } @@ -56,6 +59,11 @@ func (mock *MockLDAP) Close() { mock.closeCalledTimes = mock.closeCalledTimes + 1 } +func (mock *MockLDAP) Bind() error { + mock.bindCalledTimes++ + return mock.bindErrReturn +} + // MockMultiLDAP represents testing struct for multildap testing type MockMultiLDAP struct { LoginCalledTimes int diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 89d3e8469b6..845b0838a4c 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -11,9 +11,14 @@ import { TagBadge } from './TagBadge'; import { NoOptionsMessage, IndicatorsContainer, resetSelectStyles } from '@grafana/ui'; import { escapeStringForRegex } from '../FilterInput/FilterInput'; +export interface TermCount { + term: string; + count: number; +} + export interface Props { tags: string[]; - tagOptions: () => any; + tagOptions: () => Promise; onChange: (tags: string[]) => void; } @@ -25,7 +30,7 @@ export class TagFilter extends React.Component { } onLoadOptions = (query: string) => { - return this.props.tagOptions().then((options: any[]) => { + return this.props.tagOptions().then(options => { return options.map(option => ({ value: option.term, label: option.term, diff --git a/public/app/core/specs/file_export.test.ts b/public/app/core/specs/file_export.test.ts index ab254a94f2b..49a7b9caf6d 100644 --- a/public/app/core/specs/file_export.test.ts +++ b/public/app/core/specs/file_export.test.ts @@ -28,7 +28,7 @@ describe('file_export', () => { describe('when exporting series as rows', () => { it('should export points in proper order', () => { - const text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat); + const text = fileExport.convertSeriesListToCsv(ctx.seriesList, { dateTimeFormat: ctx.timeFormat }); const expectedText = '"Series";"Time";"Value"\r\n' + '"series_1";"1500026100";1\r\n' + @@ -48,7 +48,7 @@ describe('file_export', () => { describe('when exporting series as columns', () => { it('should export points in proper order', () => { - const text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat); + const text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, { dateTimeFormat: ctx.timeFormat }); const expectedText = '"Time";"series_1";"series_2"\r\n' + '"1500026100";1;11\r\n' + @@ -65,7 +65,7 @@ describe('file_export', () => { const expectedSeries1DataPoints = ctx.seriesList[0].datapoints.slice(); const expectedSeries2DataPoints = ctx.seriesList[1].datapoints.slice(); - fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat); + fileExport.convertSeriesListToCsvColumns(ctx.seriesList, { dateTimeFormat: ctx.timeFormat }); expect(expectedSeries1DataPoints).toEqual(ctx.seriesList[0].datapoints); expect(expectedSeries2DataPoints).toEqual(ctx.seriesList[1].datapoints); diff --git a/public/app/core/utils/file_export.ts b/public/app/core/utils/file_export.ts index 212c619e7ea..14fc10cfbb6 100644 --- a/public/app/core/utils/file_export.ts +++ b/public/app/core/utils/file_export.ts @@ -1,7 +1,7 @@ import { isBoolean, isNumber, sortedUniq, sortedIndexOf, unescape as htmlUnescaped } from 'lodash'; import { saveAs } from 'file-saver'; import { isNullOrUndefined } from 'util'; -import { dateTime } from '@grafana/data'; +import { dateTime, TimeZone } from '@grafana/data'; const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ'; const POINT_TIME_INDEX = 1; @@ -12,7 +12,19 @@ const END_ROW = '\r\n'; const QUOTE = '"'; const EXPORT_FILENAME = 'grafana_data_export.csv'; -function csvEscaped(text) { +interface SeriesListToCsvColumnsOptions { + dateTimeFormat: string; + excel: boolean; + timezone: TimeZone; +} + +const defaultOptions: SeriesListToCsvColumnsOptions = { + dateTimeFormat: DEFAULT_DATETIME_FORMAT, + excel: false, + timezone: '', +}; + +function csvEscaped(text: string) { if (!text) { return text; } @@ -25,13 +37,13 @@ function csvEscaped(text) { } const domParser = new DOMParser(); -function htmlDecoded(text) { +function htmlDecoded(text: string) { if (!text) { return text; } const regexp = /&[^;]+;/g; - function htmlDecoded(value) { + function htmlDecoded(value: string) { const parsedDom = domParser.parseFromString(value, 'text/html'); return parsedDom.body.textContent; } @@ -58,14 +70,19 @@ function formatRow(row, addEndRowDelimiter = true) { return addEndRowDelimiter ? text + END_ROW : text; } -export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { +export function convertSeriesListToCsv(seriesList, options: Partial) { + const { dateTimeFormat, excel, timezone } = { ...defaultOptions, ...options }; let text = formatSpecialHeader(excel) + formatRow(['Series', 'Time', 'Value']); for (let seriesIndex = 0; seriesIndex < seriesList.length; seriesIndex += 1) { for (let i = 0; i < seriesList[seriesIndex].datapoints.length; i += 1) { text += formatRow( [ seriesList[seriesIndex].alias, - dateTime(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat), + timezone === 'utc' + ? dateTime(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]) + .utc() + .format(dateTimeFormat) + : dateTime(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat), seriesList[seriesIndex].datapoints[i][POINT_VALUE_INDEX], ], i < seriesList[seriesIndex].datapoints.length - 1 || seriesIndex < seriesList.length - 1 @@ -75,12 +92,13 @@ export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATE return text; } -export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { - const text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel); +export function exportSeriesListToCsv(seriesList, options: Partial) { + const text = convertSeriesListToCsv(seriesList, options); saveSaveBlob(text, EXPORT_FILENAME); } -export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { +export function convertSeriesListToCsvColumns(seriesList, options: Partial) { + const { dateTimeFormat, excel, timezone } = { ...defaultOptions, ...options }; // add header let text = formatSpecialHeader(excel) + @@ -96,7 +114,13 @@ export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAU // make text for (let i = 0; i < extendedDatapointsList[0].length; i += 1) { - const timestamp = dateTime(extendedDatapointsList[0][i][POINT_TIME_INDEX]).format(dateTimeFormat); + const timestamp = + timezone === 'utc' + ? dateTime(extendedDatapointsList[0][i][POINT_TIME_INDEX]) + .utc() + .format(dateTimeFormat) + : dateTime(extendedDatapointsList[0][i][POINT_TIME_INDEX]).format(dateTimeFormat); + text += formatRow( [timestamp].concat( extendedDatapointsList.map(datapoints => { @@ -143,8 +167,8 @@ function mergeSeriesByTime(seriesList) { return result; } -export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) { - const text = convertSeriesListToCsvColumns(seriesList, dateTimeFormat, excel); +export function exportSeriesListToCsvColumns(seriesList, options: Partial) { + const text = convertSeriesListToCsvColumns(seriesList, options); saveSaveBlob(text, EXPORT_FILENAME); } diff --git a/public/app/features/admin/AdminEditUserCtrl.ts b/public/app/features/admin/AdminEditUserCtrl.ts index 8eba14fbcdc..1816a6dfb1d 100644 --- a/public/app/features/admin/AdminEditUserCtrl.ts +++ b/public/app/features/admin/AdminEditUserCtrl.ts @@ -179,7 +179,7 @@ export default class AdminEditUserCtrl { const user = $scope.user; // External user can not be disabled - if (user.authModule) { + if (user.isExternal) { event.preventDefault(); event.stopPropagation(); return; diff --git a/public/app/features/admin/AdminListUsersCtrl.ts b/public/app/features/admin/AdminListUsersCtrl.ts index 3f1e1e1bf51..3e4ba2e37a3 100644 --- a/public/app/features/admin/AdminListUsersCtrl.ts +++ b/public/app/features/admin/AdminListUsersCtrl.ts @@ -1,5 +1,6 @@ import { BackendSrv } from 'app/core/services/backend_srv'; import { NavModelSrv } from 'app/core/core'; +import tags from 'app/core/utils/tags'; export default class AdminListUsersCtrl { users: any; @@ -32,6 +33,8 @@ export default class AdminListUsersCtrl { for (let i = 1; i < this.totalPages + 1; i++) { this.pages.push({ page: i, current: i === this.page }); } + + this.addUsersAuthLabels(); }); } @@ -40,10 +43,29 @@ export default class AdminListUsersCtrl { this.getUsers(); } - getAuthModule(user: any) { - if (user.authModule && user.authModule.length) { - return user.authModule[0]; + addUsersAuthLabels() { + for (const user of this.users) { + user.authLabel = getAuthLabel(user); + user.authLabelStyle = getAuthLabelStyle(user.authLabel); } - return undefined; } } + +function getAuthLabel(user: any) { + if (user.authLabels && user.authLabels.length) { + return user.authLabels[0]; + } + return ''; +} + +function getAuthLabelStyle(label: string) { + if (label === 'LDAP' || !label) { + return {}; + } + + const { color, borderColor } = tags.getTagColorsFromName(label); + return { + 'background-color': color, + 'border-color': borderColor, + }; +} diff --git a/public/app/features/admin/partials/edit_user.html b/public/app/features/admin/partials/edit_user.html index 12bfbe8f722..7182f123149 100644 --- a/public/app/features/admin/partials/edit_user.html +++ b/public/app/features/admin/partials/edit_user.html @@ -118,48 +118,52 @@

Sessions

- - - - - - - - - - - - - - - - - - - - -
Last seenLogged onIP addressBrowser & OS
Now{{session.seenAt}}{{session.createdAt}}{{session.clientIp}}{{session.browser}} on {{session.os}} {{session.osVersion}} - -
+
+ + + + + + + + + + + + + + + + + + + + +
Last seenLogged onIP addressBrowser & OS
Now{{session.seenAt}}{{session.createdAt}}{{session.clientIp}}{{session.browser}} on {{session.os}} {{session.osVersion}} + +
+
+
+ +
- + +

User status

-

User status

-
@@ -168,8 +172,8 @@ class="btn btn-primary" ng-if="user.isDisabled" ng-click="disableUser($event)" - bs-tooltip="user.authModule ? 'External user cannot be activated or deactivated' : ''" - ng-class="{'disabled': user.authModule}" + bs-tooltip="user.isExternal ? 'External user cannot be enabled or disabled' : ''" + ng-class="{'disabled': user.isExternal}" > Enable diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html index da33deeef0c..21346b92aa7 100644 --- a/public/app/features/admin/partials/users.html +++ b/public/app/features/admin/partials/users.html @@ -55,7 +55,9 @@ - LDAP + + {{user.authLabel}} + Disabled diff --git a/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts index f87daa94ee7..023b60ac074 100644 --- a/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts +++ b/public/app/features/dashboard/components/ExportDataModal/ExportDataModalCtrl.ts @@ -1,6 +1,7 @@ import angular from 'angular'; import * as fileExport from 'app/core/utils/file_export'; import appEvents from 'app/core/app_events'; +import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; export class ExportDataModalCtrl { private data: any[]; @@ -9,14 +10,23 @@ export class ExportDataModalCtrl { dateTimeFormat = 'YYYY-MM-DDTHH:mm:ssZ'; excel = false; + /** @ngInject */ + constructor(private dashboardSrv: DashboardSrv) {} + export() { + const timezone = this.dashboardSrv.getCurrent().timezone; + const options = { + excel: this.excel, + dateTimeFormat: this.dateTimeFormat, + timezone, + }; if (this.panel === 'table') { fileExport.exportTableDataToCsv(this.data, this.excel); } else { if (this.asRows) { - fileExport.exportSeriesListToCsv(this.data, this.dateTimeFormat, this.excel); + fileExport.exportSeriesListToCsv(this.data, options); } else { - fileExport.exportSeriesListToCsvColumns(this.data, this.dateTimeFormat, this.excel); + fileExport.exportSeriesListToCsvColumns(this.data, options); } } diff --git a/public/app/features/dashboard/panel_editor/QueryOptions.tsx b/public/app/features/dashboard/panel_editor/QueryOptions.tsx index e47b12cdb55..a10ec31fae7 100644 --- a/public/app/features/dashboard/panel_editor/QueryOptions.tsx +++ b/public/app/features/dashboard/panel_editor/QueryOptions.tsx @@ -2,12 +2,19 @@ import React, { PureComponent, ChangeEvent, FocusEvent } from 'react'; // Utils -import { isValidTimeSpan } from '@grafana/data/src/utils/rangeutil'; +import { rangeUtil } from '@grafana/data'; // Components -import { DataSourceSelectItem, EventsWithValidation, Input, InputStatus, Switch, ValidationEvents } from '@grafana/ui'; +import { + DataSourceSelectItem, + EventsWithValidation, + Input, + InputStatus, + Switch, + ValidationEvents, + FormLabel, +} from '@grafana/ui'; import { DataSourceOption } from './DataSourceOption'; -import { FormLabel } from '@grafana/ui'; // Types import { PanelModel } from '../state'; @@ -19,7 +26,7 @@ const timeRangeValidationEvents: ValidationEvents = { if (!value) { return true; } - return isValidTimeSpan(value); + return rangeUtil.isValidTimeSpan(value); }, errorMessage: 'Not a valid timespan', }, diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 4429085fcff..913f8e9a36f 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -22,6 +22,7 @@ import * as graphPanel from 'app/plugins/panel/graph/module'; import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; import * as alertListPanel from 'app/plugins/panel/alertlist/module'; +import * as annoListPanel from 'app/plugins/panel/annolist/module'; import * as heatmapPanel from 'app/plugins/panel/heatmap/module'; import * as tablePanel from 'app/plugins/panel/table/module'; import * as table2Panel from 'app/plugins/panel/table2/module'; @@ -59,6 +60,7 @@ const builtInPlugins = { 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, 'app/plugins/panel/alertlist/module': alertListPanel, + 'app/plugins/panel/annolist/module': annoListPanel, 'app/plugins/panel/heatmap/module': heatmapPanel, 'app/plugins/panel/table/module': tablePanel, 'app/plugins/panel/table2/module': table2Panel, diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 8acc0527fdb..78ff178c151 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -45,6 +45,10 @@
+ + + Sign in with SAML +
@@ -68,10 +72,6 @@ Sign in with {{oauth.generic_oauth.name}} - - - Sign in with SAML -
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx index fc4d1a44822..c49718056af 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx @@ -4,7 +4,7 @@ import Plain from 'slate-plain-serializer'; import QueryField from './query_field'; import debounce from 'lodash/debounce'; -import { getNextCharacter } from 'app/features/explore/utils/dom'; +import { DOMUtil } from '@grafana/ui'; import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto'; // import '../sass/editor.base.scss'; @@ -203,7 +203,7 @@ export default class KustoQueryField extends QueryField { // Modify suggestion based on context - const nextChar = getNextCharacter(); + const nextChar = DOMUtil.getNextCharacter(); if (suggestion.type === 'function') { if (!nextChar || nextChar !== '(') { suggestionText += '('; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx index 8646a7c6849..95d1b3d4cb3 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx @@ -10,13 +10,12 @@ import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explor // Utils & Services // dom also includes Element polyfills -import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; import BracesPlugin from 'app/features/explore/slate-plugins/braces'; // Types import { LokiQuery } from '../types'; import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; -import { DataSourceApi, ExploreQueryFieldProps, DataSourceStatus } from '@grafana/ui'; +import { DataSourceApi, ExploreQueryFieldProps, DataSourceStatus, DOMUtil } from '@grafana/ui'; import { AbsoluteTimeRange } from '@grafana/data'; function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceStatus: DataSourceStatus) { @@ -36,7 +35,7 @@ function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadTe // Modify suggestion based on context switch (typeaheadContext) { case 'context-labels': { - const nextChar = getNextCharacter(); + const nextChar = DOMUtil.getNextCharacter(); if (!nextChar || nextChar === '}' || nextChar === ',') { suggestion += '='; } @@ -48,7 +47,7 @@ function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadTe if (!typeaheadText.match(/^(!?=~?"|")/)) { suggestion = `"${suggestion}`; } - if (getNextCharacter() !== '"') { + if (DOMUtil.getNextCharacter() !== '"') { suggestion = `${suggestion}"`; } break; @@ -130,9 +129,9 @@ export class LokiQueryFieldForm extends React.PureComponent ({ label }); diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index c50fdb5d39b..729d6a8a00e 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -10,12 +10,11 @@ import Prism from 'prismjs'; import { TypeaheadOutput, HistoryItem } from 'app/types/explore'; // dom also includes Element polyfills -import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; import BracesPlugin from 'app/features/explore/slate-plugins/braces'; import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; import { PromQuery, PromContext, PromOptions } from '../types'; import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise'; -import { ExploreQueryFieldProps, DataSourceStatus, QueryHint } from '@grafana/ui'; +import { ExploreQueryFieldProps, DataSourceStatus, QueryHint, DOMUtil } from '@grafana/ui'; import { isDataFrame, toLegacyResponseData } from '@grafana/data'; import { PrometheusDatasource } from '../datasource'; @@ -73,7 +72,7 @@ export function willApplySuggestion(suggestion: string, { typeaheadContext, type // Modify suggestion based on context switch (typeaheadContext) { case 'context-labels': { - const nextChar = getNextCharacter(); + const nextChar = DOMUtil.getNextCharacter(); if (!nextChar || nextChar === '}' || nextChar === ',') { suggestion += '='; } @@ -85,7 +84,7 @@ export function willApplySuggestion(suggestion: string, { typeaheadContext, type if (!typeaheadText.match(/^(!?=~?"|")/)) { suggestion = `"${suggestion}`; } - if (getNextCharacter() !== '"') { + if (DOMUtil.getNextCharacter() !== '"') { suggestion = `${suggestion}"`; } break; @@ -282,9 +281,9 @@ class PromQueryField extends React.PureComponent, State> { + constructor(props: PanelEditorProps) { + super(props); + + this.state = { + tag: '', + }; + } + + // Display + //----------- + + onToggleShowUser = () => + this.props.onOptionsChange({ ...this.props.options, showUser: !this.props.options.showUser }); + + onToggleShowTime = () => + this.props.onOptionsChange({ ...this.props.options, showTime: !this.props.options.showTime }); + + onToggleShowTags = () => + this.props.onOptionsChange({ ...this.props.options, showTags: !this.props.options.showTags }); + + // Navigate + //----------- + + onNavigateBeforeChange = (event: ChangeEvent) => { + this.props.onOptionsChange({ ...this.props.options, navigateBefore: event.target.value }); + }; + + onNavigateAfterChange = (event: ChangeEvent) => { + this.props.onOptionsChange({ ...this.props.options, navigateAfter: event.target.value }); + }; + + onToggleNavigateToPanel = () => + this.props.onOptionsChange({ ...this.props.options, navigateToPanel: !this.props.options.navigateToPanel }); + + // Search + //----------- + onLimitChange = (event: ChangeEvent) => { + const v = toIntegerOrUndefined(event.target.value); + this.props.onOptionsChange({ ...this.props.options, limit: v }); + }; + + onToggleOnlyFromThisDashboard = () => + this.props.onOptionsChange({ + ...this.props.options, + onlyFromThisDashboard: !this.props.options.onlyFromThisDashboard, + }); + + onToggleOnlyInTimeRange = () => + this.props.onOptionsChange({ ...this.props.options, onlyInTimeRange: !this.props.options.onlyInTimeRange }); + + // Tags + //----------- + + onTagTextChange = (event: ChangeEvent) => { + this.setState({ tag: event.target.value }); + }; + + onTagClick = (e: React.SyntheticEvent, tag: string) => { + e.stopPropagation(); + + const tags = this.props.options.tags.filter(item => item !== tag); + this.props.onOptionsChange({ + ...this.props.options, + tags, + }); + }; + + renderTags = (tags: string[]): JSX.Element => { + if (!tags || !tags.length) { + return null; + } + return ( + <> + {tags.map(tag => { + return ( + this.onTagClick(e, tag)} className="pointer"> + + + ); + })} + + ); + }; + + render() { + const { options } = this.props; + const labelWidth = 8; + + return ( + + + + + + + + + + + + + + +
+ Tags + {this.renderTags(options.tags)} + { + if (this.state.tag && ev.key === 'Enter') { + const tags = [...options.tags, this.state.tag]; + this.props.onOptionsChange({ + ...this.props.options, + tags, + }); + this.setState({ tag: '' }); + ev.preventDefault(); + } + }} + /> +
+ + +
+
+ ); + } +} diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx new file mode 100644 index 00000000000..b98645ddba0 --- /dev/null +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -0,0 +1,304 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Types +import { AnnoOptions } from './types'; +import { dateTime, DurationUnit, AnnotationEvent } from '@grafana/data'; +import { PanelProps, Tooltip } from '@grafana/ui'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { AbstractList } from '@grafana/ui/src/components/List/AbstractList'; +import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import appEvents from 'app/core/app_events'; + +import { updateLocation } from 'app/core/actions'; +import { store } from 'app/store/store'; +import { cx, css } from 'emotion'; + +interface UserInfo { + id: number; + login: string; + email: string; +} + +interface Props extends PanelProps {} +interface State { + annotations: AnnotationEvent[]; + timeInfo: string; + loaded: boolean; + queryUser?: UserInfo; + queryTags: string[]; +} + +export class AnnoListPanel extends PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + annotations: [], + timeInfo: '', + loaded: false, + queryTags: [], + }; + } + + componentDidMount() { + this.doSearch(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { options, timeRange } = this.props; + const needsQuery = + options !== prevProps.options || + this.state.queryTags !== prevState.queryTags || + this.state.queryUser !== prevState.queryUser || + timeRange !== prevProps.timeRange; + + if (needsQuery) { + this.doSearch(); + } + } + + async doSearch() { + // http://docs.grafana.org/http_api/annotations/ + // https://github.com/grafana/grafana/blob/master/public/app/core/services/backend_srv.ts + // https://github.com/grafana/grafana/blob/master/public/app/features/annotations/annotations_srv.ts + + const { options } = this.props; + const { queryUser, queryTags } = this.state; + + const params: any = { + tags: options.tags, + limit: options.limit, + type: 'annotation', // Skip the Annotations that are really alerts. (Use the alerts panel!) + }; + + if (options.onlyFromThisDashboard) { + params.dashboardId = getDashboardSrv().getCurrent().id; + } + + let timeInfo = ''; + if (options.onlyInTimeRange) { + const { timeRange } = this.props; + params.from = timeRange.from.valueOf(); + params.to = timeRange.to.valueOf(); + } else { + timeInfo = 'All Time'; + } + + if (queryUser) { + params.userId = queryUser.id; + } + + if (options.tags && options.tags.length) { + params.tags = options.tags; + } + + if (queryTags.length) { + params.tags = params.tags ? [...params.tags, ...queryTags] : queryTags; + } + + const annotations = await getBackendSrv().get('/api/annotations', params); + this.setState({ + annotations, + timeInfo, + loaded: true, + }); + } + + onAnnoClick = (e: React.SyntheticEvent, anno: AnnotationEvent) => { + e.stopPropagation(); + const { options } = this.props; + const dashboardSrv = getDashboardSrv(); + const current = dashboardSrv.getCurrent(); + + const params: any = { + from: this._timeOffset(anno.time, options.navigateBefore, true), + to: this._timeOffset(anno.time, options.navigateAfter, false), + }; + + if (options.navigateToPanel) { + params.panelId = anno.panelId; + params.fullscreen = true; + } + + if (current.id === anno.dashboardId) { + store.dispatch( + updateLocation({ + query: params, + partial: true, + }) + ); + return; + } + + getBackendSrv() + .get('/api/search', { dashboardIds: anno.dashboardId }) + .then((res: any[]) => { + if (res && res.length && res[0].id === anno.dashboardId) { + const dash = res[0]; + store.dispatch( + updateLocation({ + query: params, + path: dash.url, + }) + ); + return; + } + appEvents.emit('alert-warning', ['Unknown Dashboard: ' + anno.dashboardId]); + }); + }; + + _timeOffset(time: number, offset: string, subtract = false): number { + let incr = 5; + let unit = 'm'; + const parts = /^(\d+)(\w)/.exec(offset); + if (parts && parts.length === 3) { + incr = parseInt(parts[1], 10); + unit = parts[2]; + } + + const t = dateTime(time); + if (subtract) { + incr *= -1; + } + return t.add(incr, unit as DurationUnit).valueOf(); + } + + onTagClick = (e: React.SyntheticEvent, tag: string, remove: boolean) => { + e.stopPropagation(); + const queryTags = remove ? this.state.queryTags.filter(item => item !== tag) : [...this.state.queryTags, tag]; + + this.setState({ queryTags }); + }; + + onUserClick = (e: React.SyntheticEvent, anno: AnnotationEvent) => { + e.stopPropagation(); + this.setState({ + queryUser: { + id: anno.userId, + login: anno.login, + email: anno.email, + }, + }); + }; + + onClearUser = () => { + this.setState({ + queryUser: undefined, + }); + }; + + renderTags = (tags: string[], remove: boolean): JSX.Element => { + if (!tags || !tags.length) { + return null; + } + return ( + <> + {tags.map(tag => { + return ( + this.onTagClick(e, tag, remove)} className="pointer"> + + + ); + })} + + ); + }; + + renderItem = (anno: AnnotationEvent, index: number): JSX.Element => { + const { options } = this.props; + const { showUser, showTags, showTime } = options; + const dashboard = getDashboardSrv().getCurrent(); + + return ( +
+ { + this.onAnnoClick(e, anno); + }} + > + + {anno.text} + + + + {anno.login && showUser && ( + + + Created by: +
{anno.email} +
+ } + theme="info" + placement="top" + > + this.onUserClick(e, anno)} className="graph-annotation__user"> + + + +
+ )} + {showTags && this.renderTags(anno.tags, false)} +
+ + {showTime && {dashboard.formatDate(anno.time)}} + +
+ ); + }; + + render() { + const { height } = this.props; + const { loaded, annotations, queryUser, queryTags } = this.state; + if (!loaded) { + return
loading...
; + } + + // Previously we showed inidication that it covered all time + // { timeInfo && ( + // + // {timeInfo} + // + // )} + + const hasFilter = queryUser || queryTags.length > 0; + + return ( +
+ {hasFilter && ( +
+ Filter:   + {queryUser && ( + + {queryUser.email} + + )} + {queryTags.length > 0 && this.renderTags(queryTags, true)} +
+ )} + + {annotations.length < 1 &&
No Annotations Found
} + + { + return item.id + ''; + }} + className="dashlist" + /> +
+ ); + } +} diff --git a/public/app/plugins/panel/annolist/README.md b/public/app/plugins/panel/annolist/README.md new file mode 100644 index 00000000000..ef0e0d123aa --- /dev/null +++ b/public/app/plugins/panel/annolist/README.md @@ -0,0 +1,4 @@ +# Annotation List Panel - Native Plugin + +This Annotations List panel is **included** with Grafana. + diff --git a/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg b/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg new file mode 100644 index 00000000000..f584770dff0 --- /dev/null +++ b/public/app/plugins/panel/annolist/img/icn-annolist-panel.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/panel/annolist/module.ts b/public/app/plugins/panel/annolist/module.ts new file mode 100644 index 00000000000..4f7156361af --- /dev/null +++ b/public/app/plugins/panel/annolist/module.ts @@ -0,0 +1,16 @@ +import { AnnoListPanel } from './AnnoListPanel'; +import { AnnoOptions, defaults } from './types'; +import { AnnoListEditor } from './AnnoListEditor'; +import { PanelPlugin } from '@grafana/ui'; + +export const plugin = new PanelPlugin(AnnoListPanel) + .setDefaults(defaults) + .setEditor(AnnoListEditor) + + // TODO, we should support this directly in the plugin infrastructure + .setPanelChangeHandler((options: AnnoOptions, prevPluginId: string, prevOptions: any) => { + if (prevPluginId === 'ryantxu-annolist-panel') { + return prevOptions as AnnoOptions; + } + return options; + }); diff --git a/public/app/plugins/panel/annolist/plugin.json b/public/app/plugins/panel/annolist/plugin.json new file mode 100644 index 00000000000..d21890a0cb3 --- /dev/null +++ b/public/app/plugins/panel/annolist/plugin.json @@ -0,0 +1,20 @@ +{ + "type": "panel", + "name": "Annotations list (alpha)", + "id": "annolist", + "state": "alpha", + + "skipDataQuery": true, + + "info": { + "description": "List annotations", + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-annolist-panel.svg", + "large": "img/icn-annolist-panel.svg" + } + } +} diff --git a/public/app/plugins/panel/annolist/types.ts b/public/app/plugins/panel/annolist/types.ts new file mode 100644 index 00000000000..e78f562b214 --- /dev/null +++ b/public/app/plugins/panel/annolist/types.ts @@ -0,0 +1,29 @@ +export interface AnnoOptions { + limit: number; + tags: string[]; + onlyFromThisDashboard: boolean; + onlyInTimeRange: boolean; + + showTags: boolean; + showUser: boolean; + showTime: boolean; + + navigateBefore: string; + navigateAfter: string; + navigateToPanel: boolean; +} + +export const defaults: AnnoOptions = { + limit: 10, + tags: [], + onlyFromThisDashboard: false, + onlyInTimeRange: false, + + showTags: true, + showUser: true, + showTime: true, + + navigateBefore: '10m', + navigateAfter: '10m', + navigateToPanel: true, +}; diff --git a/yarn.lock b/yarn.lock index 113e9a0e954..44aab000579 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2283,10 +2283,10 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636" integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A== -"@types/lodash@4.14.119", "@types/lodash@4.14.123": - version "4.14.119" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.119.tgz#be847e5f4bc3e35e46d041c394ead8b603ad8b39" - integrity sha512-Z3TNyBL8Vd/M9D9Ms2S3LmFq2sSMzahodD6rCS9V2N44HUMINb75jNkSuwAx7eo2ufqTdfOdtGQpNbieUjPQmw== +"@types/lodash@4.14.123": + version "4.14.123" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.123.tgz#39be5d211478c8dd3bdae98ee75bb7efe4abfe4d" + integrity sha512-pQvPkc4Nltyx7G1Ww45OjVqUsJP4UsZm+GWJpigXgkikZqJgRm4c48g027o6tdgubWHwFRF15iFd+Y4Pmqv6+Q== "@types/marked@0.6.5": version "0.6.5" @@ -3432,7 +3432,7 @@ babel-plugin-add-react-displayname@^0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/babel-plugin-add-react-displayname/-/babel-plugin-add-react-displayname-0.0.5.tgz#339d4cddb7b65fd62d1df9db9fe04de134122bd5" -babel-plugin-angularjs-annotate@0.10.0: +babel-plugin-angularjs-annotate@0.10.0, babel-plugin-angularjs-annotate@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/babel-plugin-angularjs-annotate/-/babel-plugin-angularjs-annotate-0.10.0.tgz#4213b3aaae494a087aad0b8237c5d0716d22ca76" dependencies: @@ -4216,6 +4216,11 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" +caniuse-db@1.0.30000772: + version "1.0.30000772" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000772.tgz#51aae891768286eade4a3d8319ea76d6a01b512b" + integrity sha1-UarokXaChureSj2DGep21qAbUSs= + caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000929, caniuse-lite@^1.0.30000947, caniuse-lite@^1.0.30000957, caniuse-lite@^1.0.30000963: version "1.0.30000966" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000966.tgz#f3c6fefacfbfbfb981df6dfa68f2aae7bff41b64" @@ -10753,7 +10758,7 @@ ng-annotate-loader@0.6.1: normalize-path "2.0.1" source-map "0.5.6" -ng-annotate-webpack-plugin@0.3.0, ng-annotate-webpack-plugin@^0.3.0: +ng-annotate-webpack-plugin@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/ng-annotate-webpack-plugin/-/ng-annotate-webpack-plugin-0.3.0.tgz#2e7f5e29c6a4ce26649edcb06c1213408b35b84a" dependencies: