diff --git a/.gitignore b/.gitignore index dca9e2b227900..0a90f512e93ad 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ awsconfig /public/views/index.html /public/views/error.html /emails/dist + +# Enterprise emails +/emails/templates/enterprise_* +/public/emails/enterprise_* + /public_gen /public/vendor/npm /tmp diff --git a/conf/defaults.ini b/conf/defaults.ini index 3a94c17e19a01..54373992cf4f2 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -366,6 +366,7 @@ client_id = some_id client_secret = some_secret scopes = user:email email_attribute_name = email:primary +email_attribute_path = auth_url = token_url = api_url = diff --git a/conf/sample.ini b/conf/sample.ini index 5029b541638c0..ffa0d93091de5 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -319,6 +319,8 @@ ;client_id = some_id ;client_secret = some_secret ;scopes = user:email,read:org +;email_attribute_name = email:primary +;email_attribute_path = ;auth_url = https://foo.bar/login/oauth/authorize ;token_url = https://foo.bar/login/oauth/access_token ;api_url = https://foo.bar/user diff --git a/devenv/dev-dashboards/feature-templating/testdata-nested-variables.json b/devenv/dev-dashboards/feature-templating/testdata-nested-variables.json index 90df8ad99f61f..dd76b2c61d41d 100644 --- a/devenv/dev-dashboards/feature-templating/testdata-nested-variables.json +++ b/devenv/dev-dashboards/feature-templating/testdata-nested-variables.json @@ -15,14 +15,15 @@ "editable": true, "gnetId": null, "graphTooltip": 0, - "iteration": 1565097360786, + "id": 13844, + "iteration": 1566896059256, "links": [], "panels": [ { "content": "## Data center = $datacenter\n\n### server = $server\n\n#### pod = $pod", "gridPos": { - "h": 6, - "w": 14, + "h": 9, + "w": 12, "x": 0, "y": 0 }, @@ -55,9 +56,9 @@ "thresholdMarkers": true }, "gridPos": { - "h": 6, - "w": 10, - "x": 14, + "h": 9, + "w": 4, + "x": 12, "y": 0 }, "id": 6, @@ -116,6 +117,117 @@ ], "valueName": "avg" }, + { + "cacheTimeout": null, + "gridPos": { + "h": 9, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 8, + "links": [], + "options": { + "fieldOptions": { + "calcs": ["mean"], + "defaults": { + "links": [ + { + "targetBlank": true, + "title": "Go to drilldown", + "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1&${__all_variables}&${__url_time_range}" + } + ], + "mappings": [], + "max": 100, + "min": 0, + "nullValueMode": "connected", + "thresholds": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ], + "unit": "none" + }, + "override": {}, + "values": false + }, + "orientation": "horizontal", + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "6.4.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "React gauge datalink", + "type": "gauge" + }, + { + "cacheTimeout": null, + "gridPos": { + "h": 9, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 9, + "links": [], + "options": { + "displayMode": "basic", + "fieldOptions": { + "calcs": ["mean"], + "defaults": { + "links": [ + { + "targetBlank": true, + "title": "Go to drilldown", + "url": "/d/O6GmNPvWk/dashboard-tests-nested-template-variables-drilldown?orgId=1&${__all_variables}&${__url_time_range}" + } + ], + "mappings": [], + "max": 100, + "min": 0, + "nullValueMode": "connected", + "thresholds": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ], + "unit": "none" + }, + "override": {}, + "values": false + }, + "orientation": "vertical" + }, + "pluginVersion": "6.4.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "React gauge datalink", + "type": "bargauge" + }, { "aliasColors": {}, "bars": false, @@ -128,7 +240,7 @@ "h": 13, "w": 24, "x": 0, - "y": 6 + "y": 9 }, "id": 2, "legend": { @@ -296,5 +408,5 @@ "timezone": "", "title": "Templating - Nested Template Variables", "uid": "-Y-tnEDWk", - "version": 11 + "version": 2 } diff --git a/devenv/dev-dashboards/panel-common/shared_queries.json b/devenv/dev-dashboards/panel-common/shared_queries.json new file mode 100644 index 0000000000000..eba11779ce038 --- /dev/null +++ b/devenv/dev-dashboards/panel-common/shared_queries.json @@ -0,0 +1,322 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "fill": 0, + "fillGradient": 6, + "gridPos": { + "h": 15, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "dataLinks": [] + }, + "percentage": false, + "pointradius": 2, + "points": true, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0,100" + }, + { + "refId": "B", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,-100,200" + }, + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "2.5,3.5,4.5,10.5,20.5,21.5,19.5" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Raw Data Graph", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "datasource": "-- Dashboard --", + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "options": { + "fieldOptions": { + "calcs": ["lastNotNull"], + "defaults": { + "mappings": [], + "max": 100, + "min": 0, + "thresholds": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "override": {}, + "values": false + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "6.4.0-pre", + "targets": [ + { + "panelId": 2, + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Last non nulll", + "type": "gauge" + }, + { + "datasource": "-- Dashboard --", + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 6, + "options": { + "fieldOptions": { + "calcs": ["min"], + "defaults": { + "mappings": [], + "max": 100, + "min": 0, + "thresholds": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "override": {}, + "values": false + }, + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "6.4.0-pre", + "targets": [ + { + "panelId": 2, + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "min", + "type": "gauge" + }, + { + "datasource": "-- Dashboard --", + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 5, + "options": { + "displayMode": "basic", + "fieldOptions": { + "calcs": ["max"], + "defaults": { + "mappings": [], + "max": 200, + "min": 0, + "thresholds": [ + { + "color": "green", + "value": null + }, + { + "color": "blue", + "value": 40 + }, + { + "color": "red", + "value": 120 + } + ] + }, + "override": {}, + "values": false + }, + "orientation": "vertical" + }, + "pluginVersion": "6.4.0-pre", + "targets": [ + { + "panelId": 2, + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Max", + "type": "bargauge" + }, + { + "columns": [], + "datasource": "-- Dashboard --", + "fontSize": "100%", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 15 + }, + "id": 8, + "options": {}, + "pageSize": null, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "styles": [ + { + "alias": "Time", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "alias": "", + "colorMode": null, + "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "panelId": 2, + "refId": "A" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "transform": "timeseries_to_columns", + "type": "table" + } + ], + "schemaVersion": 19, + "style": "dark", + "tags": ["gdev", "datasource-test"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "", + "title": "Datasource tests - Shared Queries", + "uid": "ZqZnVvFZz", + "version": 10 +} diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md index 510776750f353..df43e80fb2e97 100644 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -40,9 +40,11 @@ Set `api_url` to the resource that returns [OpenID UserInfo](https://connect2id. Grafana will attempt to determine the user's e-mail address by querying the OAuth provider as described below in the following order until an e-mail address is found: 1. Check for the presence of an e-mail address via the `email` field encoded in the OAuth `id_token` parameter. -2. Check for the presence of an e-mail address in the `attributes` map encoded in the OAuth `id_token` parameter. By default Grafana will perform a lookup into the attributes map using the `email:primary` key, however, this is configurable and can be adjusted by using the `email_attribute_name` configuration option. -3. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`) and check for the presence of an e-mail address marked as a primary address. -4. If no e-mail address is found in steps (1-3), then the e-mail address of the user is set to the empty string. +2. Check for the presence of an e-mail address using the [JMES path](http://jmespath.org/examples.html) specified via the `email_attribute_path` configuration option. The JSON used for the path lookup is the HTTP response obtained from querying the UserInfo endpoint specified via the `api_url` configuration option. +**Note**: Only available in Grafana v6.4+. +3. Check for the presence of an e-mail address in the `attributes` map encoded in the OAuth `id_token` parameter. By default Grafana will perform a lookup into the attributes map using the `email:primary` key, however, this is configurable and can be adjusted by using the `email_attribute_name` configuration option. +4. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`) and check for the presence of an e-mail address marked as a primary address. +5. If no e-mail address is found in steps (1-4), then the e-mail address of the user is set to the empty string. ## Set up OAuth2 with Okta diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index 599244ce3b062..2f9e4adf7026b 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -466,7 +466,7 @@ Content-Type: application/json Reloads the provisioning config files for specified type and provision entities again. It won't return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop -polling for changes in dashboard files and then restart it with new configs after returning. +polling for changes in dashboard files and then restart it with new configs after returning. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. @@ -488,3 +488,30 @@ Content-Type: application/json "message": "Dashboards config reloaded" } ``` + +## Reload LDAP configuration + +`POST /api/admin/ldap/reload` + +Reloads the LDAP configuration. + +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + +```http +POST /api/admin/ldap/reload HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "LDAP config reloaded" +} +``` diff --git a/emails/README.md b/emails/README.md index 9702abce7e62c..562d5241bc7bb 100644 --- a/emails/README.md +++ b/emails/README.md @@ -1,9 +1,15 @@ +## Prerequisites - npm install - gem install premailer -- grunt (default task will build new inlines email templates) -- grunt watch (will build on source html or css change) -assembled email templates will be in dist/ and final -inlined templates will be in ../public/emails/ +## Tasks + +- npm run build (default task will build new inlines email templates) +- npm start (will build on source html or css change) + +## Result + +Assembled email templates will be in `dist/` and final +inlined templates will be in `../public/emails/` diff --git a/emails/package.json b/emails/package.json index 96779fb560973..0ac005a395e75 100644 --- a/emails/package.json +++ b/emails/package.json @@ -8,10 +8,15 @@ "email": "delder@riester.com", "url": "https://github.com/dnnsldr" }, + "scripts": { + "build": "grunt", + "start": "grunt watch" + }, + "devDependencies": { "grunt": "^0.4.5", - "grunt-premailer": "^0.2.10", - "grunt-processhtml": "^0.3.3", + "grunt-premailer": "^1.1.10", + "grunt-processhtml": "^0.4.2", "grunt-uncss": "^0.3.7", "load-grunt-config": "^0.14.0", "grunt-contrib-watch": "^0.6.1", diff --git a/package.json b/package.json index 5da725c2f0209..8dc951b83710c 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,6 @@ "react-window": "1.7.1", "redux": "4.0.1", "redux-logger": "3.0.6", - "redux-observable": "1.1.0", "redux-thunk": "2.3.0", "reselect": "4.0.0", "rst2html": "github:thoward/rst2html#990cb89", diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index b18f6ef0e2d11..c560260b4cb85 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -3,6 +3,7 @@ import { ValueMapping } from './valueMapping'; import { QueryResultBase, Labels, NullValueMode } from './data'; import { FieldCalcs } from '../utils/index'; import { DisplayProcessor } from './displayValue'; +import { DataLink } from './dataLink'; export enum FieldType { time = 'time', // or date @@ -36,6 +37,9 @@ export interface FieldConfig { // Used when reducing field values nullValueMode?: NullValueMode; + // The behavior when clicking on a result + links?: DataLink[]; + // Alternative to empty string noValue?: string; } diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 0551eb28742e2..55b1c13af06ed 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -1,5 +1,30 @@ +/** + * Link configuration. The values may contain variables that need to be + * processed before running + */ export interface DataLink { url: string; title: string; targetBlank?: boolean; } + +export type LinkTarget = '_blank' | '_self'; + +/** + * Processed Link Model. The values are ready to use + */ +export interface LinkModel { + href: string; + title: string; + target: LinkTarget; + origin: T; +} + +/** + * Provides a way to produce links on demand + * + * TODO: ScopedVars in in GrafanaUI package! + */ +export interface LinkModelSupplier { + getLinks(scopedVars?: any): Array>; +} diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index 641af88c502b0..524588a5403c1 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -105,3 +105,10 @@ export interface LogsParser { */ test: (line: string) => any; } + +export enum LogsDedupDescription { + none = 'No de-duplication', + exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.', + numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.', + signature = 'De-duplication of successive lines that have identical punctuation and whitespace.', +} diff --git a/packages/grafana-data/src/utils/dataFrameView.ts b/packages/grafana-data/src/utils/dataFrameView.ts index d80b376a0f71c..1838779478cdf 100644 --- a/packages/grafana-data/src/utils/dataFrameView.ts +++ b/packages/grafana-data/src/utils/dataFrameView.ts @@ -44,6 +44,10 @@ export class DataFrameView implements Vector { this.obj = obj; } + get dataFrame() { + return this.data; + } + get length() { return this.data.length; } diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index a4cc7df097199..2479adafe5892 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -11,6 +11,7 @@ export * from './labels'; export * from './object'; export * from './moment_wrapper'; export * from './thresholds'; +export * from './text'; export * from './dataFrameHelper'; export * from './dataFrameView'; export * from './vector'; @@ -21,3 +22,8 @@ export { getMappedValue } from './valueMappings'; import * as dateMath from './datemath'; import * as rangeUtil from './rangeutil'; export { dateMath, rangeUtil }; + +export * from './matchers/ids'; +export * from './matchers/matchers'; +export * from './transformers/ids'; +export * from './transformers/transformers'; diff --git a/packages/grafana-data/src/utils/logs.test.ts b/packages/grafana-data/src/utils/logs.test.ts index 51c526b7d98a8..28f171e525dbc 100644 --- a/packages/grafana-data/src/utils/logs.test.ts +++ b/packages/grafana-data/src/utils/logs.test.ts @@ -1,5 +1,5 @@ import { LogLevel } from '../types/logs'; -import { getLogLevel } from './logs'; +import { getLogLevel, calculateLogsLabelStats, calculateFieldStats, getParser, LogsParsers } from './logs'; describe('getLoglevel()', () => { it('returns no log level on empty line', () => { @@ -25,3 +25,190 @@ describe('getLoglevel()', () => { expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn); }); }); + +describe('calculateLogsLabelStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateLogsLabelStats([], '')).toEqual([]); + }); + + test('should return no stats of label is not found', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]); + }); + + test('should return stats for found labels', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + { + entry: 'foo 0', + labels: { + foo: 'xxx', + }, + }, + { + entry: 'foo 2', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([ + { + value: 'bar', + count: 2, + }, + { + value: 'xxx', + count: 1, + }, + ]); + }); +}); + +describe('LogsParsers', () => { + describe('logfmt', () => { + const parser = LogsParsers.logfmt; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('foo=bar')).toBeTruthy(); + }); + + test('should return parsed fields', () => { + expect(parser.getFields('foo=bar baz="42 + 1"')).toEqual(['foo=bar', 'baz="42 + 1"']); + }); + + test('should return label for field', () => { + expect(parser.getLabelFromField('foo=bar')).toBe('foo'); + }); + + test('should return value for field', () => { + expect(parser.getValueFromField('foo=bar')).toBe('bar'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = 'foo=bar'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('bar'); + }); + }); + + describe('JSON', () => { + const parser = LogsParsers.JSON; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('{"foo":"bar"}')).toBeTruthy(); + }); + + test('should return parsed fields', () => { + expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo" : "bar"', '"baz" : 42']); + }); + + test('should return parsed fields for nested quotes', () => { + expect(parser.getFields(`{"foo":"bar: '[value=\\"42\\"]'"}`)).toEqual([`"foo":"bar: '[value=\\"42\\"]'"`]); + }); + + test('should return label for field', () => { + expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo'); + }); + + test('should return value for field', () => { + expect(parser.getValueFromField('"foo" : "bar"')).toBe('"bar"'); + expect(parser.getValueFromField('"foo" : 42')).toBe('42'); + expect(parser.getValueFromField('"foo" : 42.1')).toBe('42.1'); + }); + + test('should build a valid value matcher for strings', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":"bar"}'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('bar'); + }); + + test('should build a valid value matcher for integers', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":42.1}'.match(matcher); + expect(match).toBeDefined(); + expect(match![1]).toBe('42.1'); + }); + }); +}); + +describe('calculateFieldStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]); + }); + + test('should return no stats if extractor does not match', () => { + const rows = [ + { + entry: 'foo=bar', + }, + ]; + + expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]); + }); + + test('should return stats for found field', () => { + const rows = [ + { + entry: 'foo="42 + 1"', + }, + { + entry: 'foo=503 baz=foo', + }, + { + entry: 'foo="42 + 1"', + }, + { + entry: 't=2018-12-05T07:44:59+0000 foo=503', + }, + ]; + + expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([ + { + value: '"42 + 1"', + count: 2, + }, + { + value: '503', + count: 2, + }, + ]); + }); +}); + +describe('getParser()', () => { + test('should return no parser on empty line', () => { + expect(getParser('')).toBeUndefined(); + }); + + test('should return no parser on unknown line pattern', () => { + expect(getParser('To Be or not to be')).toBeUndefined(); + }); + + test('should return logfmt parser on key value patterns', () => { + expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt); + }); + + test('should return JSON parser on JSON log lines', () => { + // TODO implement other JSON value types than string + expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); + }); +}); diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index 43f44578a4e55..196a6a28dd734 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -1,7 +1,11 @@ -import { LogLevel } from '../types/logs'; +import { countBy, chain, map, escapeRegExp } from 'lodash'; + +import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser } from '../types/logs'; import { DataFrame, FieldType } from '../types/index'; import { ArrayVector } from './vector'; +const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/; + /** * Returns the log level of a log line. * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`. @@ -54,3 +58,98 @@ export function addLogLevelToSeries(series: DataFrame, lineIndex: number): DataF ], }; } + +export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] { + // Consider only rows that have the given label + const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); + const rowCount = rowsWithLabel.length; + + // Get label value counts for eligible rows + const countsByValue = countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]); + const sortedCounts = chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + +export const LogsParsers: { [name: string]: LogsParser } = { + JSON: { + buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`), + getFields: line => { + const fields: string[] = []; + try { + const parsed = JSON.parse(line); + map(parsed, (value, key) => { + const fieldMatcher = new RegExp(`"${key}"\\s*:\\s*"?${escapeRegExp(JSON.stringify(value))}"?`); + + const match = line.match(fieldMatcher); + if (match) { + fields.push(match[0]); + } + }); + } catch {} + return fields; + }, + getLabelFromField: field => (field.match(/^"(\w+)"\s*:/) || [])[1], + getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1], + test: line => { + try { + return JSON.parse(line); + } catch (error) {} + }, + }, + + logfmt: { + buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`), + getFields: line => { + const fields: string[] = []; + line.replace(new RegExp(LOGFMT_REGEXP, 'g'), substring => { + fields.push(substring.trim()); + return ''; + }); + return fields; + }, + getLabelFromField: field => (field.match(LOGFMT_REGEXP) || [])[1], + getValueFromField: field => (field.match(LOGFMT_REGEXP) || [])[2], + test: line => LOGFMT_REGEXP.test(line), + }, +}; + +export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] { + // Consider only rows that satisfy the matcher + const rowsWithField = rows.filter(row => extractor.test(row.entry)); + const rowCount = rowsWithField.length; + + // Get field value counts for eligible rows + const countsByValue = countBy(rowsWithField, r => { + const row: LogRowModel = r; + const match = row.entry.match(extractor); + + return match ? match[1] : null; + }); + const sortedCounts = chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + +export function getParser(line: string): LogsParser | undefined { + let parser; + try { + if (LogsParsers.JSON.test(line)) { + parser = LogsParsers.JSON; + } + } catch (error) {} + + if (!parser && LogsParsers.logfmt.test(line)) { + parser = LogsParsers.logfmt; + } + + return parser; +} diff --git a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts b/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts new file mode 100644 index 0000000000000..99884f3d60c1d --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.test.ts @@ -0,0 +1,22 @@ +import { FieldType } from '../../types/dataFrame'; +import { fieldMatchers } from './matchers'; +import { FieldMatcherID } from './ids'; +import { toDataFrame } from '../processDataFrame'; + +export const simpleSeriesWithTypes = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.time }, + { name: 'B', type: FieldType.boolean }, + { name: 'C', type: FieldType.string }, + ], +}); + +describe('Field Type Matcher', () => { + const matcher = fieldMatchers.get(FieldMatcherID.byType); + it('finds numbers', () => { + for (const field of simpleSeriesWithTypes.fields) { + const matches = matcher.get(FieldType.number); + expect(matches(field)).toBe(field.type === FieldType.number); + } + }); +}); diff --git a/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts b/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts new file mode 100644 index 0000000000000..385bcefca44fe --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/fieldTypeMatcher.ts @@ -0,0 +1,59 @@ +import { Field, FieldType } from '../../types/dataFrame'; +import { FieldMatcherInfo } from './matchers'; +import { FieldMatcherID } from './ids'; + +// General Field matcher +const fieldTypeMacher: FieldMatcherInfo = { + id: FieldMatcherID.byType, + name: 'Field Type', + description: 'match based on the field type', + defaultOptions: FieldType.number, + + get: (type: FieldType) => { + return (field: Field) => { + return type === field.type; + }; + }, + + getOptionsDisplayText: (type: FieldType) => { + return `Field type: ${type}`; + }, +}; + +// Numeric Field matcher +// This gets its own entry so it shows up in the dropdown +const numericMacher: FieldMatcherInfo = { + id: FieldMatcherID.numeric, + name: 'Numeric Fields', + description: 'Fields with type number', + + get: () => { + return fieldTypeMacher.get(FieldType.number); + }, + + getOptionsDisplayText: () => { + return 'Numeric Fields'; + }, +}; + +// Time Field matcher +const timeMacher: FieldMatcherInfo = { + id: FieldMatcherID.time, + name: 'Time Fields', + description: 'Fields with type time', + + get: () => { + return fieldTypeMacher.get(FieldType.time); + }, + + getOptionsDisplayText: () => { + return 'Time Fields'; + }, +}; + +/** + * Registry Initalization + */ +export function getFieldTypeMatchers(): FieldMatcherInfo[] { + return [fieldTypeMacher, numericMacher, timeMacher]; +} diff --git a/packages/grafana-data/src/utils/matchers/ids.ts b/packages/grafana-data/src/utils/matchers/ids.ts new file mode 100644 index 0000000000000..ee57678e26f03 --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/ids.ts @@ -0,0 +1,33 @@ +// This needs to be in its own file to avoid circular references + +// Builtin Predicates +// not using 'any' and 'never' since they are reservered keywords +export enum MatcherID { + anyMatch = 'anyMatch', // checks children + allMatch = 'allMatch', // checks children + invertMatch = 'invertMatch', // checks child + alwaysMatch = 'alwaysMatch', + neverMatch = 'neverMatch', +} + +export enum FieldMatcherID { + // Specific Types + numeric = 'numeric', + time = 'time', + + // With arguments + byType = 'byType', + byName = 'byName', + // byIndex = 'byIndex', + // byLabel = 'byLabel', +} + +/** + * Field name matchers + */ +export enum FrameMatcherID { + byName = 'byName', + byRefId = 'byRefId', + byIndex = 'byIndex', + byLabel = 'byLabel', +} diff --git a/packages/grafana-data/src/utils/matchers/matchers.test.ts b/packages/grafana-data/src/utils/matchers/matchers.test.ts new file mode 100644 index 0000000000000..0faeabb14aebb --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/matchers.test.ts @@ -0,0 +1,11 @@ +import { fieldMatchers } from './matchers'; +import { FieldMatcherID } from './ids'; + +describe('Matchers', () => { + it('should load all matchers', () => { + for (const name of Object.keys(FieldMatcherID)) { + const matcher = fieldMatchers.get(name); + expect(matcher.id).toBe(name); + } + }); +}); diff --git a/packages/grafana-data/src/utils/matchers/matchers.ts b/packages/grafana-data/src/utils/matchers/matchers.ts new file mode 100644 index 0000000000000..d5b45ff771a11 --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/matchers.ts @@ -0,0 +1,56 @@ +import { Field, DataFrame } from '../../types/dataFrame'; +import { Registry, RegistryItemWithOptions } from '../registry'; + +export type FieldMatcher = (field: Field) => boolean; +export type FrameMatcher = (frame: DataFrame) => boolean; + +export interface FieldMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FieldMatcher; +} + +export interface FrameMatcherInfo extends RegistryItemWithOptions { + get: (options: TOptions) => FrameMatcher; +} + +export interface MatcherConfig { + id: string; + options?: TOptions; +} + +// Load the Buildtin matchers +import { getFieldPredicateMatchers, getFramePredicateMatchers } from './predicates'; +import { getFieldNameMatchers, getFrameNameMatchers } from './nameMatcher'; +import { getFieldTypeMatchers } from './fieldTypeMatcher'; +import { getRefIdMatchers } from './refIdMatcher'; + +export const fieldMatchers = new Registry(() => { + return [ + ...getFieldPredicateMatchers(), // Predicates + ...getFieldTypeMatchers(), // by type + ...getFieldNameMatchers(), // by name + ]; +}); + +export const frameMatchers = new Registry(() => { + return [ + ...getFramePredicateMatchers(), // Predicates + ...getFrameNameMatchers(), // by name + ...getRefIdMatchers(), // by query refId + ]; +}); + +export function getFieldMatcher(config: MatcherConfig): FieldMatcher { + const info = fieldMatchers.get(config.id); + if (!info) { + throw new Error('Unknown Matcher: ' + config.id); + } + return info.get(config.options); +} + +export function getFrameMatchers(config: MatcherConfig): FrameMatcher { + const info = frameMatchers.get(config.id); + if (!info) { + throw new Error('Unknown Matcher: ' + config.id); + } + return info.get(config.options); +} diff --git a/packages/grafana-data/src/utils/matchers/nameMatcher.test.ts b/packages/grafana-data/src/utils/matchers/nameMatcher.test.ts new file mode 100644 index 0000000000000..7f2880ff0f631 --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/nameMatcher.test.ts @@ -0,0 +1,56 @@ +import { getFieldMatcher } from './matchers'; +import { FieldMatcherID } from './ids'; +import { toDataFrame } from '../processDataFrame'; + +describe('Field Name Matcher', () => { + it('Match all with wildcard regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }], + }); + const config = { + id: FieldMatcherID.byName, + options: '/.*/', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field)).toBe(true); + } + }); + + it('Match all with decimals regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: '12' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byName, + options: '/^\\d+$/', + }; + + const matcher = getFieldMatcher(config); + + for (const field of seriesWithNames.fields) { + expect(matcher(field)).toBe(true); + } + }); + + it('Match complex regex', () => { + const seriesWithNames = toDataFrame({ + fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }], + }); + const config = { + id: FieldMatcherID.byName, + options: '/\\b(?:\\S+?\\.)+\\S+\\b$/', + }; + + const matcher = getFieldMatcher(config); + let resultCount = 0; + for (const field of seriesWithNames.fields) { + if (matcher(field)) { + resultCount++; + } + expect(resultCount).toBe(1); + } + }); +}); diff --git a/packages/grafana-data/src/utils/matchers/nameMatcher.ts b/packages/grafana-data/src/utils/matchers/nameMatcher.ts new file mode 100644 index 0000000000000..626b6a908a8a2 --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/nameMatcher.ts @@ -0,0 +1,53 @@ +import { Field, DataFrame } from '../../types/dataFrame'; +import { FieldMatcherInfo, FrameMatcherInfo } from './matchers'; +import { FieldMatcherID, FrameMatcherID } from './ids'; +import { stringToJsRegex } from '../string'; + +// General Field matcher +const fieldNameMacher: FieldMatcherInfo = { + id: FieldMatcherID.byName, + name: 'Field Name', + description: 'match the field name', + defaultOptions: '/.*/', + + get: (pattern: string) => { + const regex = stringToJsRegex(pattern); + return (field: Field) => { + return regex.test(field.name); + }; + }, + + getOptionsDisplayText: (pattern: string) => { + return `Field name: ${pattern}`; + }, +}; + +// General Field matcher +const frameNameMacher: FrameMatcherInfo = { + id: FrameMatcherID.byName, + name: 'Frame Name', + description: 'match the frame name', + defaultOptions: '/.*/', + + get: (pattern: string) => { + const regex = stringToJsRegex(pattern); + return (frame: DataFrame) => { + return regex.test(frame.name || ''); + }; + }, + + getOptionsDisplayText: (pattern: string) => { + return `Frame name: ${pattern}`; + }, +}; + +/** + * Registry Initalization + */ +export function getFieldNameMatchers(): FieldMatcherInfo[] { + return [fieldNameMacher]; +} + +export function getFrameNameMatchers(): FrameMatcherInfo[] { + return [frameNameMacher]; +} diff --git a/packages/grafana-data/src/utils/matchers/predicates.test.ts b/packages/grafana-data/src/utils/matchers/predicates.test.ts new file mode 100644 index 0000000000000..97e95ba3129b7 --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/predicates.test.ts @@ -0,0 +1,37 @@ +import { FieldType } from '../../types/dataFrame'; +import { MatcherConfig, fieldMatchers } from './matchers'; +import { simpleSeriesWithTypes } from './fieldTypeMatcher.test'; +import { FieldMatcherID, MatcherID } from './ids'; + +const matchesNumberConfig: MatcherConfig = { + id: FieldMatcherID.byType, + options: FieldType.number, +}; +const matchesTimeConfig: MatcherConfig = { + id: FieldMatcherID.byType, + options: FieldType.time, +}; +const both = [matchesNumberConfig, matchesTimeConfig]; + +describe('Check Predicates', () => { + it('can not match both', () => { + const matches = fieldMatchers.get(MatcherID.allMatch).get(both); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field)).toBe(false); + } + }); + + it('match either time or number', () => { + const matches = fieldMatchers.get(MatcherID.anyMatch).get(both); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field)).toBe(field.type === FieldType.number || field.type === FieldType.time); + } + }); + + it('match not time', () => { + const matches = fieldMatchers.get(MatcherID.invertMatch).get(matchesTimeConfig); + for (const field of simpleSeriesWithTypes.fields) { + expect(matches(field)).toBe(field.type !== FieldType.time); + } + }); +}); diff --git a/packages/grafana-data/src/utils/matchers/predicates.ts b/packages/grafana-data/src/utils/matchers/predicates.ts new file mode 100644 index 0000000000000..502cceef31117 --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/predicates.ts @@ -0,0 +1,268 @@ +import { Field, DataFrame } from '../../types/dataFrame'; +import { MatcherID } from './ids'; +import { + FrameMatcherInfo, + FieldMatcherInfo, + MatcherConfig, + getFieldMatcher, + fieldMatchers, + getFrameMatchers, + frameMatchers, +} from './matchers'; + +const anyFieldMatcher: FieldMatcherInfo = { + id: MatcherID.anyMatch, + name: 'Any', + description: 'Any child matches (OR)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map(option => { + return getFieldMatcher(option); + }); + return (field: Field) => { + for (const child of children) { + if (child(field)) { + return true; + } + } + return false; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' OR '; + } + const matcher = fieldMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const anyFrameMatcher: FrameMatcherInfo = { + id: MatcherID.anyMatch, + name: 'Any', + description: 'Any child matches (OR)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map(option => { + return getFrameMatchers(option); + }); + return (frame: DataFrame) => { + for (const child of children) { + if (child(frame)) { + return true; + } + } + return false; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' OR '; + } + const matcher = frameMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const allFieldsMatcher: FieldMatcherInfo = { + id: MatcherID.allMatch, + name: 'All', + description: 'Everything matches (AND)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map(option => { + return getFieldMatcher(option); + }); + return (field: Field) => { + for (const child of children) { + if (!child(field)) { + return false; + } + } + return true; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' AND '; + } + const matcher = fieldMatchers.get(sub.id); // Ugho what about frame + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const allFramesMatcher: FrameMatcherInfo = { + id: MatcherID.allMatch, + name: 'All', + description: 'Everything matches (AND)', + excludeFromPicker: true, + defaultOptions: [], // empty array + + get: (options: MatcherConfig[]) => { + const children = options.map(option => { + return getFrameMatchers(option); + }); + return (frame: DataFrame) => { + for (const child of children) { + if (!child(frame)) { + return false; + } + } + return true; + }; + }, + + getOptionsDisplayText: (options: MatcherConfig[]) => { + let text = ''; + for (const sub of options) { + if (text.length > 0) { + text += ' AND '; + } + const matcher = frameMatchers.get(sub.id); + text += matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(sub) : matcher.name; + } + return text; + }, +}; + +const notFieldMatcher: FieldMatcherInfo = { + id: MatcherID.invertMatch, + name: 'NOT', + description: 'Inverts other matchers', + excludeFromPicker: true, + + get: (option: MatcherConfig) => { + const check = getFieldMatcher(option); + return (field: Field) => { + return !check(field); + }; + }, + + getOptionsDisplayText: (options: MatcherConfig) => { + const matcher = fieldMatchers.get(options.id); + const text = matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(options.options) : matcher.name; + return 'NOT ' + text; + }, +}; + +const notFrameMatcher: FrameMatcherInfo = { + id: MatcherID.invertMatch, + name: 'NOT', + description: 'Inverts other matchers', + excludeFromPicker: true, + + get: (option: MatcherConfig) => { + const check = getFrameMatchers(option); + return (frame: DataFrame) => { + return !check(frame); + }; + }, + + getOptionsDisplayText: (options: MatcherConfig) => { + const matcher = frameMatchers.get(options.id); + const text = matcher.getOptionsDisplayText ? matcher.getOptionsDisplayText(options.options) : matcher.name; + return 'NOT ' + text; + }, +}; + +export const alwaysFieldMatcher = (field: Field) => { + return true; +}; + +export const alwaysFrameMatcher = (frame: DataFrame) => { + return true; +}; + +export const neverFieldMatcher = (field: Field) => { + return false; +}; + +export const neverFrameMatcher = (frame: DataFrame) => { + return false; +}; + +const alwaysFieldMatcherInfo: FieldMatcherInfo = { + id: MatcherID.alwaysMatch, + name: 'All Fields', + description: 'Always Match', + + get: (option: any) => { + return alwaysFieldMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Always'; + }, +}; + +const alwaysFrameMatcherInfo: FrameMatcherInfo = { + id: MatcherID.alwaysMatch, + name: 'All Frames', + description: 'Always Match', + + get: (option: any) => { + return alwaysFrameMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Always'; + }, +}; + +const neverFieldMatcherInfo: FieldMatcherInfo = { + id: MatcherID.neverMatch, + name: 'No Fields', + description: 'Never Match', + excludeFromPicker: true, + + get: (option: any) => { + return neverFieldMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Never'; + }, +}; + +const neverFrameMatcherInfo: FrameMatcherInfo = { + id: MatcherID.neverMatch, + name: 'No Frames', + description: 'Never Match', + + get: (option: any) => { + return neverFrameMatcher; + }, + + getOptionsDisplayText: (options: any) => { + return 'Never'; + }, +}; + +export function getFieldPredicateMatchers(): FieldMatcherInfo[] { + return [anyFieldMatcher, allFieldsMatcher, notFieldMatcher, alwaysFieldMatcherInfo, neverFieldMatcherInfo]; +} + +export function getFramePredicateMatchers(): FrameMatcherInfo[] { + return [anyFrameMatcher, allFramesMatcher, notFrameMatcher, alwaysFrameMatcherInfo, neverFrameMatcherInfo]; +} diff --git a/packages/grafana-data/src/utils/matchers/refIdMatcher.ts b/packages/grafana-data/src/utils/matchers/refIdMatcher.ts new file mode 100644 index 0000000000000..51b0db3af8051 --- /dev/null +++ b/packages/grafana-data/src/utils/matchers/refIdMatcher.ts @@ -0,0 +1,25 @@ +import { DataFrame } from '../../types/dataFrame'; +import { FrameMatcherInfo } from './matchers'; +import { FrameMatcherID } from './ids'; + +// General Field matcher +const refIdMacher: FrameMatcherInfo = { + id: FrameMatcherID.byRefId, + name: 'Query refId', + description: 'match the refId', + defaultOptions: 'A', + + get: (pattern: string) => { + return (frame: DataFrame) => { + return pattern === frame.refId; + }; + }, + + getOptionsDisplayText: (pattern: string) => { + return `RefID: ${pattern}`; + }, +}; + +export function getRefIdMatchers(): FrameMatcherInfo[] { + return [refIdMacher]; +} diff --git a/packages/grafana-data/src/utils/registry.ts b/packages/grafana-data/src/utils/registry.ts index bc4ce20091b3a..ffe8f49850063 100644 --- a/packages/grafana-data/src/utils/registry.ts +++ b/packages/grafana-data/src/utils/registry.ts @@ -13,6 +13,18 @@ export interface RegistryItem { excludeFromPicker?: boolean; } +export interface RegistryItemWithOptions extends RegistryItem { + /** + * Convert the options to a string + */ + getOptionsDisplayText?: (options: TOptions) => string; + + /** + * Default options used if nothing else is specified + */ + defaultOptions?: TOptions; +} + interface RegistrySelectInfo { options: Array>; current: Array>; diff --git a/public/app/core/utils/text.test.ts b/packages/grafana-data/src/utils/text.test.ts similarity index 100% rename from public/app/core/utils/text.test.ts rename to packages/grafana-data/src/utils/text.test.ts diff --git a/packages/grafana-data/src/utils/text.ts b/packages/grafana-data/src/utils/text.ts new file mode 100644 index 0000000000000..f05a019720ff0 --- /dev/null +++ b/packages/grafana-data/src/utils/text.ts @@ -0,0 +1,84 @@ +export interface TextMatch { + text: string; + start: number; + length: number; + end: number; +} + +/** + * Adapt findMatchesInText for react-highlight-words findChunks handler. + * See https://github.com/bvaughn/react-highlight-words#props + */ +export function findHighlightChunksInText({ + searchWords, + textToHighlight, +}: { + searchWords: string[]; + textToHighlight: string; +}) { + return searchWords.reduce((acc: any, term: string) => [...acc, ...findMatchesInText(textToHighlight, term)], []); +} + +const cleanNeedle = (needle: string): string => { + return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); +}; + +/** + * Returns a list of substring regexp matches. + */ +export function findMatchesInText(haystack: string, needle: string): TextMatch[] { + // Empty search can send re.exec() into infinite loop, exit early + if (!haystack || !needle) { + return []; + } + const matches: TextMatch[] = []; + const { cleaned, flags } = parseFlags(cleanNeedle(needle)); + let regexp: RegExp; + try { + regexp = new RegExp(`(?:${cleaned})`, flags); + } catch (error) { + return matches; + } + haystack.replace(regexp, (substring, ...rest) => { + if (substring) { + const offset = rest[rest.length - 2]; + matches.push({ + text: substring, + start: offset, + length: substring.length, + end: offset + substring.length, + }); + } + return ''; + }); + return matches; +} + +const CLEAR_FLAG = '-'; +const FLAGS_REGEXP = /\(\?([ims-]+)\)/g; + +/** + * Converts any mode modifers in the text to the Javascript equivalent flag + */ +export function parseFlags(text: string): { cleaned: string; flags: string } { + const flags: Set = new Set(['g']); + + const cleaned = text.replace(FLAGS_REGEXP, (str, group) => { + const clearAll = group.startsWith(CLEAR_FLAG); + + for (let i = 0; i < group.length; ++i) { + const flag = group.charAt(i); + if (clearAll || group.charAt(i - 1) === CLEAR_FLAG) { + flags.delete(flag); + } else if (flag !== CLEAR_FLAG) { + flags.add(flag); + } + } + return ''; // Remove flag from text + }); + + return { + cleaned: cleaned, + flags: Array.from(flags).join(''), + }; +} diff --git a/packages/grafana-data/src/utils/transformers/__snapshots__/reduce.test.ts.snap b/packages/grafana-data/src/utils/transformers/__snapshots__/reduce.test.ts.snap new file mode 100644 index 0000000000000..fab0404045eb3 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/__snapshots__/reduce.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Reducer Transformer filters by include 1`] = ` +Object { + "fields": Array [ + Object { + "config": Object {}, + "name": "Field", + "type": "string", + "values": Array [ + "A", + "B", + ], + }, + Object { + "config": Object { + "title": "First", + }, + "name": "first", + "type": "number", + "values": Array [ + 1, + "a", + ], + }, + Object { + "config": Object { + "title": "Min", + }, + "name": "min", + "type": "number", + "values": Array [ + 1, + null, + ], + }, + Object { + "config": Object { + "title": "Max", + }, + "name": "max", + "type": "number", + "values": Array [ + 4, + null, + ], + }, + Object { + "config": Object { + "title": "Delta", + }, + "name": "delta", + "type": "number", + "values": Array [ + 3, + 0, + ], + }, + ], + "labels": undefined, + "meta": Object { + "transformations": Array [ + "reduce", + ], + }, + "name": undefined, + "refId": undefined, +} +`; diff --git a/packages/grafana-data/src/utils/transformers/append.test.ts b/packages/grafana-data/src/utils/transformers/append.test.ts new file mode 100644 index 0000000000000..5e9c8feaefd5a --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/append.test.ts @@ -0,0 +1,41 @@ +import { transformDataFrame, dataTransformers } from './transformers'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../processDataFrame'; + +const seriesAB = toDataFrame({ + columns: [{ text: 'A' }, { text: 'B' }], + rows: [ + [1, 100], // A,B + [2, 200], // A,B + ], +}); + +const seriesBC = toDataFrame({ + columns: [{ text: 'A' }, { text: 'C' }], + rows: [ + [3, 3000], // A,C + [4, 4000], // A,C + ], +}); + +describe('Append Transformer', () => { + it('filters by include', () => { + const cfg = { + id: DataTransformerID.append, + options: {}, + }; + const x = dataTransformers.get(DataTransformerID.append); + expect(x.id).toBe(cfg.id); + + const processed = transformDataFrame([cfg], [seriesAB, seriesBC])[0]; + expect(processed.fields.length).toBe(3); + + const fieldA = processed.fields[0]; + const fieldB = processed.fields[1]; + const fieldC = processed.fields[2]; + + expect(fieldA.values.toArray()).toEqual([1, 2, 3, 4]); + expect(fieldB.values.toArray()).toEqual([100, 200, undefined, undefined]); + expect(fieldC.values.toArray()).toEqual([undefined, undefined, 3000, 4000]); + }); +}); diff --git a/packages/grafana-data/src/utils/transformers/append.ts b/packages/grafana-data/src/utils/transformers/append.ts new file mode 100644 index 0000000000000..9129033c7350a --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/append.ts @@ -0,0 +1,58 @@ +import { DataTransformerInfo } from './transformers'; +import { DataFrame } from '../../types/dataFrame'; +import { DataTransformerID } from './ids'; +import { DataFrameHelper } from '../dataFrameHelper'; +import { KeyValue } from '../../types/data'; +import { AppendedVectors } from '../vector'; + +export interface AppendOptions {} + +export const appendTransformer: DataTransformerInfo = { + id: DataTransformerID.append, + name: 'Append', + description: 'Append values into a single DataFrame. This uses the name as the key', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + transformer: (options: AppendOptions) => { + return (data: DataFrame[]) => { + if (data.length < 2) { + return data; + } + + let length = 0; + const processed = new DataFrameHelper(); + for (let i = 0; i < data.length; i++) { + const frame = data[i]; + const used: KeyValue = {}; + for (let j = 0; j < frame.fields.length; j++) { + const src = frame.fields[j]; + if (used[src.name]) { + continue; + } + used[src.name] = true; + + let f = processed.getFieldByName(src.name); + if (!f) { + f = processed.addField({ + ...src, + values: new AppendedVectors(length), + }); + } + (f.values as AppendedVectors).append(src.values); + } + + // Make sure all fields have their length updated + length += frame.length; + processed.length = length; + for (const f of processed.fields) { + (f.values as AppendedVectors).setLength(processed.length); + } + } + return [processed]; + }; + }, +}; diff --git a/packages/grafana-data/src/utils/transformers/filter.test.ts b/packages/grafana-data/src/utils/transformers/filter.test.ts new file mode 100644 index 0000000000000..28b3c11f6f700 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/filter.test.ts @@ -0,0 +1,29 @@ +import { FieldType } from '../../types/dataFrame'; +import { FieldMatcherID } from '../matchers/ids'; +import { transformDataFrame } from './transformers'; +import { DataTransformerID } from './ids'; +import { toDataFrame } from '../processDataFrame'; + +export const simpleSeriesWithTypes = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.time, values: [1000, 2000] }, + { name: 'B', type: FieldType.boolean, values: [true, false] }, + { name: 'C', type: FieldType.string, values: ['a', 'b'] }, + { name: 'D', type: FieldType.number, values: [1, 2] }, + ], +}); + +describe('Filter Transformer', () => { + it('filters by include', () => { + const cfg = { + id: DataTransformerID.filterFields, + options: { + include: { id: FieldMatcherID.numeric }, + }, + }; + + const filtered = transformDataFrame([cfg], [simpleSeriesWithTypes])[0]; + expect(filtered.fields.length).toBe(1); + expect(filtered.fields[0].name).toBe('D'); + }); +}); diff --git a/packages/grafana-data/src/utils/transformers/filter.ts b/packages/grafana-data/src/utils/transformers/filter.ts new file mode 100644 index 0000000000000..02083e3d52b20 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/filter.ts @@ -0,0 +1,102 @@ +import { DataTransformerInfo, NoopDataTransformer } from './transformers'; +import { DataFrame, Field } from '../../types/dataFrame'; +import { FieldMatcherID } from '../matchers/ids'; +import { DataTransformerID } from './ids'; +import { MatcherConfig, getFieldMatcher, getFrameMatchers } from '../matchers/matchers'; + +export interface FilterOptions { + include?: MatcherConfig; + exclude?: MatcherConfig; +} + +export const filterFieldsTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFields, + name: 'Filter Fields', + description: 'select a subset of fields', + defaultOptions: { + include: { id: FieldMatcherID.numeric }, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + transformer: (options: FilterOptions) => { + if (!options.include && !options.exclude) { + return NoopDataTransformer; + } + + const include = options.include ? getFieldMatcher(options.include) : null; + const exclude = options.exclude ? getFieldMatcher(options.exclude) : null; + + return (data: DataFrame[]) => { + const processed: DataFrame[] = []; + for (const series of data) { + // Find the matching field indexes + const fields: Field[] = []; + for (let i = 0; i < series.fields.length; i++) { + const field = series.fields[i]; + if (exclude) { + if (exclude(field)) { + continue; + } + if (!include) { + fields.push(field); + } + } + if (include && include(field)) { + fields.push(field); + } + } + + if (!fields.length) { + continue; + } + const copy = { + ...series, // all the other properties + fields, // but a different set of fields + }; + processed.push(copy); + } + return processed; + }; + }, +}; + +export const filterFramesTransformer: DataTransformerInfo = { + id: DataTransformerID.filterFrames, + name: 'Filter Frames', + description: 'select a subset of frames', + defaultOptions: {}, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + transformer: (options: FilterOptions) => { + if (!options.include && !options.exclude) { + return NoopDataTransformer; + } + + const include = options.include ? getFrameMatchers(options.include) : null; + const exclude = options.exclude ? getFrameMatchers(options.exclude) : null; + + return (data: DataFrame[]) => { + const processed: DataFrame[] = []; + for (const series of data) { + if (exclude) { + if (exclude(series)) { + continue; + } + if (!include) { + processed.push(series); + } + } + if (include && include(series)) { + processed.push(series); + } + } + return processed; + }; + }, +}; diff --git a/packages/grafana-data/src/utils/transformers/ids.ts b/packages/grafana-data/src/utils/transformers/ids.ts new file mode 100644 index 0000000000000..32fab35137148 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/ids.ts @@ -0,0 +1,9 @@ +export enum DataTransformerID { + // join = 'join', // Pick a field and merge all series based on that field + append = 'append', // Merge all series together + // rotate = 'rotate', // Columns to rows + reduce = 'reduce', // Run calculations on fields + + filterFields = 'filterFields', // Pick some fields (keep all frames) + filterFrames = 'filterFrames', // Pick some frames (keep all fields) +} diff --git a/packages/grafana-data/src/utils/transformers/reduce.test.ts b/packages/grafana-data/src/utils/transformers/reduce.test.ts new file mode 100644 index 0000000000000..ed0ce16b6f71e --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/reduce.test.ts @@ -0,0 +1,25 @@ +import { transformDataFrame } from './transformers'; +import { ReducerID } from '../fieldReducer'; +import { DataTransformerID } from './ids'; +import { toDataFrame, toDataFrameDTO } from '../processDataFrame'; + +const seriesWithValues = toDataFrame({ + fields: [ + { name: 'A', values: [1, 2, 3, 4] }, // Numbers + { name: 'B', values: ['a', 'b', 'c', 'd'] }, // Strings + ], +}); + +describe('Reducer Transformer', () => { + it('filters by include', () => { + const cfg = { + id: DataTransformerID.reduce, + options: { + reducers: [ReducerID.first, ReducerID.min, ReducerID.max, ReducerID.delta], + }, + }; + const processed = transformDataFrame([cfg], [seriesWithValues])[0]; + expect(processed.fields.length).toBe(5); + expect(toDataFrameDTO(processed)).toMatchSnapshot(); + }); +}); diff --git a/packages/grafana-data/src/utils/transformers/reduce.ts b/packages/grafana-data/src/utils/transformers/reduce.ts new file mode 100644 index 0000000000000..a70ecd13bbfc5 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/reduce.ts @@ -0,0 +1,90 @@ +import { DataTransformerInfo } from './transformers'; +import { DataFrame, FieldType, Field } from '../../types/dataFrame'; +import { MatcherConfig, getFieldMatcher } from '../matchers/matchers'; +import { alwaysFieldMatcher } from '../matchers/predicates'; +import { DataTransformerID } from './ids'; +import { ReducerID, fieldReducers, reduceField } from '../fieldReducer'; +import { KeyValue } from '../../types/data'; +import { ArrayVector } from '../vector'; +import { guessFieldTypeForField } from '../processDataFrame'; + +export interface ReduceOptions { + reducers: string[]; + fields?: MatcherConfig; // Assume all fields +} + +export const reduceTransformer: DataTransformerInfo = { + id: DataTransformerID.reduce, + name: 'Reducer', + description: 'Return a DataFrame with the reduction results', + defaultOptions: { + calcs: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last], + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + transformer: (options: ReduceOptions) => { + const matcher = options.fields ? getFieldMatcher(options.fields) : alwaysFieldMatcher; + const calculators = fieldReducers.list(options.reducers); + const reducers = calculators.map(c => c.id); + + return (data: DataFrame[]) => { + const processed: DataFrame[] = []; + for (const series of data) { + const values: ArrayVector[] = []; + const fields: Field[] = []; + const byId: KeyValue = {}; + values.push(new ArrayVector()); // The name + fields.push({ + name: 'Field', + type: FieldType.string, + values: values[0], + config: {}, + }); + for (const info of calculators) { + const vals = new ArrayVector(); + byId[info.id] = vals; + values.push(vals); + fields.push({ + name: info.id, + type: FieldType.other, // UNKNOWN until after we call the functions + values: values[values.length - 1], + config: { + title: info.name, + // UNIT from original field? + }, + }); + } + for (let i = 0; i < series.fields.length; i++) { + const field = series.fields[i]; + if (matcher(field)) { + const results = reduceField({ + field, + reducers, + }); + // Update the name list + values[0].buffer.push(field.name); + for (const info of calculators) { + const v = results[info.id]; + byId[info.id].buffer.push(v); + } + } + } + for (const f of fields) { + const t = guessFieldTypeForField(f); + if (t) { + f.type = t; + } + } + processed.push({ + ...series, // Same properties, different fields + fields, + length: values[0].length, + }); + } + return processed; + }; + }, +}; diff --git a/packages/grafana-data/src/utils/transformers/transformers.test.ts b/packages/grafana-data/src/utils/transformers/transformers.test.ts new file mode 100644 index 0000000000000..81df7478d594c --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/transformers.test.ts @@ -0,0 +1,34 @@ +import { DataTransformerID } from './ids'; +import { dataTransformers } from './transformers'; +import { toDataFrame } from '../processDataFrame'; +import { ReducerID } from '../fieldReducer'; +import { DataFrameView } from '../dataFrameView'; + +describe('Transformers', () => { + it('should load all transformeres', () => { + for (const name of Object.keys(DataTransformerID)) { + const calc = dataTransformers.get(name); + expect(calc.id).toBe(name); + } + }); + + const seriesWithValues = toDataFrame({ + fields: [ + { name: 'A', values: [1, 2, 3, 4] }, // Numbers + { name: 'B', values: ['a', 'b', 'c', 'd'] }, // Strings + ], + }); + + it('should use fluent API', () => { + const results = dataTransformers.reduce([seriesWithValues], { + reducers: [ReducerID.first], + }); + expect(results.length).toBe(1); + + const view = new DataFrameView(results[0]).toJSON(); + expect(view).toEqual([ + { Field: 'A', first: 1 }, // Row 0 + { Field: 'B', first: 'a' }, // Row 1 + ]); + }); +}); diff --git a/packages/grafana-data/src/utils/transformers/transformers.ts b/packages/grafana-data/src/utils/transformers/transformers.ts new file mode 100644 index 0000000000000..9776ace625a03 --- /dev/null +++ b/packages/grafana-data/src/utils/transformers/transformers.ts @@ -0,0 +1,82 @@ +import { DataFrame } from '../../types/dataFrame'; +import { Registry, RegistryItemWithOptions } from '../registry'; + +/** + * Immutable data transformation + */ +export type DataTransformer = (data: DataFrame[]) => DataFrame[]; + +export interface DataTransformerInfo extends RegistryItemWithOptions { + transformer: (options: TOptions) => DataTransformer; +} + +export interface DataTransformerConfig { + id: string; + options: TOptions; +} + +// Transformer that does nothing +export const NoopDataTransformer = (data: DataFrame[]) => data; + +/** + * Apply configured transformations to the input data + */ +export function transformDataFrame(options: DataTransformerConfig[], data: DataFrame[]): DataFrame[] { + let processed = data; + for (const config of options) { + const info = dataTransformers.get(config.id); + const transformer = info.transformer(config.options); + const after = transformer(processed); + + // Add a key to the metadata if the data changed + if (after && after !== processed) { + for (const series of after) { + if (!series.meta) { + series.meta = {}; + } + if (!series.meta.transformations) { + series.meta.transformations = [info.id]; + } else { + series.meta.transformations = [...series.meta.transformations, info.id]; + } + } + processed = after; + } + } + return processed; +} + +// Initalize the Registry + +import { appendTransformer, AppendOptions } from './append'; +import { reduceTransformer, ReduceOptions } from './reduce'; +import { filterFieldsTransformer, filterFramesTransformer } from './filter'; + +/** + * Registry of transformation options that can be driven by + * stored configuration files. + */ +class TransformerRegistry extends Registry { + // ------------------------------------------------------------ + // Nacent options for more functional programming + // The API to these functions should change to match the actual + // needs of people trying to use it. + // filterFields|Frames is left off since it is likely easier to + // support with `frames.filter( f => {...} )` + // ------------------------------------------------------------ + + append(data: DataFrame[], options?: AppendOptions): DataFrame | undefined { + return appendTransformer.transformer(options || appendTransformer.defaultOptions)(data)[0]; + } + + reduce(data: DataFrame[], options: ReduceOptions): DataFrame[] { + return reduceTransformer.transformer(options)(data); + } +} + +export const dataTransformers = new TransformerRegistry(() => [ + filterFieldsTransformer, + filterFramesTransformer, + appendTransformer, + reduceTransformer, +]); diff --git a/packages/grafana-data/src/utils/vector.test.ts b/packages/grafana-data/src/utils/vector.test.ts index 6ec478f3d7f15..1806020ed59fa 100644 --- a/packages/grafana-data/src/utils/vector.test.ts +++ b/packages/grafana-data/src/utils/vector.test.ts @@ -1,4 +1,4 @@ -import { ConstantVector, ScaledVector, ArrayVector, CircularVector } from './vector'; +import { ConstantVector, ScaledVector, ArrayVector, CircularVector, AppendedVectors } from './vector'; describe('Check Proxy Vector', () => { it('should support constant values', () => { @@ -28,16 +28,152 @@ describe('Check Proxy Vector', () => { }); describe('Check Circular Vector', () => { - it('should support constant values', () => { - const buffer = [3, 2, 1, 0]; - const v = new CircularVector(buffer); - expect(v.length).toEqual(4); - expect(v.toJSON()).toEqual([3, 2, 1, 0]); + it('should append values', () => { + const buffer = [1, 2, 3]; + const v = new CircularVector({ buffer }); // tail is default option + expect(v.toArray()).toEqual([1, 2, 3]); + + v.add(4); + expect(v.toArray()).toEqual([2, 3, 4]); + + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + + v.add(6); + expect(v.toArray()).toEqual([4, 5, 6]); + + v.add(7); + expect(v.toArray()).toEqual([5, 6, 7]); + + v.add(8); + expect(v.toArray()).toEqual([6, 7, 8]); + }); + + it('should grow buffer until it hits capacity (append)', () => { + const v = new CircularVector({ capacity: 3 }); // tail is default option + expect(v.toArray()).toEqual([]); + + v.add(1); + expect(v.toArray()).toEqual([1]); + + v.add(2); + expect(v.toArray()).toEqual([1, 2]); + + v.add(3); + expect(v.toArray()).toEqual([1, 2, 3]); + + v.add(4); + expect(v.toArray()).toEqual([2, 3, 4]); + + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + }); + + it('should prepend values', () => { + const buffer = [3, 2, 1]; + const v = new CircularVector({ buffer, append: 'head' }); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.add(5); + expect(v.toArray()).toEqual([5, 4, 3]); + + v.add(6); + expect(v.toArray()).toEqual([6, 5, 4]); + + v.add(7); + expect(v.toArray()).toEqual([7, 6, 5]); + + v.add(8); + expect(v.toArray()).toEqual([8, 7, 6]); + }); + + it('should expand buffer and then prepend', () => { + const v = new CircularVector({ capacity: 3, append: 'head' }); + expect(v.toArray()).toEqual([]); + + v.add(1); + expect(v.toArray()).toEqual([1]); + + v.add(2); + expect(v.toArray()).toEqual([2, 1]); + + v.add(3); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.add(5); + expect(v.toArray()).toEqual([5, 4, 3]); + }); + + it('should reduce size and keep working (tail)', () => { + const buffer = [1, 2, 3, 4, 5]; + const v = new CircularVector({ buffer }); + expect(v.toArray()).toEqual([1, 2, 3, 4, 5]); + + v.setCapacity(3); + expect(v.toArray()).toEqual([3, 4, 5]); + + v.add(6); + expect(v.toArray()).toEqual([4, 5, 6]); + + v.add(7); + expect(v.toArray()).toEqual([5, 6, 7]); + }); + + it('should reduce size and keep working (head)', () => { + const buffer = [5, 4, 3, 2, 1]; + const v = new CircularVector({ buffer, append: 'head' }); + expect(v.toArray()).toEqual([5, 4, 3, 2, 1]); + + v.setCapacity(3); + expect(v.toArray()).toEqual([5, 4, 3]); + + v.add(6); + expect(v.toArray()).toEqual([6, 5, 4]); + + v.add(7); + expect(v.toArray()).toEqual([7, 6, 5]); + }); + + it('change buffer direction', () => { + const buffer = [1, 2, 3]; + const v = new CircularVector({ buffer }); + expect(v.toArray()).toEqual([1, 2, 3]); + + v.setAppendMode('head'); + expect(v.toArray()).toEqual([3, 2, 1]); + + v.add(4); + expect(v.toArray()).toEqual([4, 3, 2]); + + v.setAppendMode('tail'); + v.add(5); + expect(v.toArray()).toEqual([3, 4, 5]); + }); +}); + +describe('Check Appending Vector', () => { + it('should transparently join them', () => { + const appended = new AppendedVectors(); + appended.append(new ArrayVector([1, 2, 3])); + appended.append(new ArrayVector([4, 5, 6])); + appended.append(new ArrayVector([7, 8, 9])); + expect(appended.length).toEqual(9); - v.append(4); - expect(v.toJSON()).toEqual([4, 3, 2, 1]); + appended.setLength(5); + expect(appended.length).toEqual(5); + appended.append(new ArrayVector(['a', 'b', 'c'])); + expect(appended.length).toEqual(8); + expect(appended.toArray()).toEqual([1, 2, 3, 4, 5, 'a', 'b', 'c']); - v.append(5); - expect(v.toJSON()).toEqual([5, 4, 3, 2]); + appended.setLength(2); + appended.setLength(6); + appended.append(new ArrayVector(['x', 'y', 'z'])); + expect(appended.toArray()).toEqual([1, 2, undefined, undefined, undefined, undefined, 'x', 'y', 'z']); }); }); diff --git a/packages/grafana-data/src/utils/vector.ts b/packages/grafana-data/src/utils/vector.ts index 6e0828ba04aeb..339ebd9b06dba 100644 --- a/packages/grafana-data/src/utils/vector.ts +++ b/packages/grafana-data/src/utils/vector.ts @@ -44,11 +44,8 @@ export class ConstantVector implements Vector { } toArray(): T[] { - const arr: T[] = []; - for (let i = 0; i < this.length; i++) { - arr[i] = this.value; - } - return arr; + const arr = new Array(this.length); + return arr.fill(this.value); } toJSON(): T[] { @@ -76,28 +73,146 @@ export class ScaledVector implements Vector { } } +/** + * Values are returned in the order defined by the input parameter + */ +export class SortedVector implements Vector { + constructor(private source: Vector, private order: number[]) {} + + get length(): number { + return this.source.length; + } + + get(index: number): T { + return this.source.get(this.order[index]); + } + + toArray(): T[] { + return vectorToArray(this); + } + + toJSON(): T[] { + return vectorToArray(this); + } +} + +interface CircularOptions { + buffer?: T[]; + append?: 'head' | 'tail'; + capacity?: number; +} + +/** + * Circular vector uses a single buffer to capture a stream of values + * overwriting the oldest value on add. + * + * This supports addting to the 'head' or 'tail' and will grow the buffer + * to match a configured capacity. + */ export class CircularVector implements Vector { - buffer: T[]; - index: number; - length: number; + private buffer: T[]; + private index: number; + private capacity: number; + private tail: boolean; + + constructor(options: CircularOptions) { + this.buffer = options.buffer || []; + this.capacity = this.buffer.length; + this.tail = 'head' !== options.append; + this.index = 0; - constructor(buffer: T[]) { - this.length = buffer.length; - this.buffer = buffer; + this.add = this.getAddFunction(); + if (options.capacity) { + this.setCapacity(options.capacity); + } + } + + /** + * This gets the appropriate add function depending on the buffer state: + * * head vs tail + * * growing buffer vs overwriting values + */ + private getAddFunction() { + // When we are not at capacity, it should actually modify the buffer + if (this.capacity > this.buffer.length) { + if (this.tail) { + return (value: T) => { + this.buffer.push(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } else { + return (value: T) => { + this.buffer.unshift(value); + if (this.buffer.length >= this.capacity) { + this.add = this.getAddFunction(); + } + }; + } + } + + if (this.tail) { + return (value: T) => { + this.buffer[this.index] = value; + this.index = (this.index + 1) % this.buffer.length; + }; + } + + // Append values to the head + return (value: T) => { + let idx = this.index - 1; + if (idx < 0) { + idx = this.buffer.length - 1; + } + this.buffer[idx] = value; + this.index = idx; + }; + } + + setCapacity(v: number) { + if (this.capacity === v) { + return; + } + // Make a copy so it is in order and new additions can be at the head or tail + const copy = this.toArray(); + if (v > this.length) { + this.buffer = copy; + } else if (v < this.capacity) { + // Shrink the buffer + const delta = this.length - v; + if (this.tail) { + this.buffer = copy.slice(delta, copy.length); // Keep last items + } else { + this.buffer = copy.slice(0, copy.length - delta); // Keep first items + } + } + this.capacity = v; this.index = 0; + this.add = this.getAddFunction(); } - append(value: T) { - let idx = this.index - 1; - if (idx < 0) { - idx = this.length - 1; + setAppendMode(mode: 'head' | 'tail') { + const tail = 'head' !== mode; + if (tail !== this.tail) { + this.buffer = this.toArray().reverse(); + this.index = 0; + this.tail = tail; + this.add = this.getAddFunction(); } - this.buffer[idx] = value; - this.index = idx; } - get(index: number): T { - return this.buffer[(index + this.index) % this.length]; + /** + * Add the value to the buffer + */ + add: (value: T) => void; + + get(index: number) { + return this.buffer[(index + this.index) % this.buffer.length]; + } + + get length() { + return this.buffer.length; } toArray(): T[] { @@ -109,18 +224,66 @@ export class CircularVector implements Vector { } } +interface AppendedVectorInfo { + start: number; + end: number; + values: Vector; +} + /** - * Values are returned in the order defined by the input parameter + * This may be more trouble than it is worth. This trades some computation time for + * RAM -- rather than allocate a new array the size of all previous arrays, this just + * points the correct index to their original array values */ -export class SortedVector implements Vector { - constructor(private source: Vector, private order: number[]) {} +export class AppendedVectors implements Vector { + length = 0; + source: Array> = new Array>(); - get length(): number { - return this.source.length; + constructor(startAt = 0) { + this.length = startAt; + } + + /** + * Make the vector look like it is this long + */ + setLength(length: number) { + if (length > this.length) { + // make the vector longer (filling with undefined) + this.length = length; + } else if (length < this.length) { + // make the array shorter + const sources: Array> = new Array>(); + for (const src of this.source) { + sources.push(src); + if (src.end > length) { + src.end = length; + break; + } + } + this.source = sources; + this.length = length; + } + } + + append(v: Vector): AppendedVectorInfo { + const info = { + start: this.length, + end: this.length + v.length, + values: v, + }; + this.length = info.end; + this.source.push(info); + return info; } get(index: number): T { - return this.source.get(this.order[index]); + for (let i = 0; i < this.source.length; i++) { + const src = this.source[i]; + if (index >= src.start && index < src.end) { + return src.values.get(index - src.start); + } + } + return (undefined as unknown) as T; } toArray(): T[] { diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts index ab7c144b8635d..efef4cd549b58 100644 --- a/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts @@ -9,7 +9,7 @@ import { PluginMeta } from '@grafana/ui'; import execa = require('execa'); import path = require('path'); import fs from 'fs'; -import { getPackageDetails, findImagesInFolder, appendPluginHistory } from '../../plugins/utils'; +import { getPackageDetails, findImagesInFolder, appendPluginHistory, getGrafanaVersions } from '../../plugins/utils'; import { job, getJobFolder, @@ -318,6 +318,7 @@ const pluginReportRunner: TaskRunner = async ({ upload }) => { const pluginJsonFile = path.resolve(ciDir, 'dist', 'plugin.json'); console.log('Load info from: ' + pluginJsonFile); + const pluginMeta = getPluginJson(pluginJsonFile); const report: PluginBuildReport = { plugin: pluginMeta, @@ -326,6 +327,7 @@ const pluginReportRunner: TaskRunner = async ({ upload }) => { coverage: agregateCoverageInfo(), tests: agregateTestInfo(), artifactsBaseURL: await getCircleDownloadBaseURL(), + grafanaVersion: getGrafanaVersions(), }; const pr = getPullRequestNumber(); if (pr) { diff --git a/packages/grafana-toolkit/src/plugins/env.ts b/packages/grafana-toolkit/src/plugins/env.ts index 8e06310f8a7cf..a33945363068b 100644 --- a/packages/grafana-toolkit/src/plugins/env.ts +++ b/packages/grafana-toolkit/src/plugins/env.ts @@ -23,12 +23,21 @@ export const job = process.env.CIRCLE_JOB || getJobFromProcessArgv(); export const getPluginBuildInfo = async (): Promise => { if (process.env.CIRCLE_SHA1) { - return Promise.resolve({ + const info: PluginBuildInfo = { time: Date.now(), repo: process.env.CIRCLE_REPOSITORY_URL, branch: process.env.CIRCLE_BRANCH, hash: process.env.CIRCLE_SHA1, - }); + }; + const pr = getPullRequestNumber(); + const build = getBuildNumber(); + if (pr) { + info.pr = pr; + } + if (build) { + info.number = build; + } + return Promise.resolve(info); } const branch = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']); const hash = await execa('git', ['rev-parse', 'HEAD']); diff --git a/packages/grafana-toolkit/src/plugins/types.ts b/packages/grafana-toolkit/src/plugins/types.ts index 1a9bcccf4b5b0..a06c8a013800f 100644 --- a/packages/grafana-toolkit/src/plugins/types.ts +++ b/packages/grafana-toolkit/src/plugins/types.ts @@ -1,5 +1,5 @@ import { PluginMeta, PluginBuildInfo } from '@grafana/ui'; -import { DataFrame } from '@grafana/data'; +import { DataFrame, KeyValue } from '@grafana/data'; export interface PluginPackageDetails { plugin: ZipFileInfo; @@ -14,6 +14,7 @@ export interface PluginBuildReport { tests: TestResultsInfo[]; pullRequest?: number; artifactsBaseURL?: string; + grafanaVersion?: KeyValue; } export interface JobInfo { diff --git a/packages/grafana-toolkit/src/plugins/utils.ts b/packages/grafana-toolkit/src/plugins/utils.ts index fcf376de088b3..ea585bc64de9f 100644 --- a/packages/grafana-toolkit/src/plugins/utils.ts +++ b/packages/grafana-toolkit/src/plugins/utils.ts @@ -1,10 +1,25 @@ import execa from 'execa'; import path from 'path'; import fs from 'fs'; +import { KeyValue } from '@grafana/data'; import { PluginDevInfo, ExtensionSize, ZipFileInfo, PluginBuildReport, PluginHistory } from './types'; const md5File = require('md5-file'); +export function getGrafanaVersions(): KeyValue { + const dir = path.resolve(process.cwd(), 'node_modules', '@grafana'); + const versions: KeyValue = {}; + try { + fs.readdirSync(dir).forEach(file => { + const json = require(path.resolve(dir, file, 'package.json')); + versions[file] = json.version; + }); + } catch (err) { + console.warn('Error reading toolkit versions', err); + } + return versions; +} + export function getFileSizeReportInFolder(dir: string, info?: ExtensionSize): ExtensionSize { const acc: ExtensionSize = info ? info : {}; diff --git a/public/app/features/explore/Error.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx similarity index 100% rename from public/app/features/explore/Error.tsx rename to packages/grafana-ui/src/components/Alert/Alert.tsx diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index e282d0afb02cd..a4fc940446759 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -26,6 +26,8 @@ export interface Props extends Themeable { orientation: VizOrientation; itemSpacing?: number; displayMode: 'basic' | 'lcd' | 'gradient'; + onClick?: React.MouseEventHandler; + className?: string; } export class BarGauge extends PureComponent { @@ -43,16 +45,20 @@ export class BarGauge extends PureComponent { }; render() { + const { onClick, className } = this.props; const { title } = this.props.value; + const styles = getTitleStyles(this.props); if (!title) { - return this.renderBarAndValue(); + return ( +
+ {this.renderBarAndValue()} +
+ ); } - const styles = getTitleStyles(this.props); - return ( -
+
{title}
{this.renderBarAndValue()}
diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index 1d341a9b0d4cb..320b53aa16100 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -4,41 +4,51 @@ exports[`BarGauge Render with basic options should render 1`] = `
- 25 -
-
+ > + 25 +
+
+
`; diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.tsx index 3799fafb0d8f0..e922ad131f0f0 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.tsx @@ -1,7 +1,7 @@ // Library import React, { PureComponent, ReactNode, CSSProperties } from 'react'; import $ from 'jquery'; -import { css } from 'emotion'; +import { css, cx } from 'emotion'; import { DisplayValue } from '@grafana/data'; // Utils @@ -27,6 +27,8 @@ export interface Props extends Themeable { suffix?: DisplayValue; sparkline?: BigValueSparkline; backgroundColor?: string; + onClick?: React.MouseEventHandler; + className?: string; } /* @@ -119,15 +121,19 @@ export class BigValue extends PureComponent { } render() { - const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props; + const { height, width, value, prefix, suffix, sparkline, backgroundColor, onClick, className } = this.props; return (
{value.title && (
{ {value.title}
)} + ({ + collapse: css` + label: collapse; + margin-top: ${theme.spacing.sm}; + `, + collapseBody: css` + label: collapse__body; + padding: ${theme.panelPadding}; + `, + loader: css` + label: collapse__loader; + height: 2px; + position: relative; + overflow: hidden; + background: none; + margin: ${theme.spacing.xs}; + `, + loaderActive: css` + label: collapse__loader_active; + &:after { + content: ' '; + display: block; + width: 25%; + top: 0; + top: -50%; + height: 250%; + position: absolute; + animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67) 500ms; + animation-iteration-count: 100; + left: -25%; + background: ${theme.colors.blue}; + } + @keyframes loader { + from { + left: -25%; + opacity: 0.1; + } + to { + left: 100%; + opacity: 1; + } + } + `, + header: css` + label: collapse__header; + padding: ${theme.spacing.sm} ${theme.spacing.md} 0 ${theme.spacing.md}; + display: flex; + cursor: inherit; + transition: all 0.1s linear; + cursor: pointer; + `, + headerCollapsed: css` + label: collapse__header--collapsed; + cursor: pointer; + `, + headerButtons: css` + label: collapse__header-buttons; + margin-right: ${theme.spacing.sm}; + font-size: ${theme.typography.size.lg}; + line-height: ${theme.typography.heading.h6}; + display: inherit; + `, + headerButtonsCollapsed: css` + label: collapse__header-buttons--collapsed; + display: none; + `, + headerLabel: css` + label: collapse__header-label; + font-weight: ${theme.typography.weight.semibold}; + margin-right: ${theme.spacing.sm}; + font-size: ${theme.typography.heading.h6}; + box-shadow: ${selectThemeVariant({ light: 'none', dark: '1px 1px 4px rgb(45, 45, 45)' }, theme.type)}; + `, +}); + +interface Props { + isOpen: boolean; + label: string; + loading?: boolean; + collapsible?: boolean; + onToggle?: (isOpen: boolean) => void; +} + +export const Collapse: FunctionComponent = ({ isOpen, label, loading, collapsible, onToggle, children }) => { + const theme = useContext(ThemeContext); + const style = getStyles(theme); + const onClickToggle = () => { + if (onToggle) { + onToggle(!isOpen); + } + }; + + const panelClass = cx([style.collapse, 'panel-container']); + const iconClass = isOpen ? 'fa fa-caret-up' : 'fa fa-caret-down'; + const loaderClass = loading ? cx([style.loader, style.loaderActive]) : cx([style.loader]); + const headerClass = collapsible ? cx([style.header]) : cx([style.headerCollapsed]); + const headerButtonsClass = collapsible ? cx([style.headerButtons]) : cx([style.headerButtonsCollapsed]); + + return ( +
+
+
+ +
+
{label}
+
+ {isOpen && ( +
+
+ {children} +
+ )} +
+ ); +}; + +Collapse.displayName = 'Collapse'; diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx index d44e2e28aff93..931d7c27b076a 100644 --- a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -3,10 +3,11 @@ import { css, cx } from 'emotion'; import useClickAway from 'react-use/lib/useClickAway'; import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index'; import { Portal, List } from '../index'; +import { LinkTarget } from '@grafana/data'; export interface ContextMenuItem { label: string; - target?: string; + target?: LinkTarget; icon?: string; url?: string; onClick?: (event?: React.SyntheticEvent) => void; diff --git a/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx new file mode 100644 index 0000000000000..dd5b2fd0bbf3c --- /dev/null +++ b/packages/grafana-ui/src/components/ContextMenu/WithContextMenu.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react'; +import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu'; + +interface WithContextMenuProps { + children: (props: { openMenu: React.MouseEventHandler }) => JSX.Element; + getContextMenuItems: () => ContextMenuGroup[]; +} + +export const WithContextMenu: React.FC = ({ children, getContextMenuItems }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPositon] = useState({ x: 0, y: 0 }); + + return ( + <> + {children({ + openMenu: e => { + setIsMenuOpen(true); + setMenuPositon({ + x: e.pageX, + y: e.pageY, + }); + }, + })} + + {isMenuOpen && ( + setIsMenuOpen(false)} + x={menuPosition.x} + y={menuPosition.y} + items={getContextMenuItems()} + /> + )} + + ); +}; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx index 8a4c5df9122c2..4aafe4ebce342 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx @@ -59,6 +59,7 @@ export const DataLinkEditor: React.FC = React.memo( onBlur={onTitleBlur} inputWidth={15} labelWidth={5} + placeholder="Show details" /> ; targetClassName?: string }) => JSX.Element; + links?: LinkModelSupplier; +} + +export const DataLinksContextMenu: React.FC = ({ children, links }) => { + if (!links) { + return children({}); + } + + const getDataLinksContextMenuItems = () => { + return [{ items: linkModelToContextMenuItems(links), label: 'Data links' }]; + }; + + // Use this class name (exposed via render prop) to add context menu indicator to the click target of the visualization + const targetClassName = css` + cursor: context-menu; + `; + + return ( + + {({ openMenu }) => { + return children({ openMenu, targetClassName }); + }} + + ); +}; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx index 881fad58d4ea1..725baed7a6cf6 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksEditor.tsx @@ -68,7 +68,7 @@ export const DataLinksEditor: FC = React.memo(({ value, on {(!value || (value && value.length < (maxLinks || Infinity))) && ( )} diff --git a/packages/grafana-ui/src/components/FormField/_FormField.scss b/packages/grafana-ui/src/components/FormField/_FormField.scss index 0c69e67f82a02..4d6d18a6ccdc9 100644 --- a/packages/grafana-ui/src/components/FormField/_FormField.scss +++ b/packages/grafana-ui/src/components/FormField/_FormField.scss @@ -2,7 +2,7 @@ margin-bottom: $space-xxs; display: flex; flex-direction: row; - align-items: center; + align-items: flex-start; text-align: left; position: relative; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index 868caa43a6dc3..420c1907693f7 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -15,6 +15,8 @@ export interface Props extends Themeable { showThresholdLabels: boolean; width: number; value: DisplayValue; + onClick?: React.MouseEventHandler; + className?: string; } const FONT_SCALE = 1; @@ -133,24 +135,16 @@ export class Gauge extends PureComponent { } } - render() { - const { width, value, height } = this.props; + renderVisualization = () => { + const { width, value, height, onClick } = this.props; const autoProps = calculateGaugeAutoProps(width, height, value.title); return ( -
+ <>
(this.canvasElement = element)} + onClick={onClick} /> {autoProps.showLabel && (
{ position: 'relative', width: '100%', top: '-4px', + cursor: 'default', }} > {value.title}
)} + + ); + }; + + render() { + return ( +
+ {this.renderVisualization()}
); } diff --git a/public/app/plugins/panel/graph2/GraphSeriesToggler.tsx b/packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx similarity index 95% rename from public/app/plugins/panel/graph2/GraphSeriesToggler.tsx rename to packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx index 564f5b9efe6c3..797170f095cf8 100644 --- a/public/app/plugins/panel/graph2/GraphSeriesToggler.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx @@ -3,18 +3,18 @@ import { GraphSeriesXY } from '@grafana/data'; import difference from 'lodash/difference'; import isEqual from 'lodash/isEqual'; -interface GraphSeriesTogglerAPI { +export interface GraphSeriesTogglerAPI { onSeriesToggle: (label: string, event: React.MouseEvent) => void; toggledSeries: GraphSeriesXY[]; } -interface GraphSeriesTogglerProps { +export interface GraphSeriesTogglerProps { children: (api: GraphSeriesTogglerAPI) => JSX.Element; series: GraphSeriesXY[]; onHiddenSeriesChanged?: (hiddenSeries: string[]) => void; } -interface GraphSeriesTogglerState { +export interface GraphSeriesTogglerState { hiddenSeries: string[]; toggledSeries: GraphSeriesXY[]; } diff --git a/packages/grafana-ui/src/components/Logs/LogLabel.tsx b/packages/grafana-ui/src/components/Logs/LogLabel.tsx new file mode 100644 index 0000000000000..c97e76e76f12c --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogLabel.tsx @@ -0,0 +1,126 @@ +import React, { PureComponent } from 'react'; +import { css, cx } from 'emotion'; +import { LogRowModel, LogLabelStatsModel, calculateLogsLabelStats } from '@grafana/data'; + +import { LogLabelStats } from './LogLabelStats'; +import { GrafanaTheme, Themeable } from '../../types/theme'; +import { selectThemeVariant } from '../../themes/selectThemeVariant'; +import { withTheme } from '../../themes/ThemeContext'; + +const getStyles = (theme: GrafanaTheme) => { + return { + logsLabel: css` + label: logs-label; + display: flex; + padding: 0 2px; + background-color: ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark6 }, theme.type)}; + border-radius: ${theme.border.radius}; + margin: 0 4px 2px 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + `, + logsLabelValue: css` + label: logs-label__value; + display: inline-block; + max-width: 20em; + text-overflow: ellipsis; + overflow: hidden; + `, + logsLabelIcon: css` + label: logs-label__icon; + border-left: solid 1px ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark1 }, theme.type)}; + padding: 0 2px; + cursor: pointer; + margin-left: 2px; + `, + logsLabelStats: css` + position: absolute; + top: 1.25em; + left: -10px; + z-index: 100; + justify-content: space-between; + box-shadow: 0 0 20px ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.black }, theme.type)}; + `, + }; +}; + +interface Props extends Themeable { + value: string; + label: string; + getRows: () => LogRowModel[]; + plain?: boolean; + onClickLabel?: (label: string, value: string) => void; +} + +interface State { + showStats: boolean; + stats: LogLabelStatsModel[]; +} + +class UnThemedLogLabel extends PureComponent { + state: State = { + stats: [], + showStats: false, + }; + + onClickClose = () => { + this.setState({ showStats: false }); + }; + + onClickLabel = () => { + const { onClickLabel, label, value } = this.props; + if (onClickLabel) { + onClickLabel(label, value); + } + }; + + onClickStats = () => { + this.setState(state => { + if (state.showStats) { + return { showStats: false, stats: [] }; + } + const allRows = this.props.getRows(); + const stats = calculateLogsLabelStats(allRows, this.props.label); + return { showStats: true, stats }; + }); + }; + + render() { + const { getRows, label, plain, value, theme } = this.props; + const styles = getStyles(theme); + const { showStats, stats } = this.state; + const tooltip = `${label}: ${value}`; + return ( + + + {value} + + {!plain && ( + + )} + {!plain && getRows && ( + + )} + {showStats && ( + + + + )} + + ); + } +} + +export const LogLabel = withTheme(UnThemedLogLabel); +LogLabel.displayName = 'LogLabel'; diff --git a/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx b/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx new file mode 100644 index 0000000000000..7f59e2316137f --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx @@ -0,0 +1,98 @@ +import React, { PureComponent } from 'react'; +import { css, cx } from 'emotion'; +import { LogLabelStatsModel } from '@grafana/data'; + +import { LogLabelStatsRow } from './LogLabelStatsRow'; +import { Themeable, GrafanaTheme } from '../../types/theme'; +import { selectThemeVariant } from '../../themes/selectThemeVariant'; +import { withTheme } from '../../themes/index'; + +const STATS_ROW_LIMIT = 5; + +const getStyles = (theme: GrafanaTheme) => ({ + logsStats: css` + label: logs-stats; + background-color: ${selectThemeVariant({ light: theme.colors.pageBg, dark: theme.colors.dark2 }, theme.type)}; + color: ${theme.colors.text}; + border: 1px solid ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)}; + border-radius: ${theme.border.radius.md}; + max-width: 500px; + `, + logsStatsHeader: css` + label: logs-stats__header; + background: ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)}; + padding: 6px 10px; + display: flex; + `, + logsStatsTitle: css` + label: logs-stats__title; + font-weight: ${theme.typography.weight.semibold}; + padding-right: ${theme.spacing.d}; + overflow: hidden; + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; + flex-grow: 1; + `, + logsStatsClose: css` + label: logs-stats__close; + cursor: pointer; + `, + logsStatsBody: css` + label: logs-stats__body; + padding: 20px 10px 10px 10px; + `, +}); + +interface Props extends Themeable { + stats: LogLabelStatsModel[]; + label: string; + value: string; + rowCount: number; + onClickClose: () => void; +} + +class UnThemedLogLabelStats extends PureComponent { + render() { + const { label, rowCount, stats, value, onClickClose, theme } = this.props; + const style = getStyles(theme); + const topRows = stats.slice(0, STATS_ROW_LIMIT); + let activeRow = topRows.find(row => row.value === value); + let otherRows = stats.slice(STATS_ROW_LIMIT); + const insertActiveRow = !activeRow; + + // Remove active row from other to show extra + if (insertActiveRow) { + activeRow = otherRows.find(row => row.value === value); + otherRows = otherRows.filter(row => row.value !== value); + } + + const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); + const topCount = topRows.reduce((sum, row) => sum + row.count, 0); + const total = topCount + otherCount; + const otherProportion = otherCount / total; + + return ( +
+
+ + {label}: {total} of {rowCount} rows have that label + + +
+
+ {topRows.map(stat => ( + + ))} + {insertActiveRow && activeRow && } + {otherCount > 0 && ( + + )} +
+
+ ); + } +} + +export const LogLabelStats = withTheme(UnThemedLogLabelStats); +LogLabelStats.displayName = 'LogLabelStats'; diff --git a/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx b/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx new file mode 100644 index 0000000000000..4437ab93b7ddb --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx @@ -0,0 +1,92 @@ +import React, { FunctionComponent, useContext } from 'react'; +import { css, cx } from 'emotion'; + +import { ThemeContext } from '../../themes/ThemeContext'; +import { GrafanaTheme } from '../../types/theme'; + +const getStyles = (theme: GrafanaTheme) => ({ + logsStatsRow: css` + label: logs-stats-row; + margin: ${parseInt(theme.spacing.d, 10) / 1.75}px 0; + `, + logsStatsRowActive: css` + label: logs-stats-row--active; + color: ${theme.colors.blue}; + position: relative; + + ::after { + display: inline; + content: '*'; + position: absolute; + top: 0; + left: -8px; + } + `, + logsStatsRowLabel: css` + label: logs-stats-row__label; + display: flex; + margin-bottom: 1px; + `, + logsStatsRowValue: css` + label: logs-stats-row__value; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + `, + logsStatsRowCount: css` + label: logs-stats-row__count; + text-align: right; + margin-left: 0.5em; + `, + logsStatsRowPercent: css` + label: logs-stats-row__percent; + text-align: right; + margin-left: 0.5em; + width: 3em; + `, + logsStatsRowBar: css` + label: logs-stats-row__bar; + height: 4px; + overflow: hidden; + background: ${theme.colors.textFaint}; + `, + logsStatsRowInnerBar: css` + label: logs-stats-row__innerbar; + height: 4px; + overflow: hidden; + background: ${theme.colors.textFaint}; + background: ${theme.colors.blue}; + `, +}); + +export interface Props { + active?: boolean; + count: number; + proportion: number; + value?: string; +} + +export const LogLabelStatsRow: FunctionComponent = ({ active, count, proportion, value }) => { + const theme = useContext(ThemeContext); + const style = getStyles(theme); + const percent = `${Math.round(proportion * 100)}%`; + const barStyle = { width: percent }; + const className = active ? cx([style.logsStatsRow, style.logsStatsRowActive]) : cx([style.logsStatsRow]); + + return ( +
+
+
+ {value} +
+
{count}
+
{percent}
+
+
+
+
+
+ ); +}; + +LogLabelStatsRow.displayName = 'LogLabelStatsRow'; diff --git a/packages/grafana-ui/src/components/Logs/LogLabels.tsx b/packages/grafana-ui/src/components/Logs/LogLabels.tsx new file mode 100644 index 0000000000000..22d8aa60027b4 --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogLabels.tsx @@ -0,0 +1,43 @@ +import React, { FunctionComponent, useContext } from 'react'; +import { css, cx } from 'emotion'; +import { Labels, LogRowModel } from '@grafana/data'; + +import { LogLabel } from './LogLabel'; +import { GrafanaTheme } from '../../types/theme'; +import { ThemeContext } from '../../themes/ThemeContext'; + +const getStyles = (theme: GrafanaTheme) => ({ + logsLabels: css` + display: flex; + flex-wrap: wrap; + `, +}); + +interface Props { + labels: Labels; + getRows: () => LogRowModel[]; + plain?: boolean; + onClickLabel?: (label: string, value: string) => void; +} + +export const LogLabels: FunctionComponent = ({ getRows, labels, onClickLabel, plain }) => { + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + + return ( + + {Object.keys(labels).map(key => ( + + ))} + + ); +}; + +LogLabels.displayName = 'LogLabels'; diff --git a/public/app/features/explore/LogMessageAnsi.test.tsx b/packages/grafana-ui/src/components/Logs/LogMessageAnsi.test.tsx similarity index 100% rename from public/app/features/explore/LogMessageAnsi.test.tsx rename to packages/grafana-ui/src/components/Logs/LogMessageAnsi.test.tsx diff --git a/public/app/features/explore/LogMessageAnsi.tsx b/packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx similarity index 96% rename from public/app/features/explore/LogMessageAnsi.tsx rename to packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx index 552f6202c8c52..2d71a276a347a 100644 --- a/public/app/features/explore/LogMessageAnsi.tsx +++ b/packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx @@ -1,5 +1,5 @@ import React, { PureComponent } from 'react'; -import ansicolor from 'vendor/ansicolor/ansicolor'; +import ansicolor from '../../utils/ansicolor'; interface Style { [key: string]: string; diff --git a/public/app/features/explore/LogRow.tsx b/packages/grafana-ui/src/components/Logs/LogRow.tsx similarity index 79% rename from public/app/features/explore/LogRow.tsx rename to packages/grafana-ui/src/components/Logs/LogRow.tsx index af4d165b732b1..b61f4619e8d82 100644 --- a/public/app/features/explore/LogRow.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRow.tsx @@ -1,28 +1,34 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent, FunctionComponent, useContext } from 'react'; import _ from 'lodash'; // @ts-ignore import Highlighter from 'react-highlight-words'; -import classnames from 'classnames'; - -import { calculateFieldStats, getParser } from 'app/core/logs_model'; -import { LogLabels } from './LogLabels'; -import { findHighlightChunksInText } from 'app/core/utils/text'; -import { LogLabelStats } from './LogLabelStats'; -import { LogMessageAnsi } from './LogMessageAnsi'; +import { + LogRowModel, + LogLabelStatsModel, + LogsParser, + TimeZone, + calculateFieldStats, + getParser, + findHighlightChunksInText, +} from '@grafana/data'; +import tinycolor from 'tinycolor2'; import { css, cx } from 'emotion'; +import { DataQueryResponse, GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index'; import { - LogRowContextProvider, LogRowContextRows, - HasMoreContextRows, LogRowContextQueryErrors, + HasMoreContextRows, + LogRowContextProvider, } from './LogRowContextProvider'; -import { ThemeContext, selectThemeVariant, GrafanaTheme, DataQueryResponse } from '@grafana/ui'; - -import { LogRowModel, LogLabelStatsModel, LogsParser, TimeZone } from '@grafana/data'; import { LogRowContext } from './LogRowContext'; -import tinycolor from 'tinycolor2'; +import { LogLabels } from './LogLabels'; +import { LogMessageAnsi } from './LogMessageAnsi'; +import { LogLabelStats } from './LogLabelStats'; +import { Themeable } from '../../types/theme'; +import { withTheme } from '../../themes/index'; +import { getLogRowStyles } from './getLogRowStyles'; -interface Props { +interface Props extends Themeable { highlighterExpressions?: string[]; row: LogRowModel; showDuplicates: boolean; @@ -32,8 +38,7 @@ interface Props { getRows: () => LogRowModel[]; onClickLabel?: (label: string, value: string) => void; onContextClick?: () => void; - getRowContext?: (row: LogRowModel, options?: any) => Promise; - className?: string; + getRowContext: (row: LogRowModel, options?: any) => Promise; } interface State { @@ -52,11 +57,16 @@ interface State { * Renders a highlighted field. * When hovering, a stats icon is shown. */ -const FieldHighlight = (onClick: any) => (props: any) => { +const FieldHighlight = (onClick: any): FunctionComponent => (props: any) => { + const theme = useContext(ThemeContext); + const style = getLogRowStyles(theme); return ( {props.children} - onClick(props.children)} /> + onClick(props.children)} + /> ); }; @@ -94,8 +104,8 @@ const getLogRowWithContextStyles = (theme: GrafanaTheme, state: State) => { * Once a parser is found, it will determine fields, that will be highlighted. * When the user requests stats for a field, they will be calculated and rendered below the row. */ -export class LogRow extends PureComponent { - mouseMessageTimer: NodeJS.Timer; +class UnThemedLogRow extends PureComponent { + mouseMessageTimer: NodeJS.Timer | null = null; state: any = { fieldCount: 0, @@ -110,7 +120,7 @@ export class LogRow extends PureComponent { }; componentWillUnmount() { - clearTimeout(this.mouseMessageTimer); + this.clearMouseMessageTimer(); } onClickClose = () => { @@ -148,10 +158,16 @@ export class LogRow extends PureComponent { // See comment in onMouseOverMessage method return; } - clearTimeout(this.mouseMessageTimer); + this.clearMouseMessageTimer(); this.setState({ parsed: false }); }; + clearMouseMessageTimer = () => { + if (this.mouseMessageTimer) { + clearTimeout(this.mouseMessageTimer); + } + }; + parseMessage = () => { if (!this.state.parsed) { const { row } = this.props; @@ -206,6 +222,7 @@ export class LogRow extends PureComponent { showLabels, timeZone, showTime, + theme, } = this.props; const { fieldCount, @@ -217,13 +234,15 @@ export class LogRow extends PureComponent { showFieldStats, showContext, } = this.state; + const style = getLogRowStyles(theme, row.logLevel); const { entry, hasAnsi, raw } = row; const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords); const highlights = previewHighlights ? highlighterExpressions : row.searchWords; const needsHighlighter = highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0; - const highlightClassName = classnames('logs-row__match-highlight', { - 'logs-row__match-highlight--preview': previewHighlights, - }); + const highlightClassName = previewHighlights + ? cx([style.logsRowMatchHighLight, style.logsRowMatchHighLightPreview]) + : cx([style.logsRowMatchHighLight]); + const showUtc = timeZone === 'utc'; return ( @@ -233,28 +252,34 @@ export class LogRow extends PureComponent { ? cx(logRowStyles, getLogRowWithContextStyles(theme, this.state).row) : logRowStyles; return ( -
+
{showDuplicates && ( -
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
+
+ {row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null} +
)} -
+
{showTime && showUtc && ( -
+
{row.timeUtc}
)} {showTime && !showUtc && ( -
+
{row.timeLocal}
)} {showLabels && ( -
- +
+
)}
@@ -285,7 +310,7 @@ export class LogRow extends PureComponent { highlightTag={FieldHighlight(this.onClickHighlight)} textToHighlight={entry} searchWords={parsedFieldHighlights} - highlightClassName="logs-row__field-highlight" + highlightClassName={cx([style.logsRowFieldHighLight])} /> )} {!parsed && needsHighlighter && ( @@ -300,7 +325,7 @@ export class LogRow extends PureComponent { {hasAnsi && !parsed && !needsHighlighter && } {!hasAnsi && !parsed && !needsHighlighter && entry} {showFieldStats && ( -
+
{ position: relative; z-index: ${showContext ? 1 : 0}; cursor: pointer; - .logs-row:hover & { + .${style.logsRow}:hover & { visibility: visible; margin-left: 10px; text-decoration: underline; @@ -357,3 +382,6 @@ export class LogRow extends PureComponent { return this.renderLogRow(); } } + +export const LogRow = withTheme(UnThemedLogRow); +LogRow.displayName = 'LogRow'; diff --git a/public/app/features/explore/LogRowContext.tsx b/packages/grafana-ui/src/components/Logs/LogRowContext.tsx similarity index 86% rename from public/app/features/explore/LogRowContext.tsx rename to packages/grafana-ui/src/components/Logs/LogRowContext.tsx index e4451d14f52b5..e5026477ebcf4 100644 --- a/public/app/features/explore/LogRowContext.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRowContext.tsx @@ -1,24 +1,22 @@ import React, { useContext, useRef, useState, useLayoutEffect } from 'react'; -import { - ThemeContext, - List, - GrafanaTheme, - selectThemeVariant, - ClickOutsideWrapper, - CustomScrollbar, - DataQueryError, -} from '@grafana/ui'; - import { LogRowModel } from '@grafana/data'; import { css, cx } from 'emotion'; -import { LogRowContextRows, HasMoreContextRows, LogRowContextQueryErrors } from './LogRowContextProvider'; -import { Alert } from './Error'; + +import { Alert } from '../Alert/Alert'; +import { LogRowContextRows, LogRowContextQueryErrors, HasMoreContextRows } from './LogRowContextProvider'; +import { GrafanaTheme } from '../../types/theme'; +import { selectThemeVariant } from '../../themes/selectThemeVariant'; +import { DataQueryError } from '../../types/datasource'; +import { ThemeContext } from '../../themes/ThemeContext'; +import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; +import { List } from '../List/List'; +import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper'; interface LogRowContextProps { row: LogRowModel; context: LogRowContextRows; errors?: LogRowContextQueryErrors; - hasMoreContextRows: HasMoreContextRows; + hasMoreContextRows?: HasMoreContextRows; onOutsideClick: () => void; onLoadMoreContext: () => void; } @@ -143,7 +141,7 @@ const LogRowContextGroup: React.FunctionComponent = ({ const theme = useContext(ThemeContext); const { commonStyles, logs } = getLogRowContextStyles(theme); const [scrollTop, setScrollTop] = useState(0); - const listContainerRef = useRef(); + const listContainerRef = useRef() as React.RefObject; useLayoutEffect(() => { if (shouldScrollToBottom && listContainerRef.current) { @@ -211,7 +209,7 @@ export const LogRowContext: React.FunctionComponent = ({ top: -250px; `} shouldScrollToBottom - canLoadMoreRows={hasMoreContextRows.after} + canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.after : false} onLoadMoreContext={onLoadMoreContext} /> )} @@ -219,7 +217,7 @@ export const LogRowContext: React.FunctionComponent = ({ {context.before && ( { describe('when called with a DataFrame and results are returned', () => { @@ -22,10 +24,10 @@ describe('getRowContexts', () => { }); const row: LogRowModel = { entry: '4', - labels: null, + labels: (null as any) as Labels, hasAnsi: false, raw: '4', - logLevel: null, + logLevel: LogLevel.info, timeEpochMs: 4, timeFromNow: '', timeLocal: '', @@ -33,14 +35,18 @@ describe('getRowContexts', () => { timestamp: '4', }; - const getRowContext = jest - .fn() - .mockResolvedValueOnce({ data: [firstResult] }) - .mockResolvedValueOnce({ data: [secondResult] }); + let called = false; + const getRowContextMock = (row: LogRowModel, options?: any): Promise => { + if (!called) { + called = true; + return Promise.resolve({ data: [firstResult] }); + } + return Promise.resolve({ data: [secondResult] }); + }; - const result = await getRowContexts(getRowContext, row, 10); + const result = await getRowContexts(getRowContextMock, row, 10); - expect(result).toEqual({ data: [[['3', '2', '1']], [['6', '5', '4']]], errors: [null, null] }); + expect(result).toEqual({ data: [[['3', '2', '1']], [['6', '5', '4']]], errors: ['', ''] }); }); }); @@ -50,10 +56,10 @@ describe('getRowContexts', () => { const secondError = new Error('Error 2'); const row: LogRowModel = { entry: '4', - labels: null, + labels: (null as any) as Labels, hasAnsi: false, raw: '4', - logLevel: null, + logLevel: LogLevel.info, timeEpochMs: 4, timeFromNow: '', timeLocal: '', @@ -61,12 +67,16 @@ describe('getRowContexts', () => { timestamp: '4', }; - const getRowContext = jest - .fn() - .mockRejectedValueOnce(firstError) - .mockRejectedValueOnce(secondError); + let called = false; + const getRowContextMock = (row: LogRowModel, options?: any): Promise => { + if (!called) { + called = true; + return Promise.reject(firstError); + } + return Promise.reject(secondError); + }; - const result = await getRowContexts(getRowContext, row, 10); + const result = await getRowContexts(getRowContextMock, row, 10); expect(result).toEqual({ data: [[], []], errors: ['Error 1', 'Error 2'] }); }); diff --git a/public/app/features/explore/LogRowContextProvider.tsx b/packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx similarity index 93% rename from public/app/features/explore/LogRowContextProvider.tsx rename to packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx index 775934be943d7..607364f7bb07d 100644 --- a/public/app/features/explore/LogRowContextProvider.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx @@ -1,9 +1,10 @@ -import { DataQueryResponse, DataQueryError } from '@grafana/ui'; import { LogRowModel, toDataFrame, Field } from '@grafana/data'; import { useState, useEffect } from 'react'; import flatten from 'lodash/flatten'; import useAsync from 'react-use/lib/useAsync'; +import { DataQueryResponse, DataQueryError } from '../../types/datasource'; + export interface LogRowContextRows { before?: string[]; after?: string[]; @@ -18,6 +19,11 @@ export interface HasMoreContextRows { after: boolean; } +interface ResultType { + data: string[][]; + errors: string[]; +} + interface LogRowContextProviderProps { row: LogRowModel; getRowContext: (row: LogRowModel, options?: any) => Promise; @@ -84,7 +90,7 @@ export const getRowContexts = async ( errors: results.map(result => { const errorResult: DataQueryError = result as DataQueryError; if (!errorResult.message) { - return null; + return ''; } return errorResult.message; @@ -105,10 +111,7 @@ export const LogRowContextProvider: React.FunctionComponent(null); + const [result, setResult] = useState((null as any) as ResultType); // React Hook that creates an object state value called hasMoreContextRows to component state and a setter function called setHasMoreContextRows // The intial value for hasMoreContextRows is {before: true, after: true} @@ -130,7 +133,7 @@ export const LogRowContextProvider: React.FunctionComponent { if (value) { - setResult(currentResult => { + setResult((currentResult: any) => { let hasMoreLogsBefore = true, hasMoreLogsAfter = true; @@ -158,8 +161,8 @@ export const LogRowContextProvider: React.FunctionComponent setLimit(limit + 10), diff --git a/packages/grafana-ui/src/components/Logs/LogRows.tsx b/packages/grafana-ui/src/components/Logs/LogRows.tsx new file mode 100644 index 0000000000000..9743ad1311a75 --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/LogRows.tsx @@ -0,0 +1,143 @@ +import React, { PureComponent } from 'react'; +import { cx } from 'emotion'; +import { LogsModel, TimeZone, LogsDedupStrategy, LogRowModel } from '@grafana/data'; + +import { LogRow } from './LogRow'; +import { Themeable } from '../../types/theme'; +import { withTheme } from '../../themes/index'; +import { getLogRowStyles } from './getLogRowStyles'; + +const PREVIEW_LIMIT = 100; +const RENDER_LIMIT = 500; + +export interface Props extends Themeable { + data: LogsModel; + dedupStrategy: LogsDedupStrategy; + highlighterExpressions: string[]; + showTime: boolean; + showLabels: boolean; + timeZone: TimeZone; + deduplicatedData?: LogsModel; + rowLimit?: number; + onClickLabel?: (label: string, value: string) => void; + getRowContext?: (row: LogRowModel, options?: any) => Promise; +} + +interface State { + deferLogs: boolean; + renderAll: boolean; +} + +class UnThemedLogRows extends PureComponent { + deferLogsTimer: NodeJS.Timer | null = null; + renderAllTimer: NodeJS.Timer | null = null; + + state: State = { + deferLogs: true, + renderAll: false, + }; + + componentDidMount() { + // Staged rendering + if (this.state.deferLogs) { + const { data } = this.props; + const rowCount = data && data.rows ? data.rows.length : 0; + // Render all right away if not too far over the limit + const renderAll = rowCount <= PREVIEW_LIMIT * 2; + this.deferLogsTimer = setTimeout(() => this.setState({ deferLogs: false, renderAll }), rowCount); + } + } + + componentDidUpdate(prevProps: Props, prevState: State) { + // Staged rendering + if (prevState.deferLogs && !this.state.deferLogs && !this.state.renderAll) { + this.renderAllTimer = setTimeout(() => this.setState({ renderAll: true }), 2000); + } + } + + componentWillUnmount() { + if (this.deferLogsTimer) { + clearTimeout(this.deferLogsTimer); + } + + if (this.renderAllTimer) { + clearTimeout(this.renderAllTimer); + } + } + + render() { + const { + dedupStrategy, + showTime, + data, + deduplicatedData, + highlighterExpressions, + showLabels, + timeZone, + onClickLabel, + rowLimit, + theme, + } = this.props; + const { deferLogs, renderAll } = this.state; + const dedupedData = deduplicatedData ? deduplicatedData : data; + const hasData = data && data.rows && data.rows.length > 0; + const hasLabel = hasData && dedupedData && dedupedData.hasUniqueLabels ? true : false; + const dedupCount = dedupedData + ? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0) + : 0; + const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0; + + // Staged rendering + const processedRows = dedupedData ? dedupedData.rows : []; + const firstRows = processedRows.slice(0, PREVIEW_LIMIT); + const renderLimit = rowLimit || RENDER_LIMIT; + const rowCount = Math.min(processedRows.length, renderLimit); + const lastRows = processedRows.slice(PREVIEW_LIMIT, rowCount); + + // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead + const getRows = () => processedRows; + const getRowContext = this.props.getRowContext ? this.props.getRowContext : () => Promise.resolve([]); + const { logsRows } = getLogRowStyles(theme); + + return ( +
+ {hasData && + !deferLogs && // Only inject highlighterExpression in the first set for performance reasons + firstRows.map((row, index) => ( + + ))} + {hasData && + !deferLogs && + renderAll && + lastRows.map((row, index) => ( + + ))} + {hasData && deferLogs && Rendering {rowCount} rows...} +
+ ); + } +} + +export const LogRows = withTheme(UnThemedLogRows); +LogRows.displayName = 'LogsRows'; diff --git a/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts b/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts new file mode 100644 index 0000000000000..ff16bbd4a609a --- /dev/null +++ b/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts @@ -0,0 +1,133 @@ +import { css } from 'emotion'; +import { LogLevel } from '@grafana/data'; + +import { GrafanaTheme } from '../../types/theme'; +import { selectThemeVariant } from '../../themes/selectThemeVariant'; + +export const getLogRowStyles = (theme: GrafanaTheme, logLevel?: LogLevel) => { + let logColor = selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.gray2 }, theme.type); + switch (logLevel) { + case LogLevel.crit: + case LogLevel.critical: + logColor = '#705da0'; + break; + case LogLevel.error: + case LogLevel.err: + logColor = '#e24d42'; + break; + case LogLevel.warning: + case LogLevel.warn: + logColor = theme.colors.yellow; + break; + case LogLevel.info: + logColor = '#7eb26d'; + break; + case LogLevel.debug: + logColor = '#1f78c1'; + break; + case LogLevel.trace: + logColor = '#6ed0e0'; + break; + } + + return { + logsRowFieldHighLight: css` + label: logs-row__field-highlight; + background: inherit; + padding: inherit; + border-bottom: 1px dotted ${theme.colors.yellow}; + + .logs-row__field-highlight--icon { + margin-left: 0.5em; + cursor: pointer; + display: none; + } + + &:hover { + color: ${theme.colors.yellow}; + border-bottom-style: solid; + + .logs-row__field-highlight--icon { + display: inline; + } + } + `, + logsRowMatchHighLight: css` + label: logs-row__match-highlight; + background: inherit; + padding: inherit; + + color: ${theme.colors.yellow}; + border-bottom: 1px solid ${theme.colors.yellow}; + background-color: rgba(${theme.colors.yellow}, 0.1); + `, + logsRowMatchHighLightPreview: css` + label: logs-row__match-highlight--preview; + background-color: rgba(${theme.colors.yellow}, 0.2); + border-bottom-style: dotted; + `, + logsRows: css` + label: logs-rows; + font-family: ${theme.typography.fontFamily.monospace}; + font-size: ${theme.typography.size.sm}; + display: table; + table-layout: fixed; + width: 100%; + `, + logsRow: css` + label: logs-row; + display: table-row; + + > div { + display: table-cell; + padding-right: 10px; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + height: 100%; + } + + &:hover { + background: ${theme.colors.pageBg}; + } + `, + logsRowDuplicates: css` + label: logs-row__duplicates; + text-align: right; + width: 4em; + `, + logsRowLevel: css` + label: logs-row__level; + position: relative; + width: 10px; + + &::after { + content: ''; + display: block; + position: absolute; + top: 1px; + bottom: 1px; + width: 3px; + background-color: ${logColor}; + } + `, + logsRowLocalTime: css` + label: logs-row__localtime; + white-space: nowrap; + width: 12.5em; + `, + logsRowLabels: css` + label: logs-row__labels; + width: 20%; + line-height: 1.2; + position: relative; + `, + logsRowMessage: css` + label: logs-row__message; + word-break: break-all; + `, + logsRowStats: css` + label: logs-row__stats; + margin: 5px 0; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Switch/Switch.story.tsx b/packages/grafana-ui/src/components/Switch/Switch.story.tsx index c3cb6fef76d48..5ef9a9e195e5b 100644 --- a/packages/grafana-ui/src/components/Switch/Switch.story.tsx +++ b/packages/grafana-ui/src/components/Switch/Switch.story.tsx @@ -8,13 +8,14 @@ import { text } from '@storybook/addon-knobs'; const getKnobs = () => { return { label: text('Label Text', 'Label'), + tooltip: text('Tooltip', null), }; }; const SwitchWrapper = () => { - const { label } = getKnobs(); + const { label, tooltip } = getKnobs(); const [checked, setChecked] = useState(false); - return setChecked(!checked)} />; + return setChecked(!checked)} tooltip={tooltip} />; }; const story = storiesOf('UI/Switch', module); diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx similarity index 91% rename from public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx rename to packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx index 07a39cfd1094f..e9600d9971e13 100644 --- a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx +++ b/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -1,5 +1,5 @@ import React, { FC, ReactNode, PureComponent } from 'react'; -import { Tooltip } from '@grafana/ui'; +import { Tooltip } from '../Tooltip/Tooltip'; interface ToggleButtonGroupProps { label?: string; @@ -7,7 +7,7 @@ interface ToggleButtonGroupProps { transparent?: boolean; } -export default class ToggleButtonGroup extends PureComponent { +export class ToggleButtonGroup extends PureComponent { render() { const { children, label, transparent } = this.props; diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx index 33c05156c1099..9e5ea12fc5d46 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx @@ -59,7 +59,7 @@ describe('Next id to add', () => { it('should be 4', () => { const { instance } = setup(); - instance.addMapping(); + instance.onAddMapping(); expect(instance.state.nextIdToAdd).toEqual(4); }); diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx index d3e87a806dcfa..5f187ee2a4451 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx @@ -2,6 +2,7 @@ import React, { PureComponent } from 'react'; import MappingRow from './MappingRow'; import { MappingType, ValueMapping } from '@grafana/data'; +import { Button } from '../index'; import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup'; export interface Props { @@ -30,7 +31,7 @@ export class ValueMappingsEditor extends PureComponent { return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1; } - addMapping = () => + onAddMapping = () => this.setState(prevState => ({ valueMappings: [ ...prevState.valueMappings, @@ -81,16 +82,21 @@ export class ValueMappingsEditor extends PureComponent { const { valueMappings } = this.state; return ( - - {valueMappings.length > 0 && - valueMappings.map((valueMapping, index) => ( - this.onRemoveMapping(valueMapping.id)} - /> - ))} + +
+ {valueMappings.length > 0 && + valueMappings.map((valueMapping, index) => ( + this.onRemoveMapping(valueMapping.id)} + /> + ))} + +
); } diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap index b0dd7d818406d..11257b7a47e05 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap @@ -2,37 +2,45 @@ exports[`Render should render component 1`] = ` - + - + + /> + +
`; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 52a1994640f5b..f915b6ce681a3 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -61,6 +61,13 @@ export { LegendPlacement, LegendDisplayMode, } from './Legend/Legend'; +export { Alert } from './Alert/Alert'; +export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler'; +export { Collapse } from './Collapse/Collapse'; +export { LogLabels } from './Logs/LogLabels'; +export { LogRows } from './Logs/LogRows'; +export { getLogRowStyles } from './Logs/getLogRowStyles'; +export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup'; // Panel editors export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor'; export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper'; @@ -69,4 +76,5 @@ export { CallToActionCard } from './CallToActionCard/CallToActionCard'; export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu'; export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions'; export { DataLinksEditor } from './DataLinks/DataLinksEditor'; +export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu'; export { SeriesIcon } from './Legend/SeriesIcon'; diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts index c84c236661771..f47591e2f115d 100644 --- a/packages/grafana-ui/src/types/plugin.ts +++ b/packages/grafana-ui/src/types/plugin.ts @@ -124,6 +124,9 @@ export class GrafanaPlugin { // Meta is filled in by the plugin loading system meta?: T; + // This is set if the plugin system had errors loading the plugin + loadError?: boolean; + // Config control (app/datasource) angularConfigCtrl?: any; diff --git a/public/vendor/ansicolor/ansicolor.ts b/packages/grafana-ui/src/utils/ansicolor.ts similarity index 92% rename from public/vendor/ansicolor/ansicolor.ts rename to packages/grafana-ui/src/utils/ansicolor.ts index 305f0870e5a5c..574e389ac5c66 100644 --- a/public/vendor/ansicolor/ansicolor.ts +++ b/packages/grafana-ui/src/utils/ansicolor.ts @@ -100,7 +100,9 @@ class Color { return rgb ? prop + 'rgba(' + [...rgb, alpha].join(',') + ');' - : !color.background && alpha < 1 ? 'color:rgba(0,0,0,0.5);' : ''; // Chrome does not support 'opacity' property... + : !color.background && alpha < 1 + ? 'color:rgba(0,0,0,0.5);' + : ''; // Chrome does not support 'opacity' property... } } @@ -118,11 +120,13 @@ class Code { static noColor = 39; static noBgColor = 49; - value: number; + value: number | undefined; constructor(n?: string | number) { if (n !== undefined) { this.value = Number(n); + } else { + this.value = undefined; } } @@ -178,45 +182,42 @@ const camel = (a: string, b: string) => a + b.charAt(0).toUpperCase() + b.slice( const stringWrappingMethods = (() => [ - ...colorCodes.map( - (k, i) => - !k - ? [] - : [ - // color methods - - [k, 30 + i, Code.noColor], - [camel('bg', k), 40 + i, Code.noBgColor], - ] + ...colorCodes.map((k, i) => + !k + ? [] + : [ + // color methods + + [k, 30 + i, Code.noColor], + [camel('bg', k), 40 + i, Code.noBgColor], + ] ), - ...colorCodesLight.map( - (k, i) => - !k - ? [] - : [ - // light color methods + ...colorCodesLight.map((k, i) => + !k + ? [] + : [ + // light color methods - [k, 90 + i, Code.noColor], - [camel('bg', k), 100 + i, Code.noBgColor], - ] + [k, 90 + i, Code.noColor], + [camel('bg', k), 100 + i, Code.noBgColor], + ] ), /* THIS ONE IS FOR BACKWARDS COMPATIBILITY WITH PREVIOUS VERSIONS (had 'bright' instead of 'light' for backgrounds) - */ - ...['', 'BrightRed', 'BrightGreen', 'BrightYellow', 'BrightBlue', 'BrightMagenta', 'BrightCyan'].map( - (k, i) => (!k ? [] : [['bg' + k, 100 + i, Code.noBgColor]]) + */ + ...['', 'BrightRed', 'BrightGreen', 'BrightYellow', 'BrightBlue', 'BrightMagenta', 'BrightCyan'].map((k, i) => + !k ? [] : [['bg' + k, 100 + i, Code.noBgColor]] ), - ...styleCodes.map( - (k, i) => - !k - ? [] - : [ - // style methods + ...styleCodes.map((k, i) => + !k + ? [] + : [ + // style methods - [k, i, k === 'bright' || k === 'dim' ? Code.noBrightness : 20 + i], - ] + [k, i, k === 'bright' || k === 'dim' ? Code.noBrightness : 20 + i], + ] ), ].reduce((a, b) => a.concat(b)))(); diff --git a/packages/grafana-ui/src/utils/dataLinks.ts b/packages/grafana-ui/src/utils/dataLinks.ts new file mode 100644 index 0000000000000..38f212a745a63 --- /dev/null +++ b/packages/grafana-ui/src/utils/dataLinks.ts @@ -0,0 +1,24 @@ +import { ContextMenuItem } from '../components/ContextMenu/ContextMenu'; +import { LinkModelSupplier } from '@grafana/data'; + +export const DataLinkBuiltInVars = { + keepTime: '__url_time_range', + includeVars: '__all_variables', + seriesName: '__series_name', + valueTime: '__value_time', +}; + +/** + * Delays creating links until we need to open the ContextMenu + */ +export const linkModelToContextMenuItems: (links: LinkModelSupplier) => ContextMenuItem[] = links => { + return links.getLinks().map(link => { + return { + label: link.title, + // TODO: rename to href + url: link.href, + target: link.target, + icon: `fa ${link.target === '_self' ? 'fa-link' : 'fa-external-link'}`, + }; + }); +}; diff --git a/packages/grafana-ui/src/utils/fieldDisplay.ts b/packages/grafana-ui/src/utils/fieldDisplay.ts index 86c149745a442..66e06f01e0c3a 100644 --- a/packages/grafana-ui/src/utils/fieldDisplay.ts +++ b/packages/grafana-ui/src/utils/fieldDisplay.ts @@ -6,6 +6,7 @@ import { FieldConfig, DisplayValue, GraphSeriesValue, + DataFrameView, } from '@grafana/data'; import toNumber from 'lodash/toNumber'; @@ -14,6 +15,7 @@ import toString from 'lodash/toString'; import { GrafanaTheme, InterpolateFunction, ScopedVars } from '../types/index'; import { getDisplayProcessor } from './displayProcessor'; import { getFlotPairs } from './flotPairs'; +import { DataLinkBuiltInVars } from '../utils/dataLinks'; export interface FieldDisplayOptions { values?: boolean; // If true show each row value @@ -23,7 +25,7 @@ export interface FieldDisplayOptions { defaults: FieldConfig; // Use these values unless otherwise stated override: FieldConfig; // Set these values regardless of the source } - +// TODO: use built in variables, same as for data links? export const VAR_SERIES_NAME = '__series_name'; export const VAR_FIELD_NAME = '__field_name'; export const VAR_CALC = '__calc'; @@ -59,10 +61,15 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat } export interface FieldDisplay { - name: string; // NOT title! + name: string; // The field name (title is in display) field: FieldConfig; display: DisplayValue; sparkline?: GraphSeriesValue[][]; + + // Expose to the original values for delayed inspection (DataLinks etc) + view?: DataFrameView; + column?: number; // The field column index + row?: number; // only filled in when the value is from a row (ie, not a reduction) } export interface GetFieldDisplayValuesOptions { @@ -75,8 +82,19 @@ export interface GetFieldDisplayValuesOptions { export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25; +const getTimeColumnIdx = (series: DataFrame) => { + let timeColumn = -1; + for (let i = 0; i < series.fields.length; i++) { + if (series.fields[i].type === FieldType.time) { + timeColumn = i; + break; + } + } + return timeColumn; +}; + export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => { - const { data, replaceVariables, fieldOptions, sparkline } = options; + const { data, replaceVariables, fieldOptions } = options; const { defaults, override } = fieldOptions; const calcs = fieldOptions.calcs.length ? fieldOptions.calcs : [ReducerID.last]; @@ -96,17 +114,11 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi name: series.refId ? series.refId : `Series[${s}]`, }; } - scopedVars[VAR_SERIES_NAME] = { text: 'Series', value: series.name }; - - let timeColumn = -1; - if (sparkline) { - for (let i = 0; i < series.fields.length; i++) { - if (series.fields[i].type === FieldType.time) { - timeColumn = i; - break; - } - } - } + + scopedVars[DataLinkBuiltInVars.seriesName] = { text: 'Series', value: series.name }; + + const timeColumn = getTimeColumnIdx(series); + const view = new DataFrameView(series); for (let i = 0; i < series.fields.length && !hitLimit; i++) { const field = series.fields[i]; @@ -131,7 +143,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi const title = config.title ? config.title : defaultTitle; - // Show all number fields + // Show all rows if (fieldOptions.values) { const usesCellValues = title.indexOf(VAR_CELL_PREFIX) >= 0; @@ -154,6 +166,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi name, field: config, display: displayValue, + view, + column: i, + row: j, }); if (values.length >= limit) { @@ -166,15 +181,15 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi field, reducers: calcs, // The stats to calculate }); + let sparkline: GraphSeriesValue[][] | undefined = undefined; - // Single sparkline for a field - const points = - timeColumn < 0 - ? undefined - : getFlotPairs({ - xField: series.fields[timeColumn], - yField: series.fields[i], - }); + // Single sparkline for every reducer + if (options.sparkline && timeColumn >= 0) { + sparkline = getFlotPairs({ + xField: series.fields[timeColumn], + yField: series.fields[i], + }); + } for (const calc of calcs) { scopedVars[VAR_CALC] = { value: calc, text: calc }; @@ -184,7 +199,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi name, field: config, display: displayValue, - sparkline: points, + sparkline, + view, + column: i, }); } } diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 077721ec22e09..8f5457e4adf29 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -6,6 +6,8 @@ export * from './fieldDisplay'; export * from './validate'; export { getFlotPairs } from './flotPairs'; export * from './slate'; +export * from './dataLinks'; +export { default as ansicolor } from './ansicolor'; // Export with a namespace import * as DOMUtil from './dom'; // includes Element.closest polyfil diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 7a128481f35dd..836b9b1d95041 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -10,7 +10,7 @@ import ( "regexp" "github.com/grafana/grafana/pkg/models" - + "github.com/jmespath/go-jmespath" "golang.org/x/oauth2" ) @@ -21,6 +21,7 @@ type SocialGenericOAuth struct { apiUrl string allowSignup bool emailAttributeName string + emailAttributePath string teamIds []int } @@ -78,6 +79,37 @@ func (s *SocialGenericOAuth) IsOrganizationMember(client *http.Client) bool { return false } +// searchJSONForEmail searches the provided JSON response for an e-mail address +// using the configured e-mail attribute path associated with the generic OAuth +// provider. +// Returns an empty string if an e-mail address is not found. +func (s *SocialGenericOAuth) searchJSONForEmail(data []byte) string { + if s.emailAttributePath == "" { + s.log.Error("No e-mail attribute path specified") + return "" + } + if len(data) == 0 { + s.log.Error("Empty user info JSON response provided") + return "" + } + var buf interface{} + if err := json.Unmarshal(data, &buf); err != nil { + s.log.Error("Failed to unmarshal user info JSON response", "err", err.Error()) + return "" + } + val, err := jmespath.Search(s.emailAttributePath, buf) + if err != nil { + s.log.Error("Failed to search user info JSON response with provided path", "emailAttributePath", s.emailAttributePath, "err", err.Error()) + return "" + } + strVal, ok := val.(string) + if ok { + return strVal + } + s.log.Error("E-mail not found when searching JSON with provided path", "emailAttributePath", s.emailAttributePath) + return "" +} + func (s *SocialGenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) { type Record struct { Email string `json:"email"` @@ -181,15 +213,16 @@ type UserInfoJson struct { func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { var data UserInfoJson + var rawUserInfoResponse HttpGetResponse var err error if !s.extractToken(&data, token) { - response, err := HttpGet(client, s.apiUrl) + rawUserInfoResponse, err = HttpGet(client, s.apiUrl) if err != nil { return nil, fmt.Errorf("Error getting user info: %s", err) } - err = json.Unmarshal(response.Body, &data) + err = json.Unmarshal(rawUserInfoResponse.Body, &data) if err != nil { return nil, fmt.Errorf("Error decoding user info JSON: %s", err) } @@ -197,7 +230,7 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) name := s.extractName(&data) - email := s.extractEmail(&data) + email := s.extractEmail(&data, rawUserInfoResponse.Body) if email == "" { email, err = s.FetchPrivateEmail(client) if err != nil { @@ -250,8 +283,7 @@ func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Toke return false } - email := s.extractEmail(data) - if email == "" { + if email := s.extractEmail(data, payload); email == "" { s.log.Debug("No email found in id_token", "json", string(payload), "data", data) return false } @@ -260,11 +292,18 @@ func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Toke return true } -func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { +func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson, userInfoResp []byte) string { if data.Email != "" { return data.Email } + if s.emailAttributePath != "" { + email := s.searchJSONForEmail(userInfoResp) + if email != "" { + return email + } + } + emails, ok := data.Attributes[s.emailAttributeName] if ok && len(emails) != 0 { return emails[0] @@ -275,6 +314,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string { if emailErr == nil { return emailAddr.Address } + s.log.Debug("Failed to parse e-mail address", "err", emailErr.Error()) } return "" diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go new file mode 100644 index 0000000000000..488aeaa3716ca --- /dev/null +++ b/pkg/login/social/generic_oauth_test.go @@ -0,0 +1,86 @@ +package social + +import ( + "github.com/grafana/grafana/pkg/infra/log" + . "github.com/smartystreets/goconvey/convey" + "testing" +) + +func TestSearchJSONForEmail(t *testing.T) { + Convey("Given a generic OAuth provider", t, func() { + provider := SocialGenericOAuth{ + SocialBase: &SocialBase{ + log: log.New("generic_oauth_test"), + }, + } + + tests := []struct { + Name string + UserInfoJSONResponse []byte + EmailAttributePath string + ExpectedResult string + }{ + { + Name: "Given an invalid user info JSON response", + UserInfoJSONResponse: []byte("{"), + EmailAttributePath: "attributes.email", + ExpectedResult: "", + }, + { + Name: "Given an empty user info JSON response and empty JMES path", + UserInfoJSONResponse: []byte{}, + EmailAttributePath: "", + ExpectedResult: "", + }, + { + Name: "Given an empty user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte{}, + EmailAttributePath: "attributes.email", + ExpectedResult: "", + }, + { + Name: "Given a simple user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "email": "grafana@localhost" + } +}`), + EmailAttributePath: "attributes.email", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a user info JSON response with e-mails array and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "attributes": { + "emails": ["grafana@localhost", "admin@localhost"] + } +}`), + EmailAttributePath: "attributes.emails[0]", + ExpectedResult: "grafana@localhost", + }, + { + Name: "Given a nested user info JSON response and valid JMES path", + UserInfoJSONResponse: []byte(`{ + "identities": [ + { + "userId": "grafana@localhost" + }, + { + "userId": "admin@localhost" + } + ] +}`), + EmailAttributePath: "identities[0].userId", + ExpectedResult: "grafana@localhost", + }, + } + + for _, test := range tests { + provider.emailAttributePath = test.EmailAttributePath + Convey(test.Name, func() { + actualResult := provider.searchJSONForEmail(test.UserInfoJSONResponse) + So(actualResult, ShouldEqual, test.ExpectedResult) + }) + } + }) +} diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index b0ab3cce9aa48..101df1994ebf1 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -73,6 +73,7 @@ func NewOAuthService() { ApiUrl: sec.Key("api_url").String(), Enabled: sec.Key("enabled").MustBool(), EmailAttributeName: sec.Key("email_attribute_name").String(), + EmailAttributePath: sec.Key("email_attribute_path").String(), AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), HostedDomain: sec.Key("hosted_domain").String(), AllowSignup: sec.Key("allow_sign_up").MustBool(), @@ -167,6 +168,7 @@ func NewOAuthService() { apiUrl: info.ApiUrl, allowSignup: info.AllowSignup, emailAttributeName: info.EmailAttributeName, + emailAttributePath: info.EmailAttributePath, teamIds: sec.Key("team_ids").Ints(","), allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()), } diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go index e734dfd8e4a25..44abf19fcbe89 100644 --- a/pkg/models/notifications.go +++ b/pkg/models/notifications.go @@ -5,15 +5,25 @@ import "errors" var ErrInvalidEmailCode = errors.New("Invalid or expired email code") var ErrSmtpNotEnabled = errors.New("SMTP not configured, check your grafana.ini config file's [smtp] section") +// SendEmailAttachFile is a definition of the attached files without path +type SendEmailAttachFile struct { + Name string + Content []byte +} + +// SendEmailCommand is command for sending emails type SendEmailCommand struct { - To []string - Template string - Subject string - Data map[string]interface{} - Info string - EmbededFiles []string + To []string + Template string + Subject string + Data map[string]interface{} + Info string + ReplyTo []string + EmbededFiles []string + AttachedFiles []*SendEmailAttachFile } +// SendEmailCommandSync is command for sending emails in sync type SendEmailCommandSync struct { SendEmailCommand } diff --git a/pkg/services/alerting/notifiers/email.go b/pkg/services/alerting/notifiers/email.go index 5d3422e608b5b..462b13959a322 100644 --- a/pkg/services/alerting/notifiers/email.go +++ b/pkg/services/alerting/notifiers/email.go @@ -2,11 +2,11 @@ package notifiers import ( "os" - "strings" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/setting" @@ -48,13 +48,7 @@ func NewEmailNotifier(model *models.AlertNotification) (alerting.Notifier, error } // split addresses with a few different ways - addresses := strings.FieldsFunc(addressesString, func(r rune) bool { - switch r { - case ',', ';', '\n': - return true - } - return false - }) + addresses := util.SplitEmails(addressesString) return &EmailNotifier{ NotifierBase: NewNotifierBase(model), diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index 97cf875f9d2eb..62c2d9d7e5827 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -46,7 +46,7 @@ func (handler *defaultResultHandler) handle(evalContext *EvalContext) error { metrics.MAlertingResultState.WithLabelValues(string(evalContext.Rule.State)).Inc() if evalContext.shouldUpdateAlertState() { - handler.log.Info("New state change", "alertId", evalContext.Rule.ID, "newState", evalContext.Rule.State, "prev state", evalContext.PrevAlertState) + handler.log.Info("New state change", "ruleId", evalContext.Rule.ID, "newState", evalContext.Rule.State, "prev state", evalContext.PrevAlertState) cmd := &models.SetAlertStateCommand{ AlertId: evalContext.Rule.ID, diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index af33078b20998..4ad1b4a038910 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -141,7 +141,7 @@ func NewRuleFromDBAlert(ruleDef *models.Alert) (*Rule, error) { } else { uid, err := jsonModel.Get("uid").String() if err != nil { - return nil, ValidationError{Reason: "Neither id nor uid is specified, " + err.Error(), DashboardID: model.DashboardID, AlertID: model.ID, PanelID: model.PanelID} + return nil, ValidationError{Reason: "Neither id nor uid is specified in 'notifications' block, " + err.Error(), DashboardID: model.DashboardID, AlertID: model.ID, PanelID: model.PanelID} } model.Notifications = append(model.Notifications, uid) } diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go index 853fb5d17b5cf..84ce6fbfe3db0 100644 --- a/pkg/services/alerting/rule_test.go +++ b/pkg/services/alerting/rule_test.go @@ -179,7 +179,7 @@ func TestAlertRuleModel(t *testing.T) { _, err := NewRuleFromDBAlert(alert) So(err, ShouldNotBeNil) - So(err.Error(), ShouldEqual, "Alert validation error: Neither id nor uid is specified, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1") + So(err.Error(), ShouldEqual, "Alert validation error: Neither id nor uid is specified in 'notifications' block, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1") }) }) } diff --git a/pkg/services/notifications/email.go b/pkg/services/notifications/email.go index 803f2096b560f..aa10a7423c4d7 100644 --- a/pkg/services/notifications/email.go +++ b/pkg/services/notifications/email.go @@ -5,13 +5,22 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +// AttachedFile is struct representating email attached files +type AttachedFile struct { + Name string + Content []byte +} + +// Message is representation of the email message type Message struct { - To []string - From string - Subject string - Body string - Info string - EmbededFiles []string + To []string + From string + Subject string + Body string + Info string + ReplyTo []string + EmbededFiles []string + AttachedFiles []*AttachedFile } func setDefaultTemplateData(data map[string]interface{}, u *m.User) { diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 584914f5ff71a..17d0a4c0d576c 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -9,13 +9,15 @@ import ( "crypto/tls" "fmt" "html/template" + "io" "net" "strconv" + gomail "gopkg.in/mail.v2" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util/errutil" - gomail "gopkg.in/mail.v2" ) func (ns *NotificationService) send(msg *Message) (int, error) { @@ -30,8 +32,11 @@ func (ns *NotificationService) send(msg *Message) (int, error) { m.SetHeader("From", msg.From) m.SetHeader("To", address) m.SetHeader("Subject", msg.Subject) - for _, file := range msg.EmbededFiles { - m.Embed(file) + + ns.setFiles(m, msg) + + for _, replyTo := range msg.ReplyTo { + m.SetAddressHeader("Reply-To", replyTo, "") } m.SetBody("text/html", msg.Body) @@ -48,6 +53,23 @@ func (ns *NotificationService) send(msg *Message) (int, error) { return num, err } +// setFiles attaches files in various forms +func (ns *NotificationService) setFiles( + m *gomail.Message, + msg *Message, +) { + for _, file := range msg.EmbededFiles { + m.Embed(file) + } + + for _, file := range msg.AttachedFiles { + m.Attach(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error { + _, err := writer.Write(file.Content) + return err + })) + } +} + func (ns *NotificationService) createDialer() (*gomail.Dialer, error) { host, port, err := net.SplitHostPort(ns.Cfg.Smtp.Host) @@ -127,10 +149,27 @@ func (ns *NotificationService) buildEmailMessage(cmd *models.SendEmailCommand) ( } return &Message{ - To: cmd.To, - From: fmt.Sprintf("%s <%s>", ns.Cfg.Smtp.FromName, ns.Cfg.Smtp.FromAddress), - Subject: subject, - Body: buffer.String(), - EmbededFiles: cmd.EmbededFiles, + To: cmd.To, + From: fmt.Sprintf("%s <%s>", ns.Cfg.Smtp.FromName, ns.Cfg.Smtp.FromAddress), + Subject: subject, + Body: buffer.String(), + EmbededFiles: cmd.EmbededFiles, + AttachedFiles: buildAttachedFiles(cmd.AttachedFiles), }, nil } + +// buildAttachedFiles build attached files +func buildAttachedFiles( + attached []*models.SendEmailAttachFile, +) []*AttachedFile { + result := make([]*AttachedFile, 0) + + for _, file := range attached { + result = append(result, &AttachedFile{ + Name: file.Name, + Content: file.Content, + }) + } + + return result +} diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index f0a3beccb4499..76f77954e2c5f 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -6,6 +6,7 @@ type OAuthInfo struct { AuthUrl, TokenUrl string Enabled bool EmailAttributeName string + EmailAttributePath string AllowedDomains []string HostedDomain string ApiUrl string diff --git a/pkg/util/split_email.go b/pkg/util/split_email.go new file mode 100644 index 0000000000000..fa53c37b8f4cd --- /dev/null +++ b/pkg/util/split_email.go @@ -0,0 +1,14 @@ +package util + +import "strings" + +// SplitEmails splits addresses with a few different ways +func SplitEmails(emails string) []string { + return strings.FieldsFunc(emails, func(r rune) bool { + switch r { + case ',', ';', '\n': + return true + } + return false + }) +} diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index fc4729a7fa185..9daf2237d2571 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -1,7 +1,5 @@ import _ from 'lodash'; -import ansicolor from 'vendor/ansicolor/ansicolor'; - -import { colors, getFlotPairs } from '@grafana/ui'; +import { colors, getFlotPairs, ansicolor } from '@grafana/ui'; import { Labels, @@ -16,8 +14,6 @@ import { LogsModel, LogsMetaItem, LogsMetaKind, - LogsParser, - LogLabelStatsModel, LogsDedupStrategy, DataFrameHelper, GraphSeriesXY, @@ -41,89 +37,6 @@ export const LogLevelColor = { [LogLevel.unknown]: getThemeColor('#8e8e8e', '#dde4ed'), }; -export enum LogsDedupDescription { - none = 'No de-duplication', - exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.', - numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.', - signature = 'De-duplication of successive lines that have identical punctuation and whitespace.', -} -const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/; - -export const LogsParsers: { [name: string]: LogsParser } = { - JSON: { - buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`), - getFields: line => { - const fields: string[] = []; - try { - const parsed = JSON.parse(line); - _.map(parsed, (value, key) => { - const fieldMatcher = new RegExp(`"${key}"\\s*:\\s*"?${_.escapeRegExp(JSON.stringify(value))}"?`); - - const match = line.match(fieldMatcher); - if (match) { - fields.push(match[0]); - } - }); - } catch {} - return fields; - }, - getLabelFromField: field => (field.match(/^"(\w+)"\s*:/) || [])[1], - getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1], - test: line => { - try { - return JSON.parse(line); - } catch (error) {} - }, - }, - - logfmt: { - buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`), - getFields: line => { - const fields: string[] = []; - line.replace(new RegExp(LOGFMT_REGEXP, 'g'), substring => { - fields.push(substring.trim()); - return ''; - }); - return fields; - }, - getLabelFromField: field => (field.match(LOGFMT_REGEXP) || [])[1], - getValueFromField: field => (field.match(LOGFMT_REGEXP) || [])[2], - test: line => LOGFMT_REGEXP.test(line), - }, -}; - -export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] { - // Consider only rows that satisfy the matcher - const rowsWithField = rows.filter(row => extractor.test(row.entry)); - const rowCount = rowsWithField.length; - - // Get field value counts for eligible rows - const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]); - const sortedCounts = _.chain(countsByValue) - .map((count, value) => ({ count, value, proportion: count / rowCount })) - .sortBy('count') - .reverse() - .value(); - - return sortedCounts; -} - -export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] { - // Consider only rows that have the given label - const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); - const rowCount = rowsWithLabel.length; - - // Get label value counts for eligible rows - const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]); - const sortedCounts = _.chain(countsByValue) - .map((count, value) => ({ count, value, proportion: count / rowCount })) - .sortBy('count') - .reverse() - .value(); - - return sortedCounts; -} - const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g; function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean { switch (strategy) { @@ -165,19 +78,6 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs }; } -export function getParser(line: string): LogsParser { - let parser; - try { - if (LogsParsers.JSON.test(line)) { - parser = LogsParsers.JSON; - } - } catch (error) {} - if (!parser && LogsParsers.logfmt.test(line)) { - parser = LogsParsers.logfmt; - } - return parser; -} - export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set): LogsModel { if (hiddenLogLevels.size === 0) { return logs; diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts index 10d2e5ea0c7a6..c6479b2b06261 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/public/app/core/specs/logs_model.test.ts @@ -8,14 +8,7 @@ import { DataFrameHelper, toDataFrame, } from '@grafana/data'; -import { - dedupLogRows, - calculateFieldStats, - calculateLogsLabelStats, - getParser, - LogsParsers, - dataFrameToLogsModel, -} from '../logs_model'; +import { dedupLogRows, dataFrameToLogsModel } from '../logs_model'; describe('dedupLogRows()', () => { test('should return rows as is when dedup is set to none', () => { @@ -152,193 +145,6 @@ describe('dedupLogRows()', () => { }); }); -describe('calculateFieldStats()', () => { - test('should return no stats for empty rows', () => { - expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]); - }); - - test('should return no stats if extractor does not match', () => { - const rows = [ - { - entry: 'foo=bar', - }, - ]; - - expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]); - }); - - test('should return stats for found field', () => { - const rows = [ - { - entry: 'foo="42 + 1"', - }, - { - entry: 'foo=503 baz=foo', - }, - { - entry: 'foo="42 + 1"', - }, - { - entry: 't=2018-12-05T07:44:59+0000 foo=503', - }, - ]; - - expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([ - { - value: '"42 + 1"', - count: 2, - }, - { - value: '503', - count: 2, - }, - ]); - }); -}); - -describe('calculateLogsLabelStats()', () => { - test('should return no stats for empty rows', () => { - expect(calculateLogsLabelStats([], '')).toEqual([]); - }); - - test('should return no stats of label is not found', () => { - const rows = [ - { - entry: 'foo 1', - labels: { - foo: 'bar', - }, - }, - ]; - - expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]); - }); - - test('should return stats for found labels', () => { - const rows = [ - { - entry: 'foo 1', - labels: { - foo: 'bar', - }, - }, - { - entry: 'foo 0', - labels: { - foo: 'xxx', - }, - }, - { - entry: 'foo 2', - labels: { - foo: 'bar', - }, - }, - ]; - - expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([ - { - value: 'bar', - count: 2, - }, - { - value: 'xxx', - count: 1, - }, - ]); - }); -}); - -describe('getParser()', () => { - test('should return no parser on empty line', () => { - expect(getParser('')).toBeUndefined(); - }); - - test('should return no parser on unknown line pattern', () => { - expect(getParser('To Be or not to be')).toBeUndefined(); - }); - - test('should return logfmt parser on key value patterns', () => { - expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt); - }); - - test('should return JSON parser on JSON log lines', () => { - // TODO implement other JSON value types than string - expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); - }); -}); - -describe('LogsParsers', () => { - describe('logfmt', () => { - const parser = LogsParsers.logfmt; - - test('should detect format', () => { - expect(parser.test('foo')).toBeFalsy(); - expect(parser.test('foo=bar')).toBeTruthy(); - }); - - test('should return parsed fields', () => { - expect(parser.getFields('foo=bar baz="42 + 1"')).toEqual(['foo=bar', 'baz="42 + 1"']); - }); - - test('should return label for field', () => { - expect(parser.getLabelFromField('foo=bar')).toBe('foo'); - }); - - test('should return value for field', () => { - expect(parser.getValueFromField('foo=bar')).toBe('bar'); - }); - - test('should build a valid value matcher', () => { - const matcher = parser.buildMatcher('foo'); - const match = 'foo=bar'.match(matcher); - expect(match).toBeDefined(); - expect(match[1]).toBe('bar'); - }); - }); - - describe('JSON', () => { - const parser = LogsParsers.JSON; - - test('should detect format', () => { - expect(parser.test('foo')).toBeFalsy(); - expect(parser.test('{"foo":"bar"}')).toBeTruthy(); - }); - - test('should return parsed fields', () => { - expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo" : "bar"', '"baz" : 42']); - }); - - test('should return parsed fields for nested quotes', () => { - expect(parser.getFields(`{"foo":"bar: '[value=\\"42\\"]'"}`)).toEqual([`"foo":"bar: '[value=\\"42\\"]'"`]); - }); - - test('should return label for field', () => { - expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo'); - }); - - test('should return value for field', () => { - expect(parser.getValueFromField('"foo" : "bar"')).toBe('"bar"'); - expect(parser.getValueFromField('"foo" : 42')).toBe('42'); - expect(parser.getValueFromField('"foo" : 42.1')).toBe('42.1'); - }); - - test('should build a valid value matcher for strings', () => { - const matcher = parser.buildMatcher('foo'); - const match = '{"foo":"bar"}'.match(matcher); - expect(match).toBeDefined(); - expect(match[1]).toBe('bar'); - }); - - test('should build a valid value matcher for integers', () => { - const matcher = parser.buildMatcher('foo'); - const match = '{"foo":42.1}'.match(matcher); - expect(match).toBeDefined(); - expect(match[1]).toBe('42.1'); - }); - }); -}); - const emptyLogsModel: any = { hasUniqueLabels: false, rows: [], diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 528a464131c89..f2dc606816437 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -9,11 +9,15 @@ import { getValueWithRefId, getFirstQueryErrorWithoutRefId, getRefIds, + refreshIntervalToSortOrder, + SortOrder, + sortLogsResult, } from './explore'; import { ExploreUrlState, ExploreMode } from 'app/types/explore'; import store from 'app/core/store'; -import { LogsDedupStrategy } from '@grafana/data'; +import { LogsDedupStrategy, LogsModel, LogLevel } from '@grafana/data'; import { DataQueryError } from '@grafana/ui'; +import { liveOption, offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; const DEFAULT_EXPLORE_STATE: ExploreUrlState = { datasource: null, @@ -356,3 +360,95 @@ describe('getRefIds', () => { }); }); }); + +describe('refreshIntervalToSortOrder', () => { + describe('when called with live option', () => { + it('then it should return ascending', () => { + const result = refreshIntervalToSortOrder(liveOption.value); + + expect(result).toBe(SortOrder.Ascending); + }); + }); + + describe('when called with off option', () => { + it('then it should return descending', () => { + const result = refreshIntervalToSortOrder(offOption.value); + + expect(result).toBe(SortOrder.Descending); + }); + }); + + describe('when called with 5s option', () => { + it('then it should return descending', () => { + const result = refreshIntervalToSortOrder('5s'); + + expect(result).toBe(SortOrder.Descending); + }); + }); + + describe('when called with undefined', () => { + it('then it should return descending', () => { + const result = refreshIntervalToSortOrder(undefined); + + expect(result).toBe(SortOrder.Descending); + }); + }); +}); + +describe('sortLogsResult', () => { + const firstRow = { + timestamp: '2019-01-01T21:00:0.0000000Z', + entry: '', + hasAnsi: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 0, + timeFromNow: '', + timeLocal: '', + timeUtc: '', + }; + const sameAsFirstRow = firstRow; + const secondRow = { + timestamp: '2019-01-01T22:00:0.0000000Z', + entry: '', + hasAnsi: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 0, + timeFromNow: '', + timeLocal: '', + timeUtc: '', + }; + + describe('when called with SortOrder.Descending', () => { + it('then it should sort descending', () => { + const logsResult: LogsModel = { + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, SortOrder.Descending); + + expect(result).toEqual({ + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }); + }); + }); + + describe('when called with SortOrder.Ascending', () => { + it('then it should sort ascending', () => { + const logsResult: LogsModel = { + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, SortOrder.Ascending); + + expect(result).toEqual({ + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }); + }); + }); +}); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 408eeac4c1d07..4099259130ced 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -2,14 +2,23 @@ import _ from 'lodash'; import { from } from 'rxjs'; import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; - // Services & Utils -import { dateMath } from '@grafana/data'; +import { + dateMath, + toUtc, + TimeRange, + RawTimeRange, + TimeZone, + IntervalValues, + TimeFragment, + LogRowModel, + LogsModel, + LogsDedupStrategy, +} from '@grafana/data'; import { renderUrl } from 'app/core/utils/url'; import kbn from 'app/core/utils/kbn'; import store from 'app/core/store'; import { getNextRefIdChar } from './query'; - // Types import { DataQuery, @@ -19,17 +28,6 @@ import { DataQueryRequest, DataStreamObserver, } from '@grafana/ui'; -import { - toUtc, - TimeRange, - RawTimeRange, - TimeZone, - IntervalValues, - TimeFragment, - LogRowModel, - LogsModel, - LogsDedupStrategy, -} from '@grafana/data'; import { ExploreUrlState, HistoryItem, @@ -39,6 +37,7 @@ import { ExploreMode, } from 'app/types/explore'; import { config } from '../config'; +import { PanelQueryState } from '../../features/dashboard/state/PanelQueryState'; export const DEFAULT_RANGE = { from: 'now-1h', @@ -145,6 +144,7 @@ export function buildQueryTransaction( panelId, targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key. range, + requestId: 'explore', rangeRaw: range.raw, scopedVars: { __interval: { text: interval, value: interval }, @@ -510,10 +510,17 @@ const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => { return 0; }; -export const sortLogsResult = (logsResult: LogsModel, refreshInterval: string) => { +export enum SortOrder { + Descending = 'Descending', + Ascending = 'Ascending', +} + +export const refreshIntervalToSortOrder = (refreshInterval: string) => + isLive(refreshInterval) ? SortOrder.Ascending : SortOrder.Descending; + +export const sortLogsResult = (logsResult: LogsModel, sortOrder: SortOrder) => { const rows = logsResult ? logsResult.rows : []; - const live = isLive(refreshInterval); - live ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder); + sortOrder === SortOrder.Ascending ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder); const result: LogsModel = logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows }; return result; @@ -535,3 +542,10 @@ export const getQueryResponse = ( ) => { return from(datasourceInstance.query(options, observer)); }; + +export const stopQueryState = (queryState: PanelQueryState, reason: string) => { + if (queryState && queryState.isStarted()) { + queryState.cancel(reason); + queryState.closeStreams(false); + } +}; diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts index 160a8c5404517..2cff89f8c7ef8 100644 --- a/public/app/core/utils/text.ts +++ b/public/app/core/utils/text.ts @@ -1,84 +1,5 @@ -import { TextMatch } from 'app/types/explore'; import xss from 'xss'; -/** - * Adapt findMatchesInText for react-highlight-words findChunks handler. - * See https://github.com/bvaughn/react-highlight-words#props - */ -export function findHighlightChunksInText({ - searchWords, - textToHighlight, -}: { - searchWords: string[]; - textToHighlight: string; -}) { - return searchWords.reduce((acc: any, term: string) => [...acc, ...findMatchesInText(textToHighlight, term)], []); -} - -const cleanNeedle = (needle: string): string => { - return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); -}; - -/** - * Returns a list of substring regexp matches. - */ -export function findMatchesInText(haystack: string, needle: string): TextMatch[] { - // Empty search can send re.exec() into infinite loop, exit early - if (!haystack || !needle) { - return []; - } - const matches: TextMatch[] = []; - const { cleaned, flags } = parseFlags(cleanNeedle(needle)); - let regexp: RegExp; - try { - regexp = new RegExp(`(?:${cleaned})`, flags); - } catch (error) { - return matches; - } - haystack.replace(regexp, (substring, ...rest) => { - if (substring) { - const offset = rest[rest.length - 2]; - matches.push({ - text: substring, - start: offset, - length: substring.length, - end: offset + substring.length, - }); - } - return ''; - }); - return matches; -} - -const CLEAR_FLAG = '-'; -const FLAGS_REGEXP = /\(\?([ims-]+)\)/g; - -/** - * Converts any mode modifers in the text to the Javascript equivalent flag - */ -export function parseFlags(text: string): { cleaned: string; flags: string } { - const flags: Set = new Set(['g']); - - const cleaned = text.replace(FLAGS_REGEXP, (str, group) => { - const clearAll = group.startsWith(CLEAR_FLAG); - - for (let i = 0; i < group.length; ++i) { - const flag = group.charAt(i); - if (clearAll || group.charAt(i - 1) === CLEAR_FLAG) { - flags.delete(flag); - } else if (flag !== CLEAR_FLAG) { - flags.add(flag); - } - } - return ''; // Remove flag from text - }); - - return { - cleaned: cleaned, - flags: Array.from(flags).join(''), - }; -} - const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => { // @ts-ignore acc[element] = xss.whiteList[element].concat(['class', 'style']); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 758ef85f438c3..852476c52def0 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -11,6 +11,7 @@ import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { ClickOutsideWrapper } from '@grafana/ui'; import { DataLink } from '@grafana/data'; +import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; export interface Props { panel: PanelModel; @@ -88,7 +89,7 @@ export class PanelHeader extends Component { title={panel.title} description={panel.description} scopedVars={panel.scopedVars} - links={panel.links} + links={getPanelLinksSupplier(panel)} error={error} />
{ it('should render component', () => { const panel = new PanelModel({}); - const links: any[] = [ - { - url: 'asd', - title: 'asd', - }, - ]; - - const wrapper = shallow(); + const wrapper = shallow(); const instance = wrapper.instance() as PanelHeaderCorner; expect(instance.getInfoContent()).toBeDefined(); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx index 0a34242450ed3..ee79bc4f91c8a 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx @@ -1,12 +1,10 @@ import React, { Component } from 'react'; -import { renderMarkdown } from '@grafana/data'; +import { renderMarkdown, LinkModelSupplier } from '@grafana/data'; import { Tooltip, ScopedVars, PopoverContent } from '@grafana/ui'; -import { DataLink } from '@grafana/data'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import templateSrv from 'app/features/templating/template_srv'; -import { LinkSrv } from 'app/features/panel/panellinks/link_srv'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; enum InfoMode { @@ -20,7 +18,7 @@ interface Props { title?: string; description?: string; scopedVars?: ScopedVars; - links?: DataLink[]; + links?: LinkModelSupplier; error?: string; } @@ -45,22 +43,21 @@ export class PanelHeaderCorner extends Component { getInfoContent = (): JSX.Element => { const { panel } = this.props; const markdown = panel.description || ''; - const linkSrv = new LinkSrv(templateSrv, this.timeSrv); const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars); const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown); + const links = this.props.links && this.props.links.getLinks(panel); return (
- {panel.links && panel.links.length > 0 && ( + {links && links.length > 0 && (
    - {panel.links.map((link, idx) => { - const info = linkSrv.getDataLinkUIModel(link, panel.scopedVars); + {links.map((link, idx) => { return (
  • - - {info.title} + + {link.title}
  • ); diff --git a/public/app/features/dashboard/dashgrid/PanelPluginError.tsx b/public/app/features/dashboard/dashgrid/PanelPluginError.tsx index 5bec4191873a7..3f88dcca1c953 100644 --- a/public/app/features/dashboard/dashgrid/PanelPluginError.tsx +++ b/public/app/features/dashboard/dashgrid/PanelPluginError.tsx @@ -36,7 +36,7 @@ class PanelPluginError extends PureComponent { } export function getPanelPluginLoadError(meta: PanelPluginMeta, err: any): PanelPlugin { - const NotFound = class NotFound extends PureComponent { + const LoadError = class LoadError extends PureComponent { render() { const text = ( <> @@ -47,8 +47,9 @@ export function getPanelPluginLoadError(meta: PanelPluginMeta, err: any): PanelP return ; } }; - const plugin = new PanelPlugin(NotFound); + const plugin = new PanelPlugin(LoadError); plugin.meta = meta; + plugin.loadError = true; return plugin; } diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx index 6e32b7ca54597..17b2090e58647 100644 --- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx +++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx @@ -23,6 +23,8 @@ import { LoadingState } from '@grafana/data'; import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; import { PanelQueryRunnerFormat } from '../state/PanelQueryRunner'; import { Unsubscribable } from 'rxjs'; +import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; +import { DashboardQueryEditor } from 'app/plugins/datasource/dashboard/DashboardQueryEditor'; interface Props { panel: PanelModel; @@ -166,12 +168,13 @@ export class QueriesTab extends PureComponent { renderToolbar = () => { const { currentDS, isAddingMixed } = this.state; + const showAddButton = !(isAddingMixed || isSharedDashboardQuery(currentDS.name)); return ( <>
    - {!isAddingMixed && ( + {showAddButton && ( @@ -236,28 +239,32 @@ export class QueriesTab extends PureComponent { setScrollTop={this.setScrollTop} scrollTop={scrollTop} > - <> -
    - {panel.targets.map((query, index) => ( - this.onQueryChange(query, index)} - onRemoveQuery={this.onRemoveQuery} - onAddQuery={this.onAddQuery} - onMoveQuery={this.onMoveQuery} - inMixedMode={currentDS.meta.mixed} - /> - ))} -
    - - - - + {isSharedDashboardQuery(currentDS.name) ? ( + this.onQueryChange(query, 0)} /> + ) : ( + <> +
    + {panel.targets.map((query, index) => ( + this.onQueryChange(query, index)} + onRemoveQuery={this.onRemoveQuery} + onAddQuery={this.onAddQuery} + onMoveQuery={this.onMoveQuery} + inMixedMode={currentDS.meta.mixed} + /> + ))} +
    + + + + + )} ); } diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index 7bb0082127a25..fedc4e17fd2ff 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -3,7 +3,7 @@ import { DashboardModel } from '../state/DashboardModel'; import { PanelModel } from '../state/PanelModel'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; import { expect } from 'test/lib/common'; -import { DataLinkBuiltInVars } from 'app/features/panel/panellinks/link_srv'; +import { DataLinkBuiltInVars } from '@grafana/ui'; jest.mock('app/core/services/context_srv', () => ({})); diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index e9ec4228d603f..e7811c6dcfff4 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -20,7 +20,7 @@ import { MIN_PANEL_HEIGHT, DEFAULT_PANEL_SPAN, } from 'app/core/constants'; -import { DataLinkBuiltInVars } from 'app/features/panel/panellinks/link_srv'; +import { DataLinkBuiltInVars } from '@grafana/ui'; export class DashboardMigrator { dashboard: DashboardModel; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index addc307333968..1b037cbd65756 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -326,7 +326,7 @@ export class PanelModel { getQueryRunner(): PanelQueryRunner { if (!this.queryRunner) { - this.queryRunner = new PanelQueryRunner(); + this.queryRunner = new PanelQueryRunner(this.id); } return this.queryRunner; } diff --git a/public/app/features/dashboard/state/PanelQueryRunner.test.ts b/public/app/features/dashboard/state/PanelQueryRunner.test.ts index e4dde963bb24d..1caba8e2cc4a3 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.test.ts +++ b/public/app/features/dashboard/state/PanelQueryRunner.test.ts @@ -1,23 +1,47 @@ -import { PanelQueryRunner } from './PanelQueryRunner'; +import { PanelQueryRunner, QueryRunnerOptions } from './PanelQueryRunner'; import { PanelData, DataQueryRequest, DataStreamObserver, DataStreamState, ScopedVars } from '@grafana/ui'; import { LoadingState, DataFrameHelper } from '@grafana/data'; import { dateTime } from '@grafana/data'; +import { SHARED_DASHBODARD_QUERY } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; +import { DashboardQuery } from 'app/plugins/datasource/dashboard/types'; +import { PanelModel } from './PanelModel'; +import { Subject } from 'rxjs'; jest.mock('app/core/services/backend_srv'); +// Defined within setup functions +const panelsForCurrentDashboardMock: { [key: number]: PanelModel } = {}; +jest.mock('app/features/dashboard/services/DashboardSrv', () => ({ + getDashboardSrv: () => { + return { + getCurrent: () => { + return { + getPanelById: (id: number) => { + return panelsForCurrentDashboardMock[id]; + }, + }; + }, + }; + }, +})); + interface ScenarioContext { setup: (fn: () => void) => void; + + // Options used in setup maxDataPoints?: number | null; widthPixels: number; dsInterval?: string; minInterval?: string; + scopedVars: ScopedVars; + + // Filled in by the Scenario runner events?: PanelData[]; res?: PanelData; queryCalledWith?: DataQueryRequest; observer: DataStreamObserver; runner: PanelQueryRunner; - scopedVars: ScopedVars; } type ScenarioFn = (ctx: ScenarioContext) => void; @@ -31,7 +55,7 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn scopedVars: { server: { text: 'Server1', value: 'server-1' }, }, - runner: new PanelQueryRunner(), + runner: new PanelQueryRunner(1), observer: (args: any) => {}, setup: (fn: () => void) => { setupFn = fn; @@ -39,7 +63,7 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn }; const response: any = { - data: [{ target: 'hello', datapoints: [] }], + data: [{ target: 'hello', datapoints: [[1, 1000], [2, 2000]] }], }; beforeEach(async () => { @@ -67,17 +91,24 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn to: dateTime(), raw: { from: '1h', to: 'now' }, }, - panelId: 0, + panelId: 1, queries: [{ refId: 'A', test: 1 }], }; - ctx.runner = new PanelQueryRunner(); + ctx.runner = new PanelQueryRunner(1); ctx.runner.subscribe({ next: (data: PanelData) => { ctx.events.push(data); }, }); + panelsForCurrentDashboardMock[1] = { + id: 1, + getQueryRunner: () => { + return ctx.runner; + }, + } as PanelModel; + ctx.events = []; ctx.res = await ctx.runner.run(args); }); @@ -201,4 +232,60 @@ describe('PanelQueryRunner', () => { expect(isUnsubbed).toBe(true); }); }); + + describeQueryRunnerScenario('Shared query request', ctx => { + ctx.setup(() => {}); + + it('should get the same results as the original', async () => { + // Get the results from + const q: DashboardQuery = { refId: 'Z', panelId: 1 }; + const myPanelId = 7; + + const runnerWantingSharedResults = new PanelQueryRunner(myPanelId); + panelsForCurrentDashboardMock[myPanelId] = { + id: myPanelId, + getQueryRunner: () => { + return runnerWantingSharedResults; + }, + } as PanelModel; + + const res = await runnerWantingSharedResults.run({ + datasource: SHARED_DASHBODARD_QUERY, + queries: [q], + + // Same query setup + scopedVars: ctx.scopedVars, + minInterval: ctx.minInterval, + widthPixels: ctx.widthPixels, + maxDataPoints: ctx.maxDataPoints, + timeRange: { + from: dateTime().subtract(1, 'days'), + to: dateTime(), + raw: { from: '1h', to: 'now' }, + }, + panelId: myPanelId, // Not 1 + }); + + const req = res.request; + expect(req.panelId).toBe(1); // The source panel + expect(req.targets[0].datasource).toBe('TestDB'); + expect(res.series.length).toBe(1); + expect(res.series[0].length).toBe(2); + + // Get the private subject and check that someone is listening + const subject = (ctx.runner as any).subject as Subject; + expect(subject.observers.length).toBe(2); + + // Now change the query and we should stop listening + try { + runnerWantingSharedResults.run({ + datasource: 'unknown-datasource', + panelId: myPanelId, // Not 1 + } as QueryRunnerOptions); + } catch {} + // runnerWantingSharedResults subject is now unsubscribed + // the test listener is still subscribed + expect(subject.observers.length).toBe(1); + }); + }); }); diff --git a/public/app/features/dashboard/state/PanelQueryRunner.ts b/public/app/features/dashboard/state/PanelQueryRunner.ts index 4f0d019730a40..d0576df727924 100644 --- a/public/app/features/dashboard/state/PanelQueryRunner.ts +++ b/public/app/features/dashboard/state/PanelQueryRunner.ts @@ -8,6 +8,7 @@ import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import kbn from 'app/core/utils/kbn'; import templateSrv from 'app/features/templating/template_srv'; import { PanelQueryState } from './PanelQueryState'; +import { isSharedDashboardQuery, SharedQueryRunner } from 'app/plugins/datasource/dashboard/SharedQueryRunner'; // Types import { PanelData, DataQuery, ScopedVars, DataQueryRequest, DataSourceApi, DataSourceJsonData } from '@grafana/ui'; @@ -49,8 +50,16 @@ export class PanelQueryRunner { private state = new PanelQueryState(); - constructor() { + // Listen to another panel for changes + private sharedQueryRunner: SharedQueryRunner; + + constructor(private panelId: number) { this.state.onStreamingDataUpdated = this.onStreamingDataUpdated; + this.subject = new Subject(); + } + + getPanelId() { + return this.panelId; } /** @@ -58,10 +67,6 @@ export class PanelQueryRunner { * the results will be immediatly passed to the observer */ subscribe(observer: PartialObserver, format = PanelQueryRunnerFormat.frames): Unsubscribable { - if (!this.subject) { - this.subject = new Subject(); // Delay creating a subject until someone is listening - } - if (format === PanelQueryRunnerFormat.legacy) { this.state.sendLegacy = true; } else if (format === PanelQueryRunnerFormat.both) { @@ -79,11 +84,25 @@ export class PanelQueryRunner { return this.subject.subscribe(observer); } - async run(options: QueryRunnerOptions): Promise { - if (!this.subject) { - this.subject = new Subject(); + /** + * Subscribe one runner to another + */ + chain(runner: PanelQueryRunner): Unsubscribable { + const { sendLegacy, sendFrames } = runner.state; + let format = sendFrames ? PanelQueryRunnerFormat.frames : PanelQueryRunnerFormat.legacy; + + if (sendLegacy) { + format = PanelQueryRunnerFormat.both; } + return this.subscribe(runner.subject, format); + } + + getCurrentData(): PanelData { + return this.state.validateStreamsAndGetPanelData(); + } + + async run(options: QueryRunnerOptions): Promise { const { state } = this; const { @@ -102,6 +121,17 @@ export class PanelQueryRunner { delayStateNotification, } = options; + // Support shared queries + if (isSharedDashboardQuery(datasource)) { + if (!this.sharedQueryRunner) { + this.sharedQueryRunner = new SharedQueryRunner(this); + } + return this.sharedQueryRunner.process(options); + } else if (this.sharedQueryRunner) { + this.sharedQueryRunner.disconnect(); + this.sharedQueryRunner = null; + } + const request: DataQueryRequest = { requestId: getNextRequestId(), timezone, diff --git a/public/app/features/dashboard/state/PanelQueryState.test.ts b/public/app/features/dashboard/state/PanelQueryState.test.ts index 24c95ca4cf501..c1dbbbfb66a1f 100644 --- a/public/app/features/dashboard/state/PanelQueryState.test.ts +++ b/public/app/features/dashboard/state/PanelQueryState.test.ts @@ -207,4 +207,19 @@ describe('stream handling', () => { expect(data.series[0].refId).toBe('F'); expect(state.streams.length).toBe(0); // no streams }); + + it('should close streams on error', () => { + // Post a stream event + state.dataStreamObserver({ + state: LoadingState.Error, + key: 'C', + error: { message: 'EEEEE' }, + data: [], + request: state.request, + unsubscribe: () => {}, + }); + + expect(state.streams.length).toBe(0); + expect(state.response.state).toBe(LoadingState.Error); + }); }); diff --git a/public/app/features/dashboard/state/PanelQueryState.ts b/public/app/features/dashboard/state/PanelQueryState.ts index 22fb44dc36156..eafa013c7c876 100644 --- a/public/app/features/dashboard/state/PanelQueryState.ts +++ b/public/app/features/dashboard/state/PanelQueryState.ts @@ -1,10 +1,9 @@ // Libraries import { isArray, isEqual, isString } from 'lodash'; - // Utils & Services import { getBackendSrv } from 'app/core/services/backend_srv'; -import { dateMath } from '@grafana/data'; import { + dateMath, guessFieldTypes, LoadingState, toLegacyResponseData, @@ -12,7 +11,6 @@ import { toDataFrame, isDataFrame, } from '@grafana/data'; - // Types import { DataSourceApi, @@ -161,6 +159,12 @@ export class PanelQueryState { // Streams only work with the 'series' format this.sendFrames = true; + if (stream.state === LoadingState.Error) { + this.setError(stream.error); + this.onStreamingDataUpdated(); + return; + } + // Add the stream to our list let found = false; const active = this.streams.map(s => { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 08d10efa91969..137e13e466486 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -10,7 +10,7 @@ import { AutoSizer } from 'react-virtualized'; import store from 'app/core/store'; // Components -import { Alert } from './Error'; +import { Alert } from '@grafana/ui'; import ErrorBoundary from './ErrorBoundary'; import LogsContainer from './LogsContainer'; import QueryRows from './QueryRows'; @@ -26,10 +26,11 @@ import { refreshExplore, reconnectDatasource, updateTimeRange, + toggleGraph, } from './state/actions'; // Types -import { RawTimeRange, GraphSeriesXY } from '@grafana/data'; +import { RawTimeRange, GraphSeriesXY, LoadingState, TimeZone, AbsoluteTimeRange } from '@grafana/data'; import { DataQuery, ExploreStartPageProps, DataSourceApi, DataQueryError } from '@grafana/ui'; import { @@ -55,7 +56,7 @@ import { FadeIn } from 'app/core/components/Animations/FadeIn'; import { getTimeZone } from '../profile/state/selectors'; import { ErrorContainer } from './ErrorContainer'; import { scanStopAction } from './state/actionTypes'; -import ExploreGraphPanel from './ExploreGraphPanel'; +import { ExploreGraphPanel } from './ExploreGraphPanel'; interface ExploreProps { StartPage?: ComponentClass; @@ -88,6 +89,13 @@ interface ExploreProps { isLive: boolean; updateTimeRange: typeof updateTimeRange; graphResult?: GraphSeriesXY[]; + loading?: boolean; + absoluteRange: AbsoluteTimeRange; + showingGraph?: boolean; + showingTable?: boolean; + timeZone?: TimeZone; + onHiddenSeriesChanged?: (hiddenSeries: string[]) => void; + toggleGraph: typeof toggleGraph; } /** @@ -190,6 +198,16 @@ export class Explore extends React.PureComponent { this.props.scanStopAction({ exploreId: this.props.exploreId }); }; + onToggleGraph = (showingGraph: boolean) => { + const { toggleGraph, exploreId } = this.props; + toggleGraph(exploreId, showingGraph); + }; + + onUpdateTimeRange = (absoluteRange: AbsoluteTimeRange) => { + const { updateTimeRange, exploreId } = this.props; + updateTimeRange({ exploreId, absoluteRange }); + }; + refreshExplore = () => { const { exploreId, update } = this.props; @@ -227,6 +245,11 @@ export class Explore extends React.PureComponent { queryErrors, mode, graphResult, + loading, + absoluteRange, + showingGraph, + showingTable, + timeZone, } = this.props; const exploreClass = split ? 'explore explore-split' : 'explore'; @@ -262,7 +285,21 @@ export class Explore extends React.PureComponent { {!showingStartPage && ( <> {mode === ExploreMode.Metrics && ( - + )} {mode === ExploreMode.Metrics && ( @@ -311,6 +348,10 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { supportedModes, mode, graphResult, + loadingState, + showingGraph, + showingTable, + absoluteRange, } = item; const { datasource, queries, range: urlRange, mode: urlMode, ui } = (urlState || {}) as ExploreUrlState; @@ -335,6 +376,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { } const initialUI = ui || DEFAULT_UI_STATE; + const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming; return { StartPage, @@ -355,6 +397,10 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { queryErrors, isLive, graphResult, + loading, + showingGraph, + showingTable, + absoluteRange, }; } @@ -368,6 +414,7 @@ const mapDispatchToProps = { scanStopAction, setQueries, updateTimeRange, + toggleGraph, }; export default hot(module)( diff --git a/public/app/features/explore/ExploreGraphPanel.tsx b/public/app/features/explore/ExploreGraphPanel.tsx index affd7562fb861..4ff4140e773b9 100644 --- a/public/app/features/explore/ExploreGraphPanel.tsx +++ b/public/app/features/explore/ExploreGraphPanel.tsx @@ -1,30 +1,58 @@ import React, { PureComponent } from 'react'; -import { hot } from 'react-hot-loader'; -import { connect } from 'react-redux'; -import { LegendDisplayMode, GraphWithLegend } from '@grafana/ui'; -import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone, LoadingState } from '@grafana/data'; - -import { GraphSeriesToggler } from 'app/plugins/panel/graph2/GraphSeriesToggler'; -import Panel from './Panel'; -import { StoreState, ExploreId, ExploreMode } from 'app/types'; -import { getTimeZone } from '../profile/state/selectors'; -import { toggleGraph, updateTimeRange } from './state/actions'; +import { css, cx } from 'emotion'; +import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone } from '@grafana/data'; + +import { + GrafanaTheme, + selectThemeVariant, + Themeable, + GraphWithLegend, + LegendDisplayMode, + withTheme, + Collapse, + GraphSeriesToggler, + GraphSeriesTogglerAPI, +} from '@grafana/ui'; const MAX_NUMBER_OF_TIME_SERIES = 20; -interface Props { - exploreId: ExploreId; +const getStyles = (theme: GrafanaTheme) => ({ + timeSeriesDisclaimer: css` + label: time-series-disclaimer; + width: 300px; + margin: ${theme.spacing.sm} auto; + padding: 10px 0; + border-radius: ${theme.border.radius.md}; + text-align: center; + background-color: ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.dark4 }, theme.type)}; + `, + disclaimerIcon: css` + label: disclaimer-icon; + color: ${theme.colors.yellow}; + margin-right: ${theme.spacing.xs}; + `, + showAllTimeSeries: css` + label: show-all-time-series; + cursor: pointer; + color: ${theme.colors.linkExternal}; + `, +}); + +interface Props extends Themeable { series: GraphSeriesXY[]; width: number; - absoluteRange?: AbsoluteTimeRange; - loading?: boolean; - mode?: ExploreMode; - showingGraph?: boolean; - showingTable?: boolean; - timeZone?: TimeZone; + absoluteRange: AbsoluteTimeRange; + loading: boolean; + showPanel: boolean; + showBars: boolean; + showLines: boolean; + isStacked: boolean; + showingGraph: boolean; + showingTable: boolean; + timeZone: TimeZone; + onUpdateTimeRange: (absoluteRange: AbsoluteTimeRange) => void; + onToggleGraph?: (showingGraph: boolean) => void; onHiddenSeriesChanged?: (hiddenSeries: string[]) => void; - toggleGraph: typeof toggleGraph; - updateTimeRange: typeof updateTimeRange; } interface State { @@ -32,7 +60,7 @@ interface State { showAllTimeSeries: boolean; } -export class ExploreGraphPanel extends PureComponent { +class UnThemedExploreGraphPanel extends PureComponent { state: State = { hiddenSeries: [], showAllTimeSeries: false, @@ -45,14 +73,15 @@ export class ExploreGraphPanel extends PureComponent { }; onClickGraphButton = () => { - const { toggleGraph, exploreId, showingGraph } = this.props; - toggleGraph(exploreId, showingGraph); + const { onToggleGraph, showingGraph } = this.props; + if (onToggleGraph) { + onToggleGraph(showingGraph); + } }; onChangeTime = (absoluteRange: AbsoluteTimeRange) => { - const { exploreId, updateTimeRange } = this.props; - - updateTimeRange({ exploreId, absoluteRange }); + const { onUpdateTimeRange } = this.props; + onUpdateTimeRange(absoluteRange); }; renderGraph = () => { @@ -62,9 +91,12 @@ export class ExploreGraphPanel extends PureComponent { onHiddenSeriesChanged, timeZone, absoluteRange, - mode, + showPanel, showingGraph, showingTable, + showBars, + showLines, + isStacked, } = this.props; const { showAllTimeSeries } = this.state; @@ -80,16 +112,13 @@ export class ExploreGraphPanel extends PureComponent { to: dateTimeForTimeZone(timeZone, absoluteRange.to), }, }; - const height = mode === ExploreMode.Logs ? 100 : showingGraph && showingTable ? 200 : 400; - const showBars = mode === ExploreMode.Logs ? true : false; - const showLines = mode === ExploreMode.Metrics ? true : false; - const isStacked = mode === ExploreMode.Logs ? true : false; - const lineWidth = mode === ExploreMode.Metrics ? 1 : 5; + const height = showPanel === false ? 100 : showingGraph && showingTable ? 200 : 400; + const lineWidth = showLines ? 1 : 5; const seriesToShow = showAllTimeSeries ? series : series.slice(0, MAX_NUMBER_OF_TIME_SERIES); return ( - {({ onSeriesToggle, toggledSeries }) => { + {({ onSeriesToggle, toggledSeries }: GraphSeriesTogglerAPI) => { return ( { }; render() { - const { series, mode, showingGraph, loading } = this.props; + const { series, showPanel, showingGraph, loading, theme } = this.props; const { showAllTimeSeries } = this.state; + const style = getStyles(theme); return ( <> {series && series.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && ( -
    - +
    + {`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `} - {`Show all ${ + {`Show all ${ series.length }`}
    )} - {mode === ExploreMode.Metrics && ( - + {showPanel && ( + {this.renderGraph()} - + )} - {mode === ExploreMode.Logs && this.renderGraph()} + {!showPanel && this.renderGraph()} ); } } -function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) { - const explore = state.explore; - // @ts-ignore - const item: ExploreItemState = explore[exploreId]; - const { loadingState, showingGraph, showingTable, absoluteRange, mode } = item; - const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming; - - return { - loading, - showingGraph, - showingTable, - timeZone: getTimeZone(state.user), - absoluteRange, - mode, - }; -} - -const mapDispatchToProps = { - toggleGraph, - updateTimeRange, -}; - -export default hot(module)( - connect( - mapStateToProps, - mapDispatchToProps - )(ExploreGraphPanel) -); +export const ExploreGraphPanel = withTheme(UnThemedExploreGraphPanel); +ExploreGraphPanel.displayName = 'ExploreGraphPanel'; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 2669f203d4cf4..550f1d8bdc534 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; import { ExploreId, ExploreMode } from 'app/types/explore'; -import { DataSourceSelectItem } from '@grafana/ui'; +import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton } from '@grafana/ui'; import { RawTimeRange, TimeZone, TimeRange, LoadingState, SelectableValue } from '@grafana/data'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { StoreState } from 'app/types/store'; @@ -17,7 +17,6 @@ import { changeMode, } from './state/actions'; import { getTimeZone } from '../profile/state/selectors'; -import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup'; import { ExploreTimeControls } from './ExploreTimeControls'; enum IconSide { diff --git a/public/app/features/explore/LiveLogs.tsx b/public/app/features/explore/LiveLogs.tsx index e6184239b5613..190aa75716d11 100644 --- a/public/app/features/explore/LiveLogs.tsx +++ b/public/app/features/explore/LiveLogs.tsx @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import { css, cx } from 'emotion'; -import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton } from '@grafana/ui'; +import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton, getLogRowStyles } from '@grafana/ui'; import { LogsModel, LogRowModel, TimeZone } from '@grafana/data'; @@ -73,6 +73,7 @@ class LiveLogs extends PureComponent { const styles = getStyles(theme); const rowsToRender: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : []; const showUtc = timeZone === 'utc'; + const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme); return ( <> @@ -80,20 +81,20 @@ class LiveLogs extends PureComponent { {rowsToRender.map((row: any, index) => { return (
    {showUtc && ( -
    +
    {row.timeUtc}
    )} {!showUtc && ( -
    +
    {row.timeLocal}
    )} -
    {row.entry}
    +
    {row.entry}
    ); })} diff --git a/public/app/features/explore/LogLabel.tsx b/public/app/features/explore/LogLabel.tsx deleted file mode 100644 index 937a7e3f5bcc7..0000000000000 --- a/public/app/features/explore/LogLabel.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { PureComponent } from 'react'; - -import { LogLabelStats } from './LogLabelStats'; -import { LogRowModel, LogLabelStatsModel } from '@grafana/data'; -import { calculateLogsLabelStats } from 'app/core/logs_model'; - -interface Props { - getRows?: () => LogRowModel[]; - label: string; - plain?: boolean; - value: string; - onClickLabel?: (label: string, value: string) => void; -} - -interface State { - showStats: boolean; - stats: LogLabelStatsModel[]; -} - -export class LogLabel extends PureComponent { - state: State = { - stats: null, - showStats: false, - }; - - onClickClose = () => { - this.setState({ showStats: false }); - }; - - onClickLabel = () => { - const { onClickLabel, label, value } = this.props; - if (onClickLabel) { - onClickLabel(label, value); - } - }; - - onClickStats = () => { - this.setState(state => { - if (state.showStats) { - return { showStats: false, stats: null }; - } - const allRows = this.props.getRows(); - const stats = calculateLogsLabelStats(allRows, this.props.label); - return { showStats: true, stats }; - }); - }; - - render() { - const { getRows, label, plain, value } = this.props; - const { showStats, stats } = this.state; - const tooltip = `${label}: ${value}`; - return ( - - - {value} - - {!plain && ( - - )} - {!plain && getRows && } - {showStats && ( - - - - )} - - ); - } -} diff --git a/public/app/features/explore/LogLabelStats.tsx b/public/app/features/explore/LogLabelStats.tsx deleted file mode 100644 index b2581ca9634a7..0000000000000 --- a/public/app/features/explore/LogLabelStats.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { PureComponent } from 'react'; -import classnames from 'classnames'; -import { LogLabelStatsModel } from '@grafana/data'; - -function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) { - const { active, count, proportion, value } = logLabelStatsModel; - const percent = `${Math.round(proportion * 100)}%`; - const barStyle = { width: percent }; - const className = classnames('logs-stats-row', { 'logs-stats-row--active': active }); - - return ( -
    -
    -
    - {value} -
    -
    {count}
    -
    {percent}
    -
    -
    -
    -
    -
    - ); -} - -const STATS_ROW_LIMIT = 5; - -interface Props { - stats: LogLabelStatsModel[]; - label: string; - value: string; - rowCount: number; - onClickClose: () => void; -} - -export class LogLabelStats extends PureComponent { - render() { - const { label, rowCount, stats, value, onClickClose } = this.props; - const topRows = stats.slice(0, STATS_ROW_LIMIT); - let activeRow = topRows.find(row => row.value === value); - let otherRows = stats.slice(STATS_ROW_LIMIT); - const insertActiveRow = !activeRow; - - // Remove active row from other to show extra - if (insertActiveRow) { - activeRow = otherRows.find(row => row.value === value); - otherRows = otherRows.filter(row => row.value !== value); - } - - const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); - const topCount = topRows.reduce((sum, row) => sum + row.count, 0); - const total = topCount + otherCount; - const otherProportion = otherCount / total; - - return ( -
    -
    - - {label}: {total} of {rowCount} rows have that label - - -
    -
    - {topRows.map(stat => ( - - ))} - {insertActiveRow && activeRow && } - {otherCount > 0 && ( - - )} -
    -
    - ); - } -} diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx deleted file mode 100644 index 1bac6f035a945..0000000000000 --- a/public/app/features/explore/LogLabels.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { PureComponent } from 'react'; - -import { LogLabel } from './LogLabel'; -import { Labels, LogRowModel } from '@grafana/data'; - -interface Props { - getRows?: () => LogRowModel[]; - labels: Labels; - plain?: boolean; - onClickLabel?: (label: string, value: string) => void; -} - -export class LogLabels extends PureComponent { - render() { - const { getRows, labels, onClickLabel, plain } = this.props; - return ( - - {Object.keys(labels).map(key => ( - - ))} - - ); - } -} diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index b491be83882f7..db5088b35061d 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -1,9 +1,8 @@ import _ from 'lodash'; import React, { PureComponent } from 'react'; -import { rangeUtil } from '@grafana/data'; -import { Switch } from '@grafana/ui'; import { + rangeUtil, RawTimeRange, LogLevel, TimeZone, @@ -12,23 +11,17 @@ import { LogsModel, LogsDedupStrategy, LogRowModel, + LogsDedupDescription, } from '@grafana/data'; +import { Switch, LogLabels, ToggleButtonGroup, ToggleButton, LogRows } from '@grafana/ui'; -import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup'; - -import { LogLabels } from './LogLabels'; -import { LogRow } from './LogRow'; -import { LogsDedupDescription } from 'app/core/logs_model'; -import ExploreGraphPanel from './ExploreGraphPanel'; -import { ExploreId } from 'app/types'; - -const PREVIEW_LIMIT = 100; +import { ExploreGraphPanel } from './ExploreGraphPanel'; function renderMetaItem(value: any, kind: LogsMetaKind) { if (kind === LogsMetaKind.LabelsMap) { return ( - + []} /> ); } @@ -39,7 +32,6 @@ interface Props { data?: LogsModel; dedupedData?: LogsModel; width: number; - exploreId: ExploreId; highlighterExpressions: string[]; loading: boolean; absoluteRange: AbsoluteTimeRange; @@ -48,7 +40,7 @@ interface Props { scanRange?: RawTimeRange; dedupStrategy: LogsDedupStrategy; hiddenLogLevels: Set; - onChangeTime?: (range: AbsoluteTimeRange) => void; + onChangeTime: (range: AbsoluteTimeRange) => void; onClickLabel?: (label: string, value: string) => void; onStartScanning?: () => void; onStopScanning?: () => void; @@ -58,46 +50,16 @@ interface Props { } interface State { - deferLogs: boolean; - renderAll: boolean; showLabels: boolean; showTime: boolean; } -export default class Logs extends PureComponent { - deferLogsTimer: NodeJS.Timer; - renderAllTimer: NodeJS.Timer; - +export class Logs extends PureComponent { state = { - deferLogs: true, - renderAll: false, showLabels: false, showTime: true, }; - componentDidMount() { - // Staged rendering - if (this.state.deferLogs) { - const { data } = this.props; - const rowCount = data && data.rows ? data.rows.length : 0; - // Render all right away if not too far over the limit - const renderAll = rowCount <= PREVIEW_LIMIT * 2; - this.deferLogsTimer = setTimeout(() => this.setState({ deferLogs: false, renderAll }), rowCount); - } - } - - componentDidUpdate(prevProps: Props, prevState: State) { - // Staged rendering - if (prevState.deferLogs && !this.state.deferLogs && !this.state.renderAll) { - this.renderAllTimer = setTimeout(() => this.setState({ renderAll: true }), 2000); - } - } - - componentWillUnmount() { - clearTimeout(this.deferLogsTimer); - clearTimeout(this.renderAllTimer); - } - onChangeDedup = (dedup: LogsDedupStrategy) => { const { onDedupStrategyChange } = this.props; if (this.props.dedupStrategy === dedup) { @@ -106,39 +68,46 @@ export default class Logs extends PureComponent { return onDedupStrategyChange(dedup); }; - onChangeLabels = (event: React.SyntheticEvent) => { - const target = event.target as HTMLInputElement; - this.setState({ - showLabels: target.checked, - }); + onChangeLabels = (event?: React.SyntheticEvent) => { + const target = event && (event.target as HTMLInputElement); + if (target) { + this.setState({ + showLabels: target.checked, + }); + } }; - onChangeTime = (event: React.SyntheticEvent) => { - const target = event.target as HTMLInputElement; - this.setState({ - showTime: target.checked, - }); + onChangeTime = (event?: React.SyntheticEvent) => { + const target = event && (event.target as HTMLInputElement); + if (target) { + this.setState({ + showTime: target.checked, + }); + } }; onToggleLogLevel = (hiddenRawLevels: string[]) => { - const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map((level: LogLevel) => LogLevel[level]); + const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map(level => LogLevel[level as LogLevel]); this.props.onToggleLogLevel(hiddenLogLevels); }; onClickScan = (event: React.SyntheticEvent) => { event.preventDefault(); - this.props.onStartScanning(); + if (this.props.onStartScanning) { + this.props.onStartScanning(); + } }; onClickStopScan = (event: React.SyntheticEvent) => { event.preventDefault(); - this.props.onStopScanning(); + if (this.props.onStopScanning) { + this.props.onStopScanning(); + } }; render() { const { data, - exploreId, highlighterExpressions, loading = false, onClickLabel, @@ -147,19 +116,21 @@ export default class Logs extends PureComponent { scanRange, width, dedupedData, + absoluteRange, + onChangeTime, } = this.props; if (!data) { return null; } - const { deferLogs, renderAll, showLabels, showTime } = this.state; + const { showLabels, showTime } = this.state; const { dedupStrategy } = this.props; const hasData = data && data.rows && data.rows.length > 0; - const hasLabel = hasData && dedupedData.hasUniqueLabels; - const dedupCount = dedupedData.rows.reduce((sum, row) => sum + row.duplicates, 0); - const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0; - const meta = data.meta ? [...data.meta] : []; + const dedupCount = dedupedData + ? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0) + : 0; + const meta = data && data.meta ? [...data.meta] : []; if (dedupStrategy !== LogsDedupStrategy.none) { meta.push({ @@ -169,23 +140,26 @@ export default class Logs extends PureComponent { }); } - // Staged rendering - const processedRows = dedupedData.rows; - const firstRows = processedRows.slice(0, PREVIEW_LIMIT); - const lastRows = processedRows.slice(PREVIEW_LIMIT); const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; - - // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead - const getRows = () => processedRows; + const series = data && data.series ? data.series : []; return (
    @@ -220,41 +194,19 @@ export default class Logs extends PureComponent {
    )} -
    - {hasData && - !deferLogs && // Only inject highlighterExpression in the first set for performance reasons - firstRows.map((row, index) => ( - - ))} - {hasData && - !deferLogs && - renderAll && - lastRows.map((row, index) => ( - - ))} - {hasData && deferLogs && Rendering {dedupedData.rows.length} rows...} -
    + + {!loading && !hasData && !scanning && (
    No logs found. diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 3bb2e1373cf04..e0f16575530d9 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { DataSourceApi } from '@grafana/ui'; +import { DataSourceApi, Collapse } from '@grafana/ui'; import { RawTimeRange, @@ -19,13 +19,12 @@ import { ExploreId, ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types'; import { changeDedupStrategy, updateTimeRange } from './state/actions'; -import Logs from './Logs'; -import Panel from './Panel'; import { toggleLogLevelAction, changeRefreshIntervalAction } from 'app/features/explore/state/actionTypes'; import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors'; import { getTimeZone } from '../profile/state/selectors'; import { LiveLogsWithTheme } from './LiveLogs'; import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; +import { Logs } from './Logs'; interface LogsContainerProps { datasourceInstance: DataSourceApi | null; @@ -89,7 +88,6 @@ export class LogsContainer extends PureComponent { render() { const { - exploreId, loading, logsHighlighterExpressions, logsResult, @@ -108,19 +106,18 @@ export class LogsContainer extends PureComponent { if (isLive) { return ( - + - + ); } return ( - + { hiddenLogLevels={hiddenLogLevels} getRowContext={this.getLogRowContext} /> - + ); } } diff --git a/public/app/features/explore/NoDataSourceCallToAction.tsx b/public/app/features/explore/NoDataSourceCallToAction.tsx index 347adc9c62602..b20fe9e69cb53 100644 --- a/public/app/features/explore/NoDataSourceCallToAction.tsx +++ b/public/app/features/explore/NoDataSourceCallToAction.tsx @@ -29,6 +29,8 @@ export const NoDataSourceCallToAction = () => { const cardClassName = css` max-width: ${theme.breakpoints.lg}; + margin-top: ${theme.spacing.md}; + align-self: center; `; return ( diff --git a/public/app/features/explore/Panel.tsx b/public/app/features/explore/Panel.tsx deleted file mode 100644 index 841a19dba2a63..0000000000000 --- a/public/app/features/explore/Panel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { PureComponent } from 'react'; - -interface Props { - isOpen: boolean; - label: string; - loading?: boolean; - collapsible?: boolean; - onToggle?: (isOpen: boolean) => void; -} - -export default class Panel extends PureComponent { - onClickToggle = () => { - const { onToggle, isOpen } = this.props; - if (onToggle) { - onToggle(!isOpen); - } - }; - - render() { - const { isOpen, loading, collapsible } = this.props; - const panelClass = collapsible - ? 'explore-panel explore-panel--collapsible panel-container' - : 'explore-panel panel-container'; - const iconClass = isOpen ? 'fa fa-caret-up' : 'fa fa-caret-down'; - const loaderClass = loading ? 'explore-panel__loader explore-panel__loader--active' : 'explore-panel__loader'; - return ( -
    -
    -
    - -
    -
    {this.props.label}
    -
    - {isOpen && ( -
    -
    - {this.props.children} -
    - )} -
    - ); - } -} diff --git a/public/app/features/explore/QueryEditor.tsx b/public/app/features/explore/QueryEditor.tsx index 2f68940917953..de8706b0a216d 100644 --- a/public/app/features/explore/QueryEditor.tsx +++ b/public/app/features/explore/QueryEditor.tsx @@ -73,7 +73,7 @@ export default class QueryEditor extends PureComponent { const hasNewError = prevProps.error !== this.props.error; if (this.component) { - if (hasToggledEditorMode) { + if (hasToggledEditorMode && this.angularScope && this.angularScope.toggleEditorMode) { this.angularScope.toggleEditorMode(); } diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index 28fff3379480e..fd3d4fdb09f75 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -8,6 +8,7 @@ import { Editor } from 'slate-react'; // @ts-ignore import Plain from 'slate-plain-serializer'; import classnames from 'classnames'; +// @ts-ignore import { isKeyHotkey } from 'is-hotkey'; import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx index 2927e4ea21b90..937e830a768c5 100644 --- a/public/app/features/explore/TableContainer.tsx +++ b/public/app/features/explore/TableContainer.tsx @@ -1,15 +1,15 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; +import { LoadingState } from '@grafana/data'; +import { Collapse } from '@grafana/ui'; import { ExploreId, ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types'; import { toggleTable } from './state/actions'; import Table from './Table'; -import Panel from './Panel'; import TableModel from 'app/core/table_model'; -import { LoadingState } from '@grafana/data'; interface TableContainerProps { exploreId: ExploreId; @@ -29,9 +29,9 @@ export class TableContainer extends PureComponent { const { loading, onClickCell, showingTable, tableResult } = this.props; return ( - + {tableResult && } - + ); } } diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index 44c441f382fa6..5698b673c2875 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -1,25 +1,9 @@ // Types import { Emitter } from 'app/core/core'; -import { - DataQuery, - DataSourceSelectItem, - DataSourceApi, - QueryFixAction, - DataQueryError, - DataQueryResponseData, -} from '@grafana/ui'; - -import { - RawTimeRange, - LogLevel, - TimeRange, - DataFrame, - LogsModel, - LoadingState, - AbsoluteTimeRange, - GraphSeriesXY, -} from '@grafana/data'; -import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode, QueryOptions } from 'app/types/explore'; +import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, DataQueryError } from '@grafana/ui'; + +import { LogLevel, TimeRange, LogsModel, LoadingState, AbsoluteTimeRange, GraphSeriesXY } from '@grafana/data'; +import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode } from 'app/types/explore'; import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory'; import TableModel from 'app/core/table_model'; @@ -230,42 +214,15 @@ export interface SetUrlReplacedPayload { exploreId: ExploreId; } -export interface ProcessQueryErrorsPayload { - exploreId: ExploreId; - response: any; - datasourceId: string; -} - -export interface ProcessQueryResultsPayload { - exploreId: ExploreId; - latency: number; - datasourceId: string; - loadingState: LoadingState; - series?: DataQueryResponseData[]; - delta?: DataFrame[]; -} - -export interface RunQueriesBatchPayload { - exploreId: ExploreId; - queryOptions: QueryOptions; -} - -export interface LimitMessageRatePayload { - series: DataFrame[]; - exploreId: ExploreId; - datasourceId: string; -} - export interface ChangeRangePayload { exploreId: ExploreId; range: TimeRange; absoluteRange: AbsoluteTimeRange; } -export interface UpdateTimeRangePayload { +export interface ChangeLoadingStatePayload { exploreId: ExploreId; - rawRange?: RawTimeRange; - absoluteRange?: AbsoluteTimeRange; + loadingState: LoadingState; } /** @@ -410,8 +367,6 @@ export const splitCloseAction = actionCreatorFactory('e */ export const splitOpenAction = actionCreatorFactory('explore/SPLIT_OPEN').create(); -export const stateSaveAction = noPayloadActionCreatorFactory('explore/STATE_SAVE').create(); - /** * Update state of Explores UI elements (panels visiblity and deduplication strategy) */ @@ -460,23 +415,11 @@ export const resetQueryErrorAction = actionCreatorFactory('explore/SET_URL_REPLACED').create(); -export const processQueryErrorsAction = actionCreatorFactory( - 'explore/PROCESS_QUERY_ERRORS' -).create(); - -export const processQueryResultsAction = actionCreatorFactory( - 'explore/PROCESS_QUERY_RESULTS' -).create(); - -export const runQueriesBatchAction = actionCreatorFactory('explore/RUN_QUERIES_BATCH').create(); - -export const limitMessageRatePayloadAction = actionCreatorFactory( - 'explore/LIMIT_MESSAGE_RATE_PAYLOAD' -).create(); - export const changeRangeAction = actionCreatorFactory('explore/CHANGE_RANGE').create(); -export const updateTimeRangeAction = actionCreatorFactory('explore/UPDATE_TIMERANGE').create(); +export const changeLoadingStateAction = actionCreatorFactory( + 'changeLoadingStateAction' +).create(); export type HigherOrderAction = | ActionOf diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts index 38e0f5f482211..6cd3e03379e35 100644 --- a/public/app/features/explore/state/actions.test.ts +++ b/public/app/features/explore/state/actions.test.ts @@ -11,14 +11,12 @@ import { testDataSourceFailureAction, loadDatasourcePendingAction, loadDatasourceReadyAction, - updateTimeRangeAction, } from './actionTypes'; import { Emitter } from 'app/core/core'; import { ActionOf } from 'app/core/redux/actionCreatorFactory'; import { makeInitialUpdateState } from './reducers'; import { DataQuery } from '@grafana/ui/src/types/datasource'; -import { DefaultTimeZone, RawTimeRange, LogsDedupStrategy } from '@grafana/data'; -import { toUtc } from '@grafana/data'; +import { DefaultTimeZone, RawTimeRange, LogsDedupStrategy, toUtc } from '@grafana/data'; jest.mock('app/features/plugins/datasource_srv', () => ({ getDatasourceSrv: () => ({ @@ -30,6 +28,12 @@ jest.mock('app/features/plugins/datasource_srv', () => ({ }), })); +jest.mock('../../dashboard/services/TimeSrv', () => ({ + getTimeSrv: jest.fn().mockReturnValue({ + init: jest.fn(), + }), +})); + const t = toUtc(); const testRange = { from: t, @@ -62,6 +66,7 @@ const setup = (updateOverides?: Partial) => { const update = { ...updateDefaults, ...updateOverides }; const initialState = { user: { + orgId: '1', timeZone, }, explore: { @@ -118,19 +123,6 @@ describe('refreshExplore', () => { }); }); - describe('and update range is set', () => { - it('then it should dispatch updateTimeRangeAction', async () => { - const { exploreId, range, initialState } = setup({ range: true }); - - const dispatchedActions = await thunkTester(initialState) - .givenThunk(refreshExplore) - .whenThunkIsDispatched(exploreId); - - expect(dispatchedActions[0].type).toEqual(updateTimeRangeAction.type); - expect(dispatchedActions[0].payload).toEqual({ exploreId, rawRange: range.raw }); - }); - }); - describe('and update ui is set', () => { it('then it should dispatch updateUIStateAction', async () => { const { exploreId, initialState, ui } = setup({ ui: true }); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index c6d3ac6c0792e..29c8b16a71bbb 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -1,6 +1,4 @@ // Libraries -import _ from 'lodash'; - // Services & Utils import store from 'app/core/store'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -13,13 +11,36 @@ import { getTimeRangeFromUrl, generateNewKeyAndAddRefIdIfMissing, lastUsedDatasourceKeyForOrgId, + hasNonEmptyQuery, + buildQueryTransaction, + updateHistory, + getRefIds, + instanceOfDataQueryError, + clearQueryKeys, + serializeStateToUrlParam, + stopQueryState, } from 'app/core/utils/explore'; - // Types -import { ThunkResult } from 'app/types'; -import { DataSourceApi, DataQuery, DataSourceSelectItem, QueryFixAction } from '@grafana/ui'; +import { ThunkResult, ExploreUrlState } from 'app/types'; +import { + DataSourceApi, + DataQuery, + DataSourceSelectItem, + QueryFixAction, + PanelData, + DataQueryResponseData, +} from '@grafana/ui'; -import { RawTimeRange, LogsDedupStrategy, AbsoluteTimeRange } from '@grafana/data'; +import { + RawTimeRange, + LogsDedupStrategy, + AbsoluteTimeRange, + LoadingState, + DataFrame, + TimeRange, + isDateTime, + dateTimeForTimeZone, +} from '@grafana/data'; import { ExploreId, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore'; import { updateDatasourceInstanceAction, @@ -52,14 +73,24 @@ import { loadExploreDatasources, changeModeAction, scanStopAction, - runQueriesAction, - stateSaveAction, - updateTimeRangeAction, + changeLoadingStateAction, + historyUpdatedAction, + queryStartAction, + resetQueryErrorAction, + querySuccessAction, + queryFailureAction, + setUrlReplacedAction, + changeRangeAction, } from './actionTypes'; import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; import { getShiftedTimeRange } from 'app/core/utils/timePicker'; +import { ResultProcessor } from '../utils/ResultProcessor'; +import _ from 'lodash'; +import { toDataQueryError } from '../../dashboard/state/PanelQueryState'; +import { updateLocation } from '../../../core/actions'; +import { getTimeSrv } from '../../dashboard/services/TimeSrv'; /** * Updates UI state and save it to the URL @@ -67,7 +98,7 @@ import { getShiftedTimeRange } from 'app/core/utils/timePicker'; const updateExploreUIState = (exploreId: ExploreId, uiStateFragment: Partial): ThunkResult => { return dispatch => { dispatch(updateUIStateAction({ exploreId, ...uiStateFragment })); - dispatch(stateSaveAction()); + dispatch(stateSave()); }; }; @@ -165,7 +196,7 @@ export const updateTimeRange = (options: { absoluteRange?: AbsoluteTimeRange; }): ThunkResult => { return dispatch => { - dispatch(updateTimeRangeAction({ ...options })); + dispatch(updateTime({ ...options })); dispatch(runQueries(options.exploreId)); }; }; @@ -187,7 +218,7 @@ export function clearQueries(exploreId: ExploreId): ThunkResult { return dispatch => { dispatch(scanStopAction({ exploreId })); dispatch(clearQueriesAction({ exploreId })); - dispatch(stateSaveAction()); + dispatch(stateSave()); }; } @@ -250,7 +281,7 @@ export function initializeExplore( ui, }) ); - dispatch(updateTimeRangeAction({ exploreId })); + dispatch(updateTime({ exploreId })); }; } @@ -401,11 +432,302 @@ export function modifyQueries( */ export function runQueries(exploreId: ExploreId): ThunkResult { return (dispatch, getState) => { - dispatch(updateTimeRangeAction({ exploreId })); - dispatch(runQueriesAction({ exploreId })); + dispatch(updateTime({ exploreId })); + + const exploreItemState = getState().explore[exploreId]; + const { + datasourceInstance, + queries, + datasourceError, + containerWidth, + isLive: live, + queryState, + queryIntervals, + range, + scanning, + history, + } = exploreItemState; + + if (datasourceError) { + // let's not run any queries if data source is in a faulty state + return; + } + + if (!hasNonEmptyQuery(queries)) { + dispatch(clearQueriesAction({ exploreId })); + dispatch(stateSave()); // Remember to save to state and update location + return; + } + + // Some datasource's query builders allow per-query interval limits, + // but we're using the datasource interval limit for now + const interval = datasourceInstance.interval; + + stopQueryState(queryState, 'New request issued'); + + queryState.sendFrames = true; + queryState.sendLegacy = true; // temporary hack until we switch to PanelData + + const queryOptions = { interval, maxDataPoints: containerWidth, live }; + const datasourceId = datasourceInstance.meta.id; + const now = Date.now(); + const transaction = buildQueryTransaction(queries, queryOptions, range, queryIntervals, scanning); + + // temporary hack until we switch to PanelData, Loki already converts to DataFrame so using legacy will destroy the format + const isLokiDataSource = datasourceInstance.meta.name === 'Loki'; + + queryState.onStreamingDataUpdated = () => { + const data = queryState.validateStreamsAndGetPanelData(); + const { state, error, legacy, series } = data; + if (!data && !error && !legacy && !series) { + return; + } + + if (state === LoadingState.Error) { + dispatch(processErrorResults({ exploreId, response: error, datasourceId })); + return; + } + + if (state === LoadingState.Streaming) { + dispatch(limitMessageRate(exploreId, isLokiDataSource ? series : legacy, datasourceId)); + return; + } + + if (state === LoadingState.Done) { + dispatch(changeLoadingStateAction({ exploreId, loadingState: state })); + } + }; + + dispatch(queryStartAction({ exploreId })); + + queryState + .execute(datasourceInstance, transaction.options) + .then((response: PanelData) => { + const { legacy, error, series } = response; + if (error) { + dispatch(processErrorResults({ exploreId, response: error, datasourceId })); + return; + } + + const latency = Date.now() - now; + // Side-effect: Saving history in localstorage + const nextHistory = updateHistory(history, datasourceId, queries); + dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); + dispatch( + processQueryResults({ + exploreId, + latency, + datasourceId, + loadingState: LoadingState.Done, + series: isLokiDataSource ? series : legacy, + }) + ); + dispatch(stateSave()); + }) + .catch(error => { + dispatch(processErrorResults({ exploreId, response: error, datasourceId })); + }); }; } +export const limitMessageRate = ( + exploreId: ExploreId, + series: DataFrame[] | any[], + datasourceId: string +): ThunkResult => { + return (dispatch, getState) => { + dispatch( + processQueryResults({ + exploreId, + latency: 0, + datasourceId, + loadingState: LoadingState.Streaming, + series, + }) + ); + }; +}; + +export const processQueryResults = (config: { + exploreId: ExploreId; + latency: number; + datasourceId: string; + loadingState: LoadingState; + series?: DataQueryResponseData[]; +}): ThunkResult => { + return (dispatch, getState) => { + const { exploreId, datasourceId, latency, loadingState, series } = config; + const { datasourceInstance, scanning, eventBridge } = getState().explore[exploreId]; + + // If datasource already changed, results do not matter + if (datasourceInstance.meta.id !== datasourceId) { + return; + } + + const result = series || []; + const replacePreviousResults = loadingState === LoadingState.Done && series ? true : false; + const resultProcessor = new ResultProcessor(getState().explore[exploreId], replacePreviousResults, result); + const graphResult = resultProcessor.getGraphResult(); + const tableResult = resultProcessor.getTableResult(); + const logsResult = resultProcessor.getLogsResult(); + const refIds = getRefIds(result); + + // For Angular editors + eventBridge.emit('data-received', resultProcessor.getRawData()); + + // Clears any previous errors that now have a successful query, important so Angular editors are updated correctly + dispatch(resetQueryErrorAction({ exploreId, refIds })); + + dispatch( + querySuccessAction({ + exploreId, + latency, + loadingState, + graphResult, + tableResult, + logsResult, + }) + ); + + // Keep scanning for results if this was the last scanning transaction + if (scanning) { + if (_.size(result) === 0) { + const range = getShiftedTimeRange(-1, getState().explore[exploreId].range); + dispatch(updateTime({ exploreId, absoluteRange: range })); + dispatch(runQueries(exploreId)); + } else { + // We can stop scanning if we have a result + dispatch(scanStopAction({ exploreId })); + } + } + }; +}; + +export const processErrorResults = (config: { + exploreId: ExploreId; + response: any; + datasourceId: string; +}): ThunkResult => { + return (dispatch, getState) => { + const { exploreId, datasourceId } = config; + let { response } = config; + const { datasourceInstance, eventBridge } = getState().explore[exploreId]; + + if (datasourceInstance.meta.id !== datasourceId || response.cancelled) { + // Navigated away, queries did not matter + return; + } + + // For Angular editors + eventBridge.emit('data-error', response); + + console.error(response); // To help finding problems with query syntax + + if (!instanceOfDataQueryError(response)) { + response = toDataQueryError(response); + } + + dispatch(queryFailureAction({ exploreId, response })); + }; +}; + +const toRawTimeRange = (range: TimeRange): RawTimeRange => { + let from = range.raw.from; + if (isDateTime(from)) { + from = from.valueOf().toString(10); + } + + let to = range.raw.to; + if (isDateTime(to)) { + to = to.valueOf().toString(10); + } + + return { + from, + to, + }; +}; + +export const stateSave = (): ThunkResult => { + return (dispatch, getState) => { + const { left, right, split } = getState().explore; + const orgId = getState().user.orgId.toString(); + const replace = left && left.urlReplaced === false; + const urlStates: { [index: string]: string } = { orgId }; + const leftUrlState: ExploreUrlState = { + datasource: left.datasourceInstance.name, + queries: left.queries.map(clearQueryKeys), + range: toRawTimeRange(left.range), + mode: left.mode, + ui: { + showingGraph: left.showingGraph, + showingLogs: true, + showingTable: left.showingTable, + dedupStrategy: left.dedupStrategy, + }, + }; + urlStates.left = serializeStateToUrlParam(leftUrlState, true); + if (split) { + const rightUrlState: ExploreUrlState = { + datasource: right.datasourceInstance.name, + queries: right.queries.map(clearQueryKeys), + range: toRawTimeRange(right.range), + mode: right.mode, + ui: { + showingGraph: right.showingGraph, + showingLogs: true, + showingTable: right.showingTable, + dedupStrategy: right.dedupStrategy, + }, + }; + + urlStates.right = serializeStateToUrlParam(rightUrlState, true); + } + + dispatch(updateLocation({ query: urlStates, replace })); + if (replace) { + dispatch(setUrlReplacedAction({ exploreId: ExploreId.left })); + } + }; +}; + +export const updateTime = (config: { + exploreId: ExploreId; + rawRange?: RawTimeRange; + absoluteRange?: AbsoluteTimeRange; +}): ThunkResult => { + return (dispatch, getState) => { + const { exploreId, absoluteRange: absRange, rawRange: actionRange } = config; + const itemState = getState().explore[exploreId]; + const timeZone = getTimeZone(getState().user); + const { range: rangeInState } = itemState; + let rawRange: RawTimeRange = rangeInState.raw; + + if (absRange) { + rawRange = { + from: dateTimeForTimeZone(timeZone, absRange.from), + to: dateTimeForTimeZone(timeZone, absRange.to), + }; + } + + if (actionRange) { + rawRange = actionRange; + } + + const range = getTimeRange(timeZone, rawRange); + const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; + + getTimeSrv().init({ + time: range.raw, + refresh: false, + getTimezone: () => timeZone, + timeRangeUpdated: (): any => undefined, + }); + + dispatch(changeRangeAction({ exploreId, range, absoluteRange })); + }; +}; + /** * Start a scan for more results using the given scanner. * @param exploreId Explore area @@ -418,8 +740,8 @@ export function scanStart(exploreId: ExploreId): ThunkResult { // Scanning must trigger query run, and return the new range const range = getShiftedTimeRange(-1, getState().explore[exploreId].range); // Set the new range to be displayed - dispatch(updateTimeRangeAction({ exploreId, absoluteRange: range })); - dispatch(runQueriesAction({ exploreId })); + dispatch(updateTime({ exploreId, absoluteRange: range })); + dispatch(runQueries(exploreId)); }; } @@ -443,7 +765,7 @@ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): Thunk export function splitClose(itemId: ExploreId): ThunkResult { return dispatch => { dispatch(splitCloseAction({ itemId })); - dispatch(stateSaveAction()); + dispatch(stateSave()); }; } @@ -467,7 +789,7 @@ export function splitOpen(): ThunkResult { urlState, }; dispatch(splitOpenAction({ itemState })); - dispatch(stateSaveAction()); + dispatch(stateSave()); }; } @@ -544,7 +866,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult { } if (update.range) { - dispatch(updateTimeRangeAction({ exploreId, rawRange: range.raw })); + dispatch(updateTime({ exploreId, rawRange: range.raw })); } // need to refresh ui state diff --git a/public/app/features/explore/state/epics/limitMessageRateEpic.ts b/public/app/features/explore/state/epics/limitMessageRateEpic.ts deleted file mode 100644 index a2eb256e3127a..0000000000000 --- a/public/app/features/explore/state/epics/limitMessageRateEpic.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Epic } from 'redux-observable'; -import { map, throttleTime } from 'rxjs/operators'; -import { LoadingState } from '@grafana/data'; - -import { StoreState } from 'app/types'; -import { ActionOf } from '../../../../core/redux/actionCreatorFactory'; -import { limitMessageRatePayloadAction, LimitMessageRatePayload, processQueryResultsAction } from '../actionTypes'; -import { EpicDependencies } from 'app/store/configureStore'; - -export const limitMessageRateEpic: Epic, ActionOf, StoreState, EpicDependencies> = action$ => { - return action$.ofType(limitMessageRatePayloadAction.type).pipe( - throttleTime(1), - map((action: ActionOf) => { - const { exploreId, series, datasourceId } = action.payload; - return processQueryResultsAction({ - exploreId, - latency: 0, - datasourceId, - loadingState: LoadingState.Streaming, - series: null, - delta: series, - }); - }) - ); -}; diff --git a/public/app/features/explore/state/epics/processQueryErrorsEpic.test.ts b/public/app/features/explore/state/epics/processQueryErrorsEpic.test.ts deleted file mode 100644 index 7cdaca78f7d0f..0000000000000 --- a/public/app/features/explore/state/epics/processQueryErrorsEpic.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { mockExploreState } from 'test/mocks/mockExploreState'; -import { epicTester } from 'test/core/redux/epicTester'; -import { processQueryErrorsAction, queryFailureAction } from '../actionTypes'; -import { processQueryErrorsEpic } from './processQueryErrorsEpic'; - -describe('processQueryErrorsEpic', () => { - let originalConsoleError = console.error; - - beforeEach(() => { - originalConsoleError = console.error; - console.error = jest.fn(); - }); - - afterEach(() => { - console.error = originalConsoleError; - }); - - describe('when processQueryErrorsAction is dispatched', () => { - describe('and datasourceInstance is the same', () => { - describe('and the response is not cancelled', () => { - it('then queryFailureAction is dispatched', () => { - const { datasourceId, exploreId, state, eventBridge } = mockExploreState(); - const response = { message: 'Something went terribly wrong!' }; - - epicTester(processQueryErrorsEpic, state) - .whenActionIsDispatched(processQueryErrorsAction({ exploreId, datasourceId, response })) - .thenResultingActionsEqual(queryFailureAction({ exploreId, response })); - - expect(console.error).toBeCalledTimes(1); - expect(console.error).toBeCalledWith(response); - expect(eventBridge.emit).toBeCalledTimes(1); - expect(eventBridge.emit).toBeCalledWith('data-error', response); - }); - }); - - describe('and the response is cancelled', () => { - it('then no actions are dispatched', () => { - const { datasourceId, exploreId, state, eventBridge } = mockExploreState(); - const response = { cancelled: true, message: 'Something went terribly wrong!' }; - - epicTester(processQueryErrorsEpic, state) - .whenActionIsDispatched(processQueryErrorsAction({ exploreId, datasourceId, response })) - .thenNoActionsWhereDispatched(); - - expect(console.error).not.toBeCalled(); - expect(eventBridge.emit).not.toBeCalled(); - }); - }); - }); - - describe('and datasourceInstance is not the same', () => { - describe('and the response is not cancelled', () => { - it('then no actions are dispatched', () => { - const { exploreId, state, eventBridge } = mockExploreState(); - const response = { message: 'Something went terribly wrong!' }; - - epicTester(processQueryErrorsEpic, state) - .whenActionIsDispatched(processQueryErrorsAction({ exploreId, datasourceId: 'other id', response })) - .thenNoActionsWhereDispatched(); - - expect(console.error).not.toBeCalled(); - expect(eventBridge.emit).not.toBeCalled(); - }); - }); - }); - }); -}); diff --git a/public/app/features/explore/state/epics/processQueryErrorsEpic.ts b/public/app/features/explore/state/epics/processQueryErrorsEpic.ts deleted file mode 100644 index ea029186dc89e..0000000000000 --- a/public/app/features/explore/state/epics/processQueryErrorsEpic.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Epic } from 'redux-observable'; -import { mergeMap } from 'rxjs/operators'; -import { NEVER, of } from 'rxjs'; - -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { StoreState } from 'app/types/store'; -import { instanceOfDataQueryError } from 'app/core/utils/explore'; -import { toDataQueryError } from 'app/features/dashboard/state/PanelQueryState'; -import { processQueryErrorsAction, ProcessQueryErrorsPayload, queryFailureAction } from '../actionTypes'; - -export const processQueryErrorsEpic: Epic, ActionOf, StoreState> = (action$, state$) => { - return action$.ofType(processQueryErrorsAction.type).pipe( - mergeMap((action: ActionOf) => { - const { exploreId, datasourceId } = action.payload; - let { response } = action.payload; - const { datasourceInstance, eventBridge } = state$.value.explore[exploreId]; - - if (datasourceInstance.meta.id !== datasourceId || response.cancelled) { - // Navigated away, queries did not matter - return NEVER; - } - - // For Angular editors - eventBridge.emit('data-error', response); - - console.error(response); // To help finding problems with query syntax - - if (!instanceOfDataQueryError(response)) { - response = toDataQueryError(response); - } - - return of( - queryFailureAction({ - exploreId, - response, - }) - ); - }) - ); -}; diff --git a/public/app/features/explore/state/epics/processQueryResultsEpic.test.ts b/public/app/features/explore/state/epics/processQueryResultsEpic.test.ts deleted file mode 100644 index fabf426cd3148..0000000000000 --- a/public/app/features/explore/state/epics/processQueryResultsEpic.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { mockExploreState } from 'test/mocks/mockExploreState'; -import { epicTester, MOCKED_ABSOLUTE_RANGE } from 'test/core/redux/epicTester'; -import { - processQueryResultsAction, - resetQueryErrorAction, - querySuccessAction, - scanStopAction, - updateTimeRangeAction, - runQueriesAction, -} from '../actionTypes'; -import { DataFrame, LoadingState, toDataFrame } from '@grafana/data'; -import { processQueryResultsEpic } from './processQueryResultsEpic'; -import TableModel from 'app/core/table_model'; - -const testContext = () => { - const serieA: DataFrame = toDataFrame({ - fields: [], - refId: 'A', - }); - const serieB: DataFrame = toDataFrame({ - fields: [], - refId: 'B', - }); - const series = [serieA, serieB]; - const latency = 0; - const loadingState = LoadingState.Done; - - return { - latency, - series, - loadingState, - }; -}; - -describe('processQueryResultsEpic', () => { - describe('when processQueryResultsAction is dispatched', () => { - describe('and datasourceInstance is the same', () => { - describe('and explore is not scanning', () => { - it('then resetQueryErrorAction and querySuccessAction are dispatched and eventBridge emits correct message', () => { - const { datasourceId, exploreId, state, eventBridge } = mockExploreState(); - const { latency, series, loadingState } = testContext(); - const graphResult: any[] = []; - const tableResult = new TableModel(); - const logsResult: any = null; - - epicTester(processQueryResultsEpic, state) - .whenActionIsDispatched( - processQueryResultsAction({ exploreId, datasourceId, loadingState, series, latency }) - ) - .thenResultingActionsEqual( - resetQueryErrorAction({ exploreId, refIds: ['A', 'B'] }), - querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency }) - ); - - expect(eventBridge.emit).toBeCalledTimes(1); - expect(eventBridge.emit).toBeCalledWith('data-received', series); - }); - }); - - describe('and explore is scanning', () => { - describe('and we have a result', () => { - it('then correct actions are dispatched', () => { - const { datasourceId, exploreId, state } = mockExploreState({ scanning: true }); - const { latency, series, loadingState } = testContext(); - const graphResult: any[] = []; - const tableResult = new TableModel(); - const logsResult: any = null; - - epicTester(processQueryResultsEpic, state) - .whenActionIsDispatched( - processQueryResultsAction({ exploreId, datasourceId, loadingState, series, latency }) - ) - .thenResultingActionsEqual( - resetQueryErrorAction({ exploreId, refIds: ['A', 'B'] }), - querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency }), - scanStopAction({ exploreId }) - ); - }); - }); - - describe('and we do not have a result', () => { - it('then correct actions are dispatched', () => { - const { datasourceId, exploreId, state } = mockExploreState({ scanning: true }); - const { latency, loadingState } = testContext(); - const graphResult: any[] = []; - const tableResult = new TableModel(); - const logsResult: any = null; - - epicTester(processQueryResultsEpic, state) - .whenActionIsDispatched( - processQueryResultsAction({ exploreId, datasourceId, loadingState, series: [], latency }) - ) - .thenResultingActionsEqual( - resetQueryErrorAction({ exploreId, refIds: [] }), - querySuccessAction({ exploreId, loadingState, graphResult, tableResult, logsResult, latency }), - updateTimeRangeAction({ exploreId, absoluteRange: MOCKED_ABSOLUTE_RANGE }), - runQueriesAction({ exploreId }) - ); - }); - }); - }); - }); - - describe('and datasourceInstance is not the same', () => { - it('then no actions are dispatched and eventBridge does not emit message', () => { - const { exploreId, state, eventBridge } = mockExploreState(); - const { series, loadingState } = testContext(); - - epicTester(processQueryResultsEpic, state) - .whenActionIsDispatched( - processQueryResultsAction({ exploreId, datasourceId: 'other id', loadingState, series, latency: 0 }) - ) - .thenNoActionsWhereDispatched(); - - expect(eventBridge.emit).not.toBeCalled(); - }); - }); - }); -}); diff --git a/public/app/features/explore/state/epics/processQueryResultsEpic.ts b/public/app/features/explore/state/epics/processQueryResultsEpic.ts deleted file mode 100644 index e2328d730fb51..0000000000000 --- a/public/app/features/explore/state/epics/processQueryResultsEpic.ts +++ /dev/null @@ -1,82 +0,0 @@ -import _ from 'lodash'; -import { Epic } from 'redux-observable'; -import { mergeMap } from 'rxjs/operators'; -import { NEVER } from 'rxjs'; -import { LoadingState } from '@grafana/data'; - -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { StoreState } from 'app/types/store'; -import { getRefIds } from 'app/core/utils/explore'; -import { - processQueryResultsAction, - ProcessQueryResultsPayload, - querySuccessAction, - resetQueryErrorAction, - scanStopAction, - updateTimeRangeAction, - runQueriesAction, -} from '../actionTypes'; -import { ResultProcessor } from '../../utils/ResultProcessor'; - -export const processQueryResultsEpic: Epic, ActionOf, StoreState> = ( - action$, - state$, - { getTimeZone, getShiftedTimeRange } -) => { - return action$.ofType(processQueryResultsAction.type).pipe( - mergeMap((action: ActionOf) => { - const { exploreId, datasourceId, latency, loadingState, series, delta } = action.payload; - const { datasourceInstance, scanning, eventBridge } = state$.value.explore[exploreId]; - - // If datasource already changed, results do not matter - if (datasourceInstance.meta.id !== datasourceId) { - return NEVER; - } - - const result = series || delta || []; - const replacePreviousResults = loadingState === LoadingState.Done && series && !delta ? true : false; - const resultProcessor = new ResultProcessor(state$.value.explore[exploreId], replacePreviousResults, result); - const graphResult = resultProcessor.getGraphResult(); - const tableResult = resultProcessor.getTableResult(); - const logsResult = resultProcessor.getLogsResult(); - const refIds = getRefIds(result); - const actions: Array> = []; - - // For Angular editors - eventBridge.emit('data-received', resultProcessor.getRawData()); - - // Clears any previous errors that now have a successful query, important so Angular editors are updated correctly - actions.push( - resetQueryErrorAction({ - exploreId, - refIds, - }) - ); - - actions.push( - querySuccessAction({ - exploreId, - latency, - loadingState, - graphResult, - tableResult, - logsResult, - }) - ); - - // Keep scanning for results if this was the last scanning transaction - if (scanning) { - if (_.size(result) === 0) { - const range = getShiftedTimeRange(-1, state$.value.explore[exploreId].range, getTimeZone(state$.value.user)); - actions.push(updateTimeRangeAction({ exploreId, absoluteRange: range })); - actions.push(runQueriesAction({ exploreId })); - } else { - // We can stop scanning if we have a result - actions.push(scanStopAction({ exploreId })); - } - } - - return actions; - }) - ); -}; diff --git a/public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts b/public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts deleted file mode 100644 index 8e612e80cb425..0000000000000 --- a/public/app/features/explore/state/epics/runQueriesBatchEpic.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { mockExploreState } from 'test/mocks/mockExploreState'; -import { epicTester } from 'test/core/redux/epicTester'; -import { runQueriesBatchEpic } from './runQueriesBatchEpic'; -import { - runQueriesBatchAction, - queryStartAction, - historyUpdatedAction, - processQueryResultsAction, - processQueryErrorsAction, - limitMessageRatePayloadAction, - resetExploreAction, - updateDatasourceInstanceAction, - changeRefreshIntervalAction, - clearQueriesAction, - stateSaveAction, -} from '../actionTypes'; -import { LoadingState, DataFrame, FieldType, DataFrameHelper } from '@grafana/data'; -import { DataQueryRequest } from '@grafana/ui'; - -const testContext = () => { - const series: DataFrame[] = [ - new DataFrameHelper({ - fields: [ - { - name: 'Value', - values: [], - }, - { - name: 'Time', - type: FieldType.time, - config: { - unit: 'dateTimeAsIso', - }, - values: [], - }, - ], - refId: 'A', - }), - ]; - const response = { data: series }; - - return { - response, - series, - }; -}; - -describe('runQueriesBatchEpic', () => { - let originalDateNow = Date.now; - beforeEach(() => { - originalDateNow = Date.now; - Date.now = () => 1337; - }); - - afterEach(() => { - Date.now = originalDateNow; - }); - - describe('when runQueriesBatchAction is dispatched', () => { - describe('and query targets are not live', () => { - describe('and query is successful', () => { - it('then correct actions are dispatched', () => { - const { response, series } = testContext(); - const { exploreId, state, history, datasourceId } = mockExploreState(); - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryReceivesResponse(response) - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - historyUpdatedAction({ exploreId, history }), - processQueryResultsAction({ - exploreId, - delta: null, - series, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction() - ); - }); - }); - - describe('and query is not successful', () => { - it('then correct actions are dispatched', () => { - const error = { - message: 'Error parsing line x', - }; - const { exploreId, state, datasourceId } = mockExploreState(); - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryThrowsError(error) - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - processQueryErrorsAction({ exploreId, response: error, datasourceId }) - ); - }); - }); - }); - - describe('and query targets are live', () => { - describe('and state equals Streaming', () => { - it('then correct actions are dispatched', () => { - const { exploreId, state, datasourceId } = mockExploreState(); - const unsubscribe = jest.fn(); - const serieA: any = { - fields: [], - rows: [], - refId: 'A', - }; - const serieB: any = { - fields: [], - rows: [], - refId: 'B', - }; - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryObserverReceivesEvent({ - state: LoadingState.Streaming, - delta: [serieA], - key: 'some key', - request: {} as DataQueryRequest, - unsubscribe, - }) - .whenQueryObserverReceivesEvent({ - state: LoadingState.Streaming, - delta: [serieB], - key: 'some key', - request: {} as DataQueryRequest, - unsubscribe, - }) - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - limitMessageRatePayloadAction({ exploreId, series: [serieA], datasourceId }), - limitMessageRatePayloadAction({ exploreId, series: [serieB], datasourceId }) - ); - }); - }); - - describe('and state equals Error', () => { - it('then correct actions are dispatched', () => { - const { exploreId, state, datasourceId } = mockExploreState(); - const unsubscribe = jest.fn(); - const error = { message: 'Something went really wrong!' }; - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryObserverReceivesEvent({ - state: LoadingState.Error, - error, - key: 'some key', - request: {} as DataQueryRequest, - unsubscribe, - }) - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - processQueryErrorsAction({ exploreId, response: error, datasourceId }) - ); - }); - }); - - describe('and state equals Done', () => { - it('then correct actions are dispatched', () => { - const { exploreId, state, datasourceId, history } = mockExploreState(); - const unsubscribe = jest.fn(); - const serieA: any = { - fields: [], - rows: [], - refId: 'A', - }; - const serieB: any = { - fields: [], - rows: [], - refId: 'B', - }; - const delta = [serieA, serieB]; - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryObserverReceivesEvent({ - state: LoadingState.Done, - data: null, - delta, - key: 'some key', - request: {} as DataQueryRequest, - unsubscribe, - }) - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - historyUpdatedAction({ exploreId, history }), - processQueryResultsAction({ - exploreId, - delta, - series: null, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction() - ); - }); - }); - }); - - describe('and another runQueriesBatchAction is dispatched', () => { - it('then the observable should be unsubscribed', () => { - const { response, series } = testContext(); - const { exploreId, state, history, datasourceId } = mockExploreState(); - const unsubscribe = jest.fn(); - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) // first observable - ) - .whenQueryReceivesResponse(response) - .whenQueryObserverReceivesEvent({ - key: 'some key', - request: {} as DataQueryRequest, - state: LoadingState.Loading, // fake just to setup and test unsubscribe - unsubscribe, - }) - .whenActionIsDispatched( - // second observable and unsubscribes the first observable - runQueriesBatchAction({ exploreId, queryOptions: { live: true, interval: '', maxDataPoints: 800 } }) - ) - .whenQueryReceivesResponse(response) - .whenQueryObserverReceivesEvent({ - key: 'some key', - request: {} as DataQueryRequest, - state: LoadingState.Loading, // fake just to setup and test unsubscribe - unsubscribe, - }) - .thenResultingActionsEqual( - queryStartAction({ exploreId }), // output from first observable - historyUpdatedAction({ exploreId, history }), // output from first observable - processQueryResultsAction({ - exploreId, - delta: null, - series, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction(), - // output from first observable - queryStartAction({ exploreId }), // output from second observable - historyUpdatedAction({ exploreId, history }), // output from second observable - processQueryResultsAction({ - exploreId, - delta: null, - series, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction() - // output from second observable - ); - - expect(unsubscribe).toBeCalledTimes(1); // first unsubscribe should be called but not second as that isn't unsubscribed - }); - }); - - describe('and resetExploreAction is dispatched', () => { - it('then the observable should be unsubscribed', () => { - const { response, series } = testContext(); - const { exploreId, state, history, datasourceId } = mockExploreState(); - const unsubscribe = jest.fn(); - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryReceivesResponse(response) - .whenQueryObserverReceivesEvent({ - key: 'some key', - request: {} as DataQueryRequest, - state: LoadingState.Loading, // fake just to setup and test unsubscribe - unsubscribe, - }) - .whenActionIsDispatched(resetExploreAction()) // unsubscribes the observable - .whenQueryReceivesResponse(response) // new updates will not reach anywhere - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - historyUpdatedAction({ exploreId, history }), - processQueryResultsAction({ - exploreId, - delta: null, - series, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction() - ); - - expect(unsubscribe).toBeCalledTimes(1); - }); - }); - - describe('and updateDatasourceInstanceAction is dispatched', () => { - it('then the observable should be unsubscribed', () => { - const { response, series } = testContext(); - const { exploreId, state, history, datasourceId, datasourceInstance } = mockExploreState(); - const unsubscribe = jest.fn(); - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryReceivesResponse(response) - .whenQueryObserverReceivesEvent({ - key: 'some key', - request: {} as DataQueryRequest, - state: LoadingState.Loading, // fake just to setup and test unsubscribe - unsubscribe, - }) - .whenActionIsDispatched(updateDatasourceInstanceAction({ exploreId, datasourceInstance })) // unsubscribes the observable - .whenQueryReceivesResponse(response) // new updates will not reach anywhere - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - historyUpdatedAction({ exploreId, history }), - processQueryResultsAction({ - exploreId, - delta: null, - series, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction() - ); - - expect(unsubscribe).toBeCalledTimes(1); - }); - }); - - describe('and changeRefreshIntervalAction is dispatched', () => { - it('then the observable should be unsubscribed', () => { - const { response, series } = testContext(); - const { exploreId, state, history, datasourceId } = mockExploreState(); - const unsubscribe = jest.fn(); - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryReceivesResponse(response) - .whenQueryObserverReceivesEvent({ - key: 'some key', - request: {} as DataQueryRequest, - state: LoadingState.Loading, // fake just to setup and test unsubscribe - unsubscribe, - }) - .whenActionIsDispatched(changeRefreshIntervalAction({ exploreId, refreshInterval: '' })) // unsubscribes the observable - .whenQueryReceivesResponse(response) // new updates will not reach anywhere - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - historyUpdatedAction({ exploreId, history }), - processQueryResultsAction({ - exploreId, - delta: null, - series, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction() - ); - - expect(unsubscribe).toBeCalledTimes(1); - }); - }); - - describe('and clearQueriesAction is dispatched', () => { - it('then the observable should be unsubscribed', () => { - const { response, series } = testContext(); - const { exploreId, state, history, datasourceId } = mockExploreState(); - const unsubscribe = jest.fn(); - - epicTester(runQueriesBatchEpic, state) - .whenActionIsDispatched( - runQueriesBatchAction({ exploreId, queryOptions: { live: false, interval: '', maxDataPoints: 1980 } }) - ) - .whenQueryReceivesResponse(response) - .whenQueryObserverReceivesEvent({ - key: 'some key', - request: {} as DataQueryRequest, - state: LoadingState.Loading, // fake just to setup and test unsubscribe - unsubscribe, - }) - .whenActionIsDispatched(clearQueriesAction({ exploreId })) // unsubscribes the observable - .whenQueryReceivesResponse(response) // new updates will not reach anywhere - .thenResultingActionsEqual( - queryStartAction({ exploreId }), - historyUpdatedAction({ exploreId, history }), - processQueryResultsAction({ - exploreId, - delta: null, - series, - latency: 0, - datasourceId, - loadingState: LoadingState.Done, - }), - stateSaveAction() - ); - - expect(unsubscribe).toBeCalledTimes(1); - }); - }); - }); -}); diff --git a/public/app/features/explore/state/epics/runQueriesBatchEpic.ts b/public/app/features/explore/state/epics/runQueriesBatchEpic.ts deleted file mode 100644 index 4d7876a37f79d..0000000000000 --- a/public/app/features/explore/state/epics/runQueriesBatchEpic.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { Epic } from 'redux-observable'; -import { Observable, Subject } from 'rxjs'; -import { mergeMap, catchError, takeUntil, filter } from 'rxjs/operators'; -import _, { isString } from 'lodash'; -import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; -import { DataStreamState, DataQueryResponse, DataQueryResponseData } from '@grafana/ui'; - -import { LoadingState, DataFrame, AbsoluteTimeRange } from '@grafana/data'; -import { dateMath } from '@grafana/data'; - -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { StoreState } from 'app/types/store'; -import { buildQueryTransaction, updateHistory } from 'app/core/utils/explore'; -import { - clearQueriesAction, - historyUpdatedAction, - resetExploreAction, - updateDatasourceInstanceAction, - changeRefreshIntervalAction, - processQueryErrorsAction, - processQueryResultsAction, - runQueriesBatchAction, - RunQueriesBatchPayload, - queryStartAction, - limitMessageRatePayloadAction, - stateSaveAction, - changeRangeAction, -} from '../actionTypes'; -import { ExploreId, ExploreItemState } from 'app/types'; - -const publishActions = (outerObservable: Subject, actions: Array>) => { - for (const action of actions) { - outerObservable.next(action); - } -}; - -interface ProcessResponseConfig { - exploreId: ExploreId; - exploreItemState: ExploreItemState; - datasourceId: string; - now: number; - loadingState: LoadingState; - series?: DataQueryResponseData[]; - delta?: DataFrame[]; -} - -const processResponse = (config: ProcessResponseConfig) => { - const { exploreId, exploreItemState, datasourceId, now, loadingState, series, delta } = config; - const { queries, history } = exploreItemState; - const latency = Date.now() - now; - - // Side-effect: Saving history in localstorage - const nextHistory = updateHistory(history, datasourceId, queries); - return [ - historyUpdatedAction({ exploreId, history: nextHistory }), - processQueryResultsAction({ exploreId, latency, datasourceId, loadingState, series, delta }), - stateSaveAction(), - ]; -}; - -interface ProcessErrorConfig { - exploreId: ExploreId; - datasourceId: string; - error: any; -} - -const processError = (config: ProcessErrorConfig) => { - const { exploreId, datasourceId, error } = config; - - return [processQueryErrorsAction({ exploreId, response: error, datasourceId })]; -}; - -export const runQueriesBatchEpic: Epic, ActionOf, StoreState> = ( - action$, - state$, - { getQueryResponse } -) => { - return action$.ofType(runQueriesBatchAction.type).pipe( - mergeMap((action: ActionOf) => { - const { exploreId, queryOptions } = action.payload; - const exploreItemState = state$.value.explore[exploreId]; - const { datasourceInstance, queries, queryIntervals, range, scanning } = exploreItemState; - - // Create an observable per run queries action - // Within the observable create two subscriptions - // First subscription: 'querySubscription' subscribes to the call to query method on datasourceinstance - // Second subscription: 'streamSubscription' subscribes to events from the query methods observer callback - const observable: Observable> = Observable.create((outerObservable: Subject) => { - const datasourceId = datasourceInstance.meta.id; - const transaction = buildQueryTransaction(queries, queryOptions, range, queryIntervals, scanning); - outerObservable.next(queryStartAction({ exploreId })); - - const now = Date.now(); - let datasourceUnsubscribe: Function = null; - const streamHandler = new Subject(); - const observer = (event: DataStreamState) => { - datasourceUnsubscribe = event.unsubscribe; - if (!streamHandler.closed) { - // their might be a race condition when unsubscribing - streamHandler.next(event); - } - }; - - // observer subscription, handles datasourceInstance.query observer events and pushes that forward - const streamSubscription = streamHandler.subscribe({ - next: event => { - const { state, error, data, delta } = event; - if (!data && !delta && !error) { - return; - } - - if (state === LoadingState.Error) { - const actions = processError({ exploreId, datasourceId, error }); - publishActions(outerObservable, actions); - } - - if (state === LoadingState.Streaming) { - if (event.request && event.request.range) { - let newRange = event.request.range; - let absoluteRange: AbsoluteTimeRange = { - from: newRange.from.valueOf(), - to: newRange.to.valueOf(), - }; - if (isString(newRange.raw.from)) { - newRange = { - from: dateMath.parse(newRange.raw.from, false), - to: dateMath.parse(newRange.raw.to, true), - raw: newRange.raw, - }; - absoluteRange = { - from: newRange.from.valueOf(), - to: newRange.to.valueOf(), - }; - } - outerObservable.next(changeRangeAction({ exploreId, range: newRange, absoluteRange })); - } - - outerObservable.next( - limitMessageRatePayloadAction({ - exploreId, - series: delta, - datasourceId, - }) - ); - } - - if (state === LoadingState.Done || state === LoadingState.Loading) { - const actions = processResponse({ - exploreId, - exploreItemState, - datasourceId, - now, - loadingState: state, - series: null, - delta, - }); - publishActions(outerObservable, actions); - } - }, - }); - - // query subscription, handles datasourceInstance.query response and pushes that forward - const querySubscription = getQueryResponse(datasourceInstance, transaction.options, observer) - .pipe( - mergeMap((response: DataQueryResponse) => { - return processResponse({ - exploreId, - exploreItemState, - datasourceId, - now, - loadingState: LoadingState.Done, - series: response && response.data ? response.data : [], - delta: null, - }); - }), - catchError(error => { - return processError({ exploreId, datasourceId, error }); - }) - ) - .subscribe({ next: (action: ActionOf) => outerObservable.next(action) }); - - // this unsubscribe method will be called when any of the takeUntil actions below happen - const unsubscribe = () => { - if (datasourceUnsubscribe) { - datasourceUnsubscribe(); - } - querySubscription.unsubscribe(); - streamSubscription.unsubscribe(); - streamHandler.unsubscribe(); - outerObservable.unsubscribe(); - }; - - return unsubscribe; - }); - - return observable.pipe( - takeUntil( - action$ - .ofType( - runQueriesBatchAction.type, - resetExploreAction.type, - updateDatasourceInstanceAction.type, - changeRefreshIntervalAction.type, - clearQueriesAction.type - ) - .pipe( - filter(action => { - if (action.type === resetExploreAction.type) { - return true; // stops all subscriptions if user navigates away - } - - if (action.type === updateDatasourceInstanceAction.type && action.payload.exploreId === exploreId) { - return true; // stops subscriptions if user changes data source - } - - if (action.type === changeRefreshIntervalAction.type && action.payload.exploreId === exploreId) { - return !isLive(action.payload.refreshInterval); // stops subscriptions if user changes refresh interval away from 'Live' - } - - if (action.type === clearQueriesAction.type && action.payload.exploreId === exploreId) { - return true; // stops subscriptions if user clears all queries - } - - return action.payload.exploreId === exploreId; - }) - ) - ) - ); - }) - ); -}; diff --git a/public/app/features/explore/state/epics/runQueriesEpic.test.ts b/public/app/features/explore/state/epics/runQueriesEpic.test.ts deleted file mode 100644 index 1006ac5b9ad40..0000000000000 --- a/public/app/features/explore/state/epics/runQueriesEpic.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { mockExploreState } from 'test/mocks/mockExploreState'; -import { epicTester } from 'test/core/redux/epicTester'; -import { runQueriesAction, stateSaveAction, runQueriesBatchAction, clearQueriesAction } from '../actionTypes'; -import { runQueriesEpic } from './runQueriesEpic'; - -describe('runQueriesEpic', () => { - describe('when runQueriesAction is dispatched', () => { - describe('and there is no datasourceError', () => { - describe('and we have non empty queries', () => { - describe('and explore is not live', () => { - it('then runQueriesBatchAction and stateSaveAction are dispatched', () => { - const queries = [{ refId: 'A', key: '123456', expr: '{__filename__="some.log"}' }]; - const { exploreId, state, datasourceInterval, containerWidth } = mockExploreState({ queries }); - - epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId })) - .thenResultingActionsEqual( - runQueriesBatchAction({ - exploreId, - queryOptions: { interval: datasourceInterval, maxDataPoints: containerWidth, live: false }, - }) - ); - }); - }); - - describe('and explore is live', () => { - it('then runQueriesBatchAction and stateSaveAction are dispatched', () => { - const queries = [{ refId: 'A', key: '123456', expr: '{__filename__="some.log"}' }]; - const { exploreId, state, datasourceInterval, containerWidth } = mockExploreState({ - queries, - isLive: true, - streaming: true, - }); - - epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId })) - .thenResultingActionsEqual( - runQueriesBatchAction({ - exploreId, - queryOptions: { interval: datasourceInterval, maxDataPoints: containerWidth, live: true }, - }) - ); - }); - }); - }); - - describe('and we have no queries', () => { - it('then clearQueriesAction and stateSaveAction are dispatched', () => { - const queries: any[] = []; - const { exploreId, state } = mockExploreState({ queries }); - - epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId })) - .thenResultingActionsEqual(clearQueriesAction({ exploreId }), stateSaveAction()); - }); - }); - }); - - describe('and there is a datasourceError', () => { - it('then no actions are dispatched', () => { - const { exploreId, state } = mockExploreState({ - datasourceError: { message: 'Some error' }, - }); - - epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId })) - .thenNoActionsWhereDispatched(); - }); - }); - }); -}); diff --git a/public/app/features/explore/state/epics/runQueriesEpic.ts b/public/app/features/explore/state/epics/runQueriesEpic.ts deleted file mode 100644 index 2102c11b103c8..0000000000000 --- a/public/app/features/explore/state/epics/runQueriesEpic.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Epic } from 'redux-observable'; -import { NEVER } from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; - -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { StoreState } from 'app/types/store'; -import { hasNonEmptyQuery } from 'app/core/utils/explore'; -import { - clearQueriesAction, - runQueriesAction, - RunQueriesPayload, - runQueriesBatchAction, - stateSaveAction, -} from '../actionTypes'; - -export const runQueriesEpic: Epic, ActionOf, StoreState> = (action$, state$) => { - return action$.ofType(runQueriesAction.type).pipe( - mergeMap((action: ActionOf) => { - const { exploreId } = action.payload; - const { datasourceInstance, queries, datasourceError, containerWidth, isLive } = state$.value.explore[exploreId]; - - if (datasourceError) { - // let's not run any queries if data source is in a faulty state - return NEVER; - } - - if (!hasNonEmptyQuery(queries)) { - return [clearQueriesAction({ exploreId }), stateSaveAction()]; // Remember to save to state and update location - } - - // Some datasource's query builders allow per-query interval limits, - // but we're using the datasource interval limit for now - const interval = datasourceInstance.interval; - const live = isLive; - - return [runQueriesBatchAction({ exploreId, queryOptions: { interval, maxDataPoints: containerWidth, live } })]; - }) - ); -}; diff --git a/public/app/features/explore/state/epics/stateSaveEpic.test.ts b/public/app/features/explore/state/epics/stateSaveEpic.test.ts deleted file mode 100644 index 9670ae996ee92..0000000000000 --- a/public/app/features/explore/state/epics/stateSaveEpic.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { epicTester } from 'test/core/redux/epicTester'; -import { stateSaveEpic } from './stateSaveEpic'; -import { stateSaveAction, setUrlReplacedAction } from '../actionTypes'; -import { updateLocation } from 'app/core/actions/location'; -import { mockExploreState } from 'test/mocks/mockExploreState'; - -describe('stateSaveEpic', () => { - describe('when stateSaveAction is dispatched', () => { - describe('and there is a left state', () => { - describe('and no split', () => { - it('then the correct actions are dispatched', () => { - const { exploreId, state } = mockExploreState(); - - epicTester(stateSaveEpic, state) - .whenActionIsDispatched(stateSaveAction()) - .thenResultingActionsEqual( - updateLocation({ - query: { orgId: '1', left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]' }, - replace: true, - }), - setUrlReplacedAction({ exploreId }) - ); - }); - }); - - describe('and explore is split', () => { - it('then the correct actions are dispatched', () => { - const { exploreId, state } = mockExploreState({ split: true }); - - epicTester(stateSaveEpic, state) - .whenActionIsDispatched(stateSaveAction()) - .thenResultingActionsEqual( - updateLocation({ - query: { - orgId: '1', - left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]', - right: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]', - }, - replace: true, - }), - setUrlReplacedAction({ exploreId }) - ); - }); - }); - }); - - describe('and urlReplaced is true', () => { - it('then setUrlReplacedAction should not be dispatched', () => { - const { state } = mockExploreState({ urlReplaced: true }); - - epicTester(stateSaveEpic, state) - .whenActionIsDispatched(stateSaveAction()) - .thenResultingActionsEqual( - updateLocation({ - query: { orgId: '1', left: '["now-6h","now","test",{"mode":null},{"ui":[true,true,true,null]}]' }, - replace: false, - }) - ); - }); - }); - }); -}); diff --git a/public/app/features/explore/state/epics/stateSaveEpic.ts b/public/app/features/explore/state/epics/stateSaveEpic.ts deleted file mode 100644 index 1b36f92ecb008..0000000000000 --- a/public/app/features/explore/state/epics/stateSaveEpic.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Epic } from 'redux-observable'; -import { mergeMap } from 'rxjs/operators'; -import { RawTimeRange, TimeRange } from '@grafana/data'; -import { isDateTime } from '@grafana/data'; - -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { StoreState } from 'app/types/store'; -import { ExploreUrlState, ExploreId } from 'app/types/explore'; -import { clearQueryKeys, serializeStateToUrlParam } from 'app/core/utils/explore'; -import { updateLocation } from 'app/core/actions/location'; -import { setUrlReplacedAction, stateSaveAction } from '../actionTypes'; - -const toRawTimeRange = (range: TimeRange): RawTimeRange => { - let from = range.raw.from; - if (isDateTime(from)) { - from = from.valueOf().toString(10); - } - - let to = range.raw.to; - if (isDateTime(to)) { - to = to.valueOf().toString(10); - } - - return { - from, - to, - }; -}; - -export const stateSaveEpic: Epic, ActionOf, StoreState> = (action$, state$) => { - return action$.ofType(stateSaveAction.type).pipe( - mergeMap(() => { - const { left, right, split } = state$.value.explore; - const orgId = state$.value.user.orgId.toString(); - const replace = left && left.urlReplaced === false; - const urlStates: { [index: string]: string } = { orgId }; - const leftUrlState: ExploreUrlState = { - datasource: left.datasourceInstance.name, - queries: left.queries.map(clearQueryKeys), - range: toRawTimeRange(left.range), - mode: left.mode, - ui: { - showingGraph: left.showingGraph, - showingLogs: true, - showingTable: left.showingTable, - dedupStrategy: left.dedupStrategy, - }, - }; - urlStates.left = serializeStateToUrlParam(leftUrlState, true); - if (split) { - const rightUrlState: ExploreUrlState = { - datasource: right.datasourceInstance.name, - queries: right.queries.map(clearQueryKeys), - range: toRawTimeRange(right.range), - mode: right.mode, - ui: { - showingGraph: right.showingGraph, - showingLogs: true, - showingTable: right.showingTable, - dedupStrategy: right.dedupStrategy, - }, - }; - - urlStates.right = serializeStateToUrlParam(rightUrlState, true); - } - - const actions: Array> = [updateLocation({ query: urlStates, replace })]; - if (replace) { - actions.push(setUrlReplacedAction({ exploreId: ExploreId.left })); - } - - return actions; - }) - ); -}; diff --git a/public/app/features/explore/state/epics/timeEpic.test.ts b/public/app/features/explore/state/epics/timeEpic.test.ts deleted file mode 100644 index f1742374469f6..0000000000000 --- a/public/app/features/explore/state/epics/timeEpic.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { dateTime, DefaultTimeZone } from '@grafana/data'; - -import { epicTester } from 'test/core/redux/epicTester'; -import { mockExploreState } from 'test/mocks/mockExploreState'; -import { timeEpic } from './timeEpic'; -import { updateTimeRangeAction, changeRangeAction } from '../actionTypes'; -import { EpicDependencies } from 'app/store/configureStore'; - -const from = dateTime('2019-01-01 10:00:00.000Z'); -const to = dateTime('2019-01-01 16:00:00.000Z'); -const rawFrom = 'now-6h'; -const rawTo = 'now'; -const rangeMock = { - from, - to, - raw: { - from: rawFrom, - to: rawTo, - }, -}; - -describe('timeEpic', () => { - describe('when updateTimeRangeAction is dispatched', () => { - describe('and no rawRange is supplied', () => { - describe('and no absoluteRange is supplied', () => { - it('then the correct actions are dispatched', () => { - const { exploreId, state, range } = mockExploreState({ range: rangeMock }); - const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() }; - const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } }; - const getTimeRange = jest.fn().mockReturnValue(rangeMock); - const dependencies: Partial = { - getTimeRange, - }; - - epicTester(timeEpic, stateToTest, dependencies) - .whenActionIsDispatched(updateTimeRangeAction({ exploreId })) - .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init') - .thenDependencyWasCalledTimes(1, 'getTimeRange') - .thenDependencyWasCalledWith([DefaultTimeZone, rangeMock.raw], 'getTimeRange') - .thenResultingActionsEqual( - changeRangeAction({ - exploreId, - range, - absoluteRange, - }) - ); - }); - }); - - describe('and absoluteRange is supplied', () => { - it('then the correct actions are dispatched', () => { - const { exploreId, state, range } = mockExploreState({ range: rangeMock }); - const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() }; - const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } }; - const getTimeRange = jest.fn().mockReturnValue(rangeMock); - const dependencies: Partial = { - getTimeRange, - }; - - epicTester(timeEpic, stateToTest, dependencies) - .whenActionIsDispatched(updateTimeRangeAction({ exploreId, absoluteRange })) - .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init') - .thenDependencyWasCalledTimes(1, 'getTimeRange') - .thenDependencyWasCalledWith([DefaultTimeZone, { from: null, to: null }], 'getTimeRange') - .thenDependencyWasCalledTimes(2, 'dateTimeForTimeZone') - .thenResultingActionsEqual( - changeRangeAction({ - exploreId, - range, - absoluteRange, - }) - ); - }); - }); - }); - - describe('and rawRange is supplied', () => { - describe('and no absoluteRange is supplied', () => { - it('then the correct actions are dispatched', () => { - const { exploreId, state, range } = mockExploreState({ range: rangeMock }); - const rawRange = { from: 'now-5m', to: 'now' }; - const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() }; - const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } }; - const getTimeRange = jest.fn().mockReturnValue(rangeMock); - const dependencies: Partial = { - getTimeRange, - }; - - epicTester(timeEpic, stateToTest, dependencies) - .whenActionIsDispatched(updateTimeRangeAction({ exploreId, rawRange })) - .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init') - .thenDependencyWasCalledTimes(1, 'getTimeRange') - .thenDependencyWasCalledWith([DefaultTimeZone, rawRange], 'getTimeRange') - .thenResultingActionsEqual( - changeRangeAction({ - exploreId, - range, - absoluteRange, - }) - ); - }); - }); - }); - }); -}); diff --git a/public/app/features/explore/state/epics/timeEpic.ts b/public/app/features/explore/state/epics/timeEpic.ts deleted file mode 100644 index 3b2a9950ef6ca..0000000000000 --- a/public/app/features/explore/state/epics/timeEpic.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Epic } from 'redux-observable'; -import { map } from 'rxjs/operators'; -import { AbsoluteTimeRange, RawTimeRange } from '@grafana/data'; - -import { ActionOf } from 'app/core/redux/actionCreatorFactory'; -import { StoreState } from 'app/types/store'; -import { updateTimeRangeAction, UpdateTimeRangePayload, changeRangeAction } from '../actionTypes'; -import { EpicDependencies } from 'app/store/configureStore'; - -export const timeEpic: Epic, ActionOf, StoreState, EpicDependencies> = ( - action$, - state$, - { getTimeSrv, getTimeRange, getTimeZone, dateTimeForTimeZone } -) => { - return action$.ofType(updateTimeRangeAction.type).pipe( - map((action: ActionOf) => { - const { exploreId, absoluteRange: absRange, rawRange: actionRange } = action.payload; - const itemState = state$.value.explore[exploreId]; - const timeZone = getTimeZone(state$.value.user); - const { range: rangeInState } = itemState; - let rawRange: RawTimeRange = rangeInState.raw; - - if (absRange) { - rawRange = { - from: dateTimeForTimeZone(timeZone, absRange.from), - to: dateTimeForTimeZone(timeZone, absRange.to), - }; - } - - if (actionRange) { - rawRange = actionRange; - } - - const range = getTimeRange(timeZone, rawRange); - const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; - - getTimeSrv().init({ - time: range.raw, - refresh: false, - getTimezone: () => timeZone, - timeRangeUpdated: (): any => undefined, - }); - - return changeRangeAction({ exploreId, range, absoluteRange }); - }) - ); -}; diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index 4cd976b53f5c2..096e14b22e62d 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/reducers.test.ts @@ -26,12 +26,14 @@ import { serializeStateToUrlParam } from 'app/core/utils/explore'; import TableModel from 'app/core/table_model'; import { DataSourceApi, DataQuery } from '@grafana/ui'; import { LogsModel, LogsDedupStrategy, LoadingState } from '@grafana/data'; +import { PanelQueryState } from '../../dashboard/state/PanelQueryState'; describe('Explore item reducer', () => { describe('scanning', () => { it('should start scanning', () => { const initalState = { ...makeExploreItemState(), + queryState: null as PanelQueryState, scanning: false, }; @@ -40,12 +42,14 @@ describe('Explore item reducer', () => { .whenActionIsDispatched(scanStartAction({ exploreId: ExploreId.left })) .thenStateShouldEqual({ ...makeExploreItemState(), + queryState: null as PanelQueryState, scanning: true, }); }); it('should stop scanning', () => { const initalState = { ...makeExploreItemState(), + queryState: null as PanelQueryState, scanning: true, scanRange: {}, }; @@ -55,6 +59,7 @@ describe('Explore item reducer', () => { .whenActionIsDispatched(scanStopAction({ exploreId: ExploreId.left })) .thenStateShouldEqual({ ...makeExploreItemState(), + queryState: null as PanelQueryState, scanning: false, scanRange: undefined, }); diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 4c1c5070e8f29..8afa1ff5de61e 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -7,6 +7,8 @@ import { DEFAULT_UI_STATE, generateNewKeyAndAddRefIdIfMissing, sortLogsResult, + stopQueryState, + refreshIntervalToSortOrder, } from 'app/core/utils/explore'; import { ExploreItemState, ExploreState, ExploreId, ExploreUpdateState, ExploreMode } from 'app/types/explore'; import { LoadingState } from '@grafana/data'; @@ -30,9 +32,6 @@ import { queryStartAction, runQueriesAction, changeRangeAction, -} from './actionTypes'; -import { reducerFactory } from 'app/core/redux'; -import { addQueryRowAction, changeQueryAction, changeSizeAction, @@ -52,11 +51,15 @@ import { queriesImportedAction, updateUIStateAction, toggleLogLevelAction, + changeLoadingStateAction, + resetExploreAction, } from './actionTypes'; +import { reducerFactory } from 'app/core/redux'; import { updateLocation } from 'app/core/actions/location'; import { LocationUpdate } from '@grafana/runtime'; import TableModel from 'app/core/table_model'; import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; +import { PanelQueryState } from '../../dashboard/state/PanelQueryState'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -113,6 +116,7 @@ export const makeExploreItemState = (): ExploreItemState => ({ mode: null, isLive: false, urlReplaced: false, + queryState: new PanelQueryState(), }); /** @@ -183,7 +187,11 @@ export const itemReducer = reducerFactory({} as ExploreItemSta mapper: (state, action): ExploreItemState => { const { refreshInterval } = action.payload; const live = isLive(refreshInterval); - const logsResult = sortLogsResult(state.logsResult, refreshInterval); + const sortOrder = refreshIntervalToSortOrder(refreshInterval); + const logsResult = sortLogsResult(state.logsResult, sortOrder); + if (isLive(state.refreshInterval) && !live) { + stopQueryState(state.queryState, 'Live streaming stopped'); + } return { ...state, @@ -198,6 +206,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta filter: clearQueriesAction, mapper: (state): ExploreItemState => { const queries = ensureQueries(); + stopQueryState(state.queryState, 'Queries cleared'); return { ...state, queries: queries.slice(), @@ -256,6 +265,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta // Custom components const StartPage = datasourceInstance.components.ExploreStartPage; + stopQueryState(state.queryState, 'Datasource changed'); return { ...state, @@ -575,6 +585,16 @@ export const itemReducer = reducerFactory({} as ExploreItemSta }; }, }) + .addMapper({ + filter: changeLoadingStateAction, + mapper: (state, action): ExploreItemState => { + const { loadingState } = action.payload; + return { + ...state, + loadingState, + }; + }, + }) .create(); export const updateChildRefreshState = ( @@ -662,6 +682,19 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA [ExploreId.right]: updateChildRefreshState(rightState, action.payload, ExploreId.right), }; } + + case resetExploreAction.type: { + const leftState = state[ExploreId.left]; + const rightState = state[ExploreId.right]; + stopQueryState(leftState.queryState, 'Navigated away from Explore'); + stopQueryState(rightState.queryState, 'Navigated away from Explore'); + + return { + ...state, + [ExploreId.left]: updateChildRefreshState(leftState, action.payload, ExploreId.left), + [ExploreId.right]: updateChildRefreshState(rightState, action.payload, ExploreId.right), + }; + } } if (action.payload) { diff --git a/public/app/features/explore/utils/ResultProcessor.ts b/public/app/features/explore/utils/ResultProcessor.ts index 2974c2e7ba087..75c557bcaa70c 100644 --- a/public/app/features/explore/utils/ResultProcessor.ts +++ b/public/app/features/explore/utils/ResultProcessor.ts @@ -14,7 +14,7 @@ import { import { ExploreItemState, ExploreMode } from 'app/types/explore'; import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; -import { sortLogsResult } from 'app/core/utils/explore'; +import { sortLogsResult, refreshIntervalToSortOrder } from 'app/core/utils/explore'; import { dataFrameToLogsModel } from 'app/core/logs_model'; import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel'; @@ -80,14 +80,19 @@ export class ResultProcessor { const graphInterval = this.state.queryIntervals.intervalMs; const dataFrame = this.rawData.map(result => guessFieldTypes(toDataFrame(result))); const newResults = this.rawData ? dataFrameToLogsModel(dataFrame, graphInterval) : null; - const sortedNewResults = sortLogsResult(newResults, this.state.refreshInterval); + const sortOrder = refreshIntervalToSortOrder(this.state.refreshInterval); + const sortedNewResults = sortLogsResult(newResults, sortOrder); if (this.replacePreviousResults) { - return sortedNewResults; + const slice = 1000; + const rows = sortedNewResults.rows.slice(0, slice); + const series = sortedNewResults.series; + + return { ...sortedNewResults, rows, series }; } const prevLogsResult: LogsModel = this.state.logsResult || { hasUniqueLabels: false, rows: [] }; - const sortedLogResult = sortLogsResult(prevLogsResult, this.state.refreshInterval); + const sortedLogResult = sortLogsResult(prevLogsResult, sortOrder); const rowsInState = sortedLogResult.rows; const seriesInState = sortedLogResult.series || []; diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 185d517025e63..b32aaae051dcd 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -18,8 +18,8 @@ import { import { GRID_COLUMN_COUNT } from 'app/core/constants'; import { auto } from 'angular'; import { TemplateSrv } from '../templating/template_srv'; -import { LinkSrv } from './panellinks/link_srv'; import { PanelPluginMeta } from '@grafana/ui/src/types/panel'; +import { getPanelLinksSupplier } from './panellinks/linkSuppliers'; export class PanelCtrl { panel: any; @@ -255,31 +255,31 @@ export class PanelCtrl { markdown = this.error || this.panel.description || ''; } - const linkSrv: LinkSrv = this.$injector.get('linkSrv'); const templateSrv: TemplateSrv = this.$injector.get('templateSrv'); const interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars); let html = '
    '; const md = renderMarkdown(interpolatedMarkdown); html += config.disableSanitizeHtml ? md : sanitize(md); + const links = this.panel.links && getPanelLinksSupplier(this.panel).getLinks(); - if (this.panel.links && this.panel.links.length > 0) { + if (links && links.length > 0) { html += ''; } html += '
    '; + return html; } diff --git a/public/app/features/panel/panellinks/linkSuppliers.ts b/public/app/features/panel/panellinks/linkSuppliers.ts new file mode 100644 index 0000000000000..376d631a192ae --- /dev/null +++ b/public/app/features/panel/panellinks/linkSuppliers.ts @@ -0,0 +1,66 @@ +import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { FieldDisplay, ScopedVars, DataLinkBuiltInVars } from '@grafana/ui'; +import { LinkModelSupplier, DataFrameHelper, FieldType } from '@grafana/data'; +import { getLinkSrv } from './link_srv'; + +/** + * Link suppliers creates link models based on a link origin + */ + +export const getFieldLinksSupplier = (value: FieldDisplay): LinkModelSupplier | undefined => { + const links = value.field.links; + if (!links || links.length === 0) { + return undefined; + } + return { + getLinks: (_scopedVars?: any) => { + const scopedVars: ScopedVars = {}; + // TODO, add values to scopedVars and/or pass objects to event listeners + if (value.view) { + scopedVars[DataLinkBuiltInVars.seriesName] = { + text: 'Series', + value: value.view.dataFrame.name, + }; + const field = value.column ? value.view.dataFrame.fields[value.column] : undefined; + if (field) { + console.log('Full Field Info:', field); + } + if (value.row) { + const row = value.view.get(value.row); + console.log('ROW:', row); + const dataFrame = new DataFrameHelper(value.view.dataFrame); + + const timeField = dataFrame.getFirstFieldOfType(FieldType.time); + if (timeField) { + scopedVars[DataLinkBuiltInVars.valueTime] = { + text: 'Value time', + value: timeField.values.get(value.row), + }; + } + } + } else { + console.log('VALUE', value); + } + + return links.map(link => { + return getLinkSrv().getDataLinkUIModel(link, scopedVars, value); + }); + }, + }; +}; + +export const getPanelLinksSupplier = (value: PanelModel): LinkModelSupplier => { + const links = value.links; + + if (!links || links.length === 0) { + return undefined; + } + + return { + getLinks: () => { + return links.map(link => { + return getLinkSrv().getDataLinkUIModel(link, value.scopedVars, value); + }); + }, + }; +}; diff --git a/public/app/features/panel/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts index 38e6a19e42249..d34214efec585 100644 --- a/public/app/features/panel/panellinks/link_srv.ts +++ b/public/app/features/panel/panellinks/link_srv.ts @@ -3,15 +3,8 @@ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import templateSrv, { TemplateSrv } from 'app/features/templating/template_srv'; import coreModule from 'app/core/core_module'; import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url'; -import { VariableSuggestion, ScopedVars, VariableOrigin } from '@grafana/ui'; -import { TimeSeriesValue, DateTime, dateTime, DataLink, KeyValue, deprecationWarning } from '@grafana/data'; - -export const DataLinkBuiltInVars = { - keepTime: '__url_time_range', - includeVars: '__all_variables', - seriesName: '__series_name', - valueTime: '__value_time', -}; +import { VariableSuggestion, ScopedVars, VariableOrigin, DataLinkBuiltInVars } from '@grafana/ui'; +import { DataLink, KeyValue, deprecationWarning, LinkModel } from '@grafana/data'; export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [ ...templateSrv.variables.map(variable => ({ @@ -44,22 +37,17 @@ export const getDataLinksVariableSuggestions = (): VariableSuggestion[] => [ }, ]; -type LinkTarget = '_blank' | '_self'; - -export interface LinkModel { - href: string; - title: string; - target: LinkTarget; -} +export const getCalculationValueDataLinksVariableSuggestions = (): VariableSuggestion[] => [ + ...getPanelLinksVariableSuggestions(), + { + value: `${DataLinkBuiltInVars.seriesName}`, + documentation: 'Adds series name', + origin: VariableOrigin.BuiltIn, + }, +]; -interface LinkDataPoint { - datapoint: TimeSeriesValue[]; - seriesName: string; - [key: number]: any; -} export interface LinkService { - getDataLinkUIModel: (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => LinkModel; - getDataPointVars: (seriesName: string, dataPointTs: DateTime) => ScopedVars; + getDataLinkUIModel: (link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel; } export class LinkSrv implements LinkService { @@ -90,33 +78,20 @@ export class LinkSrv implements LinkService { return info; } - getDataPointVars = (seriesName: string, valueTime: DateTime) => { - return { - [DataLinkBuiltInVars.valueTime]: { - text: valueTime.valueOf(), - value: valueTime.valueOf(), - }, - [DataLinkBuiltInVars.seriesName]: { - text: seriesName, - value: seriesName, - }, - }; - }; - - getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, dataPoint?: LinkDataPoint) => { + getDataLinkUIModel = (link: DataLink, scopedVars: ScopedVars, origin: T) => { const params: KeyValue = {}; const timeRangeUrl = toUrlParams(this.timeSrv.timeRangeForUrl()); - const info: LinkModel = { + const info: LinkModel = { href: link.url, title: this.templateSrv.replace(link.title || '', scopedVars), target: link.targetBlank ? '_blank' : '_self', + origin, }; this.templateSrv.fillVariableValuesForUrl(params, scopedVars); const variablesQuery = toUrlParams(params); - info.href = this.templateSrv.replace(link.url, { ...scopedVars, [DataLinkBuiltInVars.keepTime]: { @@ -129,13 +104,6 @@ export class LinkSrv implements LinkService { }, }); - if (dataPoint) { - info.href = this.templateSrv.replace( - info.href, - this.getDataPointVars(dataPoint.seriesName, dateTime(dataPoint.datapoint[0])) - ); - } - return info; }; @@ -146,7 +114,7 @@ export class LinkSrv implements LinkService { */ getPanelLinkAnchorInfo(link: DataLink, scopedVars: ScopedVars) { deprecationWarning('link_srv.ts', 'getPanelLinkAnchorInfo', 'getDataLinkUIModel'); - return this.getDataLinkUIModel(link, scopedVars); + return this.getDataLinkUIModel(link, scopedVars, {}); } } diff --git a/public/app/features/panel/panellinks/specs/link_srv.test.ts b/public/app/features/panel/panellinks/specs/link_srv.test.ts index 4cdf65dffd6c3..3320d3aa128fb 100644 --- a/public/app/features/panel/panellinks/specs/link_srv.test.ts +++ b/public/app/features/panel/panellinks/specs/link_srv.test.ts @@ -1,4 +1,5 @@ -import { LinkSrv, DataLinkBuiltInVars } from '../link_srv'; +import { LinkSrv } from '../link_srv'; +import { DataLinkBuiltInVars } from '@grafana/ui'; import _ from 'lodash'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TemplateSrv } from 'app/features/templating/template_srv'; @@ -80,6 +81,7 @@ describe('linkSrv', () => { title: 'Any title', url: `/d/1?$${DataLinkBuiltInVars.keepTime}`, }, + {}, {} ).href ).toEqual('/d/1?from=now-1h&to=now'); @@ -92,32 +94,43 @@ describe('linkSrv', () => { title: 'Any title', url: `/d/1?$${DataLinkBuiltInVars.includeVars}`, }, + {}, {} ).href ).toEqual('/d/1?var-test1=val1&var-test2=val2'); }); - it('should interpolate series name from datapoint', () => { + it('should interpolate series name', () => { expect( linkSrv.getDataLinkUIModel( { title: 'Any title', url: `/d/1?var-test=$${DataLinkBuiltInVars.seriesName}`, }, - {}, - dataPointMock + { + [DataLinkBuiltInVars.seriesName]: { + value: 'A-series', + text: 'A-series', + }, + }, + {} ).href ).toEqual('/d/1?var-test=A-series'); }); - it('should interpolate time range based on datapoint timestamp', () => { + it('should interpolate value time', () => { expect( linkSrv.getDataLinkUIModel( { title: 'Any title', url: `/d/1?time=$${DataLinkBuiltInVars.valueTime}`, }, - {}, - dataPointMock + { + [DataLinkBuiltInVars.valueTime]: { + value: dataPointMock.datapoint[0], + text: dataPointMock.datapoint[0], + }, + }, + {} ).href ).toEqual('/d/1?time=1000000001'); }); diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx index 43a65ffe6ec43..b2dd42058a2f7 100644 --- a/public/app/features/plugins/PluginPage.tsx +++ b/public/app/features/plugins/PluginPage.tsx @@ -6,7 +6,7 @@ import find from 'lodash/find'; // Types import { UrlQueryMap } from '@grafana/runtime'; -import { StoreState } from 'app/types'; +import { StoreState, AppNotificationSeverity } from 'app/types'; import { PluginType, GrafanaPlugin, @@ -30,6 +30,7 @@ import { PluginDashboards } from './PluginDashboards'; import { appEvents } from 'app/core/core'; import { config } from 'app/core/config'; import { ContextSrv } from '../../core/services/context_srv'; +import { AlertBox } from 'app/core/components/AlertBox/AlertBox'; export function getLoadingNav(): NavModel { const node = { @@ -140,7 +141,7 @@ class PluginPage extends PureComponent { const { plugin, nav } = this.state; if (!plugin) { - return
    Plugin not found.
    ; + return ; } const active = nav.main.children.find(tab => tab.active); @@ -297,7 +298,21 @@ class PluginPage extends PureComponent { {!loading && (
    -
    {this.renderBody()}
    +
    + {plugin.loadError && ( + + Check the server startup logs for more information.
    + If this plugin was loaded from git, make sure it was compiled. + + } + /> + )} + {this.renderBody()} +