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 lines
This commit is contained in:
Sven Grossmann 2023-07-12 15:52:42 +02:00 committed by GitHub
parent 26c6b753c3
commit 7e4e743a42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 650 additions and 118 deletions

View File

@ -122,6 +122,7 @@ Experimental features might be changed or removed without prior notice.
| `elasticToggleableFilters` | Enable support to toggle filters off from the query through the Logs Details component |
| `vizAndWidgetSplit` | Split panels between vizualizations and widgets |
| `prometheusIncrementalQueryInstrumentation` | Adds RudderStack events to incremental queries |
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore |
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers |
## Development feature toggles

View File

@ -109,5 +109,6 @@ export interface FeatureToggles {
elasticToggleableFilters?: boolean;
vizAndWidgetSplit?: boolean;
prometheusIncrementalQueryInstrumentation?: boolean;
logsExploreTableVisualisation?: boolean;
awsDatasourcesTempCredentials?: boolean;
}

View File

@ -621,6 +621,13 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "logsExploreTableVisualisation",
Description: "A table visualisation for logs in Explore",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "awsDatasourcesTempCredentials",
Description: "Support temporary security credentials in AWS plugins for Grafana Cloud customers",

View File

@ -90,4 +90,5 @@ alertingLokiRangeToInstant,experimental,@grafana/alerting-squad,false,false,fals
elasticToggleableFilters,experimental,@grafana/observability-logs,false,false,false,true
vizAndWidgetSplit,experimental,@grafana/dashboards-squad,false,false,false,true
prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,false,false,false,true
logsExploreTableVisualisation,experimental,@grafana/observability-logs,false,false,false,true
awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false,false

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
90 elasticToggleableFilters experimental @grafana/observability-logs false false false true
91 vizAndWidgetSplit experimental @grafana/dashboards-squad false false false true
92 prometheusIncrementalQueryInstrumentation experimental @grafana/observability-metrics false false false true
93 logsExploreTableVisualisation experimental @grafana/observability-logs false false false true
94 awsDatasourcesTempCredentials experimental @grafana/aws-datasources false false false false

View File

@ -371,6 +371,10 @@ const (
// Adds RudderStack events to incremental queries
FlagPrometheusIncrementalQueryInstrumentation = "prometheusIncrementalQueryInstrumentation"
// FlagLogsExploreTableVisualisation
// A table visualisation for logs in Explore
FlagLogsExploreTableVisualisation = "logsExploreTableVisualisation"
// FlagAwsDatasourcesTempCredentials
// Support temporary security credentials in AWS plugins for Grafana Cloud customers
FlagAwsDatasourcesTempCredentials = "awsDatasourcesTempCredentials"

View File

@ -5,12 +5,17 @@ import React, { ComponentProps } from 'react';
import {
EventBusSrv,
ExploreLogsPanelState,
FieldType,
LoadingState,
LogLevel,
LogRowModel,
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 { Logs } from './Logs';
@ -65,6 +70,21 @@ describe('Logs', () => {
writable: true,
});
});
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,
};
});
});
});
afterAll(() => {
Object.defineProperty(window, 'location', {
@ -82,6 +102,32 @@ describe('Logs', () => {
makeLog({ uid: '3', timeEpochMs: 3 }),
];
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: 'labels',
type: FieldType.other,
typeInfo: {
frame: 'json.RawMessage',
},
values: ['{"foo":"bar"}', '{"foo":"bar"}', '{"foo":"bar"}'],
},
],
length: 3,
};
return (
<Logs
exploreId={'left'}
@ -101,6 +147,11 @@ describe('Logs', () => {
from: toUtc('2019-01-01 10:00:00').valueOf(),
to: toUtc('2019-01-01 16:00:00').valueOf(),
}}
range={{
from: toUtc('2019-01-01 10:00:00'),
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
@ -108,6 +159,7 @@ describe('Logs', () => {
return [];
}}
eventBus={new EventBusSrv()}
logsFrames={[testDataFrame]}
{...partialProps}
/>
);
@ -158,6 +210,11 @@ describe('Logs', () => {
from: toUtc('2019-01-01 10:00:00').valueOf(),
to: toUtc('2019-01-01 16:00:00').valueOf(),
}}
range={{
from: toUtc('2019-01-01 10:00:00'),
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
@ -195,6 +252,11 @@ describe('Logs', () => {
from: toUtc('2019-01-01 10:00:00').valueOf(),
to: toUtc('2019-01-01 16:00:00').valueOf(),
}}
range={{
from: toUtc('2019-01-01 10:00:00'),
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
@ -236,6 +298,11 @@ describe('Logs', () => {
from: toUtc('2019-01-01 10:00:00').valueOf(),
to: toUtc('2019-01-01 16:00:00').valueOf(),
}}
range={{
from: toUtc('2019-01-01 10:00:00'),
to: toUtc('2019-01-01 16:00:00'),
raw: { from: 'now-1h', to: 'now' },
}}
addResultsToCache={() => {}}
onChangeTime={() => {}}
clearCache={() => {}}
@ -323,6 +390,34 @@ describe('Logs', () => {
);
});
});
describe('with table visualisation', () => {
let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
beforeAll(() => {
originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
config.featureToggles.logsExploreTableVisualisation = true;
});
afterAll(() => {
config.featureToggles.logsExploreTableVisualisation = originalVisualisationTypeValue;
});
it('should show visualisation type radio group', () => {
setup();
const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' });
expect(logsSection).toBeInTheDocument();
});
it('should change visualisation to table on toggle', async () => {
setup();
const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' });
await userEvent.click(logsSection);
const table = screen.getByTestId('logRowsTable');
expect(table).toBeInTheDocument();
});
});
});
const makeLog = (overrides: Partial<LogRowModel>): LogRowModel => {

View File

@ -29,6 +29,7 @@ import {
ExplorePanelsState,
serializeStateToUrlParam,
urlUtil,
TimeRange,
} from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
@ -54,6 +55,7 @@ import { changePanelState } from '../state/explorePane';
import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsTable } from './LogsTable';
import { LogsVolumePanelList } from './LogsVolumePanelList';
import { SETTINGS_KEYS } from './utils/logs';
@ -93,8 +95,12 @@ interface Props extends Themeable2 {
eventBus: EventBus;
panelState?: ExplorePanelsState;
scrollElement?: HTMLDivElement;
logsFrames?: DataFrame[];
range: TimeRange;
}
type VisualisationType = 'table' | 'logs';
interface State {
showLabels: boolean;
showTime: boolean;
@ -108,6 +114,8 @@ interface State {
forceEscape: boolean;
contextOpen: boolean;
contextRow?: LogRowModel;
tableFrame?: DataFrame;
visualisationType?: VisualisationType;
}
const scrollableLogsContainer = config.featureToggles.exploreScrollableLogsContainer;
@ -150,6 +158,8 @@ class UnthemedLogs extends PureComponent<Props, State> {
forceEscape: false,
contextOpen: false,
contextRow: undefined,
tableFrame: undefined,
visualisationType: 'logs',
};
constructor(props: Props) {
@ -213,6 +223,12 @@ class UnthemedLogs extends PureComponent<Props, State> {
}));
};
onChangeVisualisation = (visualisation: VisualisationType) => {
this.setState(() => ({
visualisationType: visualisation,
}));
};
onChangeDedup = (dedupStrategy: LogsDedupStrategy) => {
reportInteraction('grafana_explore_logs_deduplication_clicked', {
deduplicationType: dedupStrategy,
@ -515,81 +531,116 @@ class UnthemedLogs extends PureComponent<Props, State> {
/>
)}
</Collapse>
<Collapse label="Logs" loading={loading} isOpen className={styleOverridesForStickyNavigation}>
<div className={styles.logOptions}>
<InlineFieldRow>
<InlineField label="Time" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={showTime}
onChange={this.onChangeTime}
className={styles.horizontalInlineSwitch}
transparent
id={`show-time_${exploreId}`}
/>
</InlineField>
<InlineField label="Unique labels" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={showLabels}
onChange={this.onChangeLabels}
className={styles.horizontalInlineSwitch}
transparent
id={`unique-labels_${exploreId}`}
/>
</InlineField>
<InlineField label="Wrap lines" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={wrapLogMessage}
onChange={this.onChangeWrapLogMessage}
className={styles.horizontalInlineSwitch}
transparent
id={`wrap-lines_${exploreId}`}
/>
</InlineField>
<InlineField label="Prettify JSON" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={prettifyLogMessage}
onChange={this.onChangePrettifyLogMessage}
className={styles.horizontalInlineSwitch}
transparent
id={`prettify_${exploreId}`}
/>
</InlineField>
<InlineField label="Deduplication" className={styles.horizontalInlineLabel} transparent>
<RadioButtonGroup
options={DEDUP_OPTIONS.map((dedupType) => ({
label: capitalize(dedupType),
value: dedupType,
description: LogsDedupDescription[dedupType],
}))}
value={dedupStrategy}
onChange={this.onChangeDedup}
className={styles.radioButtons}
/>
</InlineField>
</InlineFieldRow>
<div>
<InlineField label="Display results" className={styles.horizontalInlineLabel} transparent>
<RadioButtonGroup
disabled={isFlipping}
options={[
{
label: 'Newest first',
value: LogsSortOrder.Descending,
description: 'Show results newest to oldest',
},
{
label: 'Oldest first',
value: LogsSortOrder.Ascending,
description: 'Show results oldest to newest',
},
]}
value={logsSortOrder}
onChange={this.onChangeLogsSortOrder}
className={styles.radioButtons}
/>
</InlineField>
<Collapse
label={
<>
{config.featureToggles.logsExploreTableVisualisation && (
<div className={styles.visualisationType}>
{this.state.visualisationType === 'logs' ? 'Logs' : 'Table'}
<RadioButtonGroup
className={styles.visualisationTypeRadio}
options={[
{
label: 'Table',
value: 'table',
description: 'Show results in table visualisation',
},
{
label: 'Logs',
value: 'logs',
description: 'Show results in logs visualisation',
},
]}
size="sm"
value={this.state.visualisationType}
onChange={this.onChangeVisualisation}
/>
</div>
)}
{!config.featureToggles.logsExploreTableVisualisation && 'Logs'}
</>
}
loading={loading}
isOpen
className={styleOverridesForStickyNavigation}
>
{this.state.visualisationType !== 'table' && (
<div className={styles.logOptions}>
<InlineFieldRow>
<InlineField label="Time" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={showTime}
onChange={this.onChangeTime}
className={styles.horizontalInlineSwitch}
transparent
id={`show-time_${exploreId}`}
/>
</InlineField>
<InlineField label="Unique labels" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={showLabels}
onChange={this.onChangeLabels}
className={styles.horizontalInlineSwitch}
transparent
id={`unique-labels_${exploreId}`}
/>
</InlineField>
<InlineField label="Wrap lines" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={wrapLogMessage}
onChange={this.onChangeWrapLogMessage}
className={styles.horizontalInlineSwitch}
transparent
id={`wrap-lines_${exploreId}`}
/>
</InlineField>
<InlineField label="Prettify JSON" className={styles.horizontalInlineLabel} transparent>
<InlineSwitch
value={prettifyLogMessage}
onChange={this.onChangePrettifyLogMessage}
className={styles.horizontalInlineSwitch}
transparent
id={`prettify_${exploreId}`}
/>
</InlineField>
<InlineField label="Deduplication" className={styles.horizontalInlineLabel} transparent>
<RadioButtonGroup
options={DEDUP_OPTIONS.map((dedupType) => ({
label: capitalize(dedupType),
value: dedupType,
description: LogsDedupDescription[dedupType],
}))}
value={dedupStrategy}
onChange={this.onChangeDedup}
className={styles.radioButtons}
/>
</InlineField>
</InlineFieldRow>
<div>
<InlineField label="Display results" className={styles.horizontalInlineLabel} transparent>
<RadioButtonGroup
disabled={isFlipping}
options={[
{
label: 'Newest first',
value: LogsSortOrder.Descending,
description: 'Show results newest to oldest',
},
{
label: 'Oldest first',
value: LogsSortOrder.Ascending,
description: 'Show results oldest to newest',
},
]}
value={logsSortOrder}
onChange={this.onChangeLogsSortOrder}
className={styles.radioButtons}
/>
</InlineField>
</div>
</div>
</div>
)}
<div ref={this.topLogsRef} />
<LogsMetaRow
logRows={logRows}
@ -603,50 +654,70 @@ class UnthemedLogs extends PureComponent<Props, State> {
clearDetectedFields={this.clearDetectedFields}
/>
<div className={styles.logsSection}>
<div className={styles.logRows} data-testid="logRows" ref={this.logsContainer}>
<LogRows
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
showContextToggle={showContextToggle}
showLabels={showLabels}
showTime={showTime}
enableLogDetails={true}
forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
onClickShowField={this.showField}
onClickHideField={this.hideField}
app={CoreApp.Explore}
onLogRowHover={this.onLogRowHover}
onOpenContext={this.onOpenContext}
onPermalinkClick={this.onPermalinkClick}
permalinkedRowId={this.props.panelState?.logs?.id}
scrollIntoView={this.scrollIntoView}
/>
{!loading && !hasData && !scanning && (
{this.state.visualisationType === 'table' && hasData && (
<div className={styles.logRows} data-testid="logRowsTable">
{/* Width should be full width minus logsnavigation and padding */}
<LogsTable
rows={logRows}
logsSortOrder={this.state.logsSortOrder}
range={this.props.range}
splitOpen={this.props.splitOpen}
timeZone={timeZone}
width={width - 80}
logsFrames={this.props.logsFrames}
/>
</div>
)}
{this.state.visualisationType === 'logs' && hasData && (
<div className={styles.logRows} data-testid="logRows" ref={this.logsContainer}>
<LogRows
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
showContextToggle={showContextToggle}
showLabels={showLabels}
showTime={showTime}
enableLogDetails={true}
forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage}
prettifyLogMessage={prettifyLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
onClickShowField={this.showField}
onClickHideField={this.hideField}
app={CoreApp.Explore}
onLogRowHover={this.onLogRowHover}
onOpenContext={this.onOpenContext}
onPermalinkClick={this.onPermalinkClick}
permalinkedRowId={this.props.panelState?.logs?.id}
scrollIntoView={this.scrollIntoView}
/>
</div>
)}
{!loading && !hasData && !scanning && (
<div className={styles.logRows}>
<div className={styles.noData}>
No logs found.
<Button size="sm" variant="secondary" onClick={this.onClickScan}>
Scan for older logs
</Button>
</div>
)}
{scanning && (
</div>
)}
{scanning && (
<div className={styles.logRows}>
<div className={styles.noData}>
<span>{scanText}</span>
<Button size="sm" variant="secondary" onClick={this.onClickStopScan}>
Stop scan
</Button>
</div>
)}
</div>
</div>
)}
<LogsNavigation
logsSortOrder={logsSortOrder}
visibleRange={navigationRange ?? absoluteRange}
@ -711,5 +782,12 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean) => {
width: 100%;
${scrollableLogsContainer && 'max-height: calc(100vh - 170px);'}
`,
visualisationType: css`
display: flex;
justify-content: space-between;
`,
visualisationTypeRadio: css`
margin: 0 0 0 ${theme.spacing(1)};
`,
};
};

View File

@ -214,7 +214,9 @@ class LogsContainer extends PureComponent<LogsContainerProps> {
clearCache={() => clearCache(exploreId)}
eventBus={this.props.eventBus}
panelState={this.props.panelState}
logsFrames={this.props.logsFrames}
scrollElement={scrollElement}
range={range}
/>
</LogsCrossFadeTransition>
</>
@ -258,6 +260,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
absoluteRange,
logsVolume,
panelState,
logsFrames: item.queryResponse.logsFrames,
};
}

View File

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

View File

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

View File

@ -85,7 +85,13 @@ export const getDataframeFields = memoizeOne(
}
);
function shouldRemoveField(field: Field, index: number, row: LogRowModel) {
export function shouldRemoveField(
field: Field,
index: number,
row: LogRowModel,
shouldRemoveLine = true,
shouldRemoveTime = true
) {
// hidden field, remove
if (field.config.custom?.hidden) {
return true;
@ -112,19 +118,23 @@ function shouldRemoveField(field: Field, index: number, row: LogRowModel) {
if (field.name === 'id' || field.name === 'tsNs') {
return true;
}
const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time);
if (
field.name === firstTimeField?.name &&
field.type === FieldType.time &&
field.values[0] === firstTimeField.values[0]
) {
return true;
if (shouldRemoveTime) {
const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time);
if (
field.name === firstTimeField?.name &&
field.type === FieldType.time &&
field.values[0] === firstTimeField.values[0]
) {
return true;
}
}
// first string-field is the log-line
const firstStringFieldIndex = row.dataFrame.fields.findIndex((f) => f.type === FieldType.string);
if (firstStringFieldIndex === index) {
return true;
if (shouldRemoveLine) {
// first string-field is the log-line
const firstStringFieldIndex = row.dataFrame.fields.findIndex((f) => f.type === FieldType.string);
if (firstStringFieldIndex === index) {
return true;
}
}
return false;