Merge pull request #15012 from grafana/loki-query-editor

WIP: Loki query editor for dashboard panels
pull/15206/head
Torkel Ödegaard 7 years ago committed by GitHub
commit 116e70740c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-ui/src/types/datasource.ts
  2. 4
      packages/grafana-ui/src/types/plugin.ts
  3. 3
      public/app/core/components/Select/MetricSelect.tsx
  4. 5
      public/app/core/logs_model.ts
  5. 6
      public/app/features/dashboard/panel_editor/QueriesTab.tsx
  6. 16
      public/app/features/dashboard/panel_editor/QueryEditorRow.tsx
  7. 13
      public/app/features/dashboard/state/PanelModel.ts
  8. 2
      public/app/features/explore/Explore.tsx
  9. 5
      public/app/features/explore/Logs.tsx
  10. 17
      public/app/features/explore/QueryField.tsx
  11. 4
      public/app/features/explore/QueryRow.tsx
  12. 80
      public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx
  13. 5
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  14. 43
      public/app/plugins/datasource/loki/datasource.test.ts
  15. 44
      public/app/plugins/datasource/loki/datasource.ts
  16. 2
      public/app/plugins/datasource/loki/module.ts
  17. 4
      public/app/plugins/datasource/loki/plugin.json
  18. 3
      public/app/plugins/datasource/loki/types.ts
  19. 4
      public/app/plugins/datasource/testdata/QueryEditor.tsx
  20. 1
      public/sass/components/_slate_editor.scss

@ -3,7 +3,7 @@ import { PluginMeta } from './plugin';
import { TableData, TimeSeries } from './data'; import { TableData, TimeSeries } from './data';
export interface DataQueryResponse { export interface DataQueryResponse {
data: TimeSeries[] | [TableData]; data: TimeSeries[] | [TableData] | any;
} }
export interface DataQuery { export interface DataQuery {

@ -44,8 +44,8 @@ export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> { export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
datasource: DSType; datasource: DSType;
query: TQuery; query: TQuery;
onExecuteQuery?: () => void; onRunQuery: () => void;
onQueryChange?: (value: TQuery) => void; onChange: (value: TQuery) => void;
} }
export interface PluginExports { export interface PluginExports {

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { Select } from '@grafana/ui'; import { Select, SelectOptionItem } from '@grafana/ui';
import { SelectOptionItem } from '@grafana/ui';
import { Variable } from 'app/types/templates'; import { Variable } from 'app/types/templates';
export interface Props { export interface Props {

@ -1,7 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { colors } from '@grafana/ui';
import { TimeSeries } from 'app/core/core'; import { colors, TimeSeries } from '@grafana/ui';
import { getThemeColor } from 'app/core/utils/colors'; import { getThemeColor } from 'app/core/utils/colors';
/** /**
@ -341,6 +340,6 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Time
return a[1] - b[1]; return a[1] - b[1];
}); });
return new TimeSeries(series); return { datapoints: series.datapoints, target: series.alias, color: series.color };
}); });
} }

@ -165,6 +165,11 @@ export class QueriesTab extends PureComponent<Props, State> {
this.setState({ isAddingMixed: false }); this.setState({ isAddingMixed: false });
}; };
onQueryChange = (query: DataQuery, index) => {
this.props.panel.changeQuery(query, index);
this.forceUpdate();
};
setScrollTop = (event: React.MouseEvent<HTMLElement>) => { setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
this.setState({ scrollTop: target.scrollTop }); this.setState({ scrollTop: target.scrollTop });
@ -201,6 +206,7 @@ export class QueriesTab extends PureComponent<Props, State> {
key={query.refId} key={query.refId}
panel={panel} panel={panel}
query={query} query={query}
onChange={query => this.onQueryChange(query, index)}
onRemoveQuery={this.onRemoveQuery} onRemoveQuery={this.onRemoveQuery}
onAddQuery={this.onAddQuery} onAddQuery={this.onAddQuery}
onMoveQuery={this.onMoveQuery} onMoveQuery={this.onMoveQuery}

@ -18,6 +18,7 @@ interface Props {
onAddQuery: (query?: DataQuery) => void; onAddQuery: (query?: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void; onRemoveQuery: (query: DataQuery) => void;
onMoveQuery: (query: DataQuery, direction: number) => void; onMoveQuery: (query: DataQuery, direction: number) => void;
onChange: (query: DataQuery) => void;
dataSourceValue: string | null; dataSourceValue: string | null;
inMixedMode: boolean; inMixedMode: boolean;
} }
@ -105,17 +106,12 @@ export class QueryEditorRow extends PureComponent<Props, State> {
this.setState({ isCollapsed: !this.state.isCollapsed }); this.setState({ isCollapsed: !this.state.isCollapsed });
}; };
onQueryChange = (query: DataQuery) => { onRunQuery = () => {
Object.assign(this.props.query, query);
this.onExecuteQuery();
};
onExecuteQuery = () => {
this.props.panel.refresh(); this.props.panel.refresh();
}; };
renderPluginEditor() { renderPluginEditor() {
const { query } = this.props; const { query, onChange } = this.props;
const { datasource } = this.state; const { datasource } = this.state;
if (datasource.pluginExports.QueryCtrl) { if (datasource.pluginExports.QueryCtrl) {
@ -128,8 +124,8 @@ export class QueryEditorRow extends PureComponent<Props, State> {
<QueryEditor <QueryEditor
query={query} query={query}
datasource={datasource} datasource={datasource}
onQueryChange={this.onQueryChange} onChange={onChange}
onExecuteQuery={this.onExecuteQuery} onRunQuery={this.onRunQuery}
/> />
); );
} }
@ -166,7 +162,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
onDisableQuery = () => { onDisableQuery = () => {
this.props.query.hide = !this.props.query.hide; this.props.query.hide = !this.props.query.hide;
this.onExecuteQuery(); this.onRunQuery();
this.forceUpdate(); this.forceUpdate();
}; };

@ -269,6 +269,19 @@ export class PanelModel {
}); });
} }
changeQuery(query: DataQuery, index: number) {
// ensure refId is maintained
query.refId = this.targets[index].refId;
// update query in array
this.targets = this.targets.map((item, itemIndex) => {
if (itemIndex === index) {
return query;
}
return item;
});
}
destroy() { destroy() {
this.events.emit('panel-teardown'); this.events.emit('panel-teardown');
this.events.removeAllListeners(); this.events.removeAllListeners();

@ -216,7 +216,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
{showingStartPage && <StartPage onClickExample={this.onClickExample} />} {showingStartPage && <StartPage onClickExample={this.onClickExample} />}
{!showingStartPage && ( {!showingStartPage && (
<> <>
{supportsGraph && <GraphContainer exploreId={exploreId} />} {supportsGraph && !supportsLogs && <GraphContainer exploreId={exploreId} />}
{supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />} {supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />}
{supportsLogs && ( {supportsLogs && (
<LogsContainer <LogsContainer

@ -3,6 +3,8 @@ import React, { PureComponent } from 'react';
import * as rangeUtil from 'app/core/utils/rangeutil'; import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange, Switch } from '@grafana/ui'; import { RawTimeRange, Switch } from '@grafana/ui';
import TimeSeries from 'app/core/time_series2';
import { import {
LogsDedupDescription, LogsDedupDescription,
LogsDedupStrategy, LogsDedupStrategy,
@ -205,12 +207,13 @@ export default class Logs extends PureComponent<Props, State> {
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = () => processedRows; const getRows = () => processedRows;
const timeSeries = data.series.map(series => new TimeSeries(series));
return ( return (
<div className="logs-panel"> <div className="logs-panel">
<div className="logs-panel-graph"> <div className="logs-panel-graph">
<Graph <Graph
data={data.series} data={timeSeries}
height="100px" height="100px"
range={range} range={range}
id={`explore-logs-graph-${exploreId}`} id={`explore-logs-graph-${exploreId}`}

@ -104,11 +104,20 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
} }
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) { componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
const { initialQuery, syntax } = this.props;
const { value, suggestions } = this.state;
// if query changed from the outside
if (initialQuery !== prevProps.initialQuery) {
// and we have a version that differs
if (initialQuery !== Plain.serialize(value)) {
this.placeholdersBuffer = new PlaceholdersBuffer(initialQuery || '');
this.setState({ value: makeValue(this.placeholdersBuffer.toString(), syntax) });
}
}
// Only update menu location when suggestion existence or text/selection changed // Only update menu location when suggestion existence or text/selection changed
if ( if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
this.state.value !== prevState.value ||
hasSuggestions(this.state.suggestions) !== hasSuggestions(prevState.suggestions)
) {
this.updateMenu(); this.updateMenu();
} }
} }

@ -65,6 +65,10 @@ export class QueryRow extends PureComponent<QueryRowProps> {
} }
}; };
componentWillUnmount() {
console.log('QueryRow will unmount');
}
onClickAddButton = () => { onClickAddButton = () => {
const { exploreId, index } = this.props; const { exploreId, index } = this.props;
this.props.addQueryRow(exploreId, index); this.props.addQueryRow(exploreId, index);

@ -0,0 +1,80 @@
// Libraries
import React, { PureComponent } from 'react';
// Components
import { Select, SelectOptionItem } from '@grafana/ui';
// Types
import { QueryEditorProps } from '@grafana/ui/src/types';
import { LokiDatasource } from '../datasource';
import { LokiQuery } from '../types';
import { LokiQueryField } from './LokiQueryField';
type Props = QueryEditorProps<LokiDatasource, LokiQuery>;
interface State {
query: LokiQuery;
}
export class LokiQueryEditor extends PureComponent<Props> {
state: State = {
query: this.props.query,
};
onRunQuery = () => {
const { query } = this.state;
this.props.onChange(query);
this.props.onRunQuery();
};
onFieldChange = (query: LokiQuery, override?) => {
this.setState({
query: {
...this.state.query,
expr: query.expr,
}
});
};
onFormatChanged = (option: SelectOptionItem) => {
this.props.onChange({
...this.state.query,
resultFormat: option.value,
});
};
render() {
const { query } = this.state;
const { datasource } = this.props;
const formatOptions: SelectOptionItem[] = [
{ label: 'Time Series', value: 'time_series' },
{ label: 'Table', value: 'table' },
];
query.resultFormat = query.resultFormat || 'time_series';
const currentFormat = formatOptions.find(item => item.value === query.resultFormat);
return (
<div>
<LokiQueryField
datasource={datasource}
initialQuery={query}
onQueryChange={this.onFieldChange}
onPressEnter={this.onRunQuery}
/>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-label">Format as</div>
<Select isSearchable={false} options={formatOptions} onChange={this.onFormatChanged} value={currentFormat} />
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
</div>
);
}
}
export default LokiQueryEditor;

@ -12,6 +12,7 @@ import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explor
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom'; import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
import BracesPlugin from 'app/features/explore/slate-plugins/braces'; import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import RunnerPlugin from 'app/features/explore/slate-plugins/runner'; import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
import LokiDatasource from '../datasource';
// Types // Types
import { LokiQuery } from '../types'; import { LokiQuery } from '../types';
@ -65,7 +66,7 @@ interface CascaderOption {
} }
interface LokiQueryFieldProps { interface LokiQueryFieldProps {
datasource: any; datasource: LokiDatasource;
error?: string | JSX.Element; error?: string | JSX.Element;
hint?: any; hint?: any;
history?: any[]; history?: any[];
@ -80,7 +81,7 @@ interface LokiQueryFieldState {
syntaxLoaded: boolean; syntaxLoaded: boolean;
} }
class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> { export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
plugins: any[]; plugins: any[];
pluginsSearch: any[]; pluginsSearch: any[];
languageProvider: any; languageProvider: any;

@ -7,6 +7,17 @@ describe('LokiDatasource', () => {
url: 'myloggingurl', url: 'myloggingurl',
}; };
const testResp = {
data: {
streams: [
{
entries: [{ ts: '2019-02-01T10:27:37.498180581Z', line: 'hello' }],
labels: '{}',
},
],
},
};
describe('when querying', () => { describe('when querying', () => {
const backendSrvMock = { datasourceRequest: jest.fn() }; const backendSrvMock = { datasourceRequest: jest.fn() };
@ -17,7 +28,7 @@ describe('LokiDatasource', () => {
test('should use default max lines when no limit given', () => { test('should use default max lines when no limit given', () => {
const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock); const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn(); backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] }); const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] });
ds.query(options); ds.query(options);
@ -30,7 +41,7 @@ describe('LokiDatasource', () => {
const customData = { ...(instanceSettings.jsonData || {}), maxLines: 20 }; const customData = { ...(instanceSettings.jsonData || {}), maxLines: 20 };
const customSettings = { ...instanceSettings, jsonData: customData }; const customSettings = { ...instanceSettings, jsonData: customData };
const ds = new LokiDatasource(customSettings, backendSrvMock, templateSrvMock); const ds = new LokiDatasource(customSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn(); backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] }); const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] });
ds.query(options); ds.query(options);
@ -38,6 +49,34 @@ describe('LokiDatasource', () => {
expect(backendSrvMock.datasourceRequest.mock.calls.length).toBe(1); expect(backendSrvMock.datasourceRequest.mock.calls.length).toBe(1);
expect(backendSrvMock.datasourceRequest.mock.calls[0][0].url).toContain('limit=20'); expect(backendSrvMock.datasourceRequest.mock.calls[0][0].url).toContain('limit=20');
}); });
test('should return log streams when resultFormat is undefined', async done => {
const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: 'foo', refId: 'B' }],
});
const res = await ds.query(options);
expect(res.data[0].entries[0].line).toBe('hello');
done();
});
test('should return time series when resultFormat is time_series', async done => {
const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: 'foo', refId: 'B', resultFormat: 'time_series' }],
});
const res = await ds.query(options);
expect(res.data[0].datapoints).toBeDefined();
done();
});
}); });
describe('when performing testDataSource', () => { describe('when performing testDataSource', () => {

@ -32,7 +32,7 @@ function serializeParams(data: any) {
.join('&'); .join('&');
} }
export default class LokiDatasource { export class LokiDatasource {
languageProvider: LanguageProvider; languageProvider: LanguageProvider;
maxLines: number; maxLines: number;
@ -73,10 +73,11 @@ export default class LokiDatasource {
}; };
} }
query(options: DataQueryOptions<LokiQuery>): Promise<{ data: LogsStream[] }> { async query(options: DataQueryOptions<LokiQuery>) {
const queryTargets = options.targets const queryTargets = options.targets
.filter(target => target.expr) .filter(target => target.expr && !target.hide)
.map(target => this.prepareQueryTarget(target, options)); .map(target => this.prepareQueryTarget(target, options));
if (queryTargets.length === 0) { if (queryTargets.length === 0) {
return Promise.resolve({ data: [] }); return Promise.resolve({ data: [] });
} }
@ -84,20 +85,29 @@ export default class LokiDatasource {
const queries = queryTargets.map(target => this._request('/api/prom/query', target)); const queries = queryTargets.map(target => this._request('/api/prom/query', target));
return Promise.all(queries).then((results: any[]) => { return Promise.all(queries).then((results: any[]) => {
// Flatten streams from multiple queries const allStreams: LogsStream[] = [];
const allStreams: LogsStream[] = results.reduce((acc, response, i) => {
if (!response) { for (let i = 0; i < results.length; i++) {
return acc; const result = results[i];
} const query = queryTargets[i];
const streams: LogsStream[] = response.data.streams || [];
// Inject search for match highlighting // add search term to stream & add to array
const search: string = queryTargets[i].regexp; if (result.data) {
streams.forEach(s => { for (const stream of (result.data.streams || [])) {
s.search = search; stream.search = query.regexp;
}); allStreams.push(stream);
return [...acc, ...streams]; }
}, []); }
}
// check resultType
if (options.targets[0].resultFormat === 'time_series') {
const logs = mergeStreamsToLogs(allStreams, this.maxLines);
logs.series = makeSeriesForLogs(logs.rows, options.intervalMs);
return { data: logs.series };
} else {
return { data: allStreams }; return { data: allStreams };
}
}); });
} }
@ -173,3 +183,5 @@ export default class LokiDatasource {
}); });
} }
} }
export default LokiDatasource;

@ -2,6 +2,7 @@ import Datasource from './datasource';
import LokiStartPage from './components/LokiStartPage'; import LokiStartPage from './components/LokiStartPage';
import LokiQueryField from './components/LokiQueryField'; import LokiQueryField from './components/LokiQueryField';
import LokiQueryEditor from './components/LokiQueryEditor';
export class LokiConfigCtrl { export class LokiConfigCtrl {
static templateUrl = 'partials/config.html'; static templateUrl = 'partials/config.html';
@ -9,6 +10,7 @@ export class LokiConfigCtrl {
export { export {
Datasource, Datasource,
LokiQueryEditor as QueryEditor,
LokiConfigCtrl as ConfigCtrl, LokiConfigCtrl as ConfigCtrl,
LokiQueryField as ExploreQueryField, LokiQueryField as ExploreQueryField,
LokiStartPage as ExploreStartPage, LokiStartPage as ExploreStartPage,

@ -2,12 +2,14 @@
"type": "datasource", "type": "datasource",
"name": "Loki", "name": "Loki",
"id": "loki", "id": "loki",
"metrics": false,
"metrics": true,
"alerting": false, "alerting": false,
"annotations": false, "annotations": false,
"logs": true, "logs": true,
"explore": true, "explore": true,
"tables": false, "tables": false,
"info": { "info": {
"description": "Loki Logging Data Source for Grafana", "description": "Loki Logging Data Source for Grafana",
"author": { "author": {

@ -2,5 +2,8 @@ import { DataQuery } from '@grafana/ui/src/types';
export interface LokiQuery extends DataQuery { export interface LokiQuery extends DataQuery {
expr: string; expr: string;
resultFormat?: LokiQueryResultFormats;
} }
export type LokiQueryResultFormats = 'time_series' | 'logs';

@ -41,9 +41,9 @@ export class QueryEditor extends PureComponent<Props> {
} }
onScenarioChange = (item: SelectOptionItem) => { onScenarioChange = (item: SelectOptionItem) => {
this.props.onQueryChange({ this.props.onChange({
...this.props.query,
scenarioId: item.value, scenarioId: item.value,
...this.props.query
}); });
} }

@ -19,6 +19,7 @@
border: $panel-border; border: $panel-border;
border-radius: $border-radius; border-radius: $border-radius;
transition: all 0.3s; transition: all 0.3s;
line-height: $input-line-height;
} }
.slate-query-field__wrapper--disabled { .slate-query-field__wrapper--disabled {

Loading…
Cancel
Save