diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 0551461f9b8..3b35e348ec3 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -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 diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 655dd05e5f1..fb31e139fc7 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -109,5 +109,6 @@ export interface FeatureToggles { elasticToggleableFilters?: boolean; vizAndWidgetSplit?: boolean; prometheusIncrementalQueryInstrumentation?: boolean; + logsExploreTableVisualisation?: boolean; awsDatasourcesTempCredentials?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c9284ffcbc4..535790a5be4 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 695e1f5e3bf..8334b67b32c 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 8ba3712a9f4..93a768fd187 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index ac36208c8c0..7e472a801b0 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -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 => { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 8a66daf0d6e..f9bcc265113 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -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)}; + `, }; }; diff --git a/public/app/features/explore/Logs/LogsContainer.tsx b/public/app/features/explore/Logs/LogsContainer.tsx index acef026f34d..0a5b7391455 100644 --- a/public/app/features/explore/Logs/LogsContainer.tsx +++ b/public/app/features/explore/Logs/LogsContainer.tsx @@ -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, }; } diff --git a/public/app/features/explore/Logs/LogsTable.test.tsx b/public/app/features/explore/Logs/LogsTable.test.tsx new file mode 100644 index 00000000000..28ce33bfeda --- /dev/null +++ b/public/app/features/explore/Logs/LogsTable.test.tsx @@ -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, + }; +}; diff --git a/public/app/features/explore/Logs/LogsTable.tsx b/public/app/features/explore/Logs/LogsTable.tsx new file mode 100644 index 00000000000..96f3f1c36f9 --- /dev/null +++ b/public/app/features/explore/Logs/LogsTable.tsx @@ -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 }} + /> + ); +}; diff --git a/public/app/features/logs/components/logParser.ts b/public/app/features/logs/components/logParser.ts index ec9101167e3..88f0544eaca 100644 --- a/public/app/features/logs/components/logParser.ts +++ b/public/app/features/logs/components/logParser.ts @@ -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;