mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
26c6b753c3
commit
7e4e743a42
@ -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
|
||||
|
@ -109,5 +109,6 @@ export interface FeatureToggles {
|
||||
elasticToggleableFilters?: boolean;
|
||||
vizAndWidgetSplit?: boolean;
|
||||
prometheusIncrementalQueryInstrumentation?: boolean;
|
||||
logsExploreTableVisualisation?: boolean;
|
||||
awsDatasourcesTempCredentials?: boolean;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
@ -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 => {
|
||||
|
@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
165
public/app/features/explore/Logs/LogsTable.test.tsx
Normal file
165
public/app/features/explore/Logs/LogsTable.test.tsx
Normal 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,
|
||||
};
|
||||
};
|
167
public/app/features/explore/Logs/LogsTable.tsx
Normal file
167
public/app/features/explore/Logs/LogsTable.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user