diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index 381d0db1765..bf3a6fdb958 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -33,6 +33,11 @@ export enum LogsMetaKind { LabelsMap, } +export enum LogsSortOrder { + Descending = 'Descending', + Ascending = 'Ascending', +} + export interface LogsMetaItem { label: string; value: string | number | Labels; diff --git a/packages/grafana-data/src/utils/logs.test.ts b/packages/grafana-data/src/utils/logs.test.ts index c51e479ae7b..b92877b6db1 100644 --- a/packages/grafana-data/src/utils/logs.test.ts +++ b/packages/grafana-data/src/utils/logs.test.ts @@ -1,4 +1,5 @@ -import { LogLevel } from '../types/logs'; +import { LogLevel, LogsModel, LogRowModel, LogsSortOrder } from '../types/logs'; +import { MutableDataFrame } from '../dataframe/MutableDataFrame'; import { getLogLevel, calculateLogsLabelStats, @@ -7,6 +8,7 @@ import { LogsParsers, calculateStats, getLogLevelFromKey, + sortLogsResult, } from './logs'; describe('getLoglevel()', () => { @@ -284,3 +286,69 @@ describe('getParser()', () => { expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); }); }); + +describe('sortLogsResult', () => { + const firstRow: LogRowModel = { + rowIndex: 0, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: '', + hasAnsi: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 0, + timeEpochNs: '0', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '1', + }; + const sameAsFirstRow = firstRow; + const secondRow: LogRowModel = { + rowIndex: 1, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: '', + hasAnsi: false, + labels: {}, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '10000000', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }; + + describe('when called with LogsSortOrder.Descending', () => { + it('then it should sort descending', () => { + const logsResult: LogsModel = { + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, LogsSortOrder.Descending); + + expect(result).toEqual({ + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }); + }); + }); + + describe('when called with LogsSortOrder.Ascending', () => { + it('then it should sort ascending', () => { + const logsResult: LogsModel = { + rows: [secondRow, firstRow, sameAsFirstRow], + hasUniqueLabels: false, + }; + const result = sortLogsResult(logsResult, LogsSortOrder.Ascending); + + expect(result).toEqual({ + rows: [firstRow, sameAsFirstRow, secondRow], + hasUniqueLabels: false, + }); + }); + }); +}); diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index c9ae3f42679..2a9e48c62a0 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/packages/grafana-data/src/utils/logs.ts @@ -1,6 +1,6 @@ import { countBy, chain, escapeRegExp } from 'lodash'; -import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser } from '../types/logs'; +import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser, LogsModel, LogsSortOrder } from '../types/logs'; import { DataFrame, FieldType } from '../types/index'; import { ArrayVector } from '../vector/ArrayVector'; @@ -158,3 +158,55 @@ export function getParser(line: string): LogsParser | undefined { return parser; } + +export const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => { + // compare milliseconds + if (a.timeEpochMs < b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs > b.timeEpochMs) { + return 1; + } + + // if milliseconds are equal, compare nanoseconds + if (a.timeEpochNs < b.timeEpochNs) { + return -1; + } + + if (a.timeEpochNs > b.timeEpochNs) { + return 1; + } + + return 0; +}; + +export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => { + // compare milliseconds + if (a.timeEpochMs > b.timeEpochMs) { + return -1; + } + + if (a.timeEpochMs < b.timeEpochMs) { + return 1; + } + + // if milliseconds are equal, compare nanoseconds + if (a.timeEpochNs > b.timeEpochNs) { + return -1; + } + + if (a.timeEpochNs < b.timeEpochNs) { + return 1; + } + + return 0; +}; + +export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => { + const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : []; + return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows }; +}; + +export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) => + sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder); diff --git a/packages/grafana-ui/src/components/Logs/LogRow.tsx b/packages/grafana-ui/src/components/Logs/LogRow.tsx index 71ec24e7911..6f472b9bccd 100644 --- a/packages/grafana-ui/src/components/Logs/LogRow.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRow.tsx @@ -3,6 +3,7 @@ import { Field, LinkModel, LogRowModel, + LogsSortOrder, TimeZone, DataQueryResponse, GrafanaTheme, @@ -38,6 +39,7 @@ interface Props extends Themeable { wrapLogMessage: boolean; timeZone: TimeZone; allowDetails?: boolean; + logsSortOrder?: LogsSortOrder | null; getRows: () => LogRowModel[]; onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; @@ -206,11 +208,16 @@ class UnThemedLogRow extends PureComponent { render() { const { showContext } = this.state; + const { logsSortOrder } = this.props; if (showContext) { return ( <> - + {({ result, errors, hasMoreContextRows, updateLimit }) => { return <>{this.renderLogRow(result, errors, hasMoreContextRows, updateLimit)}; }} diff --git a/packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx b/packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx index f7b4c8cf3db..c87ef6f0c74 100644 --- a/packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx @@ -1,4 +1,4 @@ -import { LogRowModel, toDataFrame, Field, FieldCache } from '@grafana/data'; +import { LogRowModel, toDataFrame, Field, FieldCache, LogsSortOrder } from '@grafana/data'; import React, { useState, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; @@ -30,6 +30,7 @@ interface ResultType { interface LogRowContextProviderProps { row: LogRowModel; + logsSortOrder?: LogsSortOrder | null; getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise; children: (props: { result: LogRowContextRows; @@ -43,7 +44,8 @@ interface LogRowContextProviderProps { export const getRowContexts = async ( getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise, row: LogRowModel, - limit: number + limit: number, + logsSortOrder?: LogsSortOrder | null ) => { const promises = [ getRowContext(row, { @@ -58,62 +60,66 @@ export const getRowContexts = async ( const results: Array = await Promise.all(promises.map(p => p.catch(e => e))); - return { - data: results.map(result => { - const dataResult: DataQueryResponse = result as DataQueryResponse; - if (!dataResult.data) { - return []; - } + const data = results.map(result => { + const dataResult: DataQueryResponse = result as DataQueryResponse; + if (!dataResult.data) { + return []; + } - const data: any[] = []; - for (let index = 0; index < dataResult.data.length; index++) { - const dataFrame = toDataFrame(dataResult.data[index]); - const fieldCache = new FieldCache(dataFrame); - const timestampField: Field = fieldCache.getFieldByName('ts')!; - const idField: Field | undefined = fieldCache.getFieldByName('id'); + const data: any[] = []; + for (let index = 0; index < dataResult.data.length; index++) { + const dataFrame = toDataFrame(dataResult.data[index]); + const fieldCache = new FieldCache(dataFrame); + const timestampField: Field = fieldCache.getFieldByName('ts')!; + const idField: Field | undefined = fieldCache.getFieldByName('id'); - for (let fieldIndex = 0; fieldIndex < timestampField.values.length; fieldIndex++) { - // TODO: this filtering is datasource dependant so it will make sense to move it there so the API is - // to return correct list of lines handling inclusive ranges or how to filter the correct line on the - // datasource. + for (let fieldIndex = 0; fieldIndex < timestampField.values.length; fieldIndex++) { + // TODO: this filtering is datasource dependant so it will make sense to move it there so the API is + // to return correct list of lines handling inclusive ranges or how to filter the correct line on the + // datasource. - // Filter out the row that is the one used as a focal point for the context as we will get it in one of the - // requests. - if (idField) { - // For Loki this means we filter only the one row. Issue is we could have other rows logged at the same - // ns which came before but they come in the response that search for logs after. This means right now - // we will show those as if they came after. This is not strictly correct but seems better than losing them - // and making this correct would mean quite a bit of complexity to shuffle things around and messing up - //counts. - if (idField.values.get(fieldIndex) === row.uid) { - continue; - } - } else { - // Fallback to timestamp. This should not happen right now as this feature is implemented only for loki - // and that has ID. Later this branch could be used in other DS but mind that this could also filter out - // logs which were logged in the same timestamp and that can be a problem depending on the precision. - if (parseInt(timestampField.values.get(fieldIndex), 10) === row.timeEpochMs) { - continue; - } + // Filter out the row that is the one used as a focal point for the context as we will get it in one of the + // requests. + if (idField) { + // For Loki this means we filter only the one row. Issue is we could have other rows logged at the same + // ns which came before but they come in the response that search for logs after. This means right now + // we will show those as if they came after. This is not strictly correct but seems better than losing them + // and making this correct would mean quite a bit of complexity to shuffle things around and messing up + //counts. + if (idField.values.get(fieldIndex) === row.uid) { + continue; + } + } else { + // Fallback to timestamp. This should not happen right now as this feature is implemented only for loki + // and that has ID. Later this branch could be used in other DS but mind that this could also filter out + // logs which were logged in the same timestamp and that can be a problem depending on the precision. + if (parseInt(timestampField.values.get(fieldIndex), 10) === row.timeEpochMs) { + continue; } - - const lineField: Field = dataFrame.fields.filter(field => field.name === 'line')[0]; - const line = lineField.values.get(fieldIndex); // assuming that both fields have same length - - data.push(line); } - } - return data; - }), - errors: results.map(result => { - const errorResult: DataQueryError = result as DataQueryError; - if (!errorResult.message) { - return ''; - } + const lineField: Field = dataFrame.fields.filter(field => field.name === 'line')[0]; + const line = lineField.values.get(fieldIndex); // assuming that both fields have same length - return errorResult.message; - }), + data.push(line); + } + } + + return logsSortOrder === LogsSortOrder.Ascending ? data.reverse() : data; + }); + + const errors = results.map(result => { + const errorResult: DataQueryError = result as DataQueryError; + if (!errorResult.message) { + return ''; + } + + return errorResult.message; + }); + + return { + data: logsSortOrder === LogsSortOrder.Ascending ? data.reverse() : data, + errors: logsSortOrder === LogsSortOrder.Ascending ? errors.reverse() : errors, }; }; @@ -121,6 +127,7 @@ export const LogRowContextProvider: React.FunctionComponent { // React Hook that creates a number state value called limit to component state and a setter function called setLimit // The initial value for limit is 10 @@ -144,7 +151,7 @@ export const LogRowContextProvider: React.FunctionComponent { - return await getRowContexts(getRowContext, row, limit); // Moved it to a separate function for debugging purposes + return await getRowContexts(getRowContext, row, limit, logsSortOrder); // Moved it to a separate function for debugging purposes }, [limit]); // React Hook that performs a side effect every time the value (from useAsync hook) prop changes diff --git a/packages/grafana-ui/src/components/Logs/LogRows.test.tsx b/packages/grafana-ui/src/components/Logs/LogRows.test.tsx index eeb4408f068..6e5fc500656 100644 --- a/packages/grafana-ui/src/components/Logs/LogRows.test.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRows.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { range } from 'lodash'; import { LogRows, PREVIEW_LIMIT } from './LogRows'; import { mount } from 'enzyme'; -import { LogLevel, LogRowModel, LogsDedupStrategy, MutableDataFrame } from '@grafana/data'; +import { LogLevel, LogRowModel, LogsDedupStrategy, MutableDataFrame, LogsSortOrder } from '@grafana/data'; import { LogRow } from './LogRow'; describe('LogRows', () => { @@ -93,10 +93,88 @@ describe('LogRows', () => { expect(wrapper.find(LogRow).length).toBe(100); }); + + it('renders asc ordered rows if order and function supplied', () => { + const rows: LogRowModel[] = [ + makeLog({ uid: '1', timeEpochMs: 1 }), + makeLog({ uid: '3', timeEpochMs: 3 }), + makeLog({ uid: '2', timeEpochMs: 2 }), + ]; + const wrapper = mount( + + ); + + expect( + wrapper + .find(LogRow) + .at(0) + .text() + ).toBe('log message 1'); + expect( + wrapper + .find(LogRow) + .at(1) + .text() + ).toBe('log message 2'); + expect( + wrapper + .find(LogRow) + .at(2) + .text() + ).toBe('log message 3'); + }); + it('renders desc ordered rows if order and function supplied', () => { + const rows: LogRowModel[] = [ + makeLog({ uid: '1', timeEpochMs: 1 }), + makeLog({ uid: '3', timeEpochMs: 3 }), + makeLog({ uid: '2', timeEpochMs: 2 }), + ]; + const wrapper = mount( + + ); + + expect( + wrapper + .find(LogRow) + .at(0) + .text() + ).toBe('log message 3'); + expect( + wrapper + .find(LogRow) + .at(1) + .text() + ).toBe('log message 2'); + expect( + wrapper + .find(LogRow) + .at(2) + .text() + ).toBe('log message 1'); + }); }); const makeLog = (overrides: Partial): LogRowModel => { const uid = overrides.uid || '1'; + const timeEpochMs = overrides.timeEpochMs || 1; const entry = `log message ${uid}`; return { entryFieldIndex: 0, @@ -110,8 +188,8 @@ const makeLog = (overrides: Partial): LogRowModel => { labels: {}, raw: entry, timeFromNow: '', - timeEpochMs: 1, - timeEpochNs: '1000000', + timeEpochMs, + timeEpochNs: (timeEpochMs * 1000000).toString(), timeLocal: '', timeUtc: '', searchWords: [], diff --git a/packages/grafana-ui/src/components/Logs/LogRows.tsx b/packages/grafana-ui/src/components/Logs/LogRows.tsx index d44432280ad..b13a3ddcb1f 100644 --- a/packages/grafana-ui/src/components/Logs/LogRows.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRows.tsx @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import memoizeOne from 'memoize-one'; -import { TimeZone, LogsDedupStrategy, LogRowModel, Field, LinkModel } from '@grafana/data'; +import { TimeZone, LogsDedupStrategy, LogRowModel, Field, LinkModel, LogsSortOrder, sortLogRows } from '@grafana/data'; import { Themeable } from '../../types/theme'; import { withTheme } from '../../themes/index'; @@ -23,6 +23,7 @@ export interface Props extends Themeable { showTime: boolean; wrapLogMessage: boolean; timeZone: TimeZone; + logsSortOrder?: LogsSortOrder | null; rowLimit?: number; allowDetails?: boolean; previewLimit?: number; @@ -70,10 +71,14 @@ class UnThemedLogRows extends PureComponent { } } - makeGetRows = memoizeOne((processedRows: LogRowModel[]) => { - return () => processedRows; + makeGetRows = memoizeOne((orderedRows: LogRowModel[]) => { + return () => orderedRows; }); + sortLogs = memoizeOne((logRows: LogRowModel[], logsSortOrder: LogsSortOrder): LogRowModel[] => + sortLogRows(logRows, logsSortOrder) + ); + render() { const { dedupStrategy, @@ -93,6 +98,7 @@ class UnThemedLogRows extends PureComponent { previewLimit, getFieldLinks, disableCustomHorizontalScroll, + logsSortOrder, } = this.props; const { renderAll } = this.state; const { logsRowsTable, logsRowsHorizontalScroll } = getLogRowStyles(theme); @@ -109,12 +115,13 @@ class UnThemedLogRows extends PureComponent { // Staged rendering const processedRows = dedupedRows ? dedupedRows : []; - const firstRows = processedRows.slice(0, previewLimit!); - const rowCount = Math.min(processedRows.length, rowLimit!); - const lastRows = processedRows.slice(previewLimit!, rowCount); + const orderedRows = logsSortOrder ? this.sortLogs(processedRows, logsSortOrder) : processedRows; + const firstRows = orderedRows.slice(0, previewLimit!); + const rowCount = Math.min(orderedRows.length, rowLimit!); + const lastRows = orderedRows.slice(previewLimit!, rowCount); // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead - const getRows = this.makeGetRows(processedRows); + const getRows = this.makeGetRows(orderedRows); const getRowContext = this.props.getRowContext ? this.props.getRowContext : () => Promise.resolve([]); return ( @@ -139,6 +146,7 @@ class UnThemedLogRows extends PureComponent { onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} getFieldLinks={getFieldLinks} + logsSortOrder={logsSortOrder} /> ))} {hasData && @@ -159,6 +167,7 @@ class UnThemedLogRows extends PureComponent { onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} getFieldLinks={getFieldLinks} + logsSortOrder={logsSortOrder} /> ))} {hasData && !renderAll && ( diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index c8f9c470700..a6fee79c96a 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -28,10 +28,11 @@ import { textUtil, dateTime, AbsoluteTimeRange, + sortInAscendingOrder, } from '@grafana/data'; import { getThemeColor } from 'app/core/utils/colors'; -import { sortInAscendingOrder, deduplicateLogRowsById } from 'app/core/utils/explore'; +import { deduplicateLogRowsById } from 'app/core/utils/explore'; import { decimalSIPrefix } from '@grafana/data/src/valueFormats/symbolFormatters'; export const LogLevelColor = { diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 4717e0d0233..bffe80a9f1f 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -8,23 +8,12 @@ import { hasNonEmptyQuery, parseUrlState, refreshIntervalToSortOrder, - sortLogsResult, - SortOrder, updateHistory, getExploreUrl, GetExploreUrlArguments, } from './explore'; import store from 'app/core/store'; -import { - DataQueryError, - dateTime, - LogLevel, - LogRowModel, - LogsDedupStrategy, - LogsModel, - MutableDataFrame, - ExploreUrlState, -} from '@grafana/data'; +import { DataQueryError, dateTime, LogsDedupStrategy, ExploreUrlState, LogsSortOrder } from '@grafana/data'; import { RefreshPicker } from '@grafana/ui'; import { serializeStateToUrlParam } from '@grafana/data/src/utils/url'; @@ -392,7 +381,7 @@ describe('refreshIntervalToSortOrder', () => { it('then it should return ascending', () => { const result = refreshIntervalToSortOrder(RefreshPicker.liveOption.value); - expect(result).toBe(SortOrder.Ascending); + expect(result).toBe(LogsSortOrder.Ascending); }); }); @@ -400,7 +389,7 @@ describe('refreshIntervalToSortOrder', () => { it('then it should return descending', () => { const result = refreshIntervalToSortOrder(RefreshPicker.offOption.value); - expect(result).toBe(SortOrder.Descending); + expect(result).toBe(LogsSortOrder.Descending); }); }); @@ -408,7 +397,7 @@ describe('refreshIntervalToSortOrder', () => { it('then it should return descending', () => { const result = refreshIntervalToSortOrder('5s'); - expect(result).toBe(SortOrder.Descending); + expect(result).toBe(LogsSortOrder.Descending); }); }); @@ -416,102 +405,31 @@ describe('refreshIntervalToSortOrder', () => { it('then it should return descending', () => { const result = refreshIntervalToSortOrder(undefined); - expect(result).toBe(SortOrder.Descending); + expect(result).toBe(LogsSortOrder.Descending); }); }); }); -describe('sortLogsResult', () => { - const firstRow: LogRowModel = { - rowIndex: 0, - entryFieldIndex: 0, - dataFrame: new MutableDataFrame(), - entry: '', - hasAnsi: false, - labels: {}, - logLevel: LogLevel.info, - raw: '', - timeEpochMs: 0, - timeEpochNs: '0', - timeFromNow: '', - timeLocal: '', - timeUtc: '', - uid: '1', - }; - const sameAsFirstRow = firstRow; - const secondRow: LogRowModel = { - rowIndex: 1, - entryFieldIndex: 0, - dataFrame: new MutableDataFrame(), - entry: '', - hasAnsi: false, - labels: {}, - logLevel: LogLevel.info, - raw: '', - timeEpochMs: 10, - timeEpochNs: '10000000', - timeFromNow: '', - timeLocal: '', - timeUtc: '', - uid: '2', - }; - - 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 buildQueryTransaction', () => { + it('it should calculate interval based on time range', () => { + const queries = [{ refId: 'A' }]; + const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; + const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; + const transaction = buildQueryTransaction(queries, queryOptions, range, false); + expect(transaction.request.intervalMs).toEqual(60000); }); - - 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, - }); - }); + it('it should calculate interval taking minInterval into account', () => { + const queries = [{ refId: 'A' }]; + const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; + const range = { from: dateTime().subtract(1, 'm'), to: dateTime(), raw: { from: '1h', to: '1h' } }; + const transaction = buildQueryTransaction(queries, queryOptions, range, false); + expect(transaction.request.intervalMs).toEqual(15000); }); - - describe('when buildQueryTransaction', () => { - it('it should calculate interval based on time range', () => { - const queries = [{ refId: 'A' }]; - const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; - const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; - const transaction = buildQueryTransaction(queries, queryOptions, range, false); - - expect(transaction.request.intervalMs).toEqual(60000); - }); - - it('it should calculate interval taking minInterval into account', () => { - const queries = [{ refId: 'A' }]; - const queryOptions = { maxDataPoints: 1000, minInterval: '15s' }; - const range = { from: dateTime().subtract(1, 'm'), to: dateTime(), raw: { from: '1h', to: '1h' } }; - const transaction = buildQueryTransaction(queries, queryOptions, range, false); - - expect(transaction.request.intervalMs).toEqual(15000); - }); - - it('it should calculate interval taking maxDataPoints into account', () => { - const queries = [{ refId: 'A' }]; - const queryOptions = { maxDataPoints: 10, minInterval: '15s' }; - const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; - const transaction = buildQueryTransaction(queries, queryOptions, range, false); - - expect(transaction.request.interval).toEqual('2h'); - }); + it('it should calculate interval taking maxDataPoints into account', () => { + const queries = [{ refId: 'A' }]; + const queryOptions = { maxDataPoints: 10, minInterval: '15s' }; + const range = { from: dateTime().subtract(1, 'd'), to: dateTime(), raw: { from: '1h', to: '1h' } }; + const transaction = buildQueryTransaction(queries, queryOptions, range, false); + expect(transaction.request.interval).toEqual('2h'); }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 9b15428fde8..6a5cb90e82a 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -14,7 +14,7 @@ import { IntervalValues, LogRowModel, LogsDedupStrategy, - LogsModel, + LogsSortOrder, RawTimeRange, TimeFragment, TimeRange, @@ -464,67 +464,8 @@ export const getRefIds = (value: any): string[] => { return _.uniq(_.flatten(refIds)); }; -export const sortInAscendingOrder = (a: LogRowModel, b: LogRowModel) => { - // compare milliseconds - if (a.timeEpochMs < b.timeEpochMs) { - return -1; - } - - if (a.timeEpochMs > b.timeEpochMs) { - return 1; - } - - // if milliseonds are equal, compare nanoseconds - if (a.timeEpochNs < b.timeEpochNs) { - return -1; - } - - if (a.timeEpochNs > b.timeEpochNs) { - return 1; - } - - return 0; -}; - -const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => { - // compare milliseconds - if (a.timeEpochMs > b.timeEpochMs) { - return -1; - } - - if (a.timeEpochMs < b.timeEpochMs) { - return 1; - } - - // if milliseonds are equal, compare nanoseconds - if (a.timeEpochNs > b.timeEpochNs) { - return -1; - } - - if (a.timeEpochNs < b.timeEpochNs) { - return 1; - } - - return 0; -}; - -export enum SortOrder { - Descending = 'Descending', - Ascending = 'Ascending', - DatasourceAZ = 'Datasource A-Z', - DatasourceZA = 'Datasource Z-A', -} - export const refreshIntervalToSortOrder = (refreshInterval?: string) => - RefreshPicker.isLive(refreshInterval) ? SortOrder.Ascending : SortOrder.Descending; - -export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: SortOrder): LogsModel => { - const rows = logsResult ? logsResult.rows : []; - sortOrder === SortOrder.Ascending ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder); - const result: LogsModel = logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows }; - - return result; -}; + RefreshPicker.isLive(refreshInterval) ? LogsSortOrder.Ascending : LogsSortOrder.Descending; export const convertToWebSocketUrl = (url: string) => { const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; diff --git a/public/app/core/utils/richHistory.test.ts b/public/app/core/utils/richHistory.test.ts index 834278800a7..7f35a0f84fb 100644 --- a/public/app/core/utils/richHistory.test.ts +++ b/public/app/core/utils/richHistory.test.ts @@ -10,7 +10,7 @@ import { filterAndSortQueries, } from './richHistory'; import store from 'app/core/store'; -import { SortOrder } from './explore'; +import { SortOrder } from './richHistory'; import { dateTime, DataQuery } from '@grafana/data'; const mock: any = { diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index 615b37c0707..8c41a5b40da 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -5,7 +5,6 @@ import _ from 'lodash'; import { DataQuery, DataSourceApi, dateTimeFormat, AppEvents, urlUtil, ExploreUrlState } from '@grafana/data'; import appEvents from 'app/core/app_events'; import store from 'app/core/store'; -import { SortOrder } from './explore'; import { getExploreDatasources } from '../../features/explore/state/selectors'; // Types @@ -21,6 +20,13 @@ export const RICH_HISTORY_SETTING_KEYS = { datasourceFilters: 'grafana.explore.richHistory.datasourceFilters', }; +export enum SortOrder { + Descending = 'Descending', + Ascending = 'Ascending', + DatasourceAZ = 'Datasource A-Z', + DatasourceZA = 'Datasource Z-A', +} + /* * Add queries to rich history. Save only queries within the retention period, or that are starred. * Side-effect: store history in local storage diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index a82fe031e18..fffe1aacf84 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -1,4 +1,5 @@ import React, { PureComponent } from 'react'; +import { css, cx } from 'emotion'; import { rangeUtil, @@ -11,6 +12,7 @@ import { LogRowModel, LogsDedupDescription, LogsMetaItem, + LogsSortOrder, GraphSeriesXY, LinkModel, Field, @@ -72,13 +74,39 @@ interface State { showLabels: boolean; showTime: boolean; wrapLogMessage: boolean; + logsSortOrder: LogsSortOrder | null; + isFlipping: boolean; } export class Logs extends PureComponent { + flipOrderTimer: NodeJS.Timeout; + cancelFlippingTimer: NodeJS.Timeout; + state = { showLabels: store.getBool(SETTINGS_KEYS.showLabels, false), showTime: store.getBool(SETTINGS_KEYS.showTime, true), wrapLogMessage: store.getBool(SETTINGS_KEYS.wrapLogMessage, true), + logsSortOrder: null, + isFlipping: false, + }; + + componentWillUnmount() { + clearTimeout(this.flipOrderTimer); + clearTimeout(this.cancelFlippingTimer); + } + + onChangeLogsSortOrder = () => { + this.setState({ isFlipping: true }); + // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs + this.flipOrderTimer = setTimeout(() => { + this.setState(prevState => { + if (prevState.logsSortOrder === null || prevState.logsSortOrder === LogsSortOrder.Descending) { + return { logsSortOrder: LogsSortOrder.Ascending }; + } + return { logsSortOrder: LogsSortOrder.Descending }; + }); + }, 0); + this.cancelFlippingTimer = setTimeout(() => this.setState({ isFlipping: false }), 1000); }; onChangeDedup = (dedup: LogsDedupStrategy) => { @@ -166,7 +194,7 @@ export class Logs extends PureComponent { return null; } - const { showLabels, showTime, wrapLogMessage } = this.state; + const { showLabels, showTime, wrapLogMessage, logsSortOrder, isFlipping } = this.state; const { dedupStrategy } = this.props; const hasData = logRows && logRows.length > 0; const dedupCount = dedupedRows @@ -214,23 +242,39 @@ export class Logs extends PureComponent {
- - - - - {Object.keys(LogsDedupStrategy).map((dedupType: string, i) => ( - - {dedupType} - - ))} - +
+ + + + + {Object.keys(LogsDedupStrategy).map((dedupType: string, i) => ( + + {dedupType} + + ))} + +
+
@@ -260,6 +304,7 @@ export class Logs extends PureComponent { wrapLogMessage={wrapLogMessage} timeZone={timeZone} getFieldLinks={getFieldLinks} + logsSortOrder={logsSortOrder} /> {!loading && !hasData && !scanning && ( diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx index d7eb02f3494..5cb34bcc65d 100644 --- a/public/app/features/explore/RichHistory/RichHistory.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -1,8 +1,7 @@ import React, { PureComponent } from 'react'; //Services & Utils -import { SortOrder } from 'app/core/utils/explore'; -import { RICH_HISTORY_SETTING_KEYS } from 'app/core/utils/richHistory'; +import { RICH_HISTORY_SETTING_KEYS, SortOrder } from 'app/core/utils/richHistory'; import store from 'app/core/store'; import { withTheme, TabbedContainer, TabConfig } from '@grafana/ui'; diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx index d8f5690c79c..fe74db6ca76 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { ExploreId } from '../../../types/explore'; -import { SortOrder } from 'app/core/utils/explore'; +import { SortOrder } from 'app/core/utils/richHistory'; import { RichHistoryQueriesTab, Props } from './RichHistoryQueriesTab'; import { Slider } from '@grafana/ui'; diff --git a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx index 0e0669d4f66..fad75758906 100644 --- a/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryQueriesTab.tsx @@ -9,8 +9,8 @@ import { RichHistoryQuery, ExploreId } from 'app/types/explore'; import { stylesFactory, useTheme } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; -import { SortOrder } from 'app/core/utils/explore'; import { + SortOrder, mapNumbertoTimeInSlider, mapQueriesToHeadings, createDatasourcesList, diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx index f1dc2df53b2..b9571dded88 100644 --- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { ExploreId } from '../../../types/explore'; -import { SortOrder } from 'app/core/utils/explore'; +import { SortOrder } from 'app/core/utils/richHistory'; import { RichHistoryStarredTab, Props } from './RichHistoryStarredTab'; jest.mock('../state/selectors', () => ({ getExploreDatasources: jest.fn() })); diff --git a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx index efac73550c1..eb686901a3f 100644 --- a/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx +++ b/public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx @@ -8,9 +8,7 @@ import { RichHistoryQuery, ExploreId } from 'app/types/explore'; // Utils import { stylesFactory, useTheme } from '@grafana/ui'; import { GrafanaTheme, SelectableValue } from '@grafana/data'; - -import { SortOrder } from '../../../core/utils/explore'; -import { filterAndSortQueries, createDatasourcesList } from '../../../core/utils/richHistory'; +import { filterAndSortQueries, createDatasourcesList, SortOrder } from 'app/core/utils/richHistory'; // Components import RichHistoryCard from './RichHistoryCard'; diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 6e7ac870b1d..cc3f311b496 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -12,6 +12,7 @@ import { toLegacyResponseData, ExploreMode, LogsDedupStrategy, + sortLogsResult, } from '@grafana/data'; import { RefreshPicker } from '@grafana/ui'; import { LocationUpdate } from '@grafana/runtime'; @@ -23,7 +24,6 @@ import { getQueryKeys, parseUrlState, refreshIntervalToSortOrder, - sortLogsResult, stopQueryState, } from 'app/core/utils/explore'; import { ExploreId, ExploreItemState, ExploreState, ExploreUpdateState } from 'app/types/explore'; diff --git a/public/app/features/explore/utils/ResultProcessor.ts b/public/app/features/explore/utils/ResultProcessor.ts index 9b5158f4c3d..24f187333dc 100644 --- a/public/app/features/explore/utils/ResultProcessor.ts +++ b/public/app/features/explore/utils/ResultProcessor.ts @@ -7,9 +7,10 @@ import { getDisplayProcessor, PreferredVisualisationType, standardTransformers, + sortLogsResult, } from '@grafana/data'; import { ExploreItemState } from 'app/types/explore'; -import { sortLogsResult, refreshIntervalToSortOrder } from 'app/core/utils/explore'; +import { refreshIntervalToSortOrder } from 'app/core/utils/explore'; import { dataFrameToLogsModel } from 'app/core/logs_model'; import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel'; import { config } from 'app/core/config'; diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 439c6c0b885..a2a12786748 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -3,7 +3,6 @@ import { LogRows, CustomScrollbar } from '@grafana/ui'; import { LogsDedupStrategy, PanelProps } from '@grafana/data'; import { Options } from './types'; import { dataFrameToLogsModel } from 'app/core/logs_model'; -import { sortLogsResult } from 'app/core/utils/explore'; interface LogsPanelProps extends PanelProps {} @@ -22,12 +21,11 @@ export const LogsPanel: React.FunctionComponent = ({ } const newResults = data ? dataFrameToLogsModel(data.series, data.request?.intervalMs, timeZone) : null; - const sortedNewResults = sortLogsResult(newResults, sortOrder); return ( = ({ timeZone={timeZone} allowDetails={true} disableCustomHorizontalScroll={true} + logsSortOrder={sortOrder} /> ); diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index 44e7af53768..a78da7ba136 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -1,7 +1,6 @@ -import { PanelPlugin } from '@grafana/data'; +import { PanelPlugin, LogsSortOrder } from '@grafana/data'; import { Options } from './types'; import { LogsPanel } from './LogsPanel'; -import { SortOrder } from '../../../core/utils/explore'; export const plugin = new PanelPlugin(LogsPanel).setPanelOptions(builder => { builder @@ -29,10 +28,10 @@ export const plugin = new PanelPlugin(LogsPanel).setPanelOptions(builde description: '', settings: { options: [ - { value: SortOrder.Descending, label: 'Descending' }, - { value: SortOrder.Ascending, label: 'Ascending' }, + { value: LogsSortOrder.Descending, label: 'Descending' }, + { value: LogsSortOrder.Ascending, label: 'Ascending' }, ], }, - defaultValue: SortOrder.Descending, + defaultValue: LogsSortOrder.Descending, }); }); diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index 7082b382b00..abb76a3fe30 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -1,8 +1,8 @@ -import { SortOrder } from 'app/core/utils/explore'; +import { LogsSortOrder } from '@grafana/data'; export interface Options { showLabels: boolean; showTime: boolean; wrapLogMessage: boolean; - sortOrder: SortOrder; + sortOrder: LogsSortOrder; } diff --git a/public/sass/components/_panel_logs.scss b/public/sass/components/_panel_logs.scss index 86636dc95c0..ca1696180d2 100644 --- a/public/sass/components/_panel_logs.scss +++ b/public/sass/components/_panel_logs.scss @@ -12,12 +12,18 @@ $column-horizontal-spacing: 10px; .logs-panel-controls { display: flex; - justify-items: flex-start; - align-items: center; + justify-content: space-between; + align-items: baseline; flex-wrap: wrap; + .logs-panel-controls-main { + display: flex; + justify-items: flex-start; + align-items: center; + flex-wrap: wrap; - > * { - margin-right: $spacer * 2; + > * { + margin-right: $spacer * 2; + } } }