mirror of https://github.com/grafana/grafana
Logs: Add experimental table visualisation in Explore (#71120)
* add table visualisation for logs * add `logsExploreTableVisualisation` feature flag * add feature flag to visualisation switch * fix english * improve state when no data is present * improve margin * add missing prop to test * add tests * fix logs table height * fix linting * move visualisation toggle * add field config overrides * update tests * fix explore test * fix explore test * add missing header and revert test changes * use timefield from logsFrame.ts * add TODO * move to new file * hide fields that should be hidden * add test to hide fields * remove unused linespull/71464/head
parent
26c6b753c3
commit
7e4e743a42
|
@ -0,0 +1,165 @@ |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import React, { ComponentProps } from 'react'; |
||||
|
||||
import { |
||||
FieldType, |
||||
LogLevel, |
||||
LogRowModel, |
||||
LogsSortOrder, |
||||
MutableDataFrame, |
||||
standardTransformersRegistry, |
||||
toUtc, |
||||
} from '@grafana/data'; |
||||
import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; |
||||
|
||||
import { LogsTable } from './LogsTable'; |
||||
|
||||
describe('LogsTable', () => { |
||||
beforeAll(() => { |
||||
const transformers = [extractFieldsTransformer, organizeFieldsTransformer]; |
||||
standardTransformersRegistry.setInit(() => { |
||||
return transformers.map((t) => { |
||||
return { |
||||
id: t.id, |
||||
aliasIds: t.aliasIds, |
||||
name: t.name, |
||||
transformation: t, |
||||
description: t.description, |
||||
editor: () => null, |
||||
}; |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
const getComponent = (partialProps?: Partial<ComponentProps<typeof LogsTable>>, logs?: LogRowModel[]) => { |
||||
const testDataFrame = { |
||||
fields: [ |
||||
{ |
||||
config: {}, |
||||
name: 'Time', |
||||
type: FieldType.time, |
||||
values: ['2019-01-01 10:00:00', '2019-01-01 11:00:00', '2019-01-01 12:00:00'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'line', |
||||
type: FieldType.string, |
||||
values: ['log message 1', 'log message 2', 'log message 3'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'tsNs', |
||||
type: FieldType.string, |
||||
values: ['ts1', 'ts2', 'ts3'], |
||||
}, |
||||
{ |
||||
config: {}, |
||||
name: 'labels', |
||||
type: FieldType.other, |
||||
typeInfo: { |
||||
frame: 'json.RawMessage', |
||||
}, |
||||
values: ['{"foo":"bar"}', '{"foo":"bar"}', '{"foo":"bar"}'], |
||||
}, |
||||
], |
||||
length: 3, |
||||
}; |
||||
return ( |
||||
<LogsTable |
||||
rows={[makeLog({})]} |
||||
logsSortOrder={LogsSortOrder.Descending} |
||||
splitOpen={() => undefined} |
||||
timeZone={'utc'} |
||||
width={50} |
||||
range={{ |
||||
from: toUtc('2019-01-01 10:00:00'), |
||||
to: toUtc('2019-01-01 16:00:00'), |
||||
raw: { from: 'now-1h', to: 'now' }, |
||||
}} |
||||
logsFrames={[testDataFrame]} |
||||
{...partialProps} |
||||
/> |
||||
); |
||||
}; |
||||
const setup = (partialProps?: Partial<ComponentProps<typeof LogsTable>>, logs?: LogRowModel[]) => { |
||||
return render(getComponent(partialProps, logs)); |
||||
}; |
||||
|
||||
let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation; |
||||
|
||||
beforeAll(() => { |
||||
originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation; |
||||
config.featureToggles.logsExploreTableVisualisation = true; |
||||
}); |
||||
|
||||
afterAll(() => { |
||||
config.featureToggles.logsExploreTableVisualisation = originalVisualisationTypeValue; |
||||
}); |
||||
|
||||
it('should render 4 table rows', async () => { |
||||
setup(); |
||||
|
||||
await waitFor(() => { |
||||
const rows = screen.getAllByRole('row'); |
||||
// tableFrame has 3 rows + 1 header row
|
||||
expect(rows.length).toBe(4); |
||||
}); |
||||
}); |
||||
|
||||
it('should render 4 table rows', async () => { |
||||
setup(); |
||||
|
||||
await waitFor(() => { |
||||
const rows = screen.getAllByRole('row'); |
||||
// tableFrame has 3 rows + 1 header row
|
||||
expect(rows.length).toBe(4); |
||||
}); |
||||
}); |
||||
|
||||
it('should render extracted labels as columns', async () => { |
||||
setup(); |
||||
|
||||
await waitFor(() => { |
||||
const columns = screen.getAllByRole('columnheader'); |
||||
|
||||
expect(columns[0].textContent).toContain('Time'); |
||||
expect(columns[1].textContent).toContain('line'); |
||||
expect(columns[2].textContent).toContain('foo'); |
||||
}); |
||||
}); |
||||
|
||||
it('should not render `tsNs`', async () => { |
||||
setup(); |
||||
|
||||
await waitFor(() => { |
||||
const columns = screen.queryAllByRole('columnheader', { name: 'tsNs' }); |
||||
|
||||
expect(columns.length).toBe(0); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
const makeLog = (overrides: Partial<LogRowModel>): LogRowModel => { |
||||
const uid = overrides.uid || '1'; |
||||
const entry = `log message ${uid}`; |
||||
return { |
||||
uid, |
||||
entryFieldIndex: 0, |
||||
rowIndex: 0, |
||||
dataFrame: new MutableDataFrame(), |
||||
logLevel: LogLevel.debug, |
||||
entry, |
||||
hasAnsi: false, |
||||
hasUnescapedContent: false, |
||||
labels: {}, |
||||
raw: entry, |
||||
timeFromNow: '', |
||||
timeEpochMs: 1, |
||||
timeEpochNs: '1000000', |
||||
timeLocal: '', |
||||
timeUtc: '', |
||||
...overrides, |
||||
}; |
||||
}; |
||||
@ -0,0 +1,167 @@ |
||||
import memoizeOne from 'memoize-one'; |
||||
import React, { useCallback, useEffect, useState } from 'react'; |
||||
import { lastValueFrom } from 'rxjs'; |
||||
|
||||
import { |
||||
applyFieldOverrides, |
||||
DataFrame, |
||||
Field, |
||||
LogRowModel, |
||||
LogsSortOrder, |
||||
sortDataFrame, |
||||
SplitOpen, |
||||
TimeRange, |
||||
transformDataFrame, |
||||
ValueLinkConfig, |
||||
} from '@grafana/data'; |
||||
import { config } from '@grafana/runtime'; |
||||
import { Table } from '@grafana/ui'; |
||||
import { shouldRemoveField } from 'app/features/logs/components/logParser'; |
||||
import { parseLogsFrame } from 'app/features/logs/logsFrame'; |
||||
|
||||
import { getFieldLinksForExplore } from '../utils/links'; |
||||
|
||||
interface Props { |
||||
logsFrames?: DataFrame[]; |
||||
width: number; |
||||
timeZone: string; |
||||
splitOpen: SplitOpen; |
||||
range: TimeRange; |
||||
logsSortOrder: LogsSortOrder; |
||||
rows: LogRowModel[]; |
||||
} |
||||
|
||||
const getTableHeight = memoizeOne((dataFrames: DataFrame[] | undefined) => { |
||||
const largestFrameLength = dataFrames?.reduce((length, frame) => { |
||||
return frame.length > length ? frame.length : length; |
||||
}, 0); |
||||
// from TableContainer.tsx
|
||||
return Math.min(600, Math.max(largestFrameLength ?? 0 * 36, 300) + 40 + 46); |
||||
}); |
||||
|
||||
export const LogsTable: React.FunctionComponent<Props> = (props) => { |
||||
const { timeZone, splitOpen, range, logsSortOrder, width, logsFrames, rows } = props; |
||||
|
||||
const [tableFrame, setTableFrame] = useState<DataFrame | undefined>(undefined); |
||||
|
||||
const prepareTableFrame = useCallback( |
||||
(frame: DataFrame): DataFrame => { |
||||
const logsFrame = parseLogsFrame(frame); |
||||
const timeIndex = logsFrame?.timeField.index; |
||||
const sortedFrame = sortDataFrame(frame, timeIndex, logsSortOrder === LogsSortOrder.Descending); |
||||
|
||||
const [frameWithOverrides] = applyFieldOverrides({ |
||||
data: [sortedFrame], |
||||
timeZone, |
||||
theme: config.theme2, |
||||
replaceVariables: (v: string) => v, |
||||
fieldConfig: { |
||||
defaults: { |
||||
custom: {}, |
||||
}, |
||||
overrides: [], |
||||
}, |
||||
}); |
||||
// `getLinks` and `applyFieldOverrides` are taken from TableContainer.tsx
|
||||
for (const field of frameWithOverrides.fields) { |
||||
field.getLinks = (config: ValueLinkConfig) => { |
||||
return getFieldLinksForExplore({ |
||||
field, |
||||
rowIndex: config.valueRowIndex!, |
||||
splitOpenFn: splitOpen, |
||||
range: range, |
||||
dataFrame: sortedFrame!, |
||||
}); |
||||
}; |
||||
field.config = { |
||||
custom: { |
||||
filterable: true, |
||||
inspect: true, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
return frameWithOverrides; |
||||
}, |
||||
[logsSortOrder, range, splitOpen, timeZone] |
||||
); |
||||
|
||||
useEffect(() => { |
||||
const prepare = async () => { |
||||
if (!logsFrames || !logsFrames.length) { |
||||
setTableFrame(undefined); |
||||
return; |
||||
} |
||||
// TODO: This does not work with multiple logs queries for now, as we currently only support one logs frame.
|
||||
let dataFrame = logsFrames[0]; |
||||
|
||||
const logsFrame = parseLogsFrame(dataFrame); |
||||
const timeIndex = logsFrame?.timeField.index; |
||||
dataFrame = sortDataFrame(dataFrame, timeIndex, logsSortOrder === LogsSortOrder.Descending); |
||||
|
||||
// create extract JSON transformation for every field that is `json.RawMessage`
|
||||
// TODO: explore if `logsFrame.ts` can help us with getting the right fields
|
||||
const transformations = dataFrame.fields |
||||
.filter((field: Field & { typeInfo?: { frame: string } }) => { |
||||
return field.typeInfo?.frame === 'json.RawMessage'; |
||||
}) |
||||
.flatMap((field: Field) => { |
||||
return [ |
||||
{ |
||||
id: 'extractFields', |
||||
options: { |
||||
format: 'json', |
||||
keepTime: false, |
||||
replace: false, |
||||
source: field.name, |
||||
}, |
||||
}, |
||||
// hide the field that was extracted
|
||||
{ |
||||
id: 'organize', |
||||
options: { |
||||
excludeByName: { |
||||
[field.name]: true, |
||||
}, |
||||
}, |
||||
}, |
||||
]; |
||||
}); |
||||
|
||||
// remove fields that should not be displayed
|
||||
dataFrame.fields.forEach((field: Field, index: number) => { |
||||
const row = rows[0]; // we just take the first row as the relevant row
|
||||
if (shouldRemoveField(field, index, row, false, false)) { |
||||
transformations.push({ |
||||
id: 'organize', |
||||
options: { |
||||
excludeByName: { |
||||
[field.name]: true, |
||||
}, |
||||
}, |
||||
}); |
||||
} |
||||
}); |
||||
if (transformations.length > 0) { |
||||
const [transformedDataFrame] = await lastValueFrom(transformDataFrame(transformations, [dataFrame])); |
||||
setTableFrame(prepareTableFrame(transformedDataFrame)); |
||||
} else { |
||||
setTableFrame(prepareTableFrame(dataFrame)); |
||||
} |
||||
}; |
||||
prepare(); |
||||
}, [prepareTableFrame, logsFrames, logsSortOrder, rows]); |
||||
|
||||
if (!tableFrame) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<Table |
||||
data={tableFrame} |
||||
width={width} |
||||
height={getTableHeight(props.logsFrames)} |
||||
footerOptions={{ show: true, reducer: ['count'], countRows: true }} |
||||
/> |
||||
); |
||||
}; |
||||
Loading…
Reference in new issue