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 00000000000..eba11779ce0 --- /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/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx index 6e32b7ca545..17b2090e586 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/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index addc3073339..1b037cbd657 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 e4dde963bb2..1caba8e2cc4 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 4f0d019730a..d0576df7279 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/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index fdd29299ece..5227f6e31d2 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -1,5 +1,6 @@ import * as graphitePlugin from 'app/plugins/datasource/graphite/module'; import * as cloudwatchPlugin from 'app/plugins/datasource/cloudwatch/module'; +import * as dashboardDSPlugin from 'app/plugins/datasource/dashboard/module'; import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/module'; import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module'; import * as grafanaPlugin from 'app/plugins/datasource/grafana/module'; @@ -39,6 +40,7 @@ import * as exampleApp from 'app/plugins/app/example-app/module'; const builtInPlugins: any = { 'app/plugins/datasource/graphite/module': graphitePlugin, 'app/plugins/datasource/cloudwatch/module': cloudwatchPlugin, + 'app/plugins/datasource/dashboard/module': dashboardDSPlugin, 'app/plugins/datasource/elasticsearch/module': elasticsearchPlugin, 'app/plugins/datasource/opentsdb/module': opentsdbPlugin, 'app/plugins/datasource/grafana/module': grafanaPlugin, diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index fe5fff56e59..70f59ad2ecc 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -125,8 +125,10 @@ export class DatasourceSrv implements DataSourceService { //Make sure grafana and mixed are sorted at the bottom if (value.meta.id === 'grafana') { metricSource.sort = String.fromCharCode(253); - } else if (value.meta.id === 'mixed') { + } else if (value.meta.id === 'dashboard') { metricSource.sort = String.fromCharCode(254); + } else if (value.meta.id === 'mixed') { + metricSource.sort = String.fromCharCode(255); } metricSources.push(metricSource); diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx new file mode 100644 index 00000000000..7434f0cf363 --- /dev/null +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -0,0 +1,193 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Types +import { Select, DataQuery, DataQueryError, PanelData } from '@grafana/ui'; +import { DataFrame, SelectableValue } from '@grafana/data'; +import { DashboardQuery } from './types'; +import config from 'app/core/config'; +import { css } from 'emotion'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { PanelModel } from 'app/features/dashboard/state'; +import { SHARED_DASHBODARD_QUERY } from './SharedQueryRunner'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { filterPanelDataToQuery } from 'app/features/dashboard/panel_editor/QueryEditorRow'; + +type ResultInfo = { + img: string; // The Datasource + refId: string; + query: string; // As text + data: DataFrame[]; + error?: DataQueryError; +}; + +function getQueryDisplayText(query: DataQuery): string { + return JSON.stringify(query); +} + +interface Props { + panel: PanelModel; + panelData: PanelData; + onChange: (query: DashboardQuery) => void; +} + +type State = { + defaultDatasource: string; + results: ResultInfo[]; +}; + +export class DashboardQueryEditor extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + defaultDatasource: '', + results: [], + }; + } + + getQuery(): DashboardQuery { + const { panel } = this.props; + return panel.targets[0] as DashboardQuery; + } + + async componentDidMount() { + this.componentDidUpdate(null); + } + + async componentDidUpdate(prevProps: Props) { + const { panelData } = this.props; + + if (!prevProps || prevProps.panelData !== panelData) { + const query = this.props.panel.targets[0] as DashboardQuery; + const defaultDS = await getDatasourceSrv().get(null); + const dashboard = getDashboardSrv().getCurrent(); + const panel = dashboard.getPanelById(query.panelId); + + if (!panel) { + this.setState({ defaultDatasource: defaultDS.name }); + return; + } + + const mainDS = await getDatasourceSrv().get(panel.datasource); + const info: ResultInfo[] = []; + + for (const query of panel.targets) { + const ds = query.datasource ? await getDatasourceSrv().get(query.datasource) : mainDS; + const fmt = ds.getQueryDisplayText ? ds.getQueryDisplayText : getQueryDisplayText; + + const qData = filterPanelDataToQuery(panelData, query.refId); + const queryData = qData ? qData : panelData; + + info.push({ + refId: query.refId, + query: fmt(query), + img: ds.meta.info.logos.small, + data: queryData.series, + error: queryData.error, + }); + } + + this.setState({ defaultDatasource: defaultDS.name, results: info }); + } + } + + onPanelChanged = (id: number) => { + const { onChange } = this.props; + const query = this.getQuery(); + query.panelId = id; + onChange(query); + + // Update the + this.props.panel.refresh(); + }; + + renderQueryData(editURL: string) { + const { results } = this.state; + + return ( +
+ {results.map((target, index) => { + return ( +
+
+ + {target.refId}: +
+ +
+ ); + })} +
+ ); + } + + getPanelDescription = (panel: PanelModel): string => { + const { defaultDatasource } = this.state; + const dsname = panel.datasource ? panel.datasource : defaultDatasource; + + if (panel.targets.length === 1) { + return '1 query to ' + dsname; + } + + return panel.targets.length + ' queries to ' + dsname; + }; + + render() { + const dashboard = getDashboardSrv().getCurrent(); + const query = this.getQuery(); + + let selected: SelectableValue; + const panels: Array> = []; + + for (const panel of dashboard.panels) { + if (panel.targets && panel.datasource !== SHARED_DASHBODARD_QUERY) { + const plugin = config.panels[panel.type]; + const item = { + value: panel.id, + label: panel.title ? panel.title : 'Panel ' + panel.id, + description: this.getPanelDescription(panel), + imgUrl: plugin.info.logos.small, + }; + + panels.push(item); + + if (query.panelId === panel.id) { + selected = item; + } + } + } + + if (panels.length < 1) { + return ( +
+ This dashboard does not have other panels. Add queries to other panels and try again +
+ ); + } + + // Same as current URL, but different panelId + const editURL = `d/${dashboard.uid}/${dashboard.title}?&fullscreen&edit&panelId=${query.panelId}`; + + return ( +
+
+
Use results from panel
+