Explore: Sort order of log results (#26669)

* Create sorting button and functionality

* Set up logs ordering

* Add tests

* Refactor

* Refactor

* Replace new button with old

* Move SortOrder enum to grafana/data

* Update SortOrder in test

* Update context based on sort order of logs

* Update used  method for panel, update tests

* Rename prop  to logsSortOrder

* Memoize resuults

* Add title too button

* Add disablinng of button for 1sec

* Update wordiing

* Update packages/grafana-data/src/utils/logs.ts

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>

* Update packages/grafana-data/src/utils/logs.ts

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>

* Update test by reordering logs

* Clear timers, add button flipping title

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
Ivana Huckova 2020-08-06 18:35:49 +02:00 committed by GitHub
parent 4334e34366
commit 4a523c3248
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 416 additions and 277 deletions

View File

@ -33,6 +33,11 @@ export enum LogsMetaKind {
LabelsMap,
}
export enum LogsSortOrder {
Descending = 'Descending',
Ascending = 'Ascending',
}
export interface LogsMetaItem {
label: string;
value: string | number | Labels;

View File

@ -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,
});
});
});
});

View File

@ -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);

View File

@ -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<Props, State> {
render() {
const { showContext } = this.state;
const { logsSortOrder } = this.props;
if (showContext) {
return (
<>
<LogRowContextProvider row={this.props.row} getRowContext={this.props.getRowContext}>
<LogRowContextProvider
row={this.props.row}
getRowContext={this.props.getRowContext}
logsSortOrder={logsSortOrder}
>
{({ result, errors, hasMoreContextRows, updateLimit }) => {
return <>{this.renderLogRow(result, errors, hasMoreContextRows, updateLimit)}</>;
}}

View File

@ -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<DataQueryResponse>;
children: (props: {
result: LogRowContextRows;
@ -43,7 +44,8 @@ interface LogRowContextProviderProps {
export const getRowContexts = async (
getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise<DataQueryResponse>,
row: LogRowModel,
limit: number
limit: number,
logsSortOrder?: LogsSortOrder | null
) => {
const promises = [
getRowContext(row, {
@ -58,62 +60,66 @@ export const getRowContexts = async (
const results: Array<DataQueryResponse | DataQueryError> = 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<string> = fieldCache.getFieldByName('ts')!;
const idField: Field<string> | 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<string> = fieldCache.getFieldByName('ts')!;
const idField: Field<string> | 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<string> = 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<string> = 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<LogRowContextProvide
getRowContext,
row,
children,
logsSortOrder,
}) => {
// 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<LogRowContextProvide
// First promise fetches limit number of rows backwards in time from a specific point in time
// Second promise fetches limit number of rows forwards in time from a specific point in time
const { value } = useAsync(async () => {
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

View File

@ -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(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]}
showLabels={false}
showTime={false}
wrapLogMessage={true}
timeZone={'utc'}
logsSortOrder={LogsSortOrder.Ascending}
/>
);
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(
<LogRows
logRows={rows}
dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]}
showLabels={false}
showTime={false}
wrapLogMessage={true}
timeZone={'utc'}
logsSortOrder={LogsSortOrder.Descending}
/>
);
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>): 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>): LogRowModel => {
labels: {},
raw: entry,
timeFromNow: '',
timeEpochMs: 1,
timeEpochNs: '1000000',
timeEpochMs,
timeEpochNs: (timeEpochMs * 1000000).toString(),
timeLocal: '',
timeUtc: '',
searchWords: [],

View File

@ -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<Props, State> {
}
}
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<Props, State> {
previewLimit,
getFieldLinks,
disableCustomHorizontalScroll,
logsSortOrder,
} = this.props;
const { renderAll } = this.state;
const { logsRowsTable, logsRowsHorizontalScroll } = getLogRowStyles(theme);
@ -109,12 +115,13 @@ class UnThemedLogRows extends PureComponent<Props, State> {
// 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<Props, State> {
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
/>
))}
{hasData &&
@ -159,6 +167,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
/>
))}
{hasData && !renderAll && (

View File

@ -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 = {

View File

@ -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');
});
});

View File

@ -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://';

View File

@ -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 = {

View File

@ -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

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
</div>
<div className="logs-panel-options">
<div className="logs-panel-controls">
<Switch label="Time" checked={showTime} onChange={this.onChangeTime} transparent />
<Switch label="Unique labels" checked={showLabels} onChange={this.onChangeLabels} transparent />
<Switch label="Wrap lines" checked={wrapLogMessage} onChange={this.onChangewrapLogMessage} transparent />
<ToggleButtonGroup label="Dedup" transparent={true}>
{Object.keys(LogsDedupStrategy).map((dedupType: string, i) => (
<ToggleButton
key={i}
value={dedupType}
onChange={this.onChangeDedup}
selected={dedupStrategy === dedupType}
// @ts-ignore
tooltip={LogsDedupDescription[dedupType]}
>
{dedupType}
</ToggleButton>
))}
</ToggleButtonGroup>
<div className="logs-panel-controls-main">
<Switch label="Time" checked={showTime} onChange={this.onChangeTime} transparent />
<Switch label="Unique labels" checked={showLabels} onChange={this.onChangeLabels} transparent />
<Switch label="Wrap lines" checked={wrapLogMessage} onChange={this.onChangewrapLogMessage} transparent />
<ToggleButtonGroup label="Dedup" transparent={true}>
{Object.keys(LogsDedupStrategy).map((dedupType: string, i) => (
<ToggleButton
key={i}
value={dedupType}
onChange={this.onChangeDedup}
selected={dedupStrategy === dedupType}
// @ts-ignore
tooltip={LogsDedupDescription[dedupType]}
>
{dedupType}
</ToggleButton>
))}
</ToggleButtonGroup>
</div>
<button
disabled={isFlipping}
title={logsSortOrder === LogsSortOrder.Ascending ? 'Change to newest first' : 'Change to oldest first'}
aria-label="Flip results order"
className={cx(
'gf-form-label gf-form-label--btn',
css`
margin-top: 4px;
`
)}
onClick={this.onChangeLogsSortOrder}
>
<span className="btn-title">{isFlipping ? 'Flipping...' : 'Flip results order'}</span>
</button>
</div>
</div>
@ -260,6 +304,7 @@ export class Logs extends PureComponent<Props, State> {
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
/>
{!loading && !hasData && !scanning && (

View File

@ -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';

View File

@ -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';

View File

@ -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,

View File

@ -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() }));

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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<Options> {}
@ -22,12 +21,11 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
}
const newResults = data ? dataFrameToLogsModel(data.series, data.request?.intervalMs, timeZone) : null;
const sortedNewResults = sortLogsResult(newResults, sortOrder);
return (
<CustomScrollbar autoHide>
<LogRows
logRows={sortedNewResults.rows}
logRows={newResults?.rows || []}
dedupStrategy={LogsDedupStrategy.none}
highlighterExpressions={[]}
showLabels={showLabels}
@ -36,6 +34,7 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
timeZone={timeZone}
allowDetails={true}
disableCustomHorizontalScroll={true}
logsSortOrder={sortOrder}
/>
</CustomScrollbar>
);

View File

@ -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<Options>(LogsPanel).setPanelOptions(builder => {
builder
@ -29,10 +28,10 @@ export const plugin = new PanelPlugin<Options>(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,
});
});

View File

@ -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;
}

View File

@ -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;
}
}
}