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;