You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@druid.apache.org by vo...@apache.org on 2022/08/24 23:17:20 UTC

[druid] branch master updated: Web console: Multi-stage query support (#12919)

This is an automated email from the ASF dual-hosted git repository.

vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 04ee7abeff Web console: Multi-stage query support (#12919)
04ee7abeff is described below

commit 04ee7abeff7db86aca350a5daae8bf8cfa0da81f
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Wed Aug 24 16:17:12 2022 -0700

    Web console: Multi-stage query support (#12919)
    
    * MSQ web console
    
    * fix typo in comments
    
    * remove useless conditional
    
    * wrap SQL_DATA_TYPES
    
    * fixes sus regex
    
    * rewrite regex
    
    * remove problematic regex
    
    * fix UTs
    
    * convert PARTITIONED / CLUSTERED BY to ORDER BY for preview
    
    * fix log
    
    * updated to use shuffle
    
    * Web console: Use Ace.Completion directly (#1405)
    
    * Use Ace.Completion directly
    
    * Another Ace.Completion
    
    * better comment
    
    * fix column ordering in e2e test
    
    * add nested data example also
    
    Co-authored-by: John Gozde <jo...@imply.io>
---
 licenses.yaml                                      |  14 +-
 licenses/bin/zustand.MIT                           |  21 +
 .../e2e-tests/component/datasources/overview.ts    |   2 +-
 .../e2e-tests/component/ingestion/overview.ts      |   4 +-
 .../e2e-tests/component/load-data/data-loader.ts   |   2 +-
 web-console/e2e-tests/component/query/overview.ts  |  13 +-
 .../component/{ingestion => workbench}/overview.ts |  38 +-
 web-console/e2e-tests/multi-stage-query.spec.ts    |  72 ++
 web-console/lib/keywords.js                        |   6 +
 web-console/lib/sql-docs.d.ts                      |   2 +-
 web-console/package-lock.json                      | 421 ++++-----
 web-console/package.json                           |  10 +-
 web-console/script/create-sql-docs.js              |   8 +-
 web-console/script/druid                           |   2 +-
 web-console/script/mv                              |  75 ++
 web-console/src/ace-modes/dsql.js                  |   4 +-
 web-console/src/bootstrap/react-table-defaults.tsx |   2 +-
 .../src/components/braced-text/braced-text.scss    |   1 +
 .../__snapshots__/click-to-copy.spec.tsx.snap      |  11 +
 .../click-to-copy/click-to-copy.spec.tsx}          |   8 +-
 .../click-to-copy/click-to-copy.tsx}               |  29 +-
 .../datasource-columns-table.spec.tsx.snap         | 668 --------------
 .../fancy-tab-pane.scss}                           |  56 +-
 .../components/fancy-tab-pane/fancy-tab-pane.tsx   |  83 ++
 .../form-group-with-info/form-group-with-info.tsx  |   2 +-
 .../__snapshots__/header-bar.spec.tsx.snap         | 163 +++-
 .../src/components/header-bar/header-bar.scss      |  11 +-
 .../src/components/header-bar/header-bar.spec.tsx  |   2 +-
 .../src/components/header-bar/header-bar.tsx       | 152 +++-
 web-console/src/components/index.ts                |  12 +
 .../components/interval-input/interval-input.tsx   |   2 +-
 .../__snapshots__/learn-more.spec.tsx.snap         |   0
 .../learn-more/learn-more.spec.tsx                 |   0
 .../learn-more/learn-more.tsx                      |   2 +-
 .../src/components/menu-checkbox/menu-checkbox.tsx |   5 +-
 .../__snapshots__/menu-tristate.spec.tsx.snap      | 103 +++
 .../menu-tristate/menu-tristate.spec.tsx}          |  29 +-
 .../src/components/menu-tristate/menu-tristate.tsx |  71 ++
 .../__snapshots__/more-button.spec.tsx.snap        |   2 +
 .../src/components/more-button/more-button.tsx     |   2 +-
 .../__snapshots__/query-error-pane.spec.tsx.snap}  |   4 +-
 .../query-error-pane.scss}                         |  11 +-
 .../query-error-pane/query-error-pane.spec.tsx}    |   6 +-
 .../query-error-pane/query-error-pane.tsx}         |  33 +-
 .../record-table-pane/record-table-pane.scss       | 113 +++
 .../record-table-pane/record-table-pane.tsx        | 282 ++++++
 web-console/src/components/show-json/show-json.tsx |   2 +-
 .../table-cell-unparseable.scss                    |   3 +
 .../table-clickable-cell/table-clickable-cell.tsx  |   7 +-
 web-console/src/console-application.tsx            | 127 ++-
 .../async-action-dialog/async-action-dialog.tsx    |   2 +-
 .../__snapshots__/compaction-dialog.spec.tsx.snap  |  56 ++
 ...coordinator-dynamic-config-dialog.spec.tsx.snap |   2 +-
 .../datasource-table-action-dialog.spec.tsx.snap   |  75 +-
 .../datasource-columns-table.spec.tsx.snap         | 648 ++++++++++++++
 .../datasource-columns-table.scss                  |  20 +-
 .../datasource-columns-table.spec.tsx              |   6 +-
 .../datasource-columns-table.tsx                   |  15 +-
 .../datasource-preview-pane.scss}                  |  12 +-
 .../datasource-preview-pane.tsx                    |  65 ++
 .../datasource-table-action-dialog.spec.tsx        |   2 +-
 .../datasource-table-action-dialog.tsx             |  35 +-
 .../edit-context-dialog/edit-context-dialog.tsx    |   2 +-
 .../src/dialogs/history-dialog/history-dialog.tsx  |   2 +-
 web-console/src/dialogs/index.ts                   |   3 +
 .../lookup-table-action-dialog.tsx                 |   3 +-
 .../lookup-values-table.spec.tsx.snap              |   0
 .../lookup-values-table/lookup-values-table.scss   |   0
 .../lookup-values-table.spec.tsx                   |   0
 .../lookup-values-table/lookup-values-table.tsx    |   8 +-
 .../numeric-input-dialog/numeric-input-dialog.tsx  |  79 ++
 .../overload-dynamic-config-dialog.spec.tsx.snap   |   2 +-
 .../__snapshots__/retention-dialog.spec.tsx.snap   |   2 +-
 .../segment-table-action-dialog.spec.tsx.snap      |  27 +
 .../segment-table-action-dialog.tsx                |  12 +-
 .../parse-segement-id.spec.ts                      |  65 ++
 .../segments-preview-pane.scss}                    |  13 +-
 .../segments-preview-pane.tsx                      | 119 +++
 .../dialogs/status-dialog/status-dialog.spec.tsx   |  16 +-
 .../src/dialogs/status-dialog/status-dialog.tsx    |  72 +-
 .../string-input-dialog/string-input-dialog.tsx    |  61 ++
 .../supervisor-statistics-table.spec.tsx.snap      |   0
 .../supervisor-statistics-table.scss               |   0
 .../supervisor-statistics-table.spec.tsx           |   4 +-
 .../supervisor-statistics-table.tsx                |  10 +-
 .../supervisor-table-action-dialog.tsx             |   3 +-
 .../table-action-dialog/table-action-dialog.tsx    |   2 +-
 .../task-table-action-dialog.tsx                   |   6 +-
 .../{ => compaction-config}/compaction-config.tsx  |  21 +-
 .../compaction-status.spec.ts                      |   3 +-
 .../{ => compaction-status}/compaction-status.ts   |   2 +-
 .../coordinator-dynamic-config.tsx                 |   2 +-
 .../{ => dimension-spec}/dimension-spec.spec.ts    |   3 +-
 .../{ => dimension-spec}/dimension-spec.ts         |  11 +-
 .../druid-engine.ts}                               |  22 +-
 .../execution/execution-ingest-complete.mock.ts    | 285 ++++++
 .../execution/execution-ingest-error.mock.ts       | 290 ++++++
 .../src/druid-models/execution/execution.spec.ts   | 530 +++++++++++
 .../src/druid-models/execution/execution.ts        | 467 ++++++++++
 .../external-config/external-config.ts             | 192 ++++
 .../src/druid-models/{ => filter}/filter.tsx       |   9 +-
 .../{ => flatten-spec}/flatten-spec.spec.ts        |   8 +-
 .../{ => flatten-spec}/flatten-spec.tsx            |   6 +-
 web-console/src/druid-models/index.ts              |  38 +-
 .../ingest-query-pattern.spec.ts                   |  53 ++
 .../ingest-query-pattern/ingest-query-pattern.ts   | 314 +++++++
 .../{ => ingestion-spec}/ingestion-spec.spec.ts    |  42 +-
 .../{ => ingestion-spec}/ingestion-spec.tsx        | 105 ++-
 .../{ => input-format}/input-format.tsx            |  13 +-
 web-console/src/druid-models/input-source.tsx      |  91 --
 .../src/druid-models/input-source/input-source.tsx | 590 +++++++++++++
 .../{ => lookup-spec}/lookup-spec.spec.ts          |   0
 .../druid-models/{ => lookup-spec}/lookup-spec.tsx |   4 +-
 .../{ => metric-spec}/metric-spec.spec.ts          |   3 +-
 .../druid-models/{ => metric-spec}/metric-spec.tsx |  11 +-
 .../mocks.ts}                                      |   5 +-
 .../overlord-dynamic-config.tsx                    |   2 +-
 .../druid-models/query-context/query-context.tsx   | 217 +++++
 web-console/src/druid-models/stages/stages.spec.ts | 881 ++++++++++++++++++
 web-console/src/druid-models/stages/stages.ts      | 524 +++++++++++
 .../src/druid-models/{ => time}/time.spec.ts       |   0
 web-console/src/druid-models/{ => time}/time.ts    |   2 +-
 .../{ => timestamp-spec}/timestamp-spec.tsx        |  11 +-
 .../{ => transform-spec}/transform-spec.spec.ts    |   0
 .../{ => transform-spec}/transform-spec.tsx        |   7 +-
 .../workbench-query/workbench-query-part.ts        | 256 ++++++
 .../workbench-query/workbench-query.spec.ts        | 592 +++++++++++++
 .../workbench-query/workbench-query.ts             | 665 ++++++++++++++
 web-console/src/entry.ts                           |   2 +-
 web-console/src/helpers/execution/general.ts       |  70 ++
 .../src/helpers/execution/sql-task-execution.ts    | 264 ++++++
 .../index.ts}                                      |   6 +-
 web-console/src/helpers/spec-conversion.spec.ts    | 452 ++++++++++
 web-console/src/helpers/spec-conversion.ts         | 455 ++++++++++
 web-console/src/hooks/use-query-manager.ts         |  23 +-
 web-console/{lib/sql-docs.d.ts => src/index.ts}    |  11 +-
 web-console/src/links.ts                           |   2 +-
 .../src/react-table/react-table-base-styles.scss   |   7 -
 web-console/src/react-table/react-table-extra.scss |   2 +-
 .../react-table-pagination.tsx                     |  11 +-
 .../ace-editor-state-cache.ts}                     |  44 +-
 web-console/src/singletons/api.spec.ts             |   2 +-
 web-console/src/singletons/api.ts                  |   6 +-
 .../execution-state-cache.ts}                      |  28 +-
 web-console/src/singletons/workbench-history.ts    |  73 ++
 .../src/singletons/workbench-running-promises.ts   |  50 ++
 web-console/src/utils/capabilities.ts              |  27 +
 .../src/utils/download-query-detail-archive.ts     |  61 ++
 .../download.spec.ts}                              |  18 +-
 web-console/src/utils/download.ts                  |  92 ++
 web-console/src/utils/druid-query.spec.ts          |  37 +-
 web-console/src/utils/druid-query.ts               |  54 +-
 web-console/src/utils/general.tsx                  |  56 +-
 web-console/src/utils/index.tsx                    |   6 +-
 web-console/src/utils/local-storage-keys.tsx       |  10 +
 web-console/src/utils/query-context.tsx            |  86 --
 web-console/src/utils/query-manager.tsx            |  30 +-
 web-console/src/utils/query-state.ts               |   3 +
 web-console/src/utils/sample-query.spec.tsx        |  60 ++
 web-console/src/utils/sample-query.tsx             |  81 ++
 .../test-fixtures.ts => utils/sampler.mock.ts}     |   2 +-
 web-console/src/utils/sql.ts                       |  60 ++
 .../src/utils/{data-type-utils.ts => types.ts}     |  36 +-
 .../__snapshots__/datasources-view.spec.tsx.snap   |   7 +-
 .../views/datasources-view/datasources-view.tsx    |  29 +-
 .../__snapshots__/home-view.spec.tsx.snap          |  12 +
 .../home-view/services-card/services-card.tsx      |   2 +-
 .../src/views/home-view/tasks-card/tasks-card.tsx  |   2 +-
 web-console/src/views/index.ts                     |   2 +
 .../__snapshots__/ingestion-view.spec.tsx.snap     |  32 +-
 .../views/ingestion-view/ingestion-view.spec.tsx   |   4 +-
 .../src/views/ingestion-view/ingestion-view.tsx    | 133 +--
 .../__snapshots__/load-data-view.spec.tsx.snap     | 464 +++++++---
 .../src/views/load-data-view/info-messages.tsx     |   4 +-
 .../src/views/load-data-view/load-data-view.scss   |  71 +-
 .../views/load-data-view/load-data-view.spec.tsx   |   9 +-
 .../src/views/load-data-view/load-data-view.tsx    | 163 ++--
 .../parse-data-table/parse-data-table.tsx          |   3 +-
 .../parse-time-table/parse-time-table.tsx          |   3 +-
 .../__snapshots__/query-view.spec.tsx.snap         |  10 +-
 .../query-view/column-tree/column-tree.spec.tsx    |   4 +-
 .../views/query-view/column-tree/column-tree.tsx   |  13 +-
 .../__snapshots__/explain-dialog.spec.tsx.snap     | 117 ++-
 .../query-view/explain-dialog/explain-dialog.scss  |   2 +
 .../explain-dialog/explain-dialog.spec.tsx         |   5 +-
 .../query-view/explain-dialog/explain-dialog.tsx   |  94 +-
 .../live-query-mode-button.spec.tsx.snap}          |  26 +-
 .../live-query-mode-button.scss}                   |   4 +-
 .../live-query-mode-button.spec.tsx}               |   8 +-
 .../live-query-mode-button.tsx}                    |  26 +-
 .../query-history-dialog.spec.tsx.snap             |   0
 .../query-history-dialog/query-history-dialog.scss |   2 +-
 .../query-history-dialog.spec.tsx                  |   0
 .../query-history-dialog/query-history-dialog.tsx  |   4 +-
 .../views/query-view/query-input/query-input.tsx   |  14 +-
 .../views/query-view/query-output/query-output.tsx |  16 +-
 .../src/views/query-view/query-view.spec.tsx       |   9 -
 web-console/src/views/query-view/query-view.tsx    |  75 +-
 .../src/views/query-view/run-button/run-button.tsx |  18 +-
 .../src/views/segments-view/segments-view.tsx      |  50 +-
 .../src/views/services-view/services-view.spec.tsx |   4 +-
 .../src/views/services-view/services-view.tsx      |   6 +-
 .../column-actions/column-actions.scss}            |  22 +-
 .../column-actions/column-actions.tsx              | 283 ++++++
 .../column-editor/column-editor.scss}              |  44 +-
 .../column-editor/column-editor.tsx                | 219 +++++
 .../destination-dialog/destination-dialog.scss}    |   6 +-
 .../destination-dialog/destination-dialog.tsx      |  89 ++
 .../destination-form/destination-form.scss}        |   7 +-
 .../destination-form/destination-form.tsx          | 128 +++
 .../expression-editor-dialog.scss}                 |  27 +-
 .../expression-editor-dialog.tsx                   | 107 +++
 .../ingestion-progress-dialog.scss}                |  25 +-
 .../ingestion-progress-dialog.tsx                  | 132 +++
 .../schema-step/column-list/column-list.scss}      |  51 +-
 .../schema-step/column-list/column-list.tsx        | 156 ++++
 .../expression-entry/expression-entry.scss}        |  70 +-
 .../expression-entry/expression-entry.tsx          |  70 ++
 .../schema-step/preview-error/preview-error.scss}  |  25 +-
 .../schema-step/preview-error/preview-error.tsx}   |  24 +-
 .../schema-step/preview-table/preview-table.scss   | 146 +++
 .../schema-step/preview-table/preview-table.tsx    | 222 +++++
 .../rollup-analysis-pane.scss}                     |  18 +-
 .../rollup-analysis-pane/rollup-analysis-pane.tsx  | 342 +++++++
 .../schema-step/schema-step.scss                   | 147 +++
 .../schema-step/schema-step.tsx                    | 983 +++++++++++++++++++++
 .../sql-data-loader-view/sql-data-loader-view.scss |  85 ++
 .../sql-data-loader-view/sql-data-loader-view.tsx  | 201 +++++
 .../title-frame/title-frame.scss}                  |  43 +-
 .../title-frame/title-frame.tsx}                   |  27 +-
 .../cancel-query-dialog/cancel-query-dialog.tsx}   |  35 +-
 .../connect-external-data-dialog.scss}             |  25 +-
 .../connect-external-data-dialog.tsx               |  87 ++
 .../src/views/workbench-view/demo-queries.ts       | 312 +++++++
 .../execution-details-dialog.scss}                 |  24 +-
 .../execution-details-dialog.tsx                   |  58 ++
 .../execution-details-pane-loader.tsx              |  71 ++
 .../execution-details-pane.scss}                   |  11 +-
 .../execution-details-pane.tsx                     | 147 +++
 .../execution-error-pane.spec.tsx.snap             |  40 +
 .../execution-error-pane.scss}                     |  13 +-
 .../execution-error-pane.spec.tsx}                 |  13 +-
 .../execution-error-pane/execution-error-pane.tsx  |  94 ++
 .../execution-progress-bar-pane.spec.tsx.snap      |  23 +
 .../execution-progress-bar-pane.scss}              |  26 +-
 .../execution-progress-bar-pane.spec.tsx}          |  15 +-
 .../execution-progress-bar-pane.tsx                |  95 ++
 .../execution-progress-pane.scss}                  |  53 +-
 .../execution-progress-pane.tsx                    |  62 ++
 .../execution-stages-pane-loader.tsx               |  62 ++
 .../execution-stages-pane.spec.tsx.snap            | 427 +++++++++
 .../execution-stages-pane.scss                     | 104 +++
 .../execution-stages-pane.spec.tsx}                |  15 +-
 .../execution-stages-pane.tsx                      | 649 ++++++++++++++
 .../execution-submit-dialog.scss}                  |  22 +-
 .../execution-submit-dialog.tsx                    | 139 +++
 .../execution-summary-panel.scss}                  |   5 +-
 .../execution-summary-panel.tsx                    | 100 +++
 .../execution-timer-panel.spec.tsx.snap            |  59 ++
 .../execution-timer-panel.scss}                    |   6 +-
 .../execution-timer-panel.spec.tsx}                |  28 +-
 .../execution-timer-panel.tsx                      |  71 ++
 .../execution-warnings-pane.scss}                  |   6 +-
 .../execution-warnings-pane.tsx}                   |  26 +-
 .../execution-warnings-pane/warning-callout.scss}  |   6 +-
 .../execution-warnings-pane/warning-callout.tsx    |  53 ++
 .../flexible-query-input.spec.tsx.snap             | 111 +++
 .../flexible-query-input.scss}                     |  10 +-
 .../flexible-query-input.spec.tsx}                 |  24 +-
 .../flexible-query-input/flexible-query-input.tsx} | 172 ++--
 .../workbench-view/helper-query/helper-query.scss  | 100 +++
 .../workbench-view/helper-query/helper-query.tsx   | 408 +++++++++
 .../ingest-success-pane.spec.tsx.snap              |  30 +
 .../ingest-success-pane/ingest-success-pane.scss}  |  16 +-
 .../ingest-success-pane.spec.tsx}                  |  17 +-
 .../ingest-success-pane/ingest-success-pane.tsx    |  82 ++
 .../input-format-step/input-format-step.scss}      |  56 +-
 .../input-format-step/input-format-step.tsx        | 259 ++++++
 .../input-source-step/example-inputs.ts            | 159 ++++
 .../input-source-step/input-source-info.tsx        |  78 ++
 .../input-source-step/input-source-step.scss       | 131 +++
 .../input-source-step/input-source-step.tsx        | 343 +++++++
 .../__snapshots__/max-tasks-button.spec.tsx.snap   | 205 +++++
 .../max-tasks-button/max-tasks-button.spec.tsx}    |   9 +-
 .../max-tasks-button/max-tasks-button.tsx          | 115 +++
 .../workbench-view/metadata-change-detector.tsx}   |  42 +-
 .../workbench-view/metadata-state-store.ts}        |  28 +-
 .../views/workbench-view/query-tab/query-tab.scss  | 147 +++
 .../views/workbench-view/query-tab/query-tab.tsx   | 457 ++++++++++
 .../recent-query-task-panel.scss                   | 121 +++
 .../recent-query-task-panel.tsx                    | 275 ++++++
 .../result-table-pane/result-table-pane.scss       | 117 +++
 .../result-table-pane/result-table-pane.tsx        | 778 ++++++++++++++++
 .../workbench-view/run-panel/run-panel.scss}       |   9 +-
 .../views/workbench-view/run-panel/run-panel.tsx   | 422 +++++++++
 .../tab-rename-dialog/tab-rename-dialog.tsx        |  70 ++
 .../time-floor-menu-item/time-floor-menu-item.tsx  | 102 +++
 .../workbench-view/work-state-store.ts}            |  28 +-
 .../workbench-history-dialog.scss}                 |  64 +-
 .../workbench-history-dialog.tsx                   | 134 +++
 .../src/views/workbench-view/workbench-view.scss   | 122 +++
 .../src/views/workbench-view/workbench-view.tsx    | 805 +++++++++++++++++
 302 files changed, 24094 insertions(+), 2731 deletions(-)

diff --git a/licenses.yaml b/licenses.yaml
index 226062861c..6c226d2242 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5317,7 +5317,7 @@ license_category: binary
 module: web-console
 license_name: MIT License
 copyright: Matt Zabriskie
-version: 0.21.4
+version: 0.26.1
 license_file_path: licenses/bin/axios.MIT
 
 ---
@@ -5666,7 +5666,7 @@ license_category: binary
 module: web-console
 license_name: Apache License version 2.0
 copyright: Imply Data
-version: 0.14.10
+version: 0.14.24
 
 ---
 
@@ -6536,4 +6536,14 @@ license_name: ISC License
 copyright: Eemeli Aro
 version: 1.10.2
 license_file_path: licenses/bin/yaml.ISC
+
+---
+
+name: "zustand"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Paul Henschel
+version: 3.7.2
+license_file_path: licenses/bin/zustand.MIT
 # Web console modules end
diff --git a/licenses/bin/zustand.MIT b/licenses/bin/zustand.MIT
new file mode 100644
index 0000000000..a2c2649dee
--- /dev/null
+++ b/licenses/bin/zustand.MIT
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 Paul Henschel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/web-console/e2e-tests/component/datasources/overview.ts b/web-console/e2e-tests/component/datasources/overview.ts
index fb52581112..1fa83c3672 100644
--- a/web-console/e2e-tests/component/datasources/overview.ts
+++ b/web-console/e2e-tests/component/datasources/overview.ts
@@ -141,7 +141,7 @@ export class DatasourcesOverview {
   }
 
   private async clickMoreButton(options: any): Promise<void> {
-    await this.page.click('//button[span[@icon="more"]]', options);
+    await this.page.click('.more-button button', options);
     await this.waitForPopupMenu();
   }
 }
diff --git a/web-console/e2e-tests/component/ingestion/overview.ts b/web-console/e2e-tests/component/ingestion/overview.ts
index 7c3b8cb3d7..3d1a7f2997 100644
--- a/web-console/e2e-tests/component/ingestion/overview.ts
+++ b/web-console/e2e-tests/component/ingestion/overview.ts
@@ -30,10 +30,10 @@ enum TaskColumn {
   GROUP_ID,
   TYPE,
   DATASOURCE,
-  LOCATION,
-  CREATED_TIME,
   STATUS,
+  CREATED_TIME,
   DURATION,
+  LOCATION,
 }
 
 /**
diff --git a/web-console/e2e-tests/component/load-data/data-loader.ts b/web-console/e2e-tests/component/load-data/data-loader.ts
index 7bc1837f61..37586a22bb 100644
--- a/web-console/e2e-tests/component/load-data/data-loader.ts
+++ b/web-console/e2e-tests/component/load-data/data-loader.ts
@@ -34,7 +34,7 @@ export class DataLoader {
 
   constructor(props: DataLoaderProps) {
     Object.assign(this, props);
-    this.baseUrl = props.unifiedConsoleUrl + '#load-data';
+    this.baseUrl = props.unifiedConsoleUrl + '#data-loader';
   }
 
   /**
diff --git a/web-console/e2e-tests/component/query/overview.ts b/web-console/e2e-tests/component/query/overview.ts
index 6feeebfa9c..b12909c815 100644
--- a/web-console/e2e-tests/component/query/overview.ts
+++ b/web-console/e2e-tests/component/query/overview.ts
@@ -18,7 +18,7 @@
 
 import * as playwright from 'playwright-chromium';
 
-import { clickButton, clickText, setInput } from '../../util/playwright';
+import { clickButton, clickText } from '../../util/playwright';
 import { extractTable } from '../../util/table';
 
 /**
@@ -37,8 +37,9 @@ export class QueryOverview {
     await this.page.goto(this.baseUrl);
     await this.page.reload({ waitUntil: 'networkidle' });
 
-    const input = await this.page.$('div.query-input textarea');
-    await setInput(input!, query);
+    const input = await this.page.waitForSelector('div.query-input textarea');
+    await input.fill(query);
+
     await clickButton(this.page, 'Run');
     await this.page.waitForSelector('div.query-info');
 
@@ -49,10 +50,8 @@ export class QueryOverview {
     await this.page.goto(this.baseUrl);
     await this.page.reload({ waitUntil: 'networkidle' });
 
-    await this.page.waitForSelector('div.query-input textarea');
-    const input = await this.page.$('div.query-input textarea');
-
-    await setInput(input!, query);
+    const input = await this.page.waitForSelector('div.query-input textarea');
+    await input.fill(query);
 
     await Promise.all([
       this.page.waitForRequest(
diff --git a/web-console/e2e-tests/component/ingestion/overview.ts b/web-console/e2e-tests/component/workbench/overview.ts
similarity index 66%
copy from web-console/e2e-tests/component/ingestion/overview.ts
copy to web-console/e2e-tests/component/workbench/overview.ts
index 7c3b8cb3d7..5447daf8f8 100644
--- a/web-console/e2e-tests/component/ingestion/overview.ts
+++ b/web-console/e2e-tests/component/workbench/overview.ts
@@ -18,48 +18,30 @@
 
 import * as playwright from 'playwright-chromium';
 
+import { clickButton } from '../../util/playwright';
 import { extractTable } from '../../util/table';
 
-import { IngestionTask } from './task';
-
-/**
- * Ingestion overview task table column identifiers.
- */
-enum TaskColumn {
-  TASK_ID = 0,
-  GROUP_ID,
-  TYPE,
-  DATASOURCE,
-  LOCATION,
-  CREATED_TIME,
-  STATUS,
-  DURATION,
-}
-
 /**
- * Represents ingestion overview tab.
+ * Represents the workbench tab.
  */
-export class IngestionOverview {
+export class WorkbenchOverview {
   private readonly page: playwright.Page;
   private readonly baseUrl: string;
 
   constructor(page: playwright.Page, unifiedConsoleUrl: string) {
     this.page = page;
-    this.baseUrl = unifiedConsoleUrl + '#ingestion';
+    this.baseUrl = unifiedConsoleUrl + '#workbench';
   }
 
-  async getTasks(): Promise<IngestionTask[]> {
+  async runQuery(query: string): Promise<string[][]> {
     await this.page.goto(this.baseUrl);
     await this.page.reload({ waitUntil: 'networkidle' });
 
-    const data = await extractTable(this.page, 'div.bottom-pane div.rt-tr-group', 'div.rt-td');
+    const input = await this.page.waitForSelector('div.flexible-query-input textarea');
+    await input.fill(query);
+    await clickButton(this.page, 'Run');
+    await this.page.waitForSelector('div.result-table-pane', { timeout: 120000 });
 
-    return data.map(
-      row =>
-        new IngestionTask({
-          datasource: row[TaskColumn.DATASOURCE],
-          status: row[TaskColumn.STATUS],
-        }),
-    );
+    return await extractTable(this.page, 'div.result-table-pane div.rt-tr-group', 'div.rt-td');
   }
 }
diff --git a/web-console/e2e-tests/multi-stage-query.spec.ts b/web-console/e2e-tests/multi-stage-query.spec.ts
new file mode 100644
index 0000000000..2766c5c8ff
--- /dev/null
+++ b/web-console/e2e-tests/multi-stage-query.spec.ts
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as playwright from 'playwright-chromium';
+
+import { WorkbenchOverview } from './component/workbench/overview';
+import { saveScreenshotIfError } from './util/debug';
+import { DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR, UNIFIED_CONSOLE_URL } from './util/druid';
+import { createBrowser, createPage } from './util/playwright';
+import { waitTillWebConsoleReady } from './util/setup';
+
+jest.setTimeout(5 * 60 * 1000);
+
+describe('Multi-stage query', () => {
+  let browser: playwright.Browser;
+  let page: playwright.Page;
+
+  beforeAll(async () => {
+    await waitTillWebConsoleReady();
+    browser = await createBrowser();
+  });
+
+  beforeEach(async () => {
+    page = await createPage(browser);
+  });
+
+  afterAll(async () => {
+    await browser.close();
+  });
+
+  it('runs a query that reads external data', async () => {
+    await saveScreenshotIfError('multi-stage-query', page, async () => {
+      const workbench = new WorkbenchOverview(page, UNIFIED_CONSOLE_URL);
+      const results = await workbench.runQuery(`WITH ext AS (SELECT *
+FROM TABLE(
+  EXTERN(
+    '{"type":"local","filter":"wikiticker-2015-09-12-sampled.json.gz","baseDir":${JSON.stringify(
+      DRUID_EXAMPLES_QUICKSTART_TUTORIAL_DIR,
+    )}}',
+    '{"type":"json"}',
+    '[{"name":"channel","type":"string"}]'
+  )
+))
+SELECT
+  channel,
+  CAST(COUNT(*) AS VARCHAR) AS "CountString"
+FROM ext
+GROUP BY 1
+ORDER BY COUNT(*) DESC
+LIMIT 10`);
+      expect(results).toBeDefined();
+      expect(results.length).toBe(10);
+      expect(results[0]).toStrictEqual(['#en.wikipedia', '11549']);
+      expect(results[1]).toStrictEqual(['#vi.wikipedia', '9747']);
+    });
+  });
+});
diff --git a/web-console/lib/keywords.js b/web-console/lib/keywords.js
index accae44e4c..bf30e3578e 100644
--- a/web-console/lib/keywords.js
+++ b/web-console/lib/keywords.js
@@ -54,6 +54,12 @@ exports.SQL_KEYWORDS = [
   'ROWS',
   'ONLY',
   'VALUES',
+  'PARTITIONED BY',
+  'CLUSTERED BY',
+  'TIME',
+  'INSERT INTO',
+  'REPLACE INTO',
+  'OVERWRITE',
 ];
 
 exports.SQL_EXPRESSION_PARTS = [
diff --git a/web-console/lib/sql-docs.d.ts b/web-console/lib/sql-docs.d.ts
index ce9105cde5..a5af23211d 100644
--- a/web-console/lib/sql-docs.d.ts
+++ b/web-console/lib/sql-docs.d.ts
@@ -16,5 +16,5 @@
  * limitations under the License.
  */
 
-export const SQL_DATA_TYPES: [name: string, runtime: string, description: string][];
+export const SQL_DATA_TYPES: Record<string, [runtime: string, description: string][]>;
 export const SQL_FUNCTIONS: Record<string, [args: string, description: string][]>;
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index aa9e9e8d39..649906dc25 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -5262,16 +5262,6 @@
       "integrity": "sha512-wBlsw+8n21e6eTd4yVv8YD/E3xq0O6nNnJIquutAsFGE7EyMKz7W6RNT6BRu1SmdgmlCZ9tb0X+j+D6HGr8pZw==",
       "dev": true
     },
-    "@types/yauzl": {
-      "version": "2.9.2",
-      "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
-      "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==",
-      "dev": true,
-      "optional": true,
-      "requires": {
-        "@types/node": "*"
-      }
-    },
     "@typescript-eslint/eslint-plugin": {
       "version": "5.11.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.11.0.tgz",
@@ -6465,11 +6455,18 @@
       "dev": true
     },
     "axios": {
-      "version": "0.21.4",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
-      "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
+      "version": "0.26.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
+      "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
       "requires": {
-        "follow-redirects": "^1.14.0"
+        "follow-redirects": "^1.14.8"
+      },
+      "dependencies": {
+        "follow-redirects": {
+          "version": "1.15.1",
+          "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
+          "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
+        }
       }
     },
     "babel-jest": {
@@ -7003,12 +7000,6 @@
         "node-int64": "^0.4.0"
       }
     },
-    "buffer-crc32": {
-      "version": "0.2.13",
-      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
-      "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
-      "dev": true
-    },
     "buffer-from": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
@@ -8469,11 +8460,11 @@
       }
     },
     "druid-query-toolkit": {
-      "version": "0.14.10",
-      "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.14.10.tgz",
-      "integrity": "sha512-Y720YxnT3EmqtE/x1QkrkEiomn5TdVArxI3+gdLRH8FYMRedpSPe2nkQVNYma9b7Lww/rzk4Q+a8mNWQ1YH9oQ==",
+      "version": "0.14.24",
+      "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.14.24.tgz",
+      "integrity": "sha512-NBV9prXllZiiYLCfD/k5UmJZg7EU7aqsQPIfTYiYgl9XY4QheY4IO8c5mD7lW8qPpS2qEr4mM9CXjGPPZTQrmw==",
       "requires": {
-        "tslib": "^2.2.0"
+        "tslib": "^2.3.1"
       }
     },
     "duplexer": {
@@ -10121,44 +10112,6 @@
         }
       }
     },
-    "extract-zip": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
-      "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
-      "dev": true,
-      "requires": {
-        "@types/yauzl": "^2.9.1",
-        "debug": "^4.1.1",
-        "get-stream": "^5.1.0",
-        "yauzl": "^2.10.0"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "4.3.3",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
-          "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
-          "dev": true,
-          "requires": {
-            "ms": "2.1.2"
-          }
-        },
-        "get-stream": {
-          "version": "5.2.0",
-          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
-          "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
-          "dev": true,
-          "requires": {
-            "pump": "^3.0.0"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-          "dev": true
-        }
-      }
-    },
     "extsprintf": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
@@ -10290,15 +10243,6 @@
         "bser": "2.1.1"
       }
     },
-    "fd-slicer": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
-      "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
-      "dev": true,
-      "requires": {
-        "pend": "~1.2.0"
-      }
-    },
     "file-entry-cache": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -14986,12 +14930,6 @@
         }
       }
     },
-    "jpeg-js": {
-      "version": "0.4.3",
-      "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz",
-      "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==",
-      "dev": true
-    },
     "js-base64": {
       "version": "2.5.2",
       "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.2.tgz",
@@ -17004,12 +16942,6 @@
       "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
       "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
     },
-    "pend": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
-      "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
-      "dev": true
-    },
     "performance-now": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -17095,67 +17027,19 @@
       }
     },
     "playwright-chromium": {
-      "version": "1.18.1",
-      "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.18.1.tgz",
-      "integrity": "sha512-DHAOdzZhhu4pMe9yg2zL49JSGXLHTO+DL76duukoy807o+ccu1tEbqyUId46ogLYk7rOjblVK6o7YG/vyVCasQ==",
+      "version": "1.25.0",
+      "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.25.0.tgz",
+      "integrity": "sha512-FH9ho3noAWVStCJx4XW78+D8QW0A99WDp53DDkYeVdEpJqCmAIKHCSE6dl5XtaDKrZPYC1ZG5hGXQh1K5H/p+g==",
       "dev": true,
       "requires": {
-        "playwright-core": "=1.18.1"
+        "playwright-core": "1.25.0"
       },
       "dependencies": {
-        "commander": {
-          "version": "8.3.0",
-          "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
-          "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
-          "dev": true
-        },
-        "debug": {
-          "version": "4.3.3",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
-          "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
-          "dev": true,
-          "requires": {
-            "ms": "2.1.2"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-          "dev": true
-        },
         "playwright-core": {
-          "version": "1.18.1",
-          "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.18.1.tgz",
-          "integrity": "sha512-NALGl8R1GHzGLlhUApmpmfh6M1rrrPcDTygWvhTbprxwGB9qd/j9DRwyn4HTQcUB6o0/VOpo46fH9ez3+D/Rog==",
-          "dev": true,
-          "requires": {
-            "commander": "^8.2.0",
-            "debug": "^4.1.1",
-            "extract-zip": "^2.0.1",
-            "https-proxy-agent": "^5.0.0",
-            "jpeg-js": "^0.4.2",
-            "mime": "^2.4.6",
-            "pngjs": "^5.0.0",
-            "progress": "^2.0.3",
-            "proper-lockfile": "^4.1.1",
-            "proxy-from-env": "^1.1.0",
-            "rimraf": "^3.0.2",
-            "socks-proxy-agent": "^6.1.0",
-            "stack-utils": "^2.0.3",
-            "ws": "^7.4.6",
-            "yauzl": "^2.10.0",
-            "yazl": "^2.5.1"
-          }
-        },
-        "rimraf": {
-          "version": "3.0.2",
-          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-          "dev": true,
-          "requires": {
-            "glob": "^7.1.3"
-          }
+          "version": "1.25.0",
+          "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.25.0.tgz",
+          "integrity": "sha512-kZ3Jwaf3wlu0GgU0nB8UMQ+mXFTqBIFz9h1svTlNduNKjnbPXFxw7mJanLVjqxHJRn62uBfmgBj93YHidk2N5Q==",
+          "dev": true
         }
       }
     },
@@ -17165,12 +17049,6 @@
       "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
       "dev": true
     },
-    "pngjs": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
-      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
-      "dev": true
-    },
     "popper.js": {
       "version": "1.16.1",
       "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
@@ -18791,12 +18669,6 @@
       "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
       "dev": true
     },
-    "progress": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
-      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
-      "dev": true
-    },
     "prompts": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -18828,25 +18700,6 @@
         "reflect.ownkeys": "^0.2.0"
       }
     },
-    "proper-lockfile": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
-      "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
-      "dev": true,
-      "requires": {
-        "graceful-fs": "^4.2.4",
-        "retry": "^0.12.0",
-        "signal-exit": "^3.0.2"
-      },
-      "dependencies": {
-        "graceful-fs": {
-          "version": "4.2.9",
-          "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
-          "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==",
-          "dev": true
-        }
-      }
-    },
     "proxy-addr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
@@ -18857,12 +18710,6 @@
         "ipaddr.js": "1.9.1"
       }
     },
-    "proxy-from-env": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
-      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
-      "dev": true
-    },
     "prr": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -19500,6 +19347,160 @@
         "is-finite": "^1.0.0"
       }
     },
+    "replace": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/replace/-/replace-1.2.1.tgz",
+      "integrity": "sha512-KZCBe/tPanwBlbjSMQby4l+zjSiFi3CLEP/6VLClnRYgJ46DZ5u9tmA6ceWeFS8coaUnU4ZdGNb/puUGMHNSRg==",
+      "dev": true,
+      "requires": {
+        "chalk": "2.4.2",
+        "minimatch": "3.0.4",
+        "yargs": "^15.3.1"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+          "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+          "dev": true
+        },
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "cliui": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+          "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+          "dev": true,
+          "requires": {
+            "string-width": "^4.2.0",
+            "strip-ansi": "^6.0.0",
+            "wrap-ansi": "^6.2.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "is-fullwidth-code-point": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+          "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+          "dev": true
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        },
+        "path-exists": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+          "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+          "dev": true
+        },
+        "string-width": {
+          "version": "4.2.3",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+          "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+          "dev": true,
+          "requires": {
+            "emoji-regex": "^8.0.0",
+            "is-fullwidth-code-point": "^3.0.0",
+            "strip-ansi": "^6.0.1"
+          }
+        },
+        "strip-ansi": {
+          "version": "6.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+          "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "^5.0.1"
+          }
+        },
+        "wrap-ansi": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+          "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "string-width": "^4.1.0",
+            "strip-ansi": "^6.0.0"
+          }
+        },
+        "yargs": {
+          "version": "15.4.1",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+          "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+          "dev": true,
+          "requires": {
+            "cliui": "^6.0.0",
+            "decamelize": "^1.2.0",
+            "find-up": "^4.1.0",
+            "get-caller-file": "^2.0.1",
+            "require-directory": "^2.1.1",
+            "require-main-filename": "^2.0.0",
+            "set-blocking": "^2.0.0",
+            "string-width": "^4.2.0",
+            "which-module": "^2.0.0",
+            "y18n": "^4.0.0",
+            "yargs-parser": "^18.1.2"
+          }
+        },
+        "yargs-parser": {
+          "version": "18.1.3",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+          "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+          "dev": true,
+          "requires": {
+            "camelcase": "^5.0.0",
+            "decamelize": "^1.2.0"
+          }
+        }
+      }
+    },
     "request": {
       "version": "2.88.2",
       "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
@@ -20086,12 +20087,6 @@
       "integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=",
       "dev": true
     },
-    "smart-buffer": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
-      "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
-      "dev": true
-    },
     "snake-case": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
@@ -20279,44 +20274,6 @@
         }
       }
     },
-    "socks": {
-      "version": "2.6.2",
-      "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz",
-      "integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==",
-      "dev": true,
-      "requires": {
-        "ip": "^1.1.5",
-        "smart-buffer": "^4.2.0"
-      }
-    },
-    "socks-proxy-agent": {
-      "version": "6.1.1",
-      "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.1.1.tgz",
-      "integrity": "sha512-t8J0kG3csjA4g6FTbsMOWws+7R7vuRC8aQ/wy3/1OWmsgwA68zs/+cExQ0koSitUDXqhufF/YJr9wtNMZHw5Ew==",
-      "dev": true,
-      "requires": {
-        "agent-base": "^6.0.2",
-        "debug": "^4.3.1",
-        "socks": "^2.6.1"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "4.3.3",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
-          "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
-          "dev": true,
-          "requires": {
-            "ms": "2.1.2"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-          "dev": true
-        }
-      }
-    },
     "source-list-map": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@@ -23529,25 +23486,6 @@
         "decamelize": "^1.2.0"
       }
     },
-    "yauzl": {
-      "version": "2.10.0",
-      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
-      "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
-      "dev": true,
-      "requires": {
-        "buffer-crc32": "~0.2.3",
-        "fd-slicer": "~1.1.0"
-      }
-    },
-    "yazl": {
-      "version": "2.5.1",
-      "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz",
-      "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==",
-      "dev": true,
-      "requires": {
-        "buffer-crc32": "~0.2.3"
-      }
-    },
     "yn": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
@@ -23560,6 +23498,11 @@
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
       "dev": true
     },
+    "zustand": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
+      "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="
+    },
     "zwitch": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
diff --git a/web-console/package.json b/web-console/package.json
index a57f669226..e62d6e7e82 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -71,7 +71,7 @@
     "@blueprintjs/icons": "^4.1.1",
     "@blueprintjs/popover2": "^1.0.3",
     "ace-builds": "^1.4.13",
-    "axios": "^0.21.4",
+    "axios": "^0.26.1",
     "classnames": "^2.2.6",
     "copy-to-clipboard": "^3.2.0",
     "core-js": "^3.10.1",
@@ -79,7 +79,7 @@
     "d3-axis": "^1.0.12",
     "d3-scale": "^3.2.0",
     "d3-selection": "^1.4.0",
-    "druid-query-toolkit": "^0.14.10",
+    "druid-query-toolkit": "^0.14.24",
     "file-saver": "^2.0.2",
     "follow-redirects": "^1.14.7",
     "fontsource-open-sans": "^3.0.9",
@@ -100,7 +100,8 @@
     "react-splitter-layout": "^4.0.0",
     "react-table": "~6.10.3",
     "regenerator-runtime": "^0.13.7",
-    "tslib": "^2.3.1"
+    "tslib": "^2.3.1",
+    "zustand": "^3.6.5"
   },
   "devDependencies": {
     "@awesome-code-style/eslint-config": "^4.0.0",
@@ -154,11 +155,12 @@
     "jest": "^27.5.0",
     "license-checker": "^25.0.1",
     "node-sass": "^5.0.0",
-    "playwright-chromium": "^1.18.1",
+    "playwright-chromium": "^1.24.1",
     "postcss": "^8.3.0",
     "postcss-loader": "^5.3.0",
     "postcss-preset-env": "^6.7.0",
     "prettier": "^2.5.1",
+    "replace": "^1.2.1",
     "sass-loader": "^11.0.1",
     "snarkdown": "^2.0.0",
     "style-loader": "^2.0.0",
diff --git a/web-console/script/create-sql-docs.js b/web-console/script/create-sql-docs.js
index 57fba5b81f..4258a0e99e 100755
--- a/web-console/script/create-sql-docs.js
+++ b/web-console/script/create-sql-docs.js
@@ -70,7 +70,7 @@ const readDoc = async () => {
   const lines = data.split('\n');
 
   const functionDocs = {};
-  const dataTypeDocs = [];
+  const dataTypeDocs = {};
   for (let line of lines) {
     const functionMatch = line.match(/^\|\s*`(\w+)\(([^|]*)\)`\s*\|([^|]+)\|(?:([^|]+)\|)?$/);
     if (functionMatch) {
@@ -84,11 +84,7 @@ const readDoc = async () => {
 
     const dataTypeMatch = line.match(/^\|([A-Z]+)\|([A-Z]+)\|([^|]*)\|([^|]*)\|$/);
     if (dataTypeMatch) {
-      dataTypeDocs.push([
-        dataTypeMatch[1],
-        dataTypeMatch[2],
-        convertMarkdownToHtml(dataTypeMatch[4]),
-      ]);
+      dataTypeDocs[dataTypeMatch[1]] = [dataTypeMatch[2], convertMarkdownToHtml(dataTypeMatch[4])];
     }
   }
 
diff --git a/web-console/script/druid b/web-console/script/druid
index 9f54949b96..67651c66a2 100755
--- a/web-console/script/druid
+++ b/web-console/script/druid
@@ -61,7 +61,7 @@ function _build_distribution() {
     && tar xzf "apache-druid-$(_get_druid_version)-bin.tar.gz" \
     && cd apache-druid-$(_get_druid_version) \
     && bin/run-java -classpath "lib/*" org.apache.druid.cli.Main tools pull-deps -c org.apache.druid.extensions:druid-testing-tools \
-    && echo -e "\n\ndruid.extensions.loadList=[\"druid-hdfs-storage\", \"druid-kafka-indexing-service\", \"druid-datasketches\", \"druid-testing-tools\"]" >> conf/druid/single-server/micro-quickstart/_common/common.runtime.properties \
+    && echo -e "\n\ndruid.extensions.loadList=[\"druid-hdfs-storage\", \"druid-kafka-indexing-service\", \"druid-datasketches\", \"druid-multi-stage-query\", \"druid-testing-tools\"]" >> conf/druid/single-server/micro-quickstart/_common/common.runtime.properties \
     && echo -e "\n\ndruid.server.http.allowedHttpMethods=[\"HEAD\"]" >> conf/druid/single-server/micro-quickstart/_common/common.runtime.properties \
   )
 }
diff --git a/web-console/script/mv b/web-console/script/mv
new file mode 100755
index 0000000000..c5bf8231d1
--- /dev/null
+++ b/web-console/script/mv
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const fs = require('fs-extra');
+const replace = require('replace');
+
+if (process.argv.length !== 5) {
+  console.log('Usage: mv <src-location> <old-component-name> <new-component-name>');
+  process.exit();
+}
+
+const location = process.argv[2];
+const oldName = process.argv[3];
+const newName = process.argv[4];
+
+if (!/^([a-z0-9-])+$/.test(oldName)) {
+  console.log('must be a hyphen case old name');
+  process.exit();
+}
+
+if (!/^([a-z0-9-])+$/.test(newName)) {
+  console.log('must be a hyphen case new name');
+  process.exit();
+}
+
+const oldPath = './src/' + location + '/' + oldName + '/';
+const newPath = './src/' + location + '/' + newName + '/';
+
+const camelOldName = oldName.replace(/(^|-)[a-z]/g, s => s.replace('-', '').toUpperCase());
+const camelNewName = newName.replace(/(^|-)[a-z]/g, s => s.replace('-', '').toUpperCase());
+
+console.log('Making path:', newPath);
+
+fs.moveSync(oldPath, newPath);
+fs.renameSync(newPath + oldName + '.tsx', newPath + newName + '.tsx');
+try {
+  fs.renameSync(newPath + oldName + '.scss', newPath + newName + '.scss');
+} catch {}
+try {
+  fs.renameSync(newPath + oldName + '.spec.tsx', newPath + newName + '.spec.tsx');
+} catch {}
+
+const replacePath = './src/';
+
+replace({
+  regex: oldName,
+  replacement: newName,
+  paths: [replacePath],
+  recursive: true,
+  silent: true,
+});
+
+replace({
+  regex: camelOldName,
+  replacement: camelNewName,
+  paths: [replacePath],
+  recursive: true,
+  silent: true,
+});
diff --git a/web-console/src/ace-modes/dsql.js b/web-console/src/ace-modes/dsql.js
index 61ff90f26d..c54970b17c 100644
--- a/web-console/src/ace-modes/dsql.js
+++ b/web-console/src/ace-modes/dsql.js
@@ -48,9 +48,7 @@ ace.define(
       ).join('|');
 
       // Stuff like: 'int|numeric|decimal|date|varchar|char|bigint|float|double|bit|binary|text|set|timestamp'
-      var dataTypes = druidFunctions.SQL_DATA_TYPES.map(function (f) {
-        return f[0];
-      }).join('|');
+      var dataTypes = Object.keys(druidFunctions.SQL_DATA_TYPES).join('|');
 
       var keywordMapper = this.createKeywordMapper(
         {
diff --git a/web-console/src/bootstrap/react-table-defaults.tsx b/web-console/src/bootstrap/react-table-defaults.tsx
index 9058b95839..4c31928064 100644
--- a/web-console/src/bootstrap/react-table-defaults.tsx
+++ b/web-console/src/bootstrap/react-table-defaults.tsx
@@ -28,7 +28,7 @@ import {
 } from '../react-table';
 import { countBy } from '../utils';
 
-const NoData = React.memo(function NoData(props) {
+const NoData = React.memo(function NoData(props: { children?: React.ReactNode }) {
   const { children } = props;
   if (!children) return null;
   return <div className="rt-noData">{children}</div>;
diff --git a/web-console/src/components/braced-text/braced-text.scss b/web-console/src/components/braced-text/braced-text.scss
index 56f649397e..e9d390c12c 100644
--- a/web-console/src/components/braced-text/braced-text.scss
+++ b/web-console/src/components/braced-text/braced-text.scss
@@ -26,6 +26,7 @@
     top: -50000px; // Send it into the stratosphere (get it out of the parent container to prevent the browser from adding '...')
     opacity: 0;
     pointer-events: none;
+    user-select: none;
   }
 
   .real-text {
diff --git a/web-console/src/components/click-to-copy/__snapshots__/click-to-copy.spec.tsx.snap b/web-console/src/components/click-to-copy/__snapshots__/click-to-copy.spec.tsx.snap
new file mode 100644
index 0000000000..a53da72f6d
--- /dev/null
+++ b/web-console/src/components/click-to-copy/__snapshots__/click-to-copy.spec.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ClickToCopy matches snapshot 1`] = `
+<a
+  class="click-to-copy"
+  title="Click to copy:
+Hello world"
+>
+  Hello world
+</a>
+`;
diff --git a/web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx b/web-console/src/components/click-to-copy/click-to-copy.spec.tsx
similarity index 83%
copy from web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx
copy to web-console/src/components/click-to-copy/click-to-copy.spec.tsx
index 22968c7925..d554d392fe 100644
--- a/web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx
+++ b/web-console/src/components/click-to-copy/click-to-copy.spec.tsx
@@ -19,13 +19,13 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
-import { LearnMore } from './learn-more';
+import { ClickToCopy } from './click-to-copy';
 
-describe('LearnMore', () => {
+describe('ClickToCopy', () => {
   it('matches snapshot', () => {
-    const learnMore = <LearnMore href="https://druid.apache.org/docs" />;
+    const arrayInput = <ClickToCopy text="Hello world" />;
 
-    const { container } = render(learnMore);
+    const { container } = render(arrayInput);
     expect(container.firstChild).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/views/load-data-view/learn-more/learn-more.tsx b/web-console/src/components/click-to-copy/click-to-copy.tsx
similarity index 58%
copy from web-console/src/views/load-data-view/learn-more/learn-more.tsx
copy to web-console/src/components/click-to-copy/click-to-copy.tsx
index afbdf9d92f..5d1d2a8cf4 100644
--- a/web-console/src/views/load-data-view/learn-more/learn-more.tsx
+++ b/web-console/src/components/click-to-copy/click-to-copy.tsx
@@ -15,21 +15,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+import { Intent } from '@blueprintjs/core';
+import copy from 'copy-to-clipboard';
 import React from 'react';
 
-import { ExternalLink } from '../../../components';
+import { AppToaster } from '../../singletons';
 
-export interface LearnMoreProps {
-  href: string;
+export interface ClickToCopyProps {
+  text: string;
 }
 
-export const LearnMore = React.memo(function LearnMore(props: LearnMoreProps) {
-  const { href } = props;
+export const ClickToCopy = React.memo(function ClickToCopy(props: ClickToCopyProps) {
+  const { text } = props;
 
   return (
-    <p className="learn-more">
-      <ExternalLink href={href}>Learn more</ExternalLink>
-    </p>
+    <a
+      className="click-to-copy"
+      title={`Click to copy:\n${text}`}
+      onClick={() => {
+        copy(text, { format: 'text/plain' });
+        AppToaster.show({
+          message: `'${text}' copied to clipboard`,
+          intent: Intent.SUCCESS,
+        });
+      }}
+    >
+      {text}
+    </a>
   );
 });
diff --git a/web-console/src/components/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap b/web-console/src/components/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap
deleted file mode 100644
index dca59389af..0000000000
--- a/web-console/src/components/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap
+++ /dev/null
@@ -1,668 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`DatasourceColumnsTable matches snapshot on error 1`] = `
-<div
-  className="datasource-columns-table"
->
-  <div
-    className="main-area"
-  >
-    <ReactTable
-      AggregatedComponent={[Function]}
-      ExpanderComponent={[Function]}
-      FilterComponent={[Function]}
-      LoadingComponent={[Function]}
-      NoDataComponent={[Function]}
-      PadRowComponent={[Function]}
-      PaginationComponent={[Function]}
-      PivotValueComponent={[Function]}
-      ResizerComponent={[Function]}
-      TableComponent={[Function]}
-      TbodyComponent={[Function]}
-      TdComponent={[Function]}
-      TfootComponent={[Function]}
-      ThComponent={[Function]}
-      TheadComponent={[Function]}
-      TrComponent={[Function]}
-      TrGroupComponent={[Function]}
-      aggregatedKey="_aggregated"
-      className=""
-      collapseOnDataChange={true}
-      collapseOnPageChange={true}
-      collapseOnSortingChange={true}
-      column={
-        Object {
-          "Aggregated": undefined,
-          "Cell": undefined,
-          "Expander": undefined,
-          "Filter": undefined,
-          "Footer": undefined,
-          "Header": undefined,
-          "Pivot": undefined,
-          "PivotValue": undefined,
-          "Placeholder": undefined,
-          "aggregate": undefined,
-          "className": "",
-          "filterAll": false,
-          "filterMethod": undefined,
-          "filterable": undefined,
-          "footerClassName": "",
-          "footerStyle": Object {},
-          "getFooterProps": [Function],
-          "getHeaderProps": [Function],
-          "getProps": [Function],
-          "headerClassName": "",
-          "headerStyle": Object {},
-          "minResizeWidth": 11,
-          "minWidth": 100,
-          "resizable": undefined,
-          "show": true,
-          "sortMethod": undefined,
-          "sortable": undefined,
-          "style": Object {},
-        }
-      }
-      columns={
-        Array [
-          Object {
-            "Header": "Column name",
-            "accessor": "COLUMN_NAME",
-            "className": "padded",
-            "width": 300,
-          },
-          Object {
-            "Header": "Data type",
-            "accessor": "DATA_TYPE",
-            "className": "padded",
-            "width": 200,
-          },
-        ]
-      }
-      data={Array []}
-      defaultExpanded={Object {}}
-      defaultFilterMethod={[Function]}
-      defaultFiltered={Array []}
-      defaultPage={0}
-      defaultPageSize={25}
-      defaultResized={Array []}
-      defaultSortDesc={false}
-      defaultSortMethod={[Function]}
-      defaultSorted={Array []}
-      expanderDefaults={
-        Object {
-          "filterable": false,
-          "resizable": false,
-          "sortable": false,
-          "width": 35,
-        }
-      }
-      filterable={true}
-      freezeWhenExpanded={false}
-      getLoadingProps={[Function]}
-      getNoDataProps={[Function]}
-      getPaginationProps={[Function]}
-      getProps={[Function]}
-      getResizerProps={[Function]}
-      getTableProps={[Function]}
-      getTbodyProps={[Function]}
-      getTdProps={[Function]}
-      getTfootProps={[Function]}
-      getTfootTdProps={[Function]}
-      getTfootTrProps={[Function]}
-      getTheadFilterProps={[Function]}
-      getTheadFilterThProps={[Function]}
-      getTheadFilterTrProps={[Function]}
-      getTheadGroupProps={[Function]}
-      getTheadGroupThProps={[Function]}
-      getTheadGroupTrProps={[Function]}
-      getTheadProps={[Function]}
-      getTheadThProps={[Function]}
-      getTheadTrProps={[Function]}
-      getTrGroupProps={[Function]}
-      getTrProps={[Function]}
-      groupedByPivotKey="_groupedByPivot"
-      indexKey="_index"
-      loading={false}
-      loadingText="Loading..."
-      multiSort={true}
-      nestingLevelKey="_nestingLevel"
-      nextText="Next"
-      noDataText="test error"
-      ofText="of"
-      onFetchData={[Function]}
-      originalKey="_original"
-      pageJumpText="jump to page"
-      pageSizeOptions={
-        Array [
-          25,
-          50,
-          100,
-        ]
-      }
-      pageText="Page"
-      pivotDefaults={Object {}}
-      pivotIDKey="_pivotID"
-      pivotValKey="_pivotVal"
-      previousText="Previous"
-      resizable={true}
-      resolveData={[Function]}
-      rowsSelectorText="rows per page"
-      rowsText="rows"
-      showPageJump={true}
-      showPageSizeOptions={true}
-      showPagination={false}
-      showPaginationBottom={true}
-      showPaginationTop={false}
-      sortable={true}
-      style={Object {}}
-      subRowsKey="_subRows"
-    />
-  </div>
-</div>
-`;
-
-exports[`DatasourceColumnsTable matches snapshot on init 1`] = `
-<div
-  className="datasource-columns-table"
->
-  <div
-    className="main-area"
-  >
-    <ReactTable
-      AggregatedComponent={[Function]}
-      ExpanderComponent={[Function]}
-      FilterComponent={[Function]}
-      LoadingComponent={[Function]}
-      NoDataComponent={[Function]}
-      PadRowComponent={[Function]}
-      PaginationComponent={[Function]}
-      PivotValueComponent={[Function]}
-      ResizerComponent={[Function]}
-      TableComponent={[Function]}
-      TbodyComponent={[Function]}
-      TdComponent={[Function]}
-      TfootComponent={[Function]}
-      ThComponent={[Function]}
-      TheadComponent={[Function]}
-      TrComponent={[Function]}
-      TrGroupComponent={[Function]}
-      aggregatedKey="_aggregated"
-      className=""
-      collapseOnDataChange={true}
-      collapseOnPageChange={true}
-      collapseOnSortingChange={true}
-      column={
-        Object {
-          "Aggregated": undefined,
-          "Cell": undefined,
-          "Expander": undefined,
-          "Filter": undefined,
-          "Footer": undefined,
-          "Header": undefined,
-          "Pivot": undefined,
-          "PivotValue": undefined,
-          "Placeholder": undefined,
-          "aggregate": undefined,
-          "className": "",
-          "filterAll": false,
-          "filterMethod": undefined,
-          "filterable": undefined,
-          "footerClassName": "",
-          "footerStyle": Object {},
-          "getFooterProps": [Function],
-          "getHeaderProps": [Function],
-          "getProps": [Function],
-          "headerClassName": "",
-          "headerStyle": Object {},
-          "minResizeWidth": 11,
-          "minWidth": 100,
-          "resizable": undefined,
-          "show": true,
-          "sortMethod": undefined,
-          "sortable": undefined,
-          "style": Object {},
-        }
-      }
-      columns={
-        Array [
-          Object {
-            "Header": "Column name",
-            "accessor": "COLUMN_NAME",
-            "className": "padded",
-            "width": 300,
-          },
-          Object {
-            "Header": "Data type",
-            "accessor": "DATA_TYPE",
-            "className": "padded",
-            "width": 200,
-          },
-        ]
-      }
-      data={Array []}
-      defaultExpanded={Object {}}
-      defaultFilterMethod={[Function]}
-      defaultFiltered={Array []}
-      defaultPage={0}
-      defaultPageSize={25}
-      defaultResized={Array []}
-      defaultSortDesc={false}
-      defaultSortMethod={[Function]}
-      defaultSorted={Array []}
-      expanderDefaults={
-        Object {
-          "filterable": false,
-          "resizable": false,
-          "sortable": false,
-          "width": 35,
-        }
-      }
-      filterable={true}
-      freezeWhenExpanded={false}
-      getLoadingProps={[Function]}
-      getNoDataProps={[Function]}
-      getPaginationProps={[Function]}
-      getProps={[Function]}
-      getResizerProps={[Function]}
-      getTableProps={[Function]}
-      getTbodyProps={[Function]}
-      getTdProps={[Function]}
-      getTfootProps={[Function]}
-      getTfootTdProps={[Function]}
-      getTfootTrProps={[Function]}
-      getTheadFilterProps={[Function]}
-      getTheadFilterThProps={[Function]}
-      getTheadFilterTrProps={[Function]}
-      getTheadGroupProps={[Function]}
-      getTheadGroupThProps={[Function]}
-      getTheadGroupTrProps={[Function]}
-      getTheadProps={[Function]}
-      getTheadThProps={[Function]}
-      getTheadTrProps={[Function]}
-      getTrGroupProps={[Function]}
-      getTrProps={[Function]}
-      groupedByPivotKey="_groupedByPivot"
-      indexKey="_index"
-      loading={false}
-      loadingText="Loading..."
-      multiSort={true}
-      nestingLevelKey="_nestingLevel"
-      nextText="Next"
-      noDataText="No column data found"
-      ofText="of"
-      onFetchData={[Function]}
-      originalKey="_original"
-      pageJumpText="jump to page"
-      pageSizeOptions={
-        Array [
-          25,
-          50,
-          100,
-        ]
-      }
-      pageText="Page"
-      pivotDefaults={Object {}}
-      pivotIDKey="_pivotID"
-      pivotValKey="_pivotVal"
-      previousText="Previous"
-      resizable={true}
-      resolveData={[Function]}
-      rowsSelectorText="rows per page"
-      rowsText="rows"
-      showPageJump={true}
-      showPageSizeOptions={true}
-      showPagination={false}
-      showPaginationBottom={true}
-      showPaginationTop={false}
-      sortable={true}
-      style={Object {}}
-      subRowsKey="_subRows"
-    />
-  </div>
-</div>
-`;
-
-exports[`DatasourceColumnsTable matches snapshot on loading 1`] = `
-<div
-  className="datasource-columns-table"
->
-  <div
-    className="main-area"
-  >
-    <Memo(Loader) />
-  </div>
-</div>
-`;
-
-exports[`DatasourceColumnsTable matches snapshot on no data 1`] = `
-<div
-  className="datasource-columns-table"
->
-  <div
-    className="main-area"
-  >
-    <ReactTable
-      AggregatedComponent={[Function]}
-      ExpanderComponent={[Function]}
-      FilterComponent={[Function]}
-      LoadingComponent={[Function]}
-      NoDataComponent={[Function]}
-      PadRowComponent={[Function]}
-      PaginationComponent={[Function]}
-      PivotValueComponent={[Function]}
-      ResizerComponent={[Function]}
-      TableComponent={[Function]}
-      TbodyComponent={[Function]}
-      TdComponent={[Function]}
-      TfootComponent={[Function]}
-      ThComponent={[Function]}
-      TheadComponent={[Function]}
-      TrComponent={[Function]}
-      TrGroupComponent={[Function]}
-      aggregatedKey="_aggregated"
-      className=""
-      collapseOnDataChange={true}
-      collapseOnPageChange={true}
-      collapseOnSortingChange={true}
-      column={
-        Object {
-          "Aggregated": undefined,
-          "Cell": undefined,
-          "Expander": undefined,
-          "Filter": undefined,
-          "Footer": undefined,
-          "Header": undefined,
-          "Pivot": undefined,
-          "PivotValue": undefined,
-          "Placeholder": undefined,
-          "aggregate": undefined,
-          "className": "",
-          "filterAll": false,
-          "filterMethod": undefined,
-          "filterable": undefined,
-          "footerClassName": "",
-          "footerStyle": Object {},
-          "getFooterProps": [Function],
-          "getHeaderProps": [Function],
-          "getProps": [Function],
-          "headerClassName": "",
-          "headerStyle": Object {},
-          "minResizeWidth": 11,
-          "minWidth": 100,
-          "resizable": undefined,
-          "show": true,
-          "sortMethod": undefined,
-          "sortable": undefined,
-          "style": Object {},
-        }
-      }
-      columns={
-        Array [
-          Object {
-            "Header": "Column name",
-            "accessor": "COLUMN_NAME",
-            "className": "padded",
-            "width": 300,
-          },
-          Object {
-            "Header": "Data type",
-            "accessor": "DATA_TYPE",
-            "className": "padded",
-            "width": 200,
-          },
-        ]
-      }
-      data={Array []}
-      defaultExpanded={Object {}}
-      defaultFilterMethod={[Function]}
-      defaultFiltered={Array []}
-      defaultPage={0}
-      defaultPageSize={25}
-      defaultResized={Array []}
-      defaultSortDesc={false}
-      defaultSortMethod={[Function]}
-      defaultSorted={Array []}
-      expanderDefaults={
-        Object {
-          "filterable": false,
-          "resizable": false,
-          "sortable": false,
-          "width": 35,
-        }
-      }
-      filterable={true}
-      freezeWhenExpanded={false}
-      getLoadingProps={[Function]}
-      getNoDataProps={[Function]}
-      getPaginationProps={[Function]}
-      getProps={[Function]}
-      getResizerProps={[Function]}
-      getTableProps={[Function]}
-      getTbodyProps={[Function]}
-      getTdProps={[Function]}
-      getTfootProps={[Function]}
-      getTfootTdProps={[Function]}
-      getTfootTrProps={[Function]}
-      getTheadFilterProps={[Function]}
-      getTheadFilterThProps={[Function]}
-      getTheadFilterTrProps={[Function]}
-      getTheadGroupProps={[Function]}
-      getTheadGroupThProps={[Function]}
-      getTheadGroupTrProps={[Function]}
-      getTheadProps={[Function]}
-      getTheadThProps={[Function]}
-      getTheadTrProps={[Function]}
-      getTrGroupProps={[Function]}
-      getTrProps={[Function]}
-      groupedByPivotKey="_groupedByPivot"
-      indexKey="_index"
-      loading={false}
-      loadingText="Loading..."
-      multiSort={true}
-      nestingLevelKey="_nestingLevel"
-      nextText="Next"
-      noDataText="No column data found"
-      ofText="of"
-      onFetchData={[Function]}
-      originalKey="_original"
-      pageJumpText="jump to page"
-      pageSizeOptions={
-        Array [
-          25,
-          50,
-          100,
-        ]
-      }
-      pageText="Page"
-      pivotDefaults={Object {}}
-      pivotIDKey="_pivotID"
-      pivotValKey="_pivotVal"
-      previousText="Previous"
-      resizable={true}
-      resolveData={[Function]}
-      rowsSelectorText="rows per page"
-      rowsText="rows"
-      showPageJump={true}
-      showPageSizeOptions={true}
-      showPagination={false}
-      showPaginationBottom={true}
-      showPaginationTop={false}
-      sortable={true}
-      style={Object {}}
-      subRowsKey="_subRows"
-    />
-  </div>
-</div>
-`;
-
-exports[`DatasourceColumnsTable matches snapshot on some data 1`] = `
-<div
-  className="datasource-columns-table"
->
-  <div
-    className="main-area"
-  >
-    <ReactTable
-      AggregatedComponent={[Function]}
-      ExpanderComponent={[Function]}
-      FilterComponent={[Function]}
-      LoadingComponent={[Function]}
-      NoDataComponent={[Function]}
-      PadRowComponent={[Function]}
-      PaginationComponent={[Function]}
-      PivotValueComponent={[Function]}
-      ResizerComponent={[Function]}
-      TableComponent={[Function]}
-      TbodyComponent={[Function]}
-      TdComponent={[Function]}
-      TfootComponent={[Function]}
-      ThComponent={[Function]}
-      TheadComponent={[Function]}
-      TrComponent={[Function]}
-      TrGroupComponent={[Function]}
-      aggregatedKey="_aggregated"
-      className=""
-      collapseOnDataChange={true}
-      collapseOnPageChange={true}
-      collapseOnSortingChange={true}
-      column={
-        Object {
-          "Aggregated": undefined,
-          "Cell": undefined,
-          "Expander": undefined,
-          "Filter": undefined,
-          "Footer": undefined,
-          "Header": undefined,
-          "Pivot": undefined,
-          "PivotValue": undefined,
-          "Placeholder": undefined,
-          "aggregate": undefined,
-          "className": "",
-          "filterAll": false,
-          "filterMethod": undefined,
-          "filterable": undefined,
-          "footerClassName": "",
-          "footerStyle": Object {},
-          "getFooterProps": [Function],
-          "getHeaderProps": [Function],
-          "getProps": [Function],
-          "headerClassName": "",
-          "headerStyle": Object {},
-          "minResizeWidth": 11,
-          "minWidth": 100,
-          "resizable": undefined,
-          "show": true,
-          "sortMethod": undefined,
-          "sortable": undefined,
-          "style": Object {},
-        }
-      }
-      columns={
-        Array [
-          Object {
-            "Header": "Column name",
-            "accessor": "COLUMN_NAME",
-            "className": "padded",
-            "width": 300,
-          },
-          Object {
-            "Header": "Data type",
-            "accessor": "DATA_TYPE",
-            "className": "padded",
-            "width": 200,
-          },
-        ]
-      }
-      data={
-        Array [
-          Object {
-            "COLUMN_NAME": "channel",
-            "DATA_TYPE": "VARCHAR",
-          },
-          Object {
-            "COLUMN_NAME": "page",
-            "DATA_TYPE": "VARCHAR",
-          },
-        ]
-      }
-      defaultExpanded={Object {}}
-      defaultFilterMethod={[Function]}
-      defaultFiltered={Array []}
-      defaultPage={0}
-      defaultPageSize={25}
-      defaultResized={Array []}
-      defaultSortDesc={false}
-      defaultSortMethod={[Function]}
-      defaultSorted={Array []}
-      expanderDefaults={
-        Object {
-          "filterable": false,
-          "resizable": false,
-          "sortable": false,
-          "width": 35,
-        }
-      }
-      filterable={true}
-      freezeWhenExpanded={false}
-      getLoadingProps={[Function]}
-      getNoDataProps={[Function]}
-      getPaginationProps={[Function]}
-      getProps={[Function]}
-      getResizerProps={[Function]}
-      getTableProps={[Function]}
-      getTbodyProps={[Function]}
-      getTdProps={[Function]}
-      getTfootProps={[Function]}
-      getTfootTdProps={[Function]}
-      getTfootTrProps={[Function]}
-      getTheadFilterProps={[Function]}
-      getTheadFilterThProps={[Function]}
-      getTheadFilterTrProps={[Function]}
-      getTheadGroupProps={[Function]}
-      getTheadGroupThProps={[Function]}
-      getTheadGroupTrProps={[Function]}
-      getTheadProps={[Function]}
-      getTheadThProps={[Function]}
-      getTheadTrProps={[Function]}
-      getTrGroupProps={[Function]}
-      getTrProps={[Function]}
-      groupedByPivotKey="_groupedByPivot"
-      indexKey="_index"
-      loading={false}
-      loadingText="Loading..."
-      multiSort={true}
-      nestingLevelKey="_nestingLevel"
-      nextText="Next"
-      noDataText="No column data found"
-      ofText="of"
-      onFetchData={[Function]}
-      originalKey="_original"
-      pageJumpText="jump to page"
-      pageSizeOptions={
-        Array [
-          25,
-          50,
-          100,
-        ]
-      }
-      pageText="Page"
-      pivotDefaults={Object {}}
-      pivotIDKey="_pivotID"
-      pivotValKey="_pivotVal"
-      previousText="Previous"
-      resizable={true}
-      resolveData={[Function]}
-      rowsSelectorText="rows per page"
-      rowsText="rows"
-      showPageJump={true}
-      showPageSizeOptions={true}
-      showPagination={false}
-      showPaginationBottom={true}
-      showPaginationTop={false}
-      sortable={true}
-      style={Object {}}
-      subRowsKey="_subRows"
-    />
-  </div>
-</div>
-`;
diff --git a/web-console/src/components/braced-text/braced-text.scss b/web-console/src/components/fancy-tab-pane/fancy-tab-pane.scss
similarity index 58%
copy from web-console/src/components/braced-text/braced-text.scss
copy to web-console/src/components/fancy-tab-pane/fancy-tab-pane.scss
index 56f649397e..a32e2b463a 100644
--- a/web-console/src/components/braced-text/braced-text.scss
+++ b/web-console/src/components/fancy-tab-pane/fancy-tab-pane.scss
@@ -16,31 +16,47 @@
  * limitations under the License.
  */
 
-.braced-text {
-  position: relative;
-  text-align: right;
-  white-space: nowrap;
-
-  .brace-text {
-    position: relative;
-    top: -50000px; // Send it into the stratosphere (get it out of the parent container to prevent the browser from adding '...')
-    opacity: 0;
-    pointer-events: none;
-  }
+@import '../../variables';
+
+$side-bar-width: 120px;
 
-  .real-text {
+.fancy-tab-pane {
+  .side-bar {
     position: absolute;
-    left: 0;
-    right: 0;
     top: 0;
-    bottom: 0;
+    left: 0;
+    width: $side-bar-width;
+    height: 100%;
+    border-right: 1px solid #1f2832;
 
-    .zero-pad {
-      visibility: hidden;
-    }
+    .tab-button {
+      width: 100%;
+      height: 10vh;
+      cursor: pointer;
+      display: flex;
+      flex-direction: column;
+      border-radius: 0;
+
+      &.active {
+        background-color: #2c74a8;
+      }
 
-    .unselectable {
-      user-select: none;
+      .#{$bp-ns}-icon {
+        margin-right: 0;
+      }
+
+      .#{$bp-ns}-button-text {
+        margin-top: 5px;
+      }
     }
   }
+
+  .main-section {
+    position: absolute;
+    top: 0;
+    left: $side-bar-width;
+    right: 0;
+    height: 100%;
+    padding: 10px 20px 15px 20px;
+  }
 }
diff --git a/web-console/src/components/fancy-tab-pane/fancy-tab-pane.tsx b/web-console/src/components/fancy-tab-pane/fancy-tab-pane.tsx
new file mode 100644
index 0000000000..500c0f24dc
--- /dev/null
+++ b/web-console/src/components/fancy-tab-pane/fancy-tab-pane.tsx
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Button, Icon, IconName, Intent } from '@blueprintjs/core';
+import classNames from 'classnames';
+import React, { ReactNode } from 'react';
+
+import { filterMap } from '../../utils';
+
+import './fancy-tab-pane.scss';
+
+export interface FancyTabButton {
+  id: string;
+  icon: IconName;
+  label: string;
+}
+
+interface FancyTabPaneProps {
+  className?: string;
+  tabs: (FancyTabButton | false | undefined)[];
+  activeTab: string;
+  onActivateTab(newActiveTab: string): void;
+  children?: ReactNode;
+}
+
+export const FancyTabPane = React.memo(function FancyTabPane(props: FancyTabPaneProps) {
+  const { className, tabs, activeTab, onActivateTab, children } = props;
+
+  return (
+    <div className={classNames('fancy-tab-pane', className)}>
+      <div className="side-bar">
+        {filterMap(tabs, d => {
+          if (!d) return;
+          return (
+            <Button
+              className="tab-button"
+              icon={<Icon icon={d.icon} size={20} />}
+              key={d.id}
+              text={d.label}
+              intent={activeTab === d.id ? Intent.PRIMARY : Intent.NONE}
+              minimal={activeTab !== d.id}
+              onClick={() => onActivateTab(d.id)}
+            />
+          );
+        })}
+      </div>
+      <div className="main-section">{children}</div>
+    </div>
+  );
+});
diff --git a/web-console/src/components/form-group-with-info/form-group-with-info.tsx b/web-console/src/components/form-group-with-info/form-group-with-info.tsx
index 1130e7fa7e..a42c4ca61d 100644
--- a/web-console/src/components/form-group-with-info/form-group-with-info.tsx
+++ b/web-console/src/components/form-group-with-info/form-group-with-info.tsx
@@ -37,7 +37,7 @@ export const FormGroupWithInfo = React.memo(function FormGroupWithInfo(
 
   const popover = (
     <Popover2 className="info-popover" content={info} position="left-bottom">
-      <Icon icon={IconNames.INFO_SIGN} iconSize={14} />
+      <Icon icon={IconNames.INFO_SIGN} size={14} />
     </Popover2>
   );
 
diff --git a/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap b/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap
index b715283ca2..cc7f53463c 100644
--- a/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap
+++ b/web-console/src/components/header-bar/__snapshots__/header-bar.spec.tsx.snap
@@ -15,32 +15,109 @@ exports[`HeaderBar matches snapshot 1`] = `
     <Blueprint4.NavbarDivider />
     <Blueprint4.AnchorButton
       active={true}
+      className="header-entry"
       disabled={false}
-      href="#load-data"
-      icon="cloud-upload"
-      intent="none"
+      href="#workbench"
+      icon="application"
       minimal={true}
-      text="Load data"
+      onClick={[Function]}
+      text="Query"
     />
+    <Blueprint4.Popover2
+      boundary="clippingParents"
+      captureDismiss={false}
+      content={
+        <Blueprint4.Menu>
+          <Blueprint4.MenuItem
+            active={false}
+            disabled={false}
+            href="#streaming-data-loader"
+            icon="feed"
+            multiline={false}
+            popoverProps={Object {}}
+            selected={false}
+            shouldDismissPopover={true}
+            text="Streaming"
+          />
+          <Blueprint4.MenuItem
+            active={false}
+            disabled={false}
+            href="#sql-data-loader"
+            icon="clean"
+            labelElement={
+              <Blueprint4.Tag
+                minimal={true}
+              >
+                multi-stage-query
+              </Blueprint4.Tag>
+            }
+            multiline={false}
+            popoverProps={Object {}}
+            selected={false}
+            shouldDismissPopover={true}
+            text="Batch - SQL"
+          />
+          <Blueprint4.MenuItem
+            active={false}
+            disabled={false}
+            href="#classic-batch-data-loader"
+            icon="list"
+            multiline={false}
+            popoverProps={Object {}}
+            selected={false}
+            shouldDismissPopover={true}
+            text="Batch - classic"
+          />
+        </Blueprint4.Menu>
+      }
+      defaultIsOpen={false}
+      disabled={false}
+      fill={false}
+      hasBackdrop={false}
+      hoverCloseDelay={300}
+      hoverOpenDelay={150}
+      inheritDarkTheme={true}
+      interactionKind="click"
+      minimal={false}
+      openOnTargetFocus={true}
+      position="bottom-left"
+      positioningStrategy="absolute"
+      shouldReturnFocusOnClose={false}
+      targetTagName="span"
+      transitionDuration={300}
+      usePortal={true}
+    >
+      <Blueprint4.Button
+        active={false}
+        className="header-entry"
+        disabled={false}
+        icon="cloud-upload"
+        minimal={true}
+        text="Load data"
+      />
+    </Blueprint4.Popover2>
     <Blueprint4.NavbarDivider />
     <Blueprint4.AnchorButton
       active={false}
+      className="header-entry"
       disabled={false}
-      href="#ingestion"
-      icon="gantt-chart"
+      href="#datasources"
+      icon="multi-select"
       minimal={true}
-      text="Ingestion"
+      text="Datasources"
     />
     <Blueprint4.AnchorButton
       active={false}
+      className="header-entry"
       disabled={false}
-      href="#datasources"
-      icon="multi-select"
+      href="#ingestion"
+      icon="gantt-chart"
       minimal={true}
-      text="Datasources"
+      text="Ingestion"
     />
     <Blueprint4.AnchorButton
       active={false}
+      className="header-entry"
       disabled={false}
       href="#segments"
       icon="stacked-chart"
@@ -49,21 +126,55 @@ exports[`HeaderBar matches snapshot 1`] = `
     />
     <Blueprint4.AnchorButton
       active={false}
+      className="header-entry"
       disabled={false}
       href="#services"
       icon="database"
       minimal={true}
       text="Services"
     />
-    <Blueprint4.NavbarDivider />
-    <Blueprint4.AnchorButton
-      active={false}
+    <Blueprint4.Popover2
+      boundary="clippingParents"
+      captureDismiss={false}
+      content={
+        <Blueprint4.Menu>
+          <Blueprint4.MenuItem
+            active={false}
+            disabled={false}
+            href="#lookups"
+            icon="properties"
+            multiline={false}
+            popoverProps={Object {}}
+            selected={false}
+            shouldDismissPopover={true}
+            text="Lookups"
+          />
+        </Blueprint4.Menu>
+      }
+      defaultIsOpen={false}
       disabled={false}
-      href="#query"
-      icon="application"
-      minimal={true}
-      text="Query"
-    />
+      fill={false}
+      hasBackdrop={false}
+      hoverCloseDelay={300}
+      hoverOpenDelay={150}
+      inheritDarkTheme={true}
+      interactionKind="click"
+      minimal={false}
+      openOnTargetFocus={true}
+      position="bottom-left"
+      positioningStrategy="absolute"
+      shouldReturnFocusOnClose={false}
+      targetTagName="span"
+      transitionDuration={300}
+      usePortal={true}
+    >
+      <Blueprint4.Button
+        active={false}
+        className="header-entry"
+        icon="more"
+        minimal={true}
+      />
+    </Blueprint4.Popover2>
   </Blueprint4.NavbarGroup>
   <Blueprint4.NavbarGroup
     align="right"
@@ -72,6 +183,7 @@ exports[`HeaderBar matches snapshot 1`] = `
       capabilities={
         Capabilities {
           "coordinator": true,
+          "multiStageQuery": true,
           "overlord": true,
           "queryType": "nativeAndSql",
         }
@@ -116,17 +228,6 @@ exports[`HeaderBar matches snapshot 1`] = `
             shouldDismissPopover={true}
             text="Overlord dynamic config"
           />
-          <Blueprint4.MenuItem
-            active={false}
-            disabled={false}
-            href="#lookups"
-            icon="properties"
-            multiline={false}
-            popoverProps={Object {}}
-            selected={false}
-            shouldDismissPopover={true}
-            text="Lookups"
-          />
           <Blueprint4.MenuDivider />
           <Blueprint4.MenuItem
             active={false}
@@ -201,6 +302,7 @@ exports[`HeaderBar matches snapshot 1`] = `
       usePortal={true}
     >
       <Blueprint4.Button
+        className="header-entry"
         icon="cog"
         minimal={true}
       />
@@ -224,7 +326,7 @@ exports[`HeaderBar matches snapshot 1`] = `
           <Blueprint4.MenuItem
             active={false}
             disabled={false}
-            href="https://druid.apache.org/docs/0.23.0"
+            href="https://druid.apache.org/docs/latest"
             icon="th"
             multiline={false}
             popoverProps={Object {}}
@@ -289,6 +391,7 @@ exports[`HeaderBar matches snapshot 1`] = `
       usePortal={true}
     >
       <Blueprint4.Button
+        className="header-entry"
         icon="help"
         minimal={true}
       />
diff --git a/web-console/src/components/header-bar/header-bar.scss b/web-console/src/components/header-bar/header-bar.scss
index 783b472cb8..062768a22c 100644
--- a/web-console/src/components/header-bar/header-bar.scss
+++ b/web-console/src/components/header-bar/header-bar.scss
@@ -63,10 +63,7 @@
     margin: 0 11px;
   }
 
-  .#{$bp-ns}-button.#{$bp-ns}-minimal {
-    border-radius: 20px;
-    margin: 0 1px;
-
+  .header-entry {
     .#{$bp-ns}-icon {
       svg {
         fill: $blue3;
@@ -76,6 +73,12 @@
         }
       }
     }
+  }
+
+  .#{$bp-ns}-button.#{$bp-ns}-minimal {
+    border-radius: 20px;
+    margin: 0 1px;
+
     .#{$bp-ns}-dark & {
       &:hover {
         background: rgba($dark-gray5, 0.5);
diff --git a/web-console/src/components/header-bar/header-bar.spec.tsx b/web-console/src/components/header-bar/header-bar.spec.tsx
index 61650aa7aa..2e17be7058 100644
--- a/web-console/src/components/header-bar/header-bar.spec.tsx
+++ b/web-console/src/components/header-bar/header-bar.spec.tsx
@@ -26,7 +26,7 @@ import { HeaderBar } from './header-bar';
 describe('HeaderBar', () => {
   it('matches snapshot', () => {
     const headerBar = shallow(
-      <HeaderBar active="load-data" capabilities={Capabilities.FULL} onUnrestrict={() => {}} />,
+      <HeaderBar active="workbench" capabilities={Capabilities.FULL} onUnrestrict={() => {}} />,
     );
     expect(headerBar).toMatchSnapshot();
   });
diff --git a/web-console/src/components/header-bar/header-bar.tsx b/web-console/src/components/header-bar/header-bar.tsx
index 53042d91a2..2d5b56af4d 100644
--- a/web-console/src/components/header-bar/header-bar.tsx
+++ b/web-console/src/components/header-bar/header-bar.tsx
@@ -28,6 +28,7 @@ import {
   NavbarDivider,
   NavbarGroup,
   Position,
+  Tag,
 } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import { Popover2 } from '@blueprintjs/popover2';
@@ -46,6 +47,7 @@ import {
   LocalStorageKeys,
   localStorageRemove,
   localStorageSetJson,
+  oneOf,
 } from '../../utils';
 import { ExternalLink } from '../external-link/external-link';
 import { PopoverText } from '../popover-text/popover-text';
@@ -56,12 +58,16 @@ const capabilitiesOverride = localStorageGetJson(LocalStorageKeys.CAPABILITIES_O
 
 export type HeaderActiveTab =
   | null
-  | 'load-data'
+  | 'data-loader'
+  | 'streaming-data-loader'
+  | 'classic-batch-data-loader'
   | 'ingestion'
   | 'datasources'
   | 'segments'
   | 'services'
   | 'query'
+  | 'workbench'
+  | 'sql-data-loader'
   | 'lookups';
 
 const DruidLogo = React.memo(function DruidLogo() {
@@ -233,7 +239,52 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
   const [coordinatorDynamicConfigDialogOpen, setCoordinatorDynamicConfigDialogOpen] =
     useState(false);
   const [overlordDynamicConfigDialogOpen, setOverlordDynamicConfigDialogOpen] = useState(false);
-  const loadDataPrimary = false;
+
+  const showSplitDataLoaderMenu = capabilities.hasMultiStageQuery();
+
+  const loadDataViewsMenuActive = oneOf(
+    active,
+    'data-loader',
+    'streaming-data-loader',
+    'classic-batch-data-loader',
+    'sql-data-loader',
+  );
+  const loadDataViewsMenu = (
+    <Menu>
+      <MenuItem
+        icon={IconNames.FEED}
+        text="Streaming"
+        href="#streaming-data-loader"
+        selected={active === 'streaming-data-loader'}
+      />
+      <MenuItem
+        icon={IconNames.CLEAN}
+        text="Batch - SQL"
+        href="#sql-data-loader"
+        labelElement={<Tag minimal>multi-stage-query</Tag>}
+        selected={active === 'sql-data-loader'}
+      />
+      <MenuItem
+        icon={IconNames.LIST}
+        text="Batch - classic"
+        href="#classic-batch-data-loader"
+        selected={active === 'classic-batch-data-loader'}
+      />
+    </Menu>
+  );
+
+  const moreViewsMenuActive = oneOf(active, 'lookups');
+  const moreViewsMenu = (
+    <Menu>
+      <MenuItem
+        icon={IconNames.PROPERTIES}
+        text="Lookups"
+        href="#lookups"
+        disabled={!capabilities.hasCoordinatorAccess()}
+        selected={active === 'lookups'}
+      />
+    </Menu>
+  );
 
   const helpMenu = (
     <Menu>
@@ -290,13 +341,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
         onClick={() => setOverlordDynamicConfigDialogOpen(true)}
         disabled={!capabilities.hasOverlordAccess()}
       />
-      <MenuItem
-        icon={IconNames.PROPERTIES}
-        active={active === 'lookups'}
-        text="Lookups"
-        href="#lookups"
-        disabled={!capabilities.hasCoordinatorAccess()}
-      />
+
       <MenuDivider />
       <MenuItem icon={IconNames.COG} text="Console options">
         {capabilitiesOverride ? (
@@ -339,28 +384,50 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
         <a href="#">
           <DruidLogo />
         </a>
-
-        <NavbarDivider />
-        <AnchorButton
-          icon={IconNames.CLOUD_UPLOAD}
-          text="Load data"
-          active={active === 'load-data'}
-          href="#load-data"
-          minimal={!loadDataPrimary}
-          intent={loadDataPrimary ? Intent.PRIMARY : Intent.NONE}
-          disabled={!capabilities.hasEverything()}
-        />
-
         <NavbarDivider />
         <AnchorButton
+          className="header-entry"
           minimal
-          active={active === 'ingestion'}
-          icon={IconNames.GANTT_CHART}
-          text="Ingestion"
-          href="#ingestion"
-          disabled={!capabilities.hasSqlOrOverlordAccess()}
+          active={oneOf(active, 'workbench', 'query')}
+          icon={IconNames.APPLICATION}
+          text="Query"
+          href="#workbench"
+          disabled={!capabilities.hasQuerying()}
+          onClick={e => {
+            if (!e.altKey) return;
+            e.preventDefault();
+            location.hash = '#query';
+          }}
         />
+        {showSplitDataLoaderMenu ? (
+          <Popover2
+            content={loadDataViewsMenu}
+            disabled={!capabilities.hasEverything()}
+            position={Position.BOTTOM_LEFT}
+          >
+            <Button
+              className="header-entry"
+              icon={IconNames.CLOUD_UPLOAD}
+              text="Load data"
+              minimal
+              active={loadDataViewsMenuActive}
+              disabled={!capabilities.hasEverything()}
+            />
+          </Popover2>
+        ) : (
+          <AnchorButton
+            className="header-entry"
+            icon={IconNames.CLOUD_UPLOAD}
+            text="Load data"
+            href="#data-loader"
+            minimal
+            active={loadDataViewsMenuActive}
+            disabled={!capabilities.hasEverything()}
+          />
+        )}
+        <NavbarDivider />
         <AnchorButton
+          className="header-entry"
           minimal
           active={active === 'datasources'}
           icon={IconNames.MULTI_SELECT}
@@ -369,6 +436,16 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
           disabled={!capabilities.hasSqlOrCoordinatorAccess()}
         />
         <AnchorButton
+          className="header-entry"
+          minimal
+          active={active === 'ingestion'}
+          icon={IconNames.GANTT_CHART}
+          text="Ingestion"
+          href="#ingestion"
+          disabled={!capabilities.hasSqlOrOverlordAccess()}
+        />
+        <AnchorButton
+          className="header-entry"
           minimal
           active={active === 'segments'}
           icon={IconNames.STACKED_CHART}
@@ -377,6 +454,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
           disabled={!capabilities.hasSqlOrCoordinatorAccess()}
         />
         <AnchorButton
+          className="header-entry"
           minimal
           active={active === 'services'}
           icon={IconNames.DATABASE}
@@ -384,24 +462,22 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
           href="#services"
           disabled={!capabilities.hasSqlOrCoordinatorAccess()}
         />
-
-        <NavbarDivider />
-        <AnchorButton
-          minimal
-          active={active === 'query'}
-          icon={IconNames.APPLICATION}
-          text="Query"
-          href="#query"
-          disabled={!capabilities.hasQuerying()}
-        />
+        <Popover2 content={moreViewsMenu} position={Position.BOTTOM_LEFT}>
+          <Button
+            className="header-entry"
+            minimal
+            icon={IconNames.MORE}
+            active={moreViewsMenuActive}
+          />
+        </Popover2>
       </NavbarGroup>
       <NavbarGroup align={Alignment.RIGHT}>
         <RestrictedMode capabilities={capabilities} onUnrestrict={onUnrestrict} />
         <Popover2 content={configMenu} position={Position.BOTTOM_RIGHT}>
-          <Button minimal icon={IconNames.COG} />
+          <Button className="header-entry" minimal icon={IconNames.COG} />
         </Popover2>
         <Popover2 content={helpMenu} position={Position.BOTTOM_RIGHT}>
-          <Button minimal icon={IconNames.HELP} />
+          <Button className="header-entry" minimal icon={IconNames.HELP} />
         </Popover2>
       </NavbarGroup>
       {aboutDialogOpen && <AboutDialog onClose={() => setAboutDialogOpen(false)} />}
diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts
index d3418fdc71..56da967599 100644
--- a/web-console/src/components/index.ts
+++ b/web-console/src/components/index.ts
@@ -23,27 +23,39 @@ export * from './auto-form/auto-form';
 export * from './braced-text/braced-text';
 export * from './center-message/center-message';
 export * from './clearable-input/clearable-input';
+export * from './click-to-copy/click-to-copy';
+export * from './deferred/deferred';
 export * from './deferred/deferred';
 export * from './external-link/external-link';
+export * from './fancy-tab-pane/fancy-tab-pane';
+export * from './form-group-with-info/form-group-with-info';
 export * from './form-json-selector/form-json-selector';
 export * from './formatted-input/formatted-input';
 export * from './header-bar/header-bar';
 export * from './highlight-text/highlight-text';
 export * from './json-collapse/json-collapse';
 export * from './json-input/json-input';
+export * from './learn-more/learn-more';
 export * from './loader/loader';
 export * from './menu-checkbox/menu-checkbox';
+export * from './menu-tristate/menu-tristate';
 export * from './more-button/more-button';
+export * from './plural-pair-if-needed/plural-pair-if-needed';
 export * from './popover-text/popover-text';
+export * from './query-error-pane/query-error-pane';
+export * from './record-table-pane/record-table-pane';
 export * from './refresh-button/refresh-button';
 export * from './rule-editor/rule-editor';
 export * from './segment-timeline/segment-timeline';
 export * from './show-json/show-json';
 export * from './show-log/show-log';
+export * from './show-value/show-value';
 export * from './suggestion-menu/suggestion-menu';
 export * from './table-cell/table-cell';
+export * from './table-cell-unparseable/table-cell-unparseable';
 export * from './table-clickable-cell/table-clickable-cell';
 export * from './table-column-selector/table-column-selector';
 export * from './table-filterable-cell/table-filterable-cell';
 export * from './timed-button/timed-button';
 export * from './view-control-bar/view-control-bar';
+export * from './warning-checklist/warning-checklist';
diff --git a/web-console/src/components/interval-input/interval-input.tsx b/web-console/src/components/interval-input/interval-input.tsx
index 5cc5e26056..ccbe212dcc 100644
--- a/web-console/src/components/interval-input/interval-input.tsx
+++ b/web-console/src/components/interval-input/interval-input.tsx
@@ -60,7 +60,7 @@ export const IntervalInput = React.memo(function IntervalInput(props: IntervalIn
         </div>
       }
       onChange={(e: any) => {
-        const value = e.target.value.replace(/[^\-0-9T:/]/g, '').substring(0, 39);
+        const value = e.target.value.replace(/[^\-\dT:/]/g, '').substring(0, 39);
         onValueChange(value);
       }}
       intent={intent}
diff --git a/web-console/src/views/load-data-view/learn-more/__snapshots__/learn-more.spec.tsx.snap b/web-console/src/components/learn-more/__snapshots__/learn-more.spec.tsx.snap
similarity index 100%
rename from web-console/src/views/load-data-view/learn-more/__snapshots__/learn-more.spec.tsx.snap
rename to web-console/src/components/learn-more/__snapshots__/learn-more.spec.tsx.snap
diff --git a/web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx b/web-console/src/components/learn-more/learn-more.spec.tsx
similarity index 100%
copy from web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx
copy to web-console/src/components/learn-more/learn-more.spec.tsx
diff --git a/web-console/src/views/load-data-view/learn-more/learn-more.tsx b/web-console/src/components/learn-more/learn-more.tsx
similarity index 94%
copy from web-console/src/views/load-data-view/learn-more/learn-more.tsx
copy to web-console/src/components/learn-more/learn-more.tsx
index afbdf9d92f..2f2e8d3fbe 100644
--- a/web-console/src/views/load-data-view/learn-more/learn-more.tsx
+++ b/web-console/src/components/learn-more/learn-more.tsx
@@ -18,7 +18,7 @@
 
 import React from 'react';
 
-import { ExternalLink } from '../../../components';
+import { ExternalLink } from '../external-link/external-link';
 
 export interface LearnMoreProps {
   href: string;
diff --git a/web-console/src/components/menu-checkbox/menu-checkbox.tsx b/web-console/src/components/menu-checkbox/menu-checkbox.tsx
index 9477c94cc5..33aa72b7d5 100644
--- a/web-console/src/components/menu-checkbox/menu-checkbox.tsx
+++ b/web-console/src/components/menu-checkbox/menu-checkbox.tsx
@@ -17,10 +17,11 @@
  */
 
 import { MenuItem, MenuItemProps } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
 import React from 'react';
 
+import { checkedCircleIcon } from '../../utils';
+
 export interface MenuCheckboxProps extends Omit<MenuItemProps, 'icon' | 'onClick'> {
   checked: boolean;
   onChange: () => void;
@@ -32,7 +33,7 @@ export function MenuCheckbox(props: MenuCheckboxProps) {
   return (
     <MenuItem
       className={classNames('menu-checkbox', className)}
-      icon={checked ? IconNames.TICK_CIRCLE : IconNames.CIRCLE}
+      icon={checkedCircleIcon(checked)}
       onClick={onChange}
       shouldDismissPopover={shouldDismissPopover ?? false}
       {...rest}
diff --git a/web-console/src/components/menu-tristate/__snapshots__/menu-tristate.spec.tsx.snap b/web-console/src/components/menu-tristate/__snapshots__/menu-tristate.spec.tsx.snap
new file mode 100644
index 0000000000..60942ec6a5
--- /dev/null
+++ b/web-console/src/components/menu-tristate/__snapshots__/menu-tristate.spec.tsx.snap
@@ -0,0 +1,103 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MenuTristate matches snapshot false 1`] = `
+<li
+  class="bp4-submenu"
+>
+  <span
+    class="bp4-popover-wrapper"
+  >
+    <span
+      aria-haspopup="true"
+      class="bp4-popover-target"
+    >
+      <a
+        class="bp4-menu-item menu-tristate"
+        label="false"
+        tabindex="0"
+      >
+        <div
+          class="bp4-fill bp4-text-overflow-ellipsis"
+        >
+          hello
+        </div>
+        <span
+          class="bp4-menu-item-label"
+        >
+          false
+        </span>
+        <span
+          class="bp4-icon bp4-icon-caret-right bp4-submenu-icon"
+          icon="caret-right"
+        >
+          <svg
+            data-icon="caret-right"
+            height="16"
+            viewBox="0 0 16 16"
+            width="16"
+          >
+            <desc>
+              Open sub menu
+            </desc>
+            <path
+              d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 006 4.5v7a.495.495 0 00.83.37l4-3.5c.1-.09.17-.22.17-.37z"
+              fill-rule="evenodd"
+            />
+          </svg>
+        </span>
+      </a>
+    </span>
+  </span>
+</li>
+`;
+
+exports[`MenuTristate matches snapshot undefined 1`] = `
+<li
+  class="bp4-submenu"
+>
+  <span
+    class="bp4-popover-wrapper"
+  >
+    <span
+      aria-haspopup="true"
+      class="bp4-popover-target"
+    >
+      <a
+        class="bp4-menu-item menu-tristate"
+        label="auto (true)"
+        tabindex="0"
+      >
+        <div
+          class="bp4-fill bp4-text-overflow-ellipsis"
+        >
+          hello
+        </div>
+        <span
+          class="bp4-menu-item-label"
+        >
+          auto (true)
+        </span>
+        <span
+          class="bp4-icon bp4-icon-caret-right bp4-submenu-icon"
+          icon="caret-right"
+        >
+          <svg
+            data-icon="caret-right"
+            height="16"
+            viewBox="0 0 16 16"
+            width="16"
+          >
+            <desc>
+              Open sub menu
+            </desc>
+            <path
+              d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 006 4.5v7a.495.495 0 00.83.37l4-3.5c.1-.09.17-.22.17-.37z"
+              fill-rule="evenodd"
+            />
+          </svg>
+        </span>
+      </a>
+    </span>
+  </span>
+</li>
+`;
diff --git a/web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx b/web-console/src/components/menu-tristate/menu-tristate.spec.tsx
similarity index 58%
copy from web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx
copy to web-console/src/components/menu-tristate/menu-tristate.spec.tsx
index 22968c7925..40fbc54f0d 100644
--- a/web-console/src/views/load-data-view/learn-more/learn-more.spec.tsx
+++ b/web-console/src/components/menu-tristate/menu-tristate.spec.tsx
@@ -19,13 +19,32 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
-import { LearnMore } from './learn-more';
+import { MenuTristate } from './menu-tristate';
 
-describe('LearnMore', () => {
-  it('matches snapshot', () => {
-    const learnMore = <LearnMore href="https://druid.apache.org/docs" />;
+describe('MenuTristate', () => {
+  it('matches snapshot undefined', () => {
+    const menuCheckbox = (
+      <MenuTristate
+        text="hello"
+        value={undefined}
+        undefinedEffectiveValue
+        onValueChange={() => {}}
+      />
+    );
+    const { container } = render(menuCheckbox);
+    expect(container.firstChild).toMatchSnapshot();
+  });
 
-    const { container } = render(learnMore);
+  it('matches snapshot false', () => {
+    const menuCheckbox = (
+      <MenuTristate
+        text="hello"
+        value={false}
+        undefinedEffectiveValue={false}
+        onValueChange={() => {}}
+      />
+    );
+    const { container } = render(menuCheckbox);
     expect(container.firstChild).toMatchSnapshot();
   });
 });
diff --git a/web-console/src/components/menu-tristate/menu-tristate.tsx b/web-console/src/components/menu-tristate/menu-tristate.tsx
new file mode 100644
index 0000000000..7f54d1a319
--- /dev/null
+++ b/web-console/src/components/menu-tristate/menu-tristate.tsx
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MenuItem, MenuItemProps } from '@blueprintjs/core';
+import classNames from 'classnames';
+import React from 'react';
+
+import { tickIcon } from '../../utils';
+
+export interface MenuTristateProps extends Omit<MenuItemProps, 'label'> {
+  value: boolean | undefined;
+  onValueChange(value: boolean | undefined): void;
+  undefinedLabel?: string;
+  undefinedEffectiveValue?: boolean;
+}
+
+export function MenuTristate(props: MenuTristateProps) {
+  const {
+    value,
+    onValueChange,
+    undefinedLabel,
+    undefinedEffectiveValue,
+    className,
+    shouldDismissPopover,
+    ...rest
+  } = props;
+  const shouldDismiss = shouldDismissPopover ?? false;
+
+  function formatValue(value: boolean | undefined): string {
+    return String(value ?? undefinedLabel ?? 'auto');
+  }
+
+  return (
+    <MenuItem
+      className={classNames('menu-tristate', className)}
+      shouldDismissPopover={shouldDismiss}
+      label={
+        formatValue(value) +
+        (typeof value === 'undefined' && typeof undefinedEffectiveValue === 'boolean'
+          ? ` (${undefinedEffectiveValue})`
+          : '')
+      }
+      {...rest}
+    >
+      {[undefined, true, false].map((v, i) => (
+        <MenuItem
+          key={i}
+          icon={tickIcon(value === v)}
+          text={formatValue(v)}
+          onClick={() => onValueChange(v)}
+          shouldDismissPopover={shouldDismiss}
+        />
+      ))}
+    </MenuItem>
+  );
+}
diff --git a/web-console/src/components/more-button/__snapshots__/more-button.spec.tsx.snap b/web-console/src/components/more-button/__snapshots__/more-button.spec.tsx.snap
index 119b10c70d..21d4875e3f 100644
--- a/web-console/src/components/more-button/__snapshots__/more-button.spec.tsx.snap
+++ b/web-console/src/components/more-button/__snapshots__/more-button.spec.tsx.snap
@@ -9,6 +9,7 @@ exports[`MoreButton matches snapshot (empty) 1`] = `
     class="bp4-button bp4-disabled"
     disabled=""
     tabindex="-1"
+    title="More actions"
     type="button"
   >
     <span
@@ -40,6 +41,7 @@ exports[`MoreButton matches snapshot (full) 1`] = `
 >
   <button
     class="bp4-button"
+    title="More actions"
     type="button"
   >
     <span
diff --git a/web-console/src/components/more-button/more-button.tsx b/web-console/src/components/more-button/more-button.tsx
index a7ac38a6c2..16157b34b8 100644
--- a/web-console/src/components/more-button/more-button.tsx
+++ b/web-console/src/components/more-button/more-button.tsx
@@ -55,7 +55,7 @@ export const MoreButton = React.memo(function MoreButton(props: MoreButtonProps)
         setOpenState(nextOpenState ? (e.altKey ? 'alt-open' : 'open') : undefined);
       }}
     >
-      <Button icon={IconNames.MORE} disabled={!childCount} />
+      <Button icon={IconNames.MORE} disabled={!childCount} title="More actions" />
     </Popover2>
   );
 });
diff --git a/web-console/src/views/query-view/query-error/__snapshots__/query-error.spec.tsx.snap b/web-console/src/components/query-error-pane/__snapshots__/query-error-pane.spec.tsx.snap
similarity index 56%
rename from web-console/src/views/query-view/query-error/__snapshots__/query-error.spec.tsx.snap
rename to web-console/src/components/query-error-pane/__snapshots__/query-error-pane.spec.tsx.snap
index 7630235852..2402e0dabb 100644
--- a/web-console/src/views/query-view/query-error/__snapshots__/query-error.spec.tsx.snap
+++ b/web-console/src/components/query-error-pane/__snapshots__/query-error-pane.spec.tsx.snap
@@ -1,8 +1,8 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`QueryError matches snapshot 1`] = `
+exports[`QueryErrorPane matches snapshot 1`] = `
 <div
-  className="query-error"
+  className="query-error-pane"
 >
   something went wrong in line 7, column 8.
 </div>
diff --git a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss b/web-console/src/components/query-error-pane/query-error-pane.scss
similarity index 82%
copy from web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
copy to web-console/src/components/query-error-pane/query-error-pane.scss
index 43efe5085b..a1c62f2bcb 100644
--- a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
+++ b/web-console/src/components/query-error-pane/query-error-pane.scss
@@ -16,6 +16,13 @@
  * limitations under the License.
  */
 
-.table-cell-unparseable {
-  color: #9e2b0e;
+@import '../../blueprint-overrides/common/colors';
+@import '../../variables';
+
+.query-error-pane {
+  padding: 20px 22px;
+
+  .#{$bp-ns}-dark & {
+    background: $dark-gray3;
+  }
 }
diff --git a/web-console/src/views/query-view/query-error/query-error.spec.tsx b/web-console/src/components/query-error-pane/query-error-pane.spec.tsx
similarity index 90%
copy from web-console/src/views/query-view/query-error/query-error.spec.tsx
copy to web-console/src/components/query-error-pane/query-error-pane.spec.tsx
index 52ece640aa..b85c2da6cc 100644
--- a/web-console/src/views/query-view/query-error/query-error.spec.tsx
+++ b/web-console/src/components/query-error-pane/query-error-pane.spec.tsx
@@ -19,12 +19,12 @@
 import { shallow } from 'enzyme';
 import React from 'react';
 
-import { QueryError } from './query-error';
+import { QueryErrorPane } from './query-error-pane';
 
-describe('QueryError', () => {
+describe('QueryErrorPane', () => {
   it('matches snapshot', () => {
     const queryError = shallow(
-      <QueryError
+      <QueryErrorPane
         error={new Error('something went wrong in line 7, column 8.')}
         moveCursorTo={() => {}}
       />,
diff --git a/web-console/src/views/query-view/query-error/query-error.tsx b/web-console/src/components/query-error-pane/query-error-pane.tsx
similarity index 76%
rename from web-console/src/views/query-view/query-error/query-error.tsx
rename to web-console/src/components/query-error-pane/query-error-pane.tsx
index 2e0c8296bd..f2e23172dd 100644
--- a/web-console/src/views/query-view/query-error/query-error.tsx
+++ b/web-console/src/components/query-error-pane/query-error-pane.tsx
@@ -18,24 +18,24 @@
 
 import React, { useState } from 'react';
 
-import { HighlightText } from '../../../components';
-import { DruidError, RowColumn } from '../../../utils';
+import { DruidError, RowColumn } from '../../utils';
+import { HighlightText } from '../highlight-text/highlight-text';
 
-import './query-error.scss';
+import './query-error-pane.scss';
 
-export interface QueryErrorProps {
+export interface QueryErrorPaneProps {
   error: DruidError;
   moveCursorTo: (rowColumn: RowColumn) => void;
   queryString?: string;
   onQueryStringChange?: (newQueryString: string, run?: boolean) => void;
 }
 
-export const QueryError = React.memo(function QueryError(props: QueryErrorProps) {
+export const QueryErrorPane = React.memo(function QueryErrorPane(props: QueryErrorPaneProps) {
   const { error, moveCursorTo, queryString, onQueryStringChange } = props;
   const [showMode, setShowMore] = useState(false);
 
   if (!error.errorMessage) {
-    return <div className="query-error">{error.message}</div>;
+    return <div className="query-error-pane">{error.message}</div>;
   }
 
   const { position, suggestion } = error;
@@ -46,21 +46,20 @@ export const QueryError = React.memo(function QueryError(props: QueryErrorProps)
       suggestionElement = (
         <p>
           Suggestion:{' '}
-          <span
-            className="suggestion"
+          <a
             onClick={() => {
               onQueryStringChange(newQuery, true);
             }}
           >
             {suggestion.label}
-          </span>
+          </a>
         </p>
       );
     }
   }
 
   return (
-    <div className="query-error">
+    <div className="query-error-pane">
       {suggestionElement}
       {error.error && <p>{`Error: ${error.error}`}</p>}
       {error.errorMessageWithoutExpectation && (
@@ -70,14 +69,13 @@ export const QueryError = React.memo(function QueryError(props: QueryErrorProps)
               text={error.errorMessageWithoutExpectation}
               find={position.match}
               replace={
-                <span
-                  className="cursor-link"
+                <a
                   onClick={() => {
                     moveCursorTo(position);
                   }}
                 >
                   {position.match}
-                </span>
+                </a>
               }
             />
           ) : (
@@ -86,19 +84,14 @@ export const QueryError = React.memo(function QueryError(props: QueryErrorProps)
           {error.expectation && !showMode && (
             <>
               {' '}
-              <span className="more-or-less" onClick={() => setShowMore(true)}>
-                More...
-              </span>
+              <a onClick={() => setShowMore(true)}>More...</a>
             </>
           )}
         </p>
       )}
       {error.expectation && showMode && (
         <p>
-          {error.expectation}{' '}
-          <span className="more-or-less" onClick={() => setShowMore(false)}>
-            Less...
-          </span>
+          {error.expectation} <a onClick={() => setShowMore(false)}>Less...</a>
         </p>
       )}
       {error.errorClass && <p>{error.errorClass}</p>}
diff --git a/web-console/src/components/record-table-pane/record-table-pane.scss b/web-console/src/components/record-table-pane/record-table-pane.scss
new file mode 100644
index 0000000000..d00a2e3d32
--- /dev/null
+++ b/web-console/src/components/record-table-pane/record-table-pane.scss
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@import '../../variables';
+@import '../../blueprint-overrides/common/colors';
+
+.record-table-pane {
+  position: relative;
+
+  &.more-results .-totalPages {
+    // Hide the total page counter as it can be confusing due to the auto limit
+    display: none;
+  }
+
+  .dead-end {
+    position: absolute;
+    left: 50%;
+    top: 45%;
+    transform: translate(-50%, -50%);
+    width: 350px;
+
+    p {
+      text-align: center;
+    }
+
+    > * {
+      margin-bottom: 10px;
+    }
+  }
+
+  .ReactTable {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    width: 100%;
+    font-feature-settings: tnum;
+    font-variant-numeric: tabular-nums;
+
+    .rt-thead.-header {
+      box-shadow: 0 1px 0 0 rgba(black, 0.2); // This is a hack! this line is sometimes too weak in tables.
+
+      .rt-th {
+        &.aggregate-header {
+          background: rgba($druid-brand, 0.06);
+        }
+
+        .asc {
+          box-shadow: inset 0 3px 0 0 rgba(255, 255, 255, 0.6);
+        }
+
+        .desc {
+          box-shadow: inset 0 -3px 0 0 rgba(255, 255, 255, 0.6);
+        }
+
+        .#{$bp-ns}-icon {
+          margin-left: 3px;
+        }
+
+        .output-name {
+          overflow: hidden;
+          text-overflow: ellipsis;
+
+          > * {
+            display: inline-block;
+            vertical-align: top;
+          }
+
+          .type-icon {
+            margin-top: 3px;
+            margin-right: 5px;
+          }
+        }
+
+        .formula {
+          font-family: monospace;
+          font-size: 11px;
+          margin-top: 4px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
+    }
+
+    .rt-td {
+      cursor: pointer;
+    }
+  }
+
+  .clickable-cell {
+    padding: $table-cell-v-padding $table-cell-h-padding;
+    cursor: pointer;
+    width: 100%;
+  }
+
+  .#{$bp-ns}-popover2-target {
+    width: 100%;
+  }
+}
diff --git a/web-console/src/components/record-table-pane/record-table-pane.tsx b/web-console/src/components/record-table-pane/record-table-pane.tsx
new file mode 100644
index 0000000000..699a4424d4
--- /dev/null
+++ b/web-console/src/components/record-table-pane/record-table-pane.tsx
@@ -0,0 +1,282 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Button, Icon, Menu, MenuItem } from '@blueprintjs/core';
+import { IconName, IconNames } from '@blueprintjs/icons';
+import { Popover2 } from '@blueprintjs/popover2';
+import classNames from 'classnames';
+import {
+  Column,
+  QueryResult,
+  SqlExpression,
+  SqlLiteral,
+  SqlRef,
+  trimString,
+} from 'druid-query-toolkit';
+import React, { useEffect, useState } from 'react';
+import ReactTable from 'react-table';
+
+import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
+import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../react-table';
+import {
+  columnToIcon,
+  columnToWidth,
+  copyAndAlert,
+  filterMap,
+  formatNumber,
+  getNumericColumnBraces,
+  Pagination,
+  prettyPrintSql,
+  stringifyValue,
+} from '../../utils';
+import { BracedText } from '../braced-text/braced-text';
+import { Deferred } from '../deferred/deferred';
+import { TableCell } from '../table-cell/table-cell';
+
+import './record-table-pane.scss';
+
+function sqlLiteralForColumnValue(column: Column, value: unknown): SqlLiteral | undefined {
+  if (column.sqlType === 'TIMESTAMP') {
+    const asDate = new Date(value as any);
+    if (!isNaN(asDate.valueOf())) {
+      return SqlLiteral.create(asDate);
+    }
+  }
+
+  return SqlLiteral.maybe(value);
+}
+
+function isComparable(x: unknown): boolean {
+  return x !== null && x !== '';
+}
+
+export interface RecordTablePaneProps {
+  queryResult: QueryResult;
+  initPageSize?: number;
+  addFilter?(filter: string): void;
+}
+
+export const RecordTablePane = React.memo(function RecordTablePane(props: RecordTablePaneProps) {
+  const { queryResult, initPageSize, addFilter } = props;
+  const parsedQuery = queryResult.sqlQuery;
+  const [pagination, setPagination] = useState<Pagination>({
+    page: 0,
+    pageSize: initPageSize || 20,
+  });
+  const [showValue, setShowValue] = useState<string>();
+
+  // Reset page to 0 if number of results changes
+  useEffect(() => {
+    setPagination(pagination => {
+      return pagination.page ? { ...pagination, page: 0 } : pagination;
+    });
+  }, [queryResult.rows.length]);
+
+  function hasFilterOnHeader(header: string, headerIndex: number): boolean {
+    if (!parsedQuery || !parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) return false;
+
+    return (
+      parsedQuery.getEffectiveWhereExpression().containsColumn(header) ||
+      parsedQuery.getEffectiveHavingExpression().containsColumn(header)
+    );
+  }
+
+  function filterOnMenuItem(icon: IconName, clause: SqlExpression) {
+    if (!parsedQuery || !addFilter) return;
+
+    return (
+      <MenuItem
+        icon={icon}
+        text={`Filter on: ${prettyPrintSql(clause)}`}
+        onClick={() => {
+          addFilter(clause.toString());
+        }}
+      />
+    );
+  }
+
+  function actionMenuItem(clause: SqlExpression) {
+    if (!addFilter) return;
+    const prettyLabel = prettyPrintSql(clause);
+    return (
+      <MenuItem
+        icon={IconNames.FILTER}
+        text={`Filter: ${prettyLabel}`}
+        onClick={() => addFilter(clause.toString())}
+      />
+    );
+  }
+
+  function getCellMenu(column: Column, headerIndex: number, value: unknown) {
+    const showFullValueMenuItem = (
+      <MenuItem
+        icon={IconNames.EYE_OPEN}
+        text="Show full value"
+        onClick={() => {
+          setShowValue(stringifyValue(value));
+        }}
+      />
+    );
+
+    const val = sqlLiteralForColumnValue(column, value);
+
+    if (parsedQuery) {
+      let ex: SqlExpression | undefined;
+      if (parsedQuery.hasStarInSelect()) {
+        ex = SqlRef.column(column.name);
+      } else {
+        const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
+        if (selectValue) {
+          ex = selectValue.getUnderlyingExpression();
+        }
+      }
+
+      const jsonColumn = column.nativeType === 'COMPLEX<json>';
+      return (
+        <Menu>
+          {ex && val && !jsonColumn && (
+            <>
+              {filterOnMenuItem(IconNames.FILTER, ex.equal(val))}
+              {filterOnMenuItem(IconNames.FILTER, ex.unequal(val))}
+              {isComparable(value) && (
+                <>
+                  {filterOnMenuItem(IconNames.FILTER, ex.greaterThanOrEqual(val))}
+                  {filterOnMenuItem(IconNames.FILTER, ex.lessThanOrEqual(val))}
+                </>
+              )}
+            </>
+          )}
+          {showFullValueMenuItem}
+        </Menu>
+      );
+    } else {
+      const ref = SqlRef.column(column.name);
+      const stringValue = stringifyValue(value);
+      const trimmedValue = trimString(stringValue, 50);
+      return (
+        <Menu>
+          <MenuItem
+            icon={IconNames.CLIPBOARD}
+            text={`Copy: ${trimmedValue}`}
+            onClick={() => copyAndAlert(stringValue, `${trimmedValue} copied to clipboard`)}
+          />
+          {val && (
+            <>
+              {actionMenuItem(ref.equal(val))}
+              {actionMenuItem(ref.unequal(val))}
+            </>
+          )}
+          {showFullValueMenuItem}
+        </Menu>
+      );
+    }
+  }
+
+  function getHeaderClassName(header: string) {
+    if (!parsedQuery) return;
+
+    const className = [];
+
+    const orderBy = parsedQuery.getOrderByForOutputColumn(header);
+    if (orderBy) {
+      className.push(orderBy.getEffectiveDirection() === 'DESC' ? '-sort-desc' : '-sort-asc');
+    }
+
+    return className.join(' ');
+  }
+
+  const outerLimit = queryResult.getSqlOuterLimit();
+  const hasMoreResults = queryResult.rows.length === outerLimit;
+  const finalPage =
+    hasMoreResults && Math.floor(queryResult.rows.length / pagination.pageSize) === pagination.page; // on the last page
+
+  const numericColumnBraces = getNumericColumnBraces(queryResult, pagination);
+  return (
+    <div className={classNames('record-table-pane', { 'more-results': hasMoreResults })}>
+      {finalPage ? (
+        <div className="dead-end">
+          <p>This is the end of the inline results but there are more results in this query.</p>
+          <Button
+            icon={IconNames.ARROW_LEFT}
+            text="Go to previous page"
+            fill
+            onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
+          />
+        </div>
+      ) : (
+        <ReactTable
+          className="-striped -highlight"
+          data={queryResult.rows as any[][]}
+          ofText={hasMoreResults ? '' : 'of'}
+          noDataText={queryResult.rows.length ? '' : 'Query returned no data'}
+          page={pagination.page}
+          pageSize={pagination.pageSize}
+          onPageChange={page => setPagination({ ...pagination, page })}
+          onPageSizeChange={(pageSize, page) => setPagination({ page, pageSize })}
+          sortable={false}
+          defaultPageSize={SMALL_TABLE_PAGE_SIZE}
+          pageSizeOptions={SMALL_TABLE_PAGE_SIZE_OPTIONS}
+          showPagination={
+            queryResult.rows.length > Math.min(SMALL_TABLE_PAGE_SIZE, pagination.pageSize)
+          }
+          columns={filterMap(queryResult.header, (column, i) => {
+            const h = column.name;
+            const icon = columnToIcon(column);
+
+            return {
+              Header() {
+                return (
+                  <div className="clickable-cell">
+                    <div className="output-name">
+                      {icon && <Icon className="type-icon" icon={icon} size={12} />}
+                      {h}
+                      {hasFilterOnHeader(h, i) && <Icon icon={IconNames.FILTER} size={14} />}
+                    </div>
+                  </div>
+                );
+              },
+              headerClassName: getHeaderClassName(h),
+              accessor: String(i),
+              Cell(row) {
+                const value = row.value;
+                return (
+                  <div>
+                    <Popover2 content={<Deferred content={() => getCellMenu(column, i, value)} />}>
+                      {numericColumnBraces[i] ? (
+                        <BracedText
+                          className="table-padding"
+                          text={formatNumber(value)}
+                          braces={numericColumnBraces[i]}
+                          padFractionalPart
+                        />
+                      ) : (
+                        <TableCell value={value} unlimited />
+                      )}
+                    </Popover2>
+                  </div>
+                );
+              },
+              width: columnToWidth(column),
+            };
+          })}
+        />
+      )}
+      {showValue && <ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} />}
+    </div>
+  );
+});
diff --git a/web-console/src/components/show-json/show-json.tsx b/web-console/src/components/show-json/show-json.tsx
index c840c95e24..2986febc4a 100644
--- a/web-console/src/components/show-json/show-json.tsx
+++ b/web-console/src/components/show-json/show-json.tsx
@@ -87,7 +87,7 @@ export const ShowJson = React.memo(function ShowJson(props: ShowJsonProps) {
         ) : (
           <AceEditor
             mode="hjson"
-            theme="tomorrow"
+            theme="solarized_dark"
             readOnly
             fontSize={12}
             width="100%"
diff --git a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss b/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
index 43efe5085b..9819fb4b1f 100644
--- a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
+++ b/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
@@ -16,6 +16,9 @@
  * limitations under the License.
  */
 
+@import '../../variables';
+
 .table-cell-unparseable {
+  padding: $table-cell-v-padding $table-cell-h-padding;
   color: #9e2b0e;
 }
diff --git a/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx b/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx
index e2e79b1fcd..cc8cfd71e5 100644
--- a/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx
+++ b/web-console/src/components/table-clickable-cell/table-clickable-cell.tsx
@@ -17,23 +17,26 @@
  */
 
 import { Icon, IconName } from '@blueprintjs/core';
+import classNames from 'classnames';
 import React, { MouseEventHandler, ReactNode } from 'react';
 
 import './table-clickable-cell.scss';
 
 export interface TableClickableCellProps {
+  className?: string;
   onClick: MouseEventHandler<any>;
   hoverIcon?: IconName;
+  title?: string;
   children?: ReactNode;
 }
 
 export const TableClickableCell = React.memo(function TableClickableCell(
   props: TableClickableCellProps,
 ) {
-  const { onClick, hoverIcon, children } = props;
+  const { className, onClick, hoverIcon, title, children } = props;
 
   return (
-    <div className="table-clickable-cell" onClick={onClick}>
+    <div className={classNames('table-clickable-cell', className)} title={title} onClick={onClick}>
       {children}
       {hoverIcon && <Icon className="hover-icon" icon={hoverIcon} />}
     </div>
diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx
index 731002632c..d5dc61cebc 100644
--- a/web-console/src/console-application.tsx
+++ b/web-console/src/console-application.tsx
@@ -20,9 +20,11 @@ import { HotkeysProvider, Intent } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
 import React from 'react';
+import { RouteComponentProps } from 'react-router';
 import { HashRouter, Route, Switch } from 'react-router-dom';
 
 import { HeaderActiveTab, HeaderBar, Loader } from './components';
+import { DruidEngine, QueryWithContext } from './druid-models';
 import { AppToaster } from './singletons';
 import { Capabilities, QueryManager } from './utils';
 import {
@@ -34,6 +36,8 @@ import {
   QueryView,
   SegmentsView,
   ServicesView,
+  SqlDataLoaderView,
+  WorkbenchView,
 } from './views';
 
 import './console-application.scss';
@@ -75,7 +79,7 @@ export class ConsoleApplication extends React.PureComponent<
   private openDialog?: string;
   private datasource?: string;
   private onlyUnavailable?: boolean;
-  private initQuery?: string;
+  private queryWithContext?: QueryWithContext;
 
   constructor(props: ConsoleApplicationProps, context: any) {
     super(props, context);
@@ -119,14 +123,19 @@ export class ConsoleApplication extends React.PureComponent<
       this.openDialog = undefined;
       this.datasource = undefined;
       this.onlyUnavailable = undefined;
-      this.initQuery = undefined;
+      this.queryWithContext = undefined;
     }, 50);
   }
 
-  private readonly goToLoadData = (supervisorId?: string, taskId?: string) => {
-    if (taskId) this.taskId = taskId;
+  private readonly goToStreamingDataLoader = (supervisorId?: string) => {
     if (supervisorId) this.supervisorId = supervisorId;
-    window.location.hash = 'load-data';
+    window.location.hash = 'streaming-data-loader';
+    this.resetInitialsWithDelay();
+  };
+
+  private readonly goToClassicBatchDataLoader = (taskId?: string) => {
+    if (taskId) this.taskId = taskId;
+    window.location.hash = 'classic-batch-data-loader';
     this.resetInitialsWithDelay();
   };
 
@@ -143,6 +152,12 @@ export class ConsoleApplication extends React.PureComponent<
     this.resetInitialsWithDelay();
   };
 
+  private readonly goToIngestionWithTaskId = (taskId?: string) => {
+    this.taskId = taskId;
+    window.location.hash = 'ingestion';
+    this.resetInitialsWithDelay();
+  };
+
   private readonly goToIngestionWithTaskGroupId = (taskGroupId?: string, openDialog?: string) => {
     this.taskGroupId = taskGroupId;
     if (openDialog) this.openDialog = openDialog;
@@ -157,9 +172,9 @@ export class ConsoleApplication extends React.PureComponent<
     this.resetInitialsWithDelay();
   };
 
-  private readonly goToQuery = (initQuery: string) => {
-    this.initQuery = initQuery;
-    window.location.hash = 'query';
+  private readonly goToQuery = (queryWithContext: QueryWithContext) => {
+    this.queryWithContext = queryWithContext;
+    window.location.hash = 'workbench';
     this.resetInitialsWithDelay();
   };
 
@@ -187,13 +202,41 @@ export class ConsoleApplication extends React.PureComponent<
     return this.wrapInViewContainer(null, <HomeView capabilities={capabilities} />);
   };
 
-  private readonly wrappedLoadDataView = () => {
+  private readonly wrappedDataLoaderView = () => {
     const { exampleManifestsUrl } = this.props;
 
     return this.wrapInViewContainer(
-      'load-data',
+      'data-loader',
+      <LoadDataView
+        mode="all"
+        initTaskId={this.taskId}
+        initSupervisorId={this.supervisorId}
+        exampleManifestsUrl={exampleManifestsUrl}
+        goToIngestion={this.goToIngestionWithTaskGroupId}
+      />,
+      'narrow-pad',
+    );
+  };
+
+  private readonly wrappedStreamingDataLoaderView = () => {
+    return this.wrapInViewContainer(
+      'streaming-data-loader',
       <LoadDataView
+        mode="streaming"
         initSupervisorId={this.supervisorId}
+        goToIngestion={this.goToIngestionWithTaskGroupId}
+      />,
+      'narrow-pad',
+    );
+  };
+
+  private readonly wrappedClassicBatchDataLoaderView = () => {
+    const { exampleManifestsUrl } = this.props;
+
+    return this.wrapInViewContainer(
+      'classic-batch-data-loader',
+      <LoadDataView
+        mode="batch"
         initTaskId={this.taskId}
         exampleManifestsUrl={exampleManifestsUrl}
         goToIngestion={this.goToIngestionWithTaskGroupId}
@@ -208,7 +251,7 @@ export class ConsoleApplication extends React.PureComponent<
     return this.wrapInViewContainer(
       'query',
       <QueryView
-        initQuery={this.initQuery}
+        initQuery={this.queryWithContext?.queryString}
         defaultQueryContext={defaultQueryContext}
         mandatoryQueryContext={mandatoryQueryContext}
       />,
@@ -216,6 +259,43 @@ export class ConsoleApplication extends React.PureComponent<
     );
   };
 
+  private readonly wrappedWorkbenchView = (p: RouteComponentProps<any>) => {
+    const { defaultQueryContext, mandatoryQueryContext } = this.props;
+    const { capabilities } = this.state;
+
+    const queryEngines: DruidEngine[] = ['native'];
+    if (capabilities.hasSql()) {
+      queryEngines.push('sql-native');
+    }
+    if (capabilities.hasMultiStageQuery()) {
+      queryEngines.push('sql-msq-task');
+    }
+
+    return this.wrapInViewContainer(
+      'workbench',
+      <WorkbenchView
+        tabId={p.match.params.tabId}
+        onTabChange={newTabId => {
+          location.hash = `#workbench/${newTabId}`;
+        }}
+        initQueryWithContext={this.queryWithContext}
+        defaultQueryContext={defaultQueryContext}
+        mandatoryQueryContext={mandatoryQueryContext}
+        queryEngines={queryEngines}
+        allowExplain
+        goToIngestion={this.goToIngestionWithTaskId}
+      />,
+      'thin',
+    );
+  };
+
+  private readonly wrappedSqlDataLoaderView = () => {
+    return this.wrapInViewContainer(
+      'sql-data-loader',
+      <SqlDataLoaderView goToQuery={this.goToQuery} goToIngestion={this.goToIngestionWithTaskId} />,
+    );
+  };
+
   private readonly wrappedDatasourcesView = () => {
     const { capabilities } = this.state;
     return this.wrapInViewContainer(
@@ -248,12 +328,14 @@ export class ConsoleApplication extends React.PureComponent<
     return this.wrapInViewContainer(
       'ingestion',
       <IngestionView
+        taskId={this.taskId}
         taskGroupId={this.taskGroupId}
         datasourceId={this.datasource}
         openDialog={this.openDialog}
         goToDatasource={this.goToDatasources}
         goToQuery={this.goToQuery}
-        goToLoadData={this.goToLoadData}
+        goToStreamingDataLoader={this.goToStreamingDataLoader}
+        goToClassicBatchDataLoader={this.goToClassicBatchDataLoader}
         capabilities={capabilities}
       />,
     );
@@ -263,11 +345,7 @@ export class ConsoleApplication extends React.PureComponent<
     const { capabilities } = this.state;
     return this.wrapInViewContainer(
       'services',
-      <ServicesView
-        goToQuery={this.goToQuery}
-        goToTask={this.goToIngestionWithTaskGroupId}
-        capabilities={capabilities}
-      />,
+      <ServicesView goToQuery={this.goToQuery} capabilities={capabilities} />,
     );
   };
 
@@ -291,7 +369,15 @@ export class ConsoleApplication extends React.PureComponent<
         <HashRouter hashType="noslash">
           <div className="console-application">
             <Switch>
-              <Route path="/load-data" component={this.wrappedLoadDataView} />
+              <Route path="/data-loader" component={this.wrappedDataLoaderView} />
+              <Route
+                path="/streaming-data-loader"
+                component={this.wrappedStreamingDataLoaderView}
+              />
+              <Route
+                path="/classic-batch-data-loader"
+                component={this.wrappedClassicBatchDataLoaderView}
+              />
 
               <Route path="/ingestion" component={this.wrappedIngestionView} />
               <Route path="/datasources" component={this.wrappedDatasourcesView} />
@@ -299,6 +385,11 @@ export class ConsoleApplication extends React.PureComponent<
               <Route path="/services" component={this.wrappedServicesView} />
 
               <Route path="/query" component={this.wrappedQueryView} />
+              <Route
+                path={['/workbench/:tabId', '/workbench']}
+                component={this.wrappedWorkbenchView}
+              />
+              <Route path="/sql-data-loader" component={this.wrappedSqlDataLoaderView} />
 
               <Route path="/lookups" component={this.wrappedLookupsView} />
               <Route component={this.wrappedHomeView} />
diff --git a/web-console/src/dialogs/async-action-dialog/async-action-dialog.tsx b/web-console/src/dialogs/async-action-dialog/async-action-dialog.tsx
index ada4620e58..f892936bab 100644
--- a/web-console/src/dialogs/async-action-dialog/async-action-dialog.tsx
+++ b/web-console/src/dialogs/async-action-dialog/async-action-dialog.tsx
@@ -30,7 +30,7 @@ import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
 import React, { ReactNode, useState } from 'react';
 
-import { WarningChecklist } from '../../components/warning-checklist/warning-checklist';
+import { WarningChecklist } from '../../components';
 import { AppToaster } from '../../singletons';
 
 import './async-action-dialog.scss';
diff --git a/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap b/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap
index 888ee17ef3..c632071fe5 100644
--- a/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/compaction-dialog/__snapshots__/compaction-dialog.spec.tsx.snap
@@ -280,6 +280,20 @@ exports[`CompactionDialog matches snapshot with compactionConfig (dynamic partit
             "name": "tuningConfig.maxNumConcurrentSubTasks",
             "type": "number",
           },
+          Object {
+            "defaultValue": -1,
+            "info": <React.Fragment>
+              <p>
+                Limit of the number of segments to merge in a single phase when merging segments for publishing. This limit affects the total number of columns present in a set of segments to merge. If the limit is exceeded, segment merging occurs in multiple phases. Druid merges at least 2 segments per phase, regardless of this setting.
+              </p>
+              <p>
+                Default: -1 (unlimited)
+              </p>
+            </React.Fragment>,
+            "min": -1,
+            "name": "tuningConfig.maxColumnsToMerge",
+            "type": "number",
+          },
           Object {
             "defaultValue": 10,
             "defined": [Function],
@@ -642,6 +656,20 @@ exports[`CompactionDialog matches snapshot with compactionConfig (hashed partiti
             "name": "tuningConfig.maxNumConcurrentSubTasks",
             "type": "number",
           },
+          Object {
+            "defaultValue": -1,
+            "info": <React.Fragment>
+              <p>
+                Limit of the number of segments to merge in a single phase when merging segments for publishing. This limit affects the total number of columns present in a set of segments to merge. If the limit is exceeded, segment merging occurs in multiple phases. Druid merges at least 2 segments per phase, regardless of this setting.
+              </p>
+              <p>
+                Default: -1 (unlimited)
+              </p>
+            </React.Fragment>,
+            "min": -1,
+            "name": "tuningConfig.maxColumnsToMerge",
+            "type": "number",
+          },
           Object {
             "defaultValue": 10,
             "defined": [Function],
@@ -1004,6 +1032,20 @@ exports[`CompactionDialog matches snapshot with compactionConfig (range partitio
             "name": "tuningConfig.maxNumConcurrentSubTasks",
             "type": "number",
           },
+          Object {
+            "defaultValue": -1,
+            "info": <React.Fragment>
+              <p>
+                Limit of the number of segments to merge in a single phase when merging segments for publishing. This limit affects the total number of columns present in a set of segments to merge. If the limit is exceeded, segment merging occurs in multiple phases. Druid merges at least 2 segments per phase, regardless of this setting.
+              </p>
+              <p>
+                Default: -1 (unlimited)
+              </p>
+            </React.Fragment>,
+            "min": -1,
+            "name": "tuningConfig.maxColumnsToMerge",
+            "type": "number",
+          },
           Object {
             "defaultValue": 10,
             "defined": [Function],
@@ -1366,6 +1408,20 @@ exports[`CompactionDialog matches snapshot without compactionConfig 1`] = `
             "name": "tuningConfig.maxNumConcurrentSubTasks",
             "type": "number",
           },
+          Object {
+            "defaultValue": -1,
+            "info": <React.Fragment>
+              <p>
+                Limit of the number of segments to merge in a single phase when merging segments for publishing. This limit affects the total number of columns present in a set of segments to merge. If the limit is exceeded, segment merging occurs in multiple phases. Druid merges at least 2 segments per phase, regardless of this setting.
+              </p>
+              <p>
+                Default: -1 (unlimited)
+              </p>
+            </React.Fragment>,
+            "min": -1,
+            "name": "tuningConfig.maxColumnsToMerge",
+            "type": "number",
+          },
           Object {
             "defaultValue": 10,
             "defined": [Function],
diff --git a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap
index e889ab8bde..3de1970e2e 100644
--- a/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/coordinator-dynamic-config-dialog/__snapshots__/coordinator-dynamic-config-dialog.spec.tsx.snap
@@ -12,7 +12,7 @@ exports[`CoordinatorDynamicConfigDialog matches snapshot 1`] = `
     Edit the coordinator dynamic configuration on the fly. For more information please refer to the
      
     <Memo(ExternalLink)
-      href="https://druid.apache.org/docs/0.23.0/configuration/index.html#dynamic-configuration"
+      href="https://druid.apache.org/docs/latest/configuration/index.html#dynamic-configuration"
     >
       documentation
     </Memo(ExternalLink)>
diff --git a/web-console/src/dialogs/datasource-table-action-dialog/__snapshots__/datasource-table-action-dialog.spec.tsx.snap b/web-console/src/dialogs/datasource-table-action-dialog/__snapshots__/datasource-table-action-dialog.spec.tsx.snap
index 4934b0e707..eb5ffe755a 100644
--- a/web-console/src/dialogs/datasource-table-action-dialog/__snapshots__/datasource-table-action-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/datasource-table-action-dialog/__snapshots__/datasource-table-action-dialog.spec.tsx.snap
@@ -65,6 +65,33 @@ exports[`Datasource table action dialog matches snapshot 1`] = `
             <button
               class="bp4-button bp4-intent-primary tab-button"
               type="button"
+            >
+              <span
+                aria-hidden="true"
+                class="bp4-icon bp4-icon-th"
+                icon="th"
+              >
+                <svg
+                  data-icon="th"
+                  height="20"
+                  viewBox="0 0 20 20"
+                  width="20"
+                >
+                  <path
+                    d="M19 1H1c-.6 0-1 .5-1 1v16c0 .5.4 1 1 1h18c.5 0 1-.5 1-1V2c0-.5-.5-1-1-1zM7 17H2v-3h5v3zm0-4H2v-3h5v3zm0-4H2V6h5v3zm11 8H8v-3h10v3zm0-4H8v-3h10v3zm0-4H8V6h10v3z"
+                    fill-rule="evenodd"
+                  />
+                </svg>
+              </span>
+              <span
+                class="bp4-button-text"
+              >
+                Records
+              </span>
+            </button>
+            <button
+              class="bp4-button bp4-minimal tab-button"
+              type="button"
             >
               <span
                 aria-hidden="true"
@@ -94,43 +121,39 @@ exports[`Datasource table action dialog matches snapshot 1`] = `
             class="main-section"
           >
             <div
-              class="datasource-columns-table"
+              class="datasource-preview-pane"
             >
               <div
-                class="main-area"
+                class="loader"
               >
                 <div
-                  class="loader"
+                  class="loader-logo"
                 >
-                  <div
-                    class="loader-logo"
+                  <svg
+                    viewBox="0 0 100 100"
                   >
-                    <svg
-                      viewBox="0 0 100 100"
-                    >
-                      <path
-                        class="one"
-                        d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
+                    <path
+                      class="one"
+                      d="M54.2,69.8h-2.7c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h2.7c11.5,0,23.8-7.4,23.8-23.7
           c0-9.1-6.9-15.8-16.4-15.8H38c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h23.6c5.3,0,10.1,1.9,13.6,5.3c3.5,3.4,5.4,8,5.4,13.1
           c0,6.6-2.3,13-6.3,17.7C69.5,66.8,62.5,69.8,54.2,69.8z"
-                      />
-                      <path
-                        class="two"
-                        d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
+                    />
+                    <path
+                      class="two"
+                      d="M55.7,59.5h-26c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h26c7.5,0,11.5-5.8,11.5-11.5
             c0-4.2-3.2-7.3-7.7-7.3h-26c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h26c5.9,0,10.3,4.3,10.3,9.9c0,3.7-1.3,7.2-3.7,9.8
             C63.5,58,59.9,59.5,55.7,59.5z"
-                      />
-                      <path
-                        class="three"
-                        d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
-                      />
-                      <path
-                        class="four"
-                        d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
+                    />
+                    <path
+                      class="three"
+                      d="M27.2,38h-6.3c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h6.3c0.7,0,1.3,0.6,1.3,1.3S27.9,38,27.2,38z"
+                    />
+                    <path
+                      class="four"
+                      d="M45.1,69.8h-5.8c-0.7,0-1.3-0.6-1.3-1.3c0-0.7,0.6-1.3,1.3-1.3h5.8c0.7,0,1.3,0.6,1.3,1.3
             C46.4,69.2,45.8,69.8,45.1,69.8z"
-                      />
-                    </svg>
-                  </div>
+                    />
+                  </svg>
                 </div>
               </div>
             </div>
diff --git a/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap
new file mode 100644
index 0000000000..4d0c223ece
--- /dev/null
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/__snapshots__/datasource-columns-table.spec.tsx.snap
@@ -0,0 +1,648 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DatasourceColumnsTable matches snapshot on error 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <ReactTable
+    AggregatedComponent={[Function]}
+    ExpanderComponent={[Function]}
+    FilterComponent={[Function]}
+    LoadingComponent={[Function]}
+    NoDataComponent={[Function]}
+    PadRowComponent={[Function]}
+    PaginationComponent={[Function]}
+    PivotValueComponent={[Function]}
+    ResizerComponent={[Function]}
+    TableComponent={[Function]}
+    TbodyComponent={[Function]}
+    TdComponent={[Function]}
+    TfootComponent={[Function]}
+    ThComponent={[Function]}
+    TheadComponent={[Function]}
+    TrComponent={[Function]}
+    TrGroupComponent={[Function]}
+    aggregatedKey="_aggregated"
+    className=""
+    collapseOnDataChange={true}
+    collapseOnPageChange={true}
+    collapseOnSortingChange={true}
+    column={
+      Object {
+        "Aggregated": undefined,
+        "Cell": undefined,
+        "Expander": undefined,
+        "Filter": undefined,
+        "Footer": undefined,
+        "Header": undefined,
+        "Pivot": undefined,
+        "PivotValue": undefined,
+        "Placeholder": undefined,
+        "aggregate": undefined,
+        "className": "",
+        "filterAll": false,
+        "filterMethod": undefined,
+        "filterable": undefined,
+        "footerClassName": "",
+        "footerStyle": Object {},
+        "getFooterProps": [Function],
+        "getHeaderProps": [Function],
+        "getProps": [Function],
+        "headerClassName": "",
+        "headerStyle": Object {},
+        "minResizeWidth": 11,
+        "minWidth": 100,
+        "resizable": undefined,
+        "show": true,
+        "sortMethod": undefined,
+        "sortable": undefined,
+        "style": Object {},
+      }
+    }
+    columns={
+      Array [
+        Object {
+          "Header": "Column name",
+          "accessor": "COLUMN_NAME",
+          "className": "padded",
+          "width": 300,
+        },
+        Object {
+          "Header": "Data type",
+          "accessor": "DATA_TYPE",
+          "className": "padded",
+          "width": 200,
+        },
+      ]
+    }
+    data={Array []}
+    defaultExpanded={Object {}}
+    defaultFilterMethod={[Function]}
+    defaultFiltered={Array []}
+    defaultPage={0}
+    defaultPageSize={25}
+    defaultResized={Array []}
+    defaultSortDesc={false}
+    defaultSortMethod={[Function]}
+    defaultSorted={Array []}
+    expanderDefaults={
+      Object {
+        "filterable": false,
+        "resizable": false,
+        "sortable": false,
+        "width": 35,
+      }
+    }
+    filterable={true}
+    freezeWhenExpanded={false}
+    getLoadingProps={[Function]}
+    getNoDataProps={[Function]}
+    getPaginationProps={[Function]}
+    getProps={[Function]}
+    getResizerProps={[Function]}
+    getTableProps={[Function]}
+    getTbodyProps={[Function]}
+    getTdProps={[Function]}
+    getTfootProps={[Function]}
+    getTfootTdProps={[Function]}
+    getTfootTrProps={[Function]}
+    getTheadFilterProps={[Function]}
+    getTheadFilterThProps={[Function]}
+    getTheadFilterTrProps={[Function]}
+    getTheadGroupProps={[Function]}
+    getTheadGroupThProps={[Function]}
+    getTheadGroupTrProps={[Function]}
+    getTheadProps={[Function]}
+    getTheadThProps={[Function]}
+    getTheadTrProps={[Function]}
+    getTrGroupProps={[Function]}
+    getTrProps={[Function]}
+    groupedByPivotKey="_groupedByPivot"
+    indexKey="_index"
+    loading={false}
+    loadingText="Loading..."
+    multiSort={true}
+    nestingLevelKey="_nestingLevel"
+    nextText="Next"
+    noDataText="test error"
+    ofText="of"
+    onFetchData={[Function]}
+    originalKey="_original"
+    pageJumpText="jump to page"
+    pageSizeOptions={
+      Array [
+        25,
+        50,
+        100,
+      ]
+    }
+    pageText="Page"
+    pivotDefaults={Object {}}
+    pivotIDKey="_pivotID"
+    pivotValKey="_pivotVal"
+    previousText="Previous"
+    resizable={true}
+    resolveData={[Function]}
+    rowsSelectorText="rows per page"
+    rowsText="rows"
+    showPageJump={true}
+    showPageSizeOptions={true}
+    showPagination={false}
+    showPaginationBottom={true}
+    showPaginationTop={false}
+    sortable={true}
+    style={Object {}}
+    subRowsKey="_subRows"
+  />
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on init 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <ReactTable
+    AggregatedComponent={[Function]}
+    ExpanderComponent={[Function]}
+    FilterComponent={[Function]}
+    LoadingComponent={[Function]}
+    NoDataComponent={[Function]}
+    PadRowComponent={[Function]}
+    PaginationComponent={[Function]}
+    PivotValueComponent={[Function]}
+    ResizerComponent={[Function]}
+    TableComponent={[Function]}
+    TbodyComponent={[Function]}
+    TdComponent={[Function]}
+    TfootComponent={[Function]}
+    ThComponent={[Function]}
+    TheadComponent={[Function]}
+    TrComponent={[Function]}
+    TrGroupComponent={[Function]}
+    aggregatedKey="_aggregated"
+    className=""
+    collapseOnDataChange={true}
+    collapseOnPageChange={true}
+    collapseOnSortingChange={true}
+    column={
+      Object {
+        "Aggregated": undefined,
+        "Cell": undefined,
+        "Expander": undefined,
+        "Filter": undefined,
+        "Footer": undefined,
+        "Header": undefined,
+        "Pivot": undefined,
+        "PivotValue": undefined,
+        "Placeholder": undefined,
+        "aggregate": undefined,
+        "className": "",
+        "filterAll": false,
+        "filterMethod": undefined,
+        "filterable": undefined,
+        "footerClassName": "",
+        "footerStyle": Object {},
+        "getFooterProps": [Function],
+        "getHeaderProps": [Function],
+        "getProps": [Function],
+        "headerClassName": "",
+        "headerStyle": Object {},
+        "minResizeWidth": 11,
+        "minWidth": 100,
+        "resizable": undefined,
+        "show": true,
+        "sortMethod": undefined,
+        "sortable": undefined,
+        "style": Object {},
+      }
+    }
+    columns={
+      Array [
+        Object {
+          "Header": "Column name",
+          "accessor": "COLUMN_NAME",
+          "className": "padded",
+          "width": 300,
+        },
+        Object {
+          "Header": "Data type",
+          "accessor": "DATA_TYPE",
+          "className": "padded",
+          "width": 200,
+        },
+      ]
+    }
+    data={Array []}
+    defaultExpanded={Object {}}
+    defaultFilterMethod={[Function]}
+    defaultFiltered={Array []}
+    defaultPage={0}
+    defaultPageSize={25}
+    defaultResized={Array []}
+    defaultSortDesc={false}
+    defaultSortMethod={[Function]}
+    defaultSorted={Array []}
+    expanderDefaults={
+      Object {
+        "filterable": false,
+        "resizable": false,
+        "sortable": false,
+        "width": 35,
+      }
+    }
+    filterable={true}
+    freezeWhenExpanded={false}
+    getLoadingProps={[Function]}
+    getNoDataProps={[Function]}
+    getPaginationProps={[Function]}
+    getProps={[Function]}
+    getResizerProps={[Function]}
+    getTableProps={[Function]}
+    getTbodyProps={[Function]}
+    getTdProps={[Function]}
+    getTfootProps={[Function]}
+    getTfootTdProps={[Function]}
+    getTfootTrProps={[Function]}
+    getTheadFilterProps={[Function]}
+    getTheadFilterThProps={[Function]}
+    getTheadFilterTrProps={[Function]}
+    getTheadGroupProps={[Function]}
+    getTheadGroupThProps={[Function]}
+    getTheadGroupTrProps={[Function]}
+    getTheadProps={[Function]}
+    getTheadThProps={[Function]}
+    getTheadTrProps={[Function]}
+    getTrGroupProps={[Function]}
+    getTrProps={[Function]}
+    groupedByPivotKey="_groupedByPivot"
+    indexKey="_index"
+    loading={false}
+    loadingText="Loading..."
+    multiSort={true}
+    nestingLevelKey="_nestingLevel"
+    nextText="Next"
+    noDataText="No column data found"
+    ofText="of"
+    onFetchData={[Function]}
+    originalKey="_original"
+    pageJumpText="jump to page"
+    pageSizeOptions={
+      Array [
+        25,
+        50,
+        100,
+      ]
+    }
+    pageText="Page"
+    pivotDefaults={Object {}}
+    pivotIDKey="_pivotID"
+    pivotValKey="_pivotVal"
+    previousText="Previous"
+    resizable={true}
+    resolveData={[Function]}
+    rowsSelectorText="rows per page"
+    rowsText="rows"
+    showPageJump={true}
+    showPageSizeOptions={true}
+    showPagination={false}
+    showPaginationBottom={true}
+    showPaginationTop={false}
+    sortable={true}
+    style={Object {}}
+    subRowsKey="_subRows"
+  />
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on loading 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <Memo(Loader) />
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on no data 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <ReactTable
+    AggregatedComponent={[Function]}
+    ExpanderComponent={[Function]}
+    FilterComponent={[Function]}
+    LoadingComponent={[Function]}
+    NoDataComponent={[Function]}
+    PadRowComponent={[Function]}
+    PaginationComponent={[Function]}
+    PivotValueComponent={[Function]}
+    ResizerComponent={[Function]}
+    TableComponent={[Function]}
+    TbodyComponent={[Function]}
+    TdComponent={[Function]}
+    TfootComponent={[Function]}
+    ThComponent={[Function]}
+    TheadComponent={[Function]}
+    TrComponent={[Function]}
+    TrGroupComponent={[Function]}
+    aggregatedKey="_aggregated"
+    className=""
+    collapseOnDataChange={true}
+    collapseOnPageChange={true}
+    collapseOnSortingChange={true}
+    column={
+      Object {
+        "Aggregated": undefined,
+        "Cell": undefined,
+        "Expander": undefined,
+        "Filter": undefined,
+        "Footer": undefined,
+        "Header": undefined,
+        "Pivot": undefined,
+        "PivotValue": undefined,
+        "Placeholder": undefined,
+        "aggregate": undefined,
+        "className": "",
+        "filterAll": false,
+        "filterMethod": undefined,
+        "filterable": undefined,
+        "footerClassName": "",
+        "footerStyle": Object {},
+        "getFooterProps": [Function],
+        "getHeaderProps": [Function],
+        "getProps": [Function],
+        "headerClassName": "",
+        "headerStyle": Object {},
+        "minResizeWidth": 11,
+        "minWidth": 100,
+        "resizable": undefined,
+        "show": true,
+        "sortMethod": undefined,
+        "sortable": undefined,
+        "style": Object {},
+      }
+    }
+    columns={
+      Array [
+        Object {
+          "Header": "Column name",
+          "accessor": "COLUMN_NAME",
+          "className": "padded",
+          "width": 300,
+        },
+        Object {
+          "Header": "Data type",
+          "accessor": "DATA_TYPE",
+          "className": "padded",
+          "width": 200,
+        },
+      ]
+    }
+    data={Array []}
+    defaultExpanded={Object {}}
+    defaultFilterMethod={[Function]}
+    defaultFiltered={Array []}
+    defaultPage={0}
+    defaultPageSize={25}
+    defaultResized={Array []}
+    defaultSortDesc={false}
+    defaultSortMethod={[Function]}
+    defaultSorted={Array []}
+    expanderDefaults={
+      Object {
+        "filterable": false,
+        "resizable": false,
+        "sortable": false,
+        "width": 35,
+      }
+    }
+    filterable={true}
+    freezeWhenExpanded={false}
+    getLoadingProps={[Function]}
+    getNoDataProps={[Function]}
+    getPaginationProps={[Function]}
+    getProps={[Function]}
+    getResizerProps={[Function]}
+    getTableProps={[Function]}
+    getTbodyProps={[Function]}
+    getTdProps={[Function]}
+    getTfootProps={[Function]}
+    getTfootTdProps={[Function]}
+    getTfootTrProps={[Function]}
+    getTheadFilterProps={[Function]}
+    getTheadFilterThProps={[Function]}
+    getTheadFilterTrProps={[Function]}
+    getTheadGroupProps={[Function]}
+    getTheadGroupThProps={[Function]}
+    getTheadGroupTrProps={[Function]}
+    getTheadProps={[Function]}
+    getTheadThProps={[Function]}
+    getTheadTrProps={[Function]}
+    getTrGroupProps={[Function]}
+    getTrProps={[Function]}
+    groupedByPivotKey="_groupedByPivot"
+    indexKey="_index"
+    loading={false}
+    loadingText="Loading..."
+    multiSort={true}
+    nestingLevelKey="_nestingLevel"
+    nextText="Next"
+    noDataText="No column data found"
+    ofText="of"
+    onFetchData={[Function]}
+    originalKey="_original"
+    pageJumpText="jump to page"
+    pageSizeOptions={
+      Array [
+        25,
+        50,
+        100,
+      ]
+    }
+    pageText="Page"
+    pivotDefaults={Object {}}
+    pivotIDKey="_pivotID"
+    pivotValKey="_pivotVal"
+    previousText="Previous"
+    resizable={true}
+    resolveData={[Function]}
+    rowsSelectorText="rows per page"
+    rowsText="rows"
+    showPageJump={true}
+    showPageSizeOptions={true}
+    showPagination={false}
+    showPaginationBottom={true}
+    showPaginationTop={false}
+    sortable={true}
+    style={Object {}}
+    subRowsKey="_subRows"
+  />
+</div>
+`;
+
+exports[`DatasourceColumnsTable matches snapshot on some data 1`] = `
+<div
+  className="datasource-columns-table"
+>
+  <ReactTable
+    AggregatedComponent={[Function]}
+    ExpanderComponent={[Function]}
+    FilterComponent={[Function]}
+    LoadingComponent={[Function]}
+    NoDataComponent={[Function]}
+    PadRowComponent={[Function]}
+    PaginationComponent={[Function]}
+    PivotValueComponent={[Function]}
+    ResizerComponent={[Function]}
+    TableComponent={[Function]}
+    TbodyComponent={[Function]}
+    TdComponent={[Function]}
+    TfootComponent={[Function]}
+    ThComponent={[Function]}
+    TheadComponent={[Function]}
+    TrComponent={[Function]}
+    TrGroupComponent={[Function]}
+    aggregatedKey="_aggregated"
+    className=""
+    collapseOnDataChange={true}
+    collapseOnPageChange={true}
+    collapseOnSortingChange={true}
+    column={
+      Object {
+        "Aggregated": undefined,
+        "Cell": undefined,
+        "Expander": undefined,
+        "Filter": undefined,
+        "Footer": undefined,
+        "Header": undefined,
+        "Pivot": undefined,
+        "PivotValue": undefined,
+        "Placeholder": undefined,
+        "aggregate": undefined,
+        "className": "",
+        "filterAll": false,
+        "filterMethod": undefined,
+        "filterable": undefined,
+        "footerClassName": "",
+        "footerStyle": Object {},
+        "getFooterProps": [Function],
+        "getHeaderProps": [Function],
+        "getProps": [Function],
+        "headerClassName": "",
+        "headerStyle": Object {},
+        "minResizeWidth": 11,
+        "minWidth": 100,
+        "resizable": undefined,
+        "show": true,
+        "sortMethod": undefined,
+        "sortable": undefined,
+        "style": Object {},
+      }
+    }
+    columns={
+      Array [
+        Object {
+          "Header": "Column name",
+          "accessor": "COLUMN_NAME",
+          "className": "padded",
+          "width": 300,
+        },
+        Object {
+          "Header": "Data type",
+          "accessor": "DATA_TYPE",
+          "className": "padded",
+          "width": 200,
+        },
+      ]
+    }
+    data={
+      Array [
+        Object {
+          "COLUMN_NAME": "channel",
+          "DATA_TYPE": "VARCHAR",
+        },
+        Object {
+          "COLUMN_NAME": "page",
+          "DATA_TYPE": "VARCHAR",
+        },
+      ]
+    }
+    defaultExpanded={Object {}}
+    defaultFilterMethod={[Function]}
+    defaultFiltered={Array []}
+    defaultPage={0}
+    defaultPageSize={25}
+    defaultResized={Array []}
+    defaultSortDesc={false}
+    defaultSortMethod={[Function]}
+    defaultSorted={Array []}
+    expanderDefaults={
+      Object {
+        "filterable": false,
+        "resizable": false,
+        "sortable": false,
+        "width": 35,
+      }
+    }
+    filterable={true}
+    freezeWhenExpanded={false}
+    getLoadingProps={[Function]}
+    getNoDataProps={[Function]}
+    getPaginationProps={[Function]}
+    getProps={[Function]}
+    getResizerProps={[Function]}
+    getTableProps={[Function]}
+    getTbodyProps={[Function]}
+    getTdProps={[Function]}
+    getTfootProps={[Function]}
+    getTfootTdProps={[Function]}
+    getTfootTrProps={[Function]}
+    getTheadFilterProps={[Function]}
+    getTheadFilterThProps={[Function]}
+    getTheadFilterTrProps={[Function]}
+    getTheadGroupProps={[Function]}
+    getTheadGroupThProps={[Function]}
+    getTheadGroupTrProps={[Function]}
+    getTheadProps={[Function]}
+    getTheadThProps={[Function]}
+    getTheadTrProps={[Function]}
+    getTrGroupProps={[Function]}
+    getTrProps={[Function]}
+    groupedByPivotKey="_groupedByPivot"
+    indexKey="_index"
+    loading={false}
+    loadingText="Loading..."
+    multiSort={true}
+    nestingLevelKey="_nestingLevel"
+    nextText="Next"
+    noDataText="No column data found"
+    ofText="of"
+    onFetchData={[Function]}
+    originalKey="_original"
+    pageJumpText="jump to page"
+    pageSizeOptions={
+      Array [
+        25,
+        50,
+        100,
+      ]
+    }
+    pageText="Page"
+    pivotDefaults={Object {}}
+    pivotIDKey="_pivotID"
+    pivotValKey="_pivotVal"
+    previousText="Previous"
+    resizable={true}
+    resolveData={[Function]}
+    rowsSelectorText="rows per page"
+    rowsText="rows"
+    showPageJump={true}
+    showPageSizeOptions={true}
+    showPagination={false}
+    showPaginationBottom={true}
+    showPaginationTop={false}
+    sortable={true}
+    style={Object {}}
+    subRowsKey="_subRows"
+  />
+</div>
+`;
diff --git a/web-console/src/components/datasource-columns-table/datasource-columns-table.scss b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.scss
similarity index 82%
copy from web-console/src/components/datasource-columns-table/datasource-columns-table.scss
copy to web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.scss
index 1d0ba14e2d..f05ca0b061 100644
--- a/web-console/src/components/datasource-columns-table/datasource-columns-table.scss
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.scss
@@ -29,21 +29,11 @@
     }
   }
 
-  .main-area {
-    height: calc(100% - 5px);
-
-    textarea {
-      height: 100%;
-      width: 100%;
-      resize: none;
-    }
-
-    .loader {
-      position: relative;
-    }
+  .loader {
+    position: relative;
+  }
 
-    .ReactTable {
-      height: 100%;
-    }
+  .ReactTable {
+    height: 100%;
   }
 }
diff --git a/web-console/src/components/datasource-columns-table/datasource-columns-table.spec.tsx b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.spec.tsx
similarity index 93%
rename from web-console/src/components/datasource-columns-table/datasource-columns-table.spec.tsx
rename to web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.spec.tsx
index 06860d9591..795b274d59 100644
--- a/web-console/src/components/datasource-columns-table/datasource-columns-table.spec.tsx
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.spec.tsx
@@ -19,12 +19,12 @@
 import { shallow } from 'enzyme';
 import React from 'react';
 
-import { QueryState } from '../../utils';
+import { QueryState } from '../../../utils';
 
 import { DatasourceColumnsTable, DatasourceColumnsTableRow } from './datasource-columns-table';
 
 let columnsState: QueryState<DatasourceColumnsTableRow[]> = QueryState.INIT;
-jest.mock('../../hooks', () => {
+jest.mock('../../../hooks', () => {
   return {
     useQueryManager: () => [columnsState],
   };
@@ -32,7 +32,7 @@ jest.mock('../../hooks', () => {
 
 describe('DatasourceColumnsTable', () => {
   function makeDatasourceColumnsTable() {
-    return <DatasourceColumnsTable datasourceId="test" downloadFilename="test" />;
+    return <DatasourceColumnsTable datasource="test" />;
   }
 
   it('matches snapshot on init', () => {
diff --git a/web-console/src/components/datasource-columns-table/datasource-columns-table.tsx b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.tsx
similarity index 87%
rename from web-console/src/components/datasource-columns-table/datasource-columns-table.tsx
rename to web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.tsx
index 007e54919b..cd56dda85f 100644
--- a/web-console/src/components/datasource-columns-table/datasource-columns-table.tsx
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-columns-table/datasource-columns-table.tsx
@@ -20,10 +20,10 @@ import { SqlLiteral } from 'druid-query-toolkit';
 import React from 'react';
 import ReactTable from 'react-table';
 
-import { useQueryManager } from '../../hooks';
-import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../react-table';
-import { ColumnMetadata, queryDruidSql } from '../../utils';
-import { Loader } from '../loader/loader';
+import { Loader } from '../../../components';
+import { useQueryManager } from '../../../hooks';
+import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../react-table';
+import { ColumnMetadata, queryDruidSql } from '../../../utils';
 
 import './datasource-columns-table.scss';
 
@@ -33,21 +33,20 @@ export interface DatasourceColumnsTableRow {
 }
 
 export interface DatasourceColumnsTableProps {
-  datasourceId: string;
-  downloadFilename?: string;
+  datasource: string;
 }
 
 export const DatasourceColumnsTable = React.memo(function DatasourceColumnsTable(
   props: DatasourceColumnsTableProps,
 ) {
   const [columnsState] = useQueryManager<string, DatasourceColumnsTableRow[]>({
+    initQuery: props.datasource,
     processQuery: async (datasourceId: string) => {
       return await queryDruidSql<ColumnMetadata>({
         query: `SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS
           WHERE TABLE_SCHEMA = 'druid' AND TABLE_NAME = ${SqlLiteral.create(datasourceId)}`,
       });
     },
-    initQuery: props.datasourceId,
   });
 
   function renderTable() {
@@ -80,7 +79,7 @@ export const DatasourceColumnsTable = React.memo(function DatasourceColumnsTable
 
   return (
     <div className="datasource-columns-table">
-      <div className="main-area">{columnsState.loading ? <Loader /> : renderTable()}</div>
+      {columnsState.loading ? <Loader /> : renderTable()}
     </div>
   );
 });
diff --git a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss b/web-console/src/dialogs/datasource-table-action-dialog/datasource-preview-pane/datasource-preview-pane.scss
similarity index 83%
copy from web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
copy to web-console/src/dialogs/datasource-table-action-dialog/datasource-preview-pane/datasource-preview-pane.scss
index 43efe5085b..f47703d052 100644
--- a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-preview-pane/datasource-preview-pane.scss
@@ -16,6 +16,14 @@
  * limitations under the License.
  */
 
-.table-cell-unparseable {
-  color: #9e2b0e;
+.datasource-preview-pane {
+  position: relative;
+  height: 100%;
+
+  .record-table-pane {
+    height: 100%;
+  }
+  .datasource-preview-error {
+    color: #9e2b0e;
+  }
 }
diff --git a/web-console/src/dialogs/datasource-table-action-dialog/datasource-preview-pane/datasource-preview-pane.tsx b/web-console/src/dialogs/datasource-table-action-dialog/datasource-preview-pane/datasource-preview-pane.tsx
new file mode 100644
index 0000000000..71c20d2202
--- /dev/null
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-preview-pane/datasource-preview-pane.tsx
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { QueryResult, QueryRunner, SqlTableRef } from 'druid-query-toolkit';
+import React from 'react';
+
+import { Loader, RecordTablePane } from '../../../components';
+import { useQueryManager } from '../../../hooks';
+import { DruidError } from '../../../utils';
+
+import './datasource-preview-pane.scss';
+
+const queryRunner = new QueryRunner({
+  inflateDateStrategy: 'none',
+});
+
+export interface DatasourcePreviewPaneProps {
+  datasource: string;
+}
+
+export const DatasourcePreviewPane = React.memo(function DatasourcePreviewPane(
+  props: DatasourcePreviewPaneProps,
+) {
+  const [recordState] = useQueryManager<string, QueryResult>({
+    initQuery: props.datasource,
+    processQuery: async (datasource, cancelToken) => {
+      let result: QueryResult;
+      try {
+        result = await queryRunner.runQuery({
+          query: `SELECT * FROM ${SqlTableRef.create(datasource)}`,
+          extraQueryContext: { sqlOuterLimit: 100 },
+          cancelToken,
+        });
+      } catch (e) {
+        throw new DruidError(e);
+      }
+      return result;
+    },
+  });
+
+  return (
+    <div className="datasource-preview-pane">
+      {recordState.loading && <Loader />}
+      {recordState.data && <RecordTablePane queryResult={recordState.data} />}
+      {recordState.error && (
+        <div className="datasource-preview-error">{recordState.error.message}</div>
+      )}
+    </div>
+  );
+});
diff --git a/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.spec.tsx b/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.spec.tsx
index 0f6d19ec76..fc069d9416 100644
--- a/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.spec.tsx
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.spec.tsx
@@ -25,7 +25,7 @@ describe('Datasource table action dialog', () => {
   it('matches snapshot', () => {
     const datasourceTableActionDialog = (
       <DatasourceTableActionDialog
-        datasourceId="test"
+        datasource="test"
         actions={[{ title: 'test', onAction: () => null }]}
         onClose={() => {}}
       />
diff --git a/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.tsx b/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.tsx
index 86711a9922..ae1698514b 100644
--- a/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.tsx
+++ b/web-console/src/dialogs/datasource-table-action-dialog/datasource-table-action-dialog.tsx
@@ -16,27 +16,36 @@
  * limitations under the License.
  */
 
+import { IconNames } from '@blueprintjs/icons';
 import React, { useState } from 'react';
 
-import { DatasourceColumnsTable } from '../../components/datasource-columns-table/datasource-columns-table';
 import { BasicAction } from '../../utils/basic-action';
 import { SideButtonMetaData, TableActionDialog } from '../table-action-dialog/table-action-dialog';
 
+import { DatasourceColumnsTable } from './datasource-columns-table/datasource-columns-table';
+import { DatasourcePreviewPane } from './datasource-preview-pane/datasource-preview-pane';
+
 interface DatasourceTableActionDialogProps {
-  datasourceId?: string;
+  datasource: string;
   actions: BasicAction[];
-  onClose: () => void;
+  onClose(): void;
 }
 
 export const DatasourceTableActionDialog = React.memo(function DatasourceTableActionDialog(
   props: DatasourceTableActionDialogProps,
 ) {
-  const { onClose, datasourceId, actions } = props;
-  const [activeTab, setActiveTab] = useState('columns');
+  const { datasource, actions, onClose } = props;
+  const [activeTab, setActiveTab] = useState<'records' | 'columns'>('records');
 
-  const taskTableSideButtonMetadata: SideButtonMetaData[] = [
+  const sideButtonMetadata: SideButtonMetaData[] = [
+    {
+      icon: IconNames.TH,
+      text: 'Records',
+      active: activeTab === 'records',
+      onClick: () => setActiveTab('records'),
+    },
     {
-      icon: 'list-columns',
+      icon: IconNames.LIST_COLUMNS,
       text: 'Columns',
       active: activeTab === 'columns',
       onClick: () => setActiveTab('columns'),
@@ -45,17 +54,13 @@ export const DatasourceTableActionDialog = React.memo(function DatasourceTableAc
 
   return (
     <TableActionDialog
-      sideButtonMetadata={taskTableSideButtonMetadata}
+      sideButtonMetadata={sideButtonMetadata}
       onClose={onClose}
-      title={`Datasource: ${datasourceId}`}
+      title={`Datasource: ${datasource}`}
       actions={actions}
     >
-      {activeTab === 'columns' && (
-        <DatasourceColumnsTable
-          datasourceId={datasourceId ? datasourceId : ''}
-          downloadFilename={`datasource-dimensions-${datasourceId}.json`}
-        />
-      )}
+      {activeTab === 'records' && <DatasourcePreviewPane datasource={datasource} />}
+      {activeTab === 'columns' && <DatasourceColumnsTable datasource={datasource} />}
     </TableActionDialog>
   );
 });
diff --git a/web-console/src/dialogs/edit-context-dialog/edit-context-dialog.tsx b/web-console/src/dialogs/edit-context-dialog/edit-context-dialog.tsx
index c5fc45e69c..5eb85d5608 100644
--- a/web-console/src/dialogs/edit-context-dialog/edit-context-dialog.tsx
+++ b/web-console/src/dialogs/edit-context-dialog/edit-context-dialog.tsx
@@ -21,7 +21,7 @@ import Hjson from 'hjson';
 import * as JSONBig from 'json-bigint-native';
 import React, { useState } from 'react';
 
-import { QueryContext } from '../../utils/query-context';
+import { QueryContext } from '../../druid-models';
 
 import './edit-context-dialog.scss';
 
diff --git a/web-console/src/dialogs/history-dialog/history-dialog.tsx b/web-console/src/dialogs/history-dialog/history-dialog.tsx
index 2b0e5315a1..9267d07d0b 100644
--- a/web-console/src/dialogs/history-dialog/history-dialog.tsx
+++ b/web-console/src/dialogs/history-dialog/history-dialog.tsx
@@ -22,7 +22,7 @@ import classNames from 'classnames';
 import * as JSONBig from 'json-bigint-native';
 import React, { useState } from 'react';
 
-import { ShowValue } from '../../components/show-value/show-value';
+import { ShowValue } from '../../components';
 import { DiffDialog } from '../diff-dialog/diff-dialog';
 
 import './history-dialog.scss';
diff --git a/web-console/src/dialogs/index.ts b/web-console/src/dialogs/index.ts
index d0e0e4ba8b..9509442c8b 100644
--- a/web-console/src/dialogs/index.ts
+++ b/web-console/src/dialogs/index.ts
@@ -22,12 +22,15 @@ export * from './compaction-dialog/compaction-dialog';
 export * from './coordinator-dynamic-config-dialog/coordinator-dynamic-config-dialog';
 export * from './diff-dialog/diff-dialog';
 export * from './doctor-dialog/doctor-dialog';
+export * from './edit-context-dialog/edit-context-dialog';
 export * from './history-dialog/history-dialog';
 export * from './lookup-edit-dialog/lookup-edit-dialog';
+export * from './numeric-input-dialog/numeric-input-dialog';
 export * from './overlord-dynamic-config-dialog/overlord-dynamic-config-dialog';
 export * from './retention-dialog/retention-dialog';
 export * from './snitch-dialog/snitch-dialog';
 export * from './spec-dialog/spec-dialog';
+export * from './string-input-dialog/string-input-dialog';
 export * from './supervisor-table-action-dialog/supervisor-table-action-dialog';
 export * from './table-action-dialog/table-action-dialog';
 export * from './task-table-action-dialog/task-table-action-dialog';
diff --git a/web-console/src/dialogs/lookup-table-action-dialog/lookup-table-action-dialog.tsx b/web-console/src/dialogs/lookup-table-action-dialog/lookup-table-action-dialog.tsx
index a44b3bd74e..e896d31f7f 100644
--- a/web-console/src/dialogs/lookup-table-action-dialog/lookup-table-action-dialog.tsx
+++ b/web-console/src/dialogs/lookup-table-action-dialog/lookup-table-action-dialog.tsx
@@ -18,10 +18,11 @@
 
 import React, { useState } from 'react';
 
-import { LookupValuesTable } from '../../components/lookup-values-table/lookup-values-table';
 import { BasicAction } from '../../utils/basic-action';
 import { SideButtonMetaData, TableActionDialog } from '../table-action-dialog/table-action-dialog';
 
+import { LookupValuesTable } from './lookup-values-table/lookup-values-table';
+
 interface LookupTableActionDialogProps {
   lookupId?: string;
   actions: BasicAction[];
diff --git a/web-console/src/components/lookup-values-table/__snapshots__/lookup-values-table.spec.tsx.snap b/web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/__snapshots__/lookup-values-table.spec.tsx.snap
similarity index 100%
rename from web-console/src/components/lookup-values-table/__snapshots__/lookup-values-table.spec.tsx.snap
rename to web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/__snapshots__/lookup-values-table.spec.tsx.snap
diff --git a/web-console/src/components/lookup-values-table/lookup-values-table.scss b/web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/lookup-values-table.scss
similarity index 100%
rename from web-console/src/components/lookup-values-table/lookup-values-table.scss
rename to web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/lookup-values-table.scss
diff --git a/web-console/src/components/lookup-values-table/lookup-values-table.spec.tsx b/web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/lookup-values-table.spec.tsx
similarity index 100%
rename from web-console/src/components/lookup-values-table/lookup-values-table.spec.tsx
rename to web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/lookup-values-table.spec.tsx
diff --git a/web-console/src/components/lookup-values-table/lookup-values-table.tsx b/web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/lookup-values-table.tsx
similarity index 93%
rename from web-console/src/components/lookup-values-table/lookup-values-table.tsx
rename to web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/lookup-values-table.tsx
index faa7810fd4..2ffa3ecc22 100644
--- a/web-console/src/components/lookup-values-table/lookup-values-table.tsx
+++ b/web-console/src/dialogs/lookup-table-action-dialog/lookup-values-table/lookup-values-table.tsx
@@ -20,10 +20,10 @@ import { SqlRef } from 'druid-query-toolkit';
 import React from 'react';
 import ReactTable from 'react-table';
 
-import { useQueryManager } from '../../hooks';
-import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../react-table';
-import { queryDruidSql } from '../../utils';
-import { Loader } from '../loader/loader';
+import { Loader } from '../../../components/loader/loader';
+import { useQueryManager } from '../../../hooks';
+import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../react-table';
+import { queryDruidSql } from '../../../utils';
 
 import './lookup-values-table.scss';
 
diff --git a/web-console/src/dialogs/numeric-input-dialog/numeric-input-dialog.tsx b/web-console/src/dialogs/numeric-input-dialog/numeric-input-dialog.tsx
new file mode 100644
index 0000000000..b115e035b8
--- /dev/null
+++ b/web-console/src/dialogs/numeric-input-dialog/numeric-input-dialog.tsx
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Button, Classes, Dialog, Intent, NumericInput } from '@blueprintjs/core';
+import React, { useState } from 'react';
+
+const DEFAULT_MIN_VALUE = 1;
+
+interface NumericInputDialogProps {
+  title: string;
+  message?: JSX.Element;
+  minValue?: number;
+  initValue: number;
+  onSubmit(value: number): void;
+  onClose(): void;
+}
+
+export const NumericInputDialog = React.memo(function NumericInputDialog(
+  props: NumericInputDialogProps,
+) {
+  const { title, message, minValue, initValue, onSubmit, onClose } = props;
+
+  const [value, setValue] = useState<number>(initValue);
+
+  return (
+    <Dialog
+      className="numeric-input-dialog"
+      onClose={onClose}
+      isOpen
+      title={title}
+      canOutsideClickClose={false}
+    >
+      <div className={Classes.DIALOG_BODY}>
+        {message}
+        <NumericInput
+          value={value}
+          onValueChange={(v: number) => {
+            if (isNaN(v)) return;
+            setValue(Math.max(v, DEFAULT_MIN_VALUE));
+          }}
+          min={minValue ?? DEFAULT_MIN_VALUE}
+          stepSize={1}
+          minorStepSize={null}
+          majorStepSize={10}
+          fill
+          autoFocus
+        />
+      </div>
+      <div className={Classes.DIALOG_FOOTER}>
+        <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+          <Button text="Close" onClick={onClose} />
+          <Button
+            text="OK"
+            intent={Intent.PRIMARY}
+            onClick={() => {
+              onSubmit(value);
+              onClose();
+            }}
+          />
+        </div>
+      </div>
+    </Dialog>
+  );
+});
diff --git a/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap b/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap
index a2f2ce41f2..008ba4d10e 100644
--- a/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/overlord-dynamic-config-dialog/__snapshots__/overload-dynamic-config-dialog.spec.tsx.snap
@@ -11,7 +11,7 @@ exports[`OverlordDynamicConfigDialog matches snapshot 1`] = `
     Edit the overlord dynamic configuration on the fly. For more information please refer to the
      
     <Memo(ExternalLink)
-      href="https://druid.apache.org/docs/0.23.0/configuration/index.html#overlord-dynamic-configuration"
+      href="https://druid.apache.org/docs/latest/configuration/index.html#overlord-dynamic-configuration"
     >
       documentation
     </Memo(ExternalLink)>
diff --git a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
index 6aaa2ffbf5..de558d3998 100644
--- a/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/retention-dialog/__snapshots__/retention-dialog.spec.tsx.snap
@@ -63,7 +63,7 @@ exports[`RetentionDialog matches snapshot 1`] = `
             Druid uses rules to determine what data should be retained in the cluster. The rules are evaluated in order from top to bottom. For more information please refer to the
              
             <a
-              href="https://druid.apache.org/docs/0.23.0/operations/rule-configuration.html"
+              href="https://druid.apache.org/docs/latest/operations/rule-configuration.html"
               rel="noopener noreferrer"
               target="_blank"
             >
diff --git a/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap b/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap
index f7cd0ea484..991b659a34 100644
--- a/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/segments-table-action-dialog/__snapshots__/segment-table-action-dialog.spec.tsx.snap
@@ -89,6 +89,33 @@ exports[`SegmentTableActionDialog matches snapshot 1`] = `
                 Metadata
               </span>
             </button>
+            <button
+              class="bp4-button bp4-minimal tab-button"
+              type="button"
+            >
+              <span
+                aria-hidden="true"
+                class="bp4-icon bp4-icon-th"
+                icon="th"
+              >
+                <svg
+                  data-icon="th"
+                  height="20"
+                  viewBox="0 0 20 20"
+                  width="20"
+                >
+                  <path
+                    d="M19 1H1c-.6 0-1 .5-1 1v16c0 .5.4 1 1 1h18c.5 0 1-.5 1-1V2c0-.5-.5-1-1-1zM7 17H2v-3h5v3zm0-4H2v-3h5v3zm0-4H2V6h5v3zm11 8H8v-3h10v3zm0-4H8v-3h10v3zm0-4H8V6h10v3z"
+                    fill-rule="evenodd"
+                  />
+                </svg>
+              </span>
+              <span
+                class="bp4-button-text"
+              >
+                Records
+              </span>
+            </button>
           </div>
           <div
             class="main-section"
diff --git a/web-console/src/dialogs/segments-table-action-dialog/segment-table-action-dialog.tsx b/web-console/src/dialogs/segments-table-action-dialog/segment-table-action-dialog.tsx
index a33c3c3cf9..cd031f3881 100644
--- a/web-console/src/dialogs/segments-table-action-dialog/segment-table-action-dialog.tsx
+++ b/web-console/src/dialogs/segments-table-action-dialog/segment-table-action-dialog.tsx
@@ -16,6 +16,7 @@
  * limitations under the License.
  */
 
+import { IconNames } from '@blueprintjs/icons';
 import React, { useState } from 'react';
 
 import { ShowJson } from '../../components';
@@ -23,6 +24,8 @@ import { Api } from '../../singletons';
 import { BasicAction } from '../../utils/basic-action';
 import { SideButtonMetaData, TableActionDialog } from '../table-action-dialog/table-action-dialog';
 
+import { SegmentsPreviewPane } from './segments-preview-pane/segments-preview-pane';
+
 interface SegmentTableActionDialogProps {
   segmentId: string;
   datasourceId: string;
@@ -38,11 +41,17 @@ export const SegmentTableActionDialog = React.memo(function SegmentTableActionDi
 
   const taskTableSideButtonMetadata: SideButtonMetaData[] = [
     {
-      icon: 'manually-entered-data',
+      icon: IconNames.MANUALLY_ENTERED_DATA,
       text: 'Metadata',
       active: activeTab === 'metadata',
       onClick: () => setActiveTab('metadata'),
     },
+    {
+      icon: IconNames.TH,
+      text: 'Records',
+      active: activeTab === 'records',
+      onClick: () => setActiveTab('records'),
+    },
   ];
 
   return (
@@ -60,6 +69,7 @@ export const SegmentTableActionDialog = React.memo(function SegmentTableActionDi
           downloadFilename={`Segment-metadata-${segmentId}.json`}
         />
       )}
+      {activeTab === 'records' && <SegmentsPreviewPane segmentId={segmentId} />}
     </TableActionDialog>
   );
 });
diff --git a/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/parse-segement-id.spec.ts b/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/parse-segement-id.spec.ts
new file mode 100644
index 0000000000..e5efec03c2
--- /dev/null
+++ b/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/parse-segement-id.spec.ts
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { parseSegmentId } from './segments-preview-pane';
+
+describe('parseSegmentId', () => {
+  it('correctly identifies segment ID parts', () => {
+    const segmentId =
+      'kttm_reingest_2019-08-25T23:00:00.000Z_2019-08-26T00:00:00.000Z_2022-08-02T18:58:41.697Z';
+    expect(parseSegmentId(segmentId).datasource).toEqual('kttm_reingest');
+    expect(parseSegmentId(segmentId).interval).toEqual(
+      '2019-08-25T23:00:00.000Z/2019-08-26T00:00:00.000Z',
+    );
+    expect(parseSegmentId(segmentId).version).toEqual('2022-08-02T18:58:41.697Z');
+    expect(parseSegmentId(segmentId).partitionNumber).toEqual(0);
+  });
+
+  it('correctly identifies segment ID parts with partitionNumber', () => {
+    const segmentId =
+      'test_segment_id1_2019-08-25T23:00:00.000Z_2019-08-26T00:00:00.000Z_2022-08-02T18:58:41.697Z_1';
+    expect(parseSegmentId(segmentId).datasource).toEqual('test_segment_id1');
+    expect(parseSegmentId(segmentId).interval).toEqual(
+      '2019-08-25T23:00:00.000Z/2019-08-26T00:00:00.000Z',
+    );
+    expect(parseSegmentId(segmentId).version).toEqual('2022-08-02T18:58:41.697Z');
+    expect(parseSegmentId(segmentId).partitionNumber).toEqual(1);
+  });
+
+  it('correctly identifies segment ID parts with without partition number and _ in name', () => {
+    const segmentId =
+      'test___2019-08-25T23:00:00.000Z_2019-08-26T00:00:00.000Z_2022-08-02T18:58:41.697Z';
+    expect(parseSegmentId(segmentId).datasource).toEqual('test__');
+    expect(parseSegmentId(segmentId).interval).toEqual(
+      '2019-08-25T23:00:00.000Z/2019-08-26T00:00:00.000Z',
+    );
+    expect(parseSegmentId(segmentId).version).toEqual('2022-08-02T18:58:41.697Z');
+    expect(parseSegmentId(segmentId).partitionNumber).toEqual(0);
+  });
+
+  it('correctly identifies segment ID parts with long partition number', () => {
+    const segmentId =
+      'test___2019-08-25T23:00:00.000Z_2019-08-26T00:00:00.000Z_2022-08-02T18:58:41.697Z_1234567';
+    expect(parseSegmentId(segmentId).datasource).toEqual('test__');
+    expect(parseSegmentId(segmentId).interval).toEqual(
+      '2019-08-25T23:00:00.000Z/2019-08-26T00:00:00.000Z',
+    );
+    expect(parseSegmentId(segmentId).version).toEqual('2022-08-02T18:58:41.697Z');
+    expect(parseSegmentId(segmentId).partitionNumber).toEqual(1234567);
+  });
+});
diff --git a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss b/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/segments-preview-pane.scss
similarity index 81%
copy from web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
copy to web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/segments-preview-pane.scss
index 43efe5085b..f072371285 100644
--- a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
+++ b/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/segments-preview-pane.scss
@@ -16,6 +16,15 @@
  * limitations under the License.
  */
 
-.table-cell-unparseable {
-  color: #9e2b0e;
+.segments-preview-pane {
+  position: relative;
+  height: 100%;
+
+  .record-table-pane {
+    height: 100%;
+  }
+  .segments-preview-error {
+    color: #9e2b0e;
+    height: 100%;
+  }
 }
diff --git a/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/segments-preview-pane.tsx b/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/segments-preview-pane.tsx
new file mode 100644
index 0000000000..6c5ace1c5f
--- /dev/null
+++ b/web-console/src/dialogs/segments-table-action-dialog/segments-preview-pane/segments-preview-pane.tsx
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { QueryResult, QueryRunner } from 'druid-query-toolkit';
+import React from 'react';
+
+import { Loader, RecordTablePane } from '../../../components';
+// import { Loader, RecordTablePane } from '../../../components';
+import { useQueryManager } from '../../../hooks/use-query-manager';
+import { DruidError } from '../../../utils';
+
+import './segments-preview-pane.scss';
+
+const queryRunner = new QueryRunner({
+  inflateDateStrategy: 'none',
+});
+
+interface ParsedSegmentId {
+  datasource: string;
+  interval: string;
+  partitionNumber: number;
+  version: string;
+}
+
+export function parseSegmentId(segmentId: string): ParsedSegmentId {
+  const segmentIdParts = segmentId.split('_');
+  const tail = Number(segmentIdParts[segmentIdParts.length - 1]);
+  let bump = 1;
+  let partitionNumber = 0;
+
+  // Check if segmentId includes a partitionNumber
+  if (!isNaN(tail)) {
+    partitionNumber = tail;
+    bump++;
+  }
+
+  const version = segmentIdParts[segmentIdParts.length - bump];
+  const interval =
+    segmentIdParts[segmentIdParts.length - bump - 2] +
+    '/' +
+    segmentIdParts[segmentIdParts.length - bump - 1];
+  const datasource = segmentIdParts.slice(0, segmentIdParts.length - bump - 2).join('_');
+
+  return {
+    datasource: datasource,
+    version: version,
+    interval: interval,
+    partitionNumber: partitionNumber,
+  };
+}
+
+export interface DatasourcePreviewPaneProps {
+  segmentId: string;
+}
+
+export const SegmentsPreviewPane = React.memo(function DatasourcePreviewPane(
+  props: DatasourcePreviewPaneProps,
+) {
+  const segmentIdParts = parseSegmentId(props.segmentId);
+
+  const [recordState] = useQueryManager<string, QueryResult>({
+    initQuery: segmentIdParts.datasource,
+    processQuery: async (datasource, cancelToken) => {
+      let result: QueryResult;
+      try {
+        result = await queryRunner.runQuery({
+          query: {
+            queryType: 'scan',
+            dataSource: datasource,
+            intervals: {
+              type: 'segments',
+              segments: [
+                {
+                  itvl: segmentIdParts.interval,
+                  ver: segmentIdParts.version,
+                  part: segmentIdParts.partitionNumber,
+                },
+              ],
+            },
+            resultFormat: 'compactedList',
+            limit: 1001,
+            columns: [],
+            granularity: 'all',
+          },
+          extraQueryContext: { sqlOuterLimit: 100 },
+          cancelToken,
+        });
+      } catch (e) {
+        throw new DruidError(e);
+      }
+      return result;
+    },
+  });
+
+  return (
+    <div className="segments-preview-pane">
+      {recordState.loading && <Loader />}
+      {recordState.data && <RecordTablePane queryResult={recordState.data} />}
+      {recordState.error && (
+        <div className="segments-preview-error">{recordState.error.message}</div>
+      )}
+    </div>
+  );
+});
diff --git a/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx b/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx
index 1aa8d9786f..11b9f61717 100644
--- a/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx
+++ b/web-console/src/dialogs/status-dialog/status-dialog.spec.tsx
@@ -19,7 +19,7 @@
 import { render } from '@testing-library/react';
 import React from 'react';
 
-import { anywhereMatcher, StatusDialog } from './status-dialog';
+import { StatusDialog } from './status-dialog';
 
 describe('StatusDialog', () => {
   it('matches snapshot', () => {
@@ -27,18 +27,4 @@ describe('StatusDialog', () => {
     render(statusDialog);
     expect(document.body.lastChild).toMatchSnapshot();
   });
-
-  it('filters data that contains input', () => {
-    const row = [
-      'org.apache.druid.common.gcp.GcpModule',
-      'org.apache.druid.common.aws.AWSModule',
-      'org.apache.druid.OtherModule',
-    ];
-
-    expect(anywhereMatcher({ id: '0', value: 'common' }, row)).toEqual(true);
-    expect(anywhereMatcher({ id: '1', value: 'common' }, row)).toEqual(true);
-    expect(anywhereMatcher({ id: '0', value: 'org' }, row)).toEqual(true);
-    expect(anywhereMatcher({ id: '1', value: 'org' }, row)).toEqual(true);
-    expect(anywhereMatcher({ id: '2', value: 'common' }, row)).toEqual(false);
-  });
 });
diff --git a/web-console/src/dialogs/status-dialog/status-dialog.tsx b/web-console/src/dialogs/status-dialog/status-dialog.tsx
index 3ae476d675..c7da95e424 100644
--- a/web-console/src/dialogs/status-dialog/status-dialog.tsx
+++ b/web-console/src/dialogs/status-dialog/status-dialog.tsx
@@ -17,20 +17,16 @@
  */
 
 import { Button, Classes, Dialog, Intent } from '@blueprintjs/core';
-import React from 'react';
+import React, { useState } from 'react';
 import ReactTable, { Filter } from 'react-table';
 
-import { Loader } from '../../components';
+import { Loader, TableFilterableCell } from '../../components';
 import { useQueryManager } from '../../hooks';
 import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../react-table';
 import { Api, UrlBaser } from '../../singletons';
 
 import './status-dialog.scss';
 
-export function anywhereMatcher(filter: Filter, row: any): boolean {
-  return String(row[filter.id]).includes(filter.value);
-}
-
 interface StatusModule {
   artifact: string;
   name: string;
@@ -43,64 +39,78 @@ interface StatusResponse {
 }
 
 interface StatusDialogProps {
-  onClose: () => void;
+  onClose(): void;
 }
 
 export const StatusDialog = React.memo(function StatusDialog(props: StatusDialogProps) {
   const { onClose } = props;
+  const [moduleFilter, setModuleFilter] = useState<Filter[]>([]);
+
   const [responseState] = useQueryManager<null, StatusResponse>({
+    initQuery: null,
     processQuery: async () => {
       const resp = await Api.instance.get(`/status`);
       return resp.data;
     },
-    initQuery: null,
   });
 
   function renderContent(): JSX.Element | undefined {
     if (responseState.loading) return <Loader />;
 
     if (responseState.error) {
-      return <span>{`Error while loading status: ${responseState.error}`}</span>;
+      return <div>{`Error while loading status: ${responseState.error}`}</div>;
     }
 
     const response = responseState.data;
     if (!response) return;
 
+    const renderModuleFilterableCell = (field: string) => {
+      return function ModuleFilterableCell(row: { value: any }) {
+        return (
+          <TableFilterableCell
+            field={field}
+            value={row.value}
+            filters={moduleFilter}
+            onFiltersChange={setModuleFilter}
+          >
+            {row.value}
+          </TableFilterableCell>
+        );
+      };
+    };
+
     return (
       <div className="main-container">
         <div className="version">
-          Version:&nbsp;<strong>{response.version}</strong>
+          Version: <strong>{response.version}</strong>
         </div>
         <ReactTable
           data={response.modules}
           loading={responseState.loading}
           filterable
-          defaultFilterMethod={anywhereMatcher}
+          filtered={moduleFilter}
+          onFilteredChange={setModuleFilter}
           defaultPageSize={SMALL_TABLE_PAGE_SIZE}
           pageSizeOptions={SMALL_TABLE_PAGE_SIZE_OPTIONS}
           showPagination={response.modules.length > SMALL_TABLE_PAGE_SIZE}
           columns={[
             {
-              columns: [
-                {
-                  Header: 'Extension name',
-                  accessor: 'artifact',
-                  width: 200,
-                  className: 'padded',
-                },
-                {
-                  Header: 'Version',
-                  accessor: 'version',
-                  width: 200,
-                  className: 'padded',
-                },
-                {
-                  Header: 'Fully qualified name',
-                  accessor: 'name',
-                  width: 500,
-                  className: 'padded',
-                },
-              ],
+              Header: 'Extension name',
+              accessor: 'artifact',
+              width: 200,
+              Cell: renderModuleFilterableCell('artifact'),
+            },
+            {
+              Header: 'Version',
+              accessor: 'version',
+              width: 200,
+              Cell: renderModuleFilterableCell('version'),
+            },
+            {
+              Header: 'Fully qualified name',
+              accessor: 'name',
+              width: 500,
+              Cell: renderModuleFilterableCell('name'),
             },
           ]}
         />
diff --git a/web-console/src/dialogs/string-input-dialog/string-input-dialog.tsx b/web-console/src/dialogs/string-input-dialog/string-input-dialog.tsx
new file mode 100644
index 0000000000..cee1161327
--- /dev/null
+++ b/web-console/src/dialogs/string-input-dialog/string-input-dialog.tsx
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Button, Classes, Dialog, InputGroup, Intent } from '@blueprintjs/core';
+import React, { useState } from 'react';
+
+export interface StringInputDialogProps {
+  title: string;
+  initValue?: string;
+  placeholder?: string;
+  maxLength?: number;
+  onSubmit(str: string): void;
+  onClose(): void;
+}
+
+export const StringInputDialog = React.memo(function StringSubmitDialog(
+  props: StringInputDialogProps,
+) {
+  const { title, initValue, placeholder, maxLength, onSubmit, onClose } = props;
+
+  const [value, setValue] = useState(initValue || '');
+
+  function handleSubmit() {
+    onSubmit(value);
+    onClose();
+  }
+
+  return (
+    <Dialog className="string-input-dialog" isOpen onClose={onClose} title={title}>
+      <div className={Classes.DIALOG_BODY}>
+        <InputGroup
+          value={value}
+          onChange={e => setValue(String(e.target.value).substring(0, maxLength || 280))}
+          autoFocus
+          placeholder={placeholder}
+        />
+      </div>
+      <div className={Classes.DIALOG_FOOTER}>
+        <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+          <Button text="Close" onClick={onClose} />
+          <Button text="Submit" intent={Intent.PRIMARY} onClick={handleSubmit} />
+        </div>
+      </div>
+    </Dialog>
+  );
+});
diff --git a/web-console/src/components/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap
similarity index 100%
rename from web-console/src/components/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap
rename to web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/__snapshots__/supervisor-statistics-table.spec.tsx.snap
diff --git a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.scss b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.scss
similarity index 100%
rename from web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.scss
rename to web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.scss
diff --git a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.spec.tsx b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
similarity index 97%
rename from web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
rename to web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
index 7a81495a08..ac71cd5a2c 100644
--- a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
+++ b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.spec.tsx
@@ -19,7 +19,7 @@
 import { shallow } from 'enzyme';
 import React from 'react';
 
-import { QueryState } from '../../utils';
+import { QueryState } from '../../../utils';
 
 import {
   normalizeSupervisorStatisticsResults,
@@ -28,7 +28,7 @@ import {
 } from './supervisor-statistics-table';
 
 let supervisorStatisticsState: QueryState<SupervisorStatisticsTableRow[]> = QueryState.INIT;
-jest.mock('../../hooks', () => {
+jest.mock('../../../hooks', () => {
   return {
     useQueryManager: () => [supervisorStatisticsState],
   };
diff --git a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.tsx b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.tsx
similarity index 95%
rename from web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.tsx
rename to web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.tsx
index fe66c4efb6..48c2f3f88a 100644
--- a/web-console/src/components/supervisor-statistics-table/supervisor-statistics-table.tsx
+++ b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-statistics-table/supervisor-statistics-table.tsx
@@ -20,11 +20,11 @@ import { Button, ButtonGroup } from '@blueprintjs/core';
 import React from 'react';
 import ReactTable, { CellInfo, Column } from 'react-table';
 
-import { useQueryManager } from '../../hooks';
-import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../react-table';
-import { Api, UrlBaser } from '../../singletons';
-import { deepGet } from '../../utils';
-import { Loader } from '../loader/loader';
+import { Loader } from '../../../components/loader/loader';
+import { useQueryManager } from '../../../hooks';
+import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../react-table';
+import { Api, UrlBaser } from '../../../singletons';
+import { deepGet } from '../../../utils';
 
 import './supervisor-statistics-table.scss';
 
diff --git a/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx
index 3303f4fa52..fba476fdce 100644
--- a/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx
+++ b/web-console/src/dialogs/supervisor-table-action-dialog/supervisor-table-action-dialog.tsx
@@ -20,13 +20,14 @@ import React, { useState } from 'react';
 
 import { ShowJson } from '../../components';
 import { ShowHistory } from '../../components/show-history/show-history';
-import { SupervisorStatisticsTable } from '../../components/supervisor-statistics-table/supervisor-statistics-table';
 import { cleanSpec } from '../../druid-models';
 import { Api } from '../../singletons';
 import { deepGet } from '../../utils';
 import { BasicAction } from '../../utils/basic-action';
 import { SideButtonMetaData, TableActionDialog } from '../table-action-dialog/table-action-dialog';
 
+import { SupervisorStatisticsTable } from './supervisor-statistics-table/supervisor-statistics-table';
+
 interface SupervisorTableActionDialogProps {
   supervisorId: string;
   actions: BasicAction[];
diff --git a/web-console/src/dialogs/table-action-dialog/table-action-dialog.tsx b/web-console/src/dialogs/table-action-dialog/table-action-dialog.tsx
index da49bc9e1d..d66b747afb 100644
--- a/web-console/src/dialogs/table-action-dialog/table-action-dialog.tsx
+++ b/web-console/src/dialogs/table-action-dialog/table-action-dialog.tsx
@@ -53,7 +53,7 @@ export const TableActionDialog = React.memo(function TableActionDialog(
           {sideButtonMetadata.map((d, i) => (
             <Button
               className="tab-button"
-              icon={<Icon icon={d.icon} iconSize={20} />}
+              icon={<Icon icon={d.icon} size={20} />}
               key={i}
               text={d.text}
               intent={d.active ? Intent.PRIMARY : Intent.NONE}
diff --git a/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx b/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx
index ecd8454761..fa6e58befb 100644
--- a/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx
+++ b/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx
@@ -75,21 +75,21 @@ export const TaskTableActionDialog = React.memo(function TaskTableActionDialog(
       {activeTab === 'status' && (
         <ShowJson
           endpoint={`${taskEndpointBase}/status`}
-          transform={x => deepGet(x, 'status')}
+          transform={x => deepGet(x, 'status') || x}
           downloadFilename={`task-status-${taskId}.json`}
         />
       )}
       {activeTab === 'payload' && (
         <ShowJson
           endpoint={taskEndpointBase}
-          transform={x => deepGet(x, 'payload')}
+          transform={x => deepGet(x, 'payload') || x}
           downloadFilename={`task-payload-${taskId}.json`}
         />
       )}
       {activeTab === 'reports' && (
         <ShowJson
           endpoint={`${taskEndpointBase}/reports`}
-          transform={x => deepGet(x, 'ingestionStatsAndErrors.payload')}
+          transform={x => deepGet(x, 'ingestionStatsAndErrors.payload') || x}
           downloadFilename={`task-reports-${taskId}.json`}
         />
       )}
diff --git a/web-console/src/druid-models/compaction-config.tsx b/web-console/src/druid-models/compaction-config/compaction-config.tsx
similarity index 94%
rename from web-console/src/druid-models/compaction-config.tsx
rename to web-console/src/druid-models/compaction-config/compaction-config.tsx
index 1d5cb1fb58..1958f2b263 100644
--- a/web-console/src/druid-models/compaction-config.tsx
+++ b/web-console/src/druid-models/compaction-config/compaction-config.tsx
@@ -19,8 +19,8 @@
 import { Code } from '@blueprintjs/core';
 import React from 'react';
 
-import { Field } from '../components';
-import { deepGet, deepSet, oneOf } from '../utils';
+import { Field } from '../../components';
+import { deepGet, deepSet, oneOf } from '../../utils';
 
 export type CompactionConfig = Record<string, any>;
 
@@ -245,6 +245,23 @@ export const COMPACTION_CONFIG_FIELDS: Field<CompactionConfig>[] = [
       </>
     ),
   },
+  {
+    name: 'tuningConfig.maxColumnsToMerge',
+    type: 'number',
+    defaultValue: -1,
+    min: -1,
+    info: (
+      <>
+        <p>
+          Limit of the number of segments to merge in a single phase when merging segments for
+          publishing. This limit affects the total number of columns present in a set of segments to
+          merge. If the limit is exceeded, segment merging occurs in multiple phases. Druid merges
+          at least 2 segments per phase, regardless of this setting.
+        </p>
+        <p>Default: -1 (unlimited)</p>
+      </>
+    ),
+  },
   {
     name: 'tuningConfig.totalNumMergeTasks',
     type: 'number',
diff --git a/web-console/src/druid-models/compaction-status.spec.ts b/web-console/src/druid-models/compaction-status/compaction-status.spec.ts
similarity index 97%
rename from web-console/src/druid-models/compaction-status.spec.ts
rename to web-console/src/druid-models/compaction-status/compaction-status.spec.ts
index 68b4cca339..8ed0c51413 100644
--- a/web-console/src/druid-models/compaction-status.spec.ts
+++ b/web-console/src/druid-models/compaction-status/compaction-status.spec.ts
@@ -16,7 +16,8 @@
  * limitations under the License.
  */
 
-import { CompactionConfig } from './compaction-config';
+import { CompactionConfig } from '../compaction-config/compaction-config';
+
 import {
   CompactionStatus,
   formatCompactionConfigAndStatus,
diff --git a/web-console/src/druid-models/compaction-status.ts b/web-console/src/druid-models/compaction-status/compaction-status.ts
similarity index 96%
rename from web-console/src/druid-models/compaction-status.ts
rename to web-console/src/druid-models/compaction-status/compaction-status.ts
index c637e44b8a..2982d9b69e 100644
--- a/web-console/src/druid-models/compaction-status.ts
+++ b/web-console/src/druid-models/compaction-status/compaction-status.ts
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { CompactionConfig } from './compaction-config';
+import { CompactionConfig } from '../compaction-config/compaction-config';
 
 function capitalizeFirst(str: string): string {
   return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase();
diff --git a/web-console/src/druid-models/coordinator-dynamic-config.tsx b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
similarity index 99%
rename from web-console/src/druid-models/coordinator-dynamic-config.tsx
rename to web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
index f646315a43..6683a721d1 100644
--- a/web-console/src/druid-models/coordinator-dynamic-config.tsx
+++ b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
@@ -19,7 +19,7 @@
 import { Code } from '@blueprintjs/core';
 import React from 'react';
 
-import { Field } from '../components';
+import { Field } from '../../components';
 
 export interface CoordinatorDynamicConfig {
   maxSegmentsToMove?: number;
diff --git a/web-console/src/druid-models/dimension-spec.spec.ts b/web-console/src/druid-models/dimension-spec/dimension-spec.spec.ts
similarity index 96%
rename from web-console/src/druid-models/dimension-spec.spec.ts
rename to web-console/src/druid-models/dimension-spec/dimension-spec.spec.ts
index 8b6a992f96..8d68d41df0 100644
--- a/web-console/src/druid-models/dimension-spec.spec.ts
+++ b/web-console/src/druid-models/dimension-spec/dimension-spec.spec.ts
@@ -16,8 +16,9 @@
  * limitations under the License.
  */
 
+import { CSV_SAMPLE, JSON_SAMPLE } from '../../utils/sampler.mock';
+
 import { getDimensionSpecs } from './dimension-spec';
-import { CSV_SAMPLE, JSON_SAMPLE } from './test-fixtures';
 
 describe('dimension-spec', () => {
   describe('getDimensionSpecs', () => {
diff --git a/web-console/src/druid-models/dimension-spec.ts b/web-console/src/druid-models/dimension-spec/dimension-spec.ts
similarity index 90%
rename from web-console/src/druid-models/dimension-spec.ts
rename to web-console/src/druid-models/dimension-spec/dimension-spec.ts
index b1b3a5491e..04144d545d 100644
--- a/web-console/src/druid-models/dimension-spec.ts
+++ b/web-console/src/druid-models/dimension-spec/dimension-spec.ts
@@ -16,11 +16,10 @@
  * limitations under the License.
  */
 
-import { Field } from '../components';
-import { filterMap, typeIs } from '../utils';
-import { SampleHeaderAndRows } from '../utils/sampler';
-
-import { guessColumnTypeFromHeaderAndRows } from './ingestion-spec';
+import { Field } from '../../components';
+import { filterMap, typeIs } from '../../utils';
+import { SampleHeaderAndRows } from '../../utils/sampler';
+import { guessColumnTypeFromHeaderAndRows } from '../ingestion-spec/ingestion-spec';
 
 export interface DimensionsSpec {
   readonly dimensions?: (string | DimensionSpec)[];
@@ -46,7 +45,7 @@ export const DIMENSION_SPEC_FIELDS: Field<DimensionSpec>[] = [
     name: 'type',
     type: 'string',
     required: true,
-    suggestions: ['string', 'long', 'float', 'double'],
+    suggestions: ['string', 'long', 'float', 'double', 'json'],
   },
   {
     name: 'createBitmapIndex',
diff --git a/web-console/src/druid-models/overlord-dynamic-config.tsx b/web-console/src/druid-models/druid-engine/druid-engine.ts
similarity index 68%
copy from web-console/src/druid-models/overlord-dynamic-config.tsx
copy to web-console/src/druid-models/druid-engine/druid-engine.ts
index 14cac82951..f1942e50c5 100644
--- a/web-console/src/druid-models/overlord-dynamic-config.tsx
+++ b/web-console/src/druid-models/druid-engine/druid-engine.ts
@@ -16,20 +16,12 @@
  * limitations under the License.
  */
 
-import { Field } from '../components';
+export type DruidEngine = 'native' | 'sql-native' | 'sql-msq-task';
 
-export interface OverlordDynamicConfig {
-  selectStrategy?: Record<string, any>;
-  autoScaler?: Record<string, any>;
-}
+export const DRUID_ENGINES: DruidEngine[] = ['native', 'sql-native', 'sql-msq-task'];
 
-export const OVERLORD_DYNAMIC_CONFIG_FIELDS: Field<OverlordDynamicConfig>[] = [
-  {
-    name: 'selectStrategy',
-    type: 'json',
-  },
-  {
-    name: 'autoScaler',
-    type: 'json',
-  },
-];
+export function validDruidEngine(
+  possibleDruidEngine: string | undefined,
+): possibleDruidEngine is DruidEngine {
+  return Boolean(possibleDruidEngine && DRUID_ENGINES.includes(possibleDruidEngine as DruidEngine));
+}
diff --git a/web-console/src/druid-models/execution/execution-ingest-complete.mock.ts b/web-console/src/druid-models/execution/execution-ingest-complete.mock.ts
new file mode 100644
index 0000000000..b40d9ab0ec
--- /dev/null
+++ b/web-console/src/druid-models/execution/execution-ingest-complete.mock.ts
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Execution } from './execution';
+
+/*
+For query:
+
+REPLACE INTO "kttm_simple" OVERWRITE ALL
+SELECT TIME_PARSE("timestamp") AS "__time", agent_type
+FROM TABLE(
+  EXTERN(
+    '{"type":"http","uris":["https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz"]}',
+    '{"type":"json"}',
+    '[{"name":"timestamp","type":"string"},{"name":"agent_type","type":"string"}]'
+  )
+)
+PARTITIONED BY ALL TIME
+*/
+
+export const EXECUTION_INGEST_COMPLETE = Execution.fromTaskPayloadAndReport(
+  {
+    task: 'query-32ced762-7679-4a25-9220-3915c5976961',
+    payload: {
+      type: 'query_controller',
+      id: 'query-32ced762-7679-4a25-9220-3915c5976961',
+      spec: {
+        query: {
+          queryType: 'scan',
+          dataSource: {
+            type: 'external',
+            inputSource: {
+              type: 'http',
+              uris: ['https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz'],
+              httpAuthenticationUsername: null,
+              httpAuthenticationPassword: null,
+            },
+            inputFormat: {
+              type: 'json',
+              flattenSpec: null,
+              featureSpec: {},
+              keepNullColumns: false,
+            },
+            signature: [
+              { name: 'timestamp', type: 'STRING' },
+              { name: 'agent_type', type: 'STRING' },
+            ],
+          },
+          intervals: {
+            type: 'intervals',
+            intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+          },
+          virtualColumns: [
+            {
+              type: 'expression',
+              name: 'v0',
+              expression: 'timestamp_parse("timestamp",null,\'UTC\')',
+              outputType: 'LONG',
+            },
+          ],
+          resultFormat: 'compactedList',
+          columns: ['agent_type', 'v0'],
+          legacy: false,
+          context: {
+            finalize: false,
+            finalizeAggregations: false,
+            groupByEnableMultiValueUnnesting: false,
+            scanSignature: '[{"name":"agent_type","type":"STRING"},{"name":"v0","type":"LONG"}]',
+            sqlInsertSegmentGranularity: '{"type":"all"}',
+            sqlQueryId: '32ced762-7679-4a25-9220-3915c5976961',
+            sqlReplaceTimeChunks: 'all',
+          },
+          granularity: { type: 'all' },
+        },
+        columnMappings: [
+          { queryColumn: 'v0', outputColumn: '__time' },
+          { queryColumn: 'agent_type', outputColumn: 'agent_type' },
+        ],
+        destination: {
+          type: 'dataSource',
+          dataSource: 'kttm_simple',
+          segmentGranularity: { type: 'all' },
+          replaceTimeChunks: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+        },
+        assignmentStrategy: 'max',
+        tuningConfig: { maxNumWorkers: 1, maxRowsInMemory: 100000, rowsPerSegment: 3000000 },
+      },
+      sqlQuery:
+        'REPLACE INTO "kttm_simple" OVERWRITE ALL\nSELECT TIME_PARSE("timestamp") AS "__time", agent_type\nFROM TABLE(\n  EXTERN(\n    \'{"type":"http","uris":["https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz"]}\',\n    \'{"type":"json"}\',\n    \'[{"name":"timestamp","type":"string"},{"name":"agent_type","type":"string"}]\'\n  )\n)\nPARTITIONED BY ALL TIME',
+      sqlQueryContext: {
+        finalizeAggregations: false,
+        groupByEnableMultiValueUnnesting: false,
+        maxParseExceptions: 0,
+        sqlInsertSegmentGranularity: '{"type":"all"}',
+        sqlQueryId: '32ced762-7679-4a25-9220-3915c5976961',
+        sqlReplaceTimeChunks: 'all',
+      },
+      sqlTypeNames: ['TIMESTAMP', 'VARCHAR'],
+      context: { forceTimeChunkLock: true, useLineageBasedSegmentAllocation: true },
+      groupId: 'query-32ced762-7679-4a25-9220-3915c5976961',
+      dataSource: 'kttm_simple',
+      resource: {
+        availabilityGroup: 'query-32ced762-7679-4a25-9220-3915c5976961',
+        requiredCapacity: 1,
+      },
+    },
+  },
+
+  {
+    multiStageQuery: {
+      taskId: 'query-32ced762-7679-4a25-9220-3915c5976961',
+      payload: {
+        status: { status: 'SUCCESS', startTime: '2022-08-22T20:12:51.391Z', durationMs: 25097 },
+        stages: [
+          {
+            stageNumber: 0,
+            definition: {
+              id: '0b353011-6ea1-480a-8ca8-386771621672_0',
+              input: [
+                {
+                  type: 'external',
+                  inputSource: {
+                    type: 'http',
+                    uris: [
+                      'https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz',
+                    ],
+                    httpAuthenticationUsername: null,
+                    httpAuthenticationPassword: null,
+                  },
+                  inputFormat: {
+                    type: 'json',
+                    flattenSpec: null,
+                    featureSpec: {},
+                    keepNullColumns: false,
+                  },
+                  signature: [
+                    { name: 'timestamp', type: 'STRING' },
+                    { name: 'agent_type', type: 'STRING' },
+                  ],
+                },
+              ],
+              processor: {
+                type: 'scan',
+                query: {
+                  queryType: 'scan',
+                  dataSource: { type: 'inputNumber', inputNumber: 0 },
+                  intervals: {
+                    type: 'intervals',
+                    intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+                  },
+                  virtualColumns: [
+                    {
+                      type: 'expression',
+                      name: 'v0',
+                      expression: 'timestamp_parse("timestamp",null,\'UTC\')',
+                      outputType: 'LONG',
+                    },
+                  ],
+                  resultFormat: 'compactedList',
+                  columns: ['agent_type', 'v0'],
+                  legacy: false,
+                  context: {
+                    __timeColumn: 'v0',
+                    finalize: false,
+                    finalizeAggregations: false,
+                    groupByEnableMultiValueUnnesting: false,
+                    scanSignature:
+                      '[{"name":"agent_type","type":"STRING"},{"name":"v0","type":"LONG"}]',
+                    sqlInsertSegmentGranularity: '{"type":"all"}',
+                    sqlQueryId: '32ced762-7679-4a25-9220-3915c5976961',
+                    sqlReplaceTimeChunks: 'all',
+                  },
+                  granularity: { type: 'all' },
+                },
+              },
+              signature: [
+                { name: '__boost', type: 'LONG' },
+                { name: 'agent_type', type: 'STRING' },
+                { name: 'v0', type: 'LONG' },
+              ],
+              shuffleSpec: {
+                type: 'targetSize',
+                clusterBy: { columns: [{ columnName: '__boost' }] },
+                targetSize: 3000000,
+              },
+              maxWorkerCount: 1,
+              shuffleCheckHasMultipleValues: true,
+            },
+            phase: 'FINISHED',
+            workerCount: 1,
+            partitionCount: 1,
+            startTime: '2022-08-22T20:12:53.790Z',
+            duration: 20229,
+            sort: true,
+          },
+          {
+            stageNumber: 1,
+            definition: {
+              id: '0b353011-6ea1-480a-8ca8-386771621672_1',
+              input: [{ type: 'stage', stage: 0 }],
+              processor: {
+                type: 'segmentGenerator',
+                dataSchema: {
+                  dataSource: 'kttm_simple',
+                  timestampSpec: { column: '__time', format: 'millis', missingValue: null },
+                  dimensionsSpec: {
+                    dimensions: [
+                      {
+                        type: 'string',
+                        name: 'agent_type',
+                        multiValueHandling: 'SORTED_ARRAY',
+                        createBitmapIndex: true,
+                      },
+                    ],
+                    dimensionExclusions: ['__time'],
+                    includeAllDimensions: false,
+                  },
+                  metricsSpec: [],
+                  granularitySpec: {
+                    type: 'arbitrary',
+                    queryGranularity: { type: 'none' },
+                    rollup: false,
+                    intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+                  },
+                  transformSpec: { filter: null, transforms: [] },
+                },
+                columnMappings: [
+                  { queryColumn: 'v0', outputColumn: '__time' },
+                  { queryColumn: 'agent_type', outputColumn: 'agent_type' },
+                ],
+                tuningConfig: {
+                  maxNumWorkers: 1,
+                  maxRowsInMemory: 100000,
+                  rowsPerSegment: 3000000,
+                },
+              },
+              signature: [],
+              maxWorkerCount: 1,
+            },
+            phase: 'FINISHED',
+            workerCount: 1,
+            partitionCount: 1,
+            startTime: '2022-08-22T20:13:13.991Z',
+            duration: 2497,
+          },
+        ],
+        counters: {
+          '0': {
+            '0': {
+              input0: { type: 'channel', rows: [465346], files: [1], totalFiles: [1] },
+              output: { type: 'channel', rows: [465346], bytes: [25430674], frames: [4] },
+              shuffle: { type: 'channel', rows: [465346], bytes: [23570446], frames: [38] },
+              sortProgress: {
+                type: 'sortProgress',
+                totalMergingLevels: 3,
+                levelToTotalBatches: { '0': 1, '1': 1, '2': 1 },
+                levelToMergedBatches: { '0': 1, '1': 1, '2': 1 },
+                totalMergersForUltimateLevel: 1,
+                progressDigest: 1.0,
+              },
+            },
+          },
+          '1': {
+            '0': { input0: { type: 'channel', rows: [465346], bytes: [23570446], frames: [38] } },
+          },
+        },
+      },
+    },
+  },
+);
diff --git a/web-console/src/druid-models/execution/execution-ingest-error.mock.ts b/web-console/src/druid-models/execution/execution-ingest-error.mock.ts
new file mode 100644
index 0000000000..b19e9f4549
--- /dev/null
+++ b/web-console/src/druid-models/execution/execution-ingest-error.mock.ts
@@ -0,0 +1,290 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Execution } from './execution';
+
+/*
+For query:
+
+REPLACE INTO "kttm_simple" OVERWRITE ALL
+SELECT TIME_PARSE("timestamp") AS "__time", agent_type
+FROM TABLE(
+  EXTERN(
+    '{"type":"http","uris":["https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz_"]}',
+    '{"type":"json"}',
+    '[{"name":"timestamp","type":"string"},{"name":"agent_type","type":"string"}]'
+  )
+)
+PARTITIONED BY ALL
+*/
+
+export const EXECUTION_INGEST_ERROR = Execution.fromTaskPayloadAndReport(
+  {
+    task: 'query-0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+    payload: {
+      type: 'query_controller',
+      id: 'query-0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+      spec: {
+        query: {
+          queryType: 'scan',
+          dataSource: {
+            type: 'external',
+            inputSource: {
+              type: 'http',
+              uris: ['https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz_'],
+              httpAuthenticationUsername: null,
+              httpAuthenticationPassword: null,
+            },
+            inputFormat: {
+              type: 'json',
+              flattenSpec: null,
+              featureSpec: {},
+              keepNullColumns: false,
+            },
+            signature: [
+              { name: 'timestamp', type: 'STRING' },
+              { name: 'agent_type', type: 'STRING' },
+            ],
+          },
+          intervals: {
+            type: 'intervals',
+            intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+          },
+          virtualColumns: [
+            {
+              type: 'expression',
+              name: 'v0',
+              expression: 'timestamp_parse("timestamp",null,\'UTC\')',
+              outputType: 'LONG',
+            },
+          ],
+          resultFormat: 'compactedList',
+          columns: ['agent_type', 'v0'],
+          legacy: false,
+          context: {
+            finalize: false,
+            finalizeAggregations: false,
+            groupByEnableMultiValueUnnesting: false,
+            scanSignature: '[{"name":"agent_type","type":"STRING"},{"name":"v0","type":"LONG"}]',
+            sqlInsertSegmentGranularity: '{"type":"all"}',
+            sqlQueryId: '0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+            sqlReplaceTimeChunks: 'all',
+          },
+          granularity: { type: 'all' },
+        },
+        columnMappings: [
+          { queryColumn: 'v0', outputColumn: '__time' },
+          { queryColumn: 'agent_type', outputColumn: 'agent_type' },
+        ],
+        destination: {
+          type: 'dataSource',
+          dataSource: 'kttm_simple',
+          segmentGranularity: { type: 'all' },
+          replaceTimeChunks: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+        },
+        assignmentStrategy: 'max',
+        tuningConfig: { maxNumWorkers: 1, maxRowsInMemory: 100000, rowsPerSegment: 3000000 },
+      },
+      sqlQuery:
+        'REPLACE INTO "kttm_simple" OVERWRITE ALL\nSELECT TIME_PARSE("timestamp") AS "__time", agent_type\nFROM TABLE(\n  EXTERN(\n    \'{"type":"http","uris":["https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz_"]}\',\n    \'{"type":"json"}\',\n    \'[{"name":"timestamp","type":"string"},{"name":"agent_type","type":"string"}]\'\n  )\n)\nPARTITIONED BY ALL',
+      sqlQueryContext: {
+        finalizeAggregations: false,
+        groupByEnableMultiValueUnnesting: false,
+        maxParseExceptions: 0,
+        sqlInsertSegmentGranularity: '{"type":"all"}',
+        sqlQueryId: '0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+        sqlReplaceTimeChunks: 'all',
+      },
+      sqlTypeNames: ['TIMESTAMP', 'VARCHAR'],
+      context: { forceTimeChunkLock: true, useLineageBasedSegmentAllocation: true },
+      groupId: 'query-0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+      dataSource: 'kttm_simple',
+      resource: {
+        availabilityGroup: 'query-0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+        requiredCapacity: 1,
+      },
+    },
+  },
+
+  {
+    multiStageQuery: {
+      taskId: 'query-0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+      payload: {
+        status: {
+          status: 'FAILED',
+          errorReport: {
+            taskId: 'query-0cf1a40a-aaef-4d17-bda4-5afa7edf07e7-worker0',
+            host: 'localhost:8101',
+            stageNumber: 0,
+            error: {
+              errorCode: 'UnknownError',
+              message:
+                'java.lang.RuntimeException: java.lang.RuntimeException: Error occured while trying to read uri: https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz_',
+              errorMessage:
+                'java.lang.RuntimeException: java.lang.RuntimeException: Error occured while trying to read uri: https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz_',
+            },
+            exceptionStackTrace:
+              'java.lang.RuntimeException: java.lang.RuntimeException: Error occured while trying to read uri: https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz_\n\tat org.apache.druid.java.util.common.Either.valueOrThrow(Either.java:91)\n\tat org.apache.druid.frame.processor.FrameProcessorExecutor$1ExecutorRunnable.runProcessorNow(FrameProcessorExecutor.java:258)\n\tat org.apache.druid.frame.processor.FrameProcessorExecutor$1ExecutorRunnable.run(FrameProcessorExecu [...]
+          },
+          startTime: '2022-08-22T20:15:04.783Z',
+          durationMs: 18297,
+        },
+        stages: [
+          {
+            stageNumber: 0,
+            definition: {
+              id: '36d4ec93-9367-419f-91c5-68aeaaab2397_0',
+              input: [
+                {
+                  type: 'external',
+                  inputSource: {
+                    type: 'http',
+                    uris: [
+                      'https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz_',
+                    ],
+                    httpAuthenticationUsername: null,
+                    httpAuthenticationPassword: null,
+                  },
+                  inputFormat: {
+                    type: 'json',
+                    flattenSpec: null,
+                    featureSpec: {},
+                    keepNullColumns: false,
+                  },
+                  signature: [
+                    { name: 'timestamp', type: 'STRING' },
+                    { name: 'agent_type', type: 'STRING' },
+                  ],
+                },
+              ],
+              processor: {
+                type: 'scan',
+                query: {
+                  queryType: 'scan',
+                  dataSource: { type: 'inputNumber', inputNumber: 0 },
+                  intervals: {
+                    type: 'intervals',
+                    intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+                  },
+                  virtualColumns: [
+                    {
+                      type: 'expression',
+                      name: 'v0',
+                      expression: 'timestamp_parse("timestamp",null,\'UTC\')',
+                      outputType: 'LONG',
+                    },
+                  ],
+                  resultFormat: 'compactedList',
+                  columns: ['agent_type', 'v0'],
+                  legacy: false,
+                  context: {
+                    __timeColumn: 'v0',
+                    finalize: false,
+                    finalizeAggregations: false,
+                    groupByEnableMultiValueUnnesting: false,
+                    scanSignature:
+                      '[{"name":"agent_type","type":"STRING"},{"name":"v0","type":"LONG"}]',
+                    sqlInsertSegmentGranularity: '{"type":"all"}',
+                    sqlQueryId: '0cf1a40a-aaef-4d17-bda4-5afa7edf07e7',
+                    sqlReplaceTimeChunks: 'all',
+                  },
+                  granularity: { type: 'all' },
+                },
+              },
+              signature: [
+                { name: '__boost', type: 'LONG' },
+                { name: 'agent_type', type: 'STRING' },
+                { name: 'v0', type: 'LONG' },
+              ],
+              shuffleSpec: {
+                type: 'targetSize',
+                clusterBy: { columns: [{ columnName: '__boost' }] },
+                targetSize: 3000000,
+              },
+              maxWorkerCount: 1,
+              shuffleCheckHasMultipleValues: true,
+            },
+            workerCount: 1,
+            startTime: '2022-08-22T20:15:07.190Z',
+            duration: 15890,
+            sort: true,
+          },
+          {
+            stageNumber: 1,
+            definition: {
+              id: '36d4ec93-9367-419f-91c5-68aeaaab2397_1',
+              input: [{ type: 'stage', stage: 0 }],
+              processor: {
+                type: 'segmentGenerator',
+                dataSchema: {
+                  dataSource: 'kttm_simple',
+                  timestampSpec: { column: '__time', format: 'millis', missingValue: null },
+                  dimensionsSpec: {
+                    dimensions: [
+                      {
+                        type: 'string',
+                        name: 'agent_type',
+                        multiValueHandling: 'SORTED_ARRAY',
+                        createBitmapIndex: true,
+                      },
+                    ],
+                    dimensionExclusions: ['__time'],
+                    includeAllDimensions: false,
+                  },
+                  metricsSpec: [],
+                  granularitySpec: {
+                    type: 'arbitrary',
+                    queryGranularity: { type: 'none' },
+                    rollup: false,
+                    intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
+                  },
+                  transformSpec: { filter: null, transforms: [] },
+                },
+                columnMappings: [
+                  { queryColumn: 'v0', outputColumn: '__time' },
+                  { queryColumn: 'agent_type', outputColumn: 'agent_type' },
+                ],
+                tuningConfig: {
+                  maxNumWorkers: 1,
+                  maxRowsInMemory: 100000,
+                  rowsPerSegment: 3000000,
+                },
+              },
+              signature: [],
+              maxWorkerCount: 1,
+            },
+          },
+        ],
+        counters: {
+          '0': {
+            '0': {
+              input0: { type: 'channel', totalFiles: [1] },
+              sortProgress: {
+                type: 'sortProgress',
+                totalMergingLevels: -1,
+                levelToTotalBatches: {},
+                levelToMergedBatches: {},
+                totalMergersForUltimateLevel: -1,
+              },
+            },
+          },
+        },
+      },
+    },
+  },
+);
diff --git a/web-console/src/druid-models/execution/execution.spec.ts b/web-console/src/druid-models/execution/execution.spec.ts
new file mode 100644
index 0000000000..31ec5af457
--- /dev/null
+++ b/web-console/src/druid-models/execution/execution.spec.ts
@@ -0,0 +1,530 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Execution } from './execution';
+import { EXECUTION_INGEST_COMPLETE } from './execution-ingest-complete.mock';
+
+describe('Execution', () => {
+  describe('.fromTaskDetail', () => {
+    it('fails for bad status (error: null)', () => {
+      expect(() =>
+        Execution.fromTaskPayloadAndReport(
+          {} as any,
+          {
+            asyncResultId: 'multi-stage-query-sql-1392d806-c17f-4937-94ee-8fa0a3ce1566',
+            error: null,
+          } as any,
+        ),
+      ).toThrowError('Invalid payload');
+    });
+
+    it('works in a general case', () => {
+      expect(EXECUTION_INGEST_COMPLETE).toMatchInlineSnapshot(`
+        Execution {
+          "_payload": Object {
+            "payload": Object {
+              "context": Object {
+                "forceTimeChunkLock": true,
+                "useLineageBasedSegmentAllocation": true,
+              },
+              "dataSource": "kttm_simple",
+              "groupId": "query-32ced762-7679-4a25-9220-3915c5976961",
+              "id": "query-32ced762-7679-4a25-9220-3915c5976961",
+              "resource": Object {
+                "availabilityGroup": "query-32ced762-7679-4a25-9220-3915c5976961",
+                "requiredCapacity": 1,
+              },
+              "spec": Object {
+                "assignmentStrategy": "max",
+                "columnMappings": Array [
+                  Object {
+                    "outputColumn": "__time",
+                    "queryColumn": "v0",
+                  },
+                  Object {
+                    "outputColumn": "agent_type",
+                    "queryColumn": "agent_type",
+                  },
+                ],
+                "destination": Object {
+                  "dataSource": "kttm_simple",
+                  "replaceTimeChunks": Array [
+                    "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z",
+                  ],
+                  "segmentGranularity": Object {
+                    "type": "all",
+                  },
+                  "type": "dataSource",
+                },
+                "query": Object {
+                  "columns": Array [
+                    "agent_type",
+                    "v0",
+                  ],
+                  "context": Object {
+                    "finalize": false,
+                    "finalizeAggregations": false,
+                    "groupByEnableMultiValueUnnesting": false,
+                    "scanSignature": "[{\\"name\\":\\"agent_type\\",\\"type\\":\\"STRING\\"},{\\"name\\":\\"v0\\",\\"type\\":\\"LONG\\"}]",
+                    "sqlInsertSegmentGranularity": "{\\"type\\":\\"all\\"}",
+                    "sqlQueryId": "32ced762-7679-4a25-9220-3915c5976961",
+                    "sqlReplaceTimeChunks": "all",
+                  },
+                  "dataSource": Object {
+                    "inputFormat": Object {
+                      "featureSpec": Object {},
+                      "flattenSpec": null,
+                      "keepNullColumns": false,
+                      "type": "json",
+                    },
+                    "inputSource": Object {
+                      "httpAuthenticationPassword": null,
+                      "httpAuthenticationUsername": null,
+                      "type": "http",
+                      "uris": Array [
+                        "https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz",
+                      ],
+                    },
+                    "signature": Array [
+                      Object {
+                        "name": "timestamp",
+                        "type": "STRING",
+                      },
+                      Object {
+                        "name": "agent_type",
+                        "type": "STRING",
+                      },
+                    ],
+                    "type": "external",
+                  },
+                  "granularity": Object {
+                    "type": "all",
+                  },
+                  "intervals": Object {
+                    "intervals": Array [
+                      "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z",
+                    ],
+                    "type": "intervals",
+                  },
+                  "legacy": false,
+                  "queryType": "scan",
+                  "resultFormat": "compactedList",
+                  "virtualColumns": Array [
+                    Object {
+                      "expression": "timestamp_parse(\\"timestamp\\",null,'UTC')",
+                      "name": "v0",
+                      "outputType": "LONG",
+                      "type": "expression",
+                    },
+                  ],
+                },
+                "tuningConfig": Object {
+                  "maxNumWorkers": 1,
+                  "maxRowsInMemory": 100000,
+                  "rowsPerSegment": 3000000,
+                },
+              },
+              "sqlQuery": "REPLACE INTO \\"kttm_simple\\" OVERWRITE ALL
+        SELECT TIME_PARSE(\\"timestamp\\") AS \\"__time\\", agent_type
+        FROM TABLE(
+          EXTERN(
+            '{\\"type\\":\\"http\\",\\"uris\\":[\\"https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz\\"]}',
+            '{\\"type\\":\\"json\\"}',
+            '[{\\"name\\":\\"timestamp\\",\\"type\\":\\"string\\"},{\\"name\\":\\"agent_type\\",\\"type\\":\\"string\\"}]'
+          )
+        )
+        PARTITIONED BY ALL TIME",
+              "sqlQueryContext": Object {
+                "finalizeAggregations": false,
+                "groupByEnableMultiValueUnnesting": false,
+                "maxParseExceptions": 0,
+                "sqlInsertSegmentGranularity": "{\\"type\\":\\"all\\"}",
+                "sqlQueryId": "32ced762-7679-4a25-9220-3915c5976961",
+                "sqlReplaceTimeChunks": "all",
+              },
+              "sqlTypeNames": Array [
+                "TIMESTAMP",
+                "VARCHAR",
+              ],
+              "type": "query_controller",
+            },
+            "task": "query-32ced762-7679-4a25-9220-3915c5976961",
+          },
+          "destination": Object {
+            "dataSource": "kttm_simple",
+            "replaceTimeChunks": Array [
+              "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z",
+            ],
+            "segmentGranularity": Object {
+              "type": "all",
+            },
+            "type": "dataSource",
+          },
+          "duration": 25097,
+          "engine": "sql-msq-task",
+          "error": undefined,
+          "id": "query-32ced762-7679-4a25-9220-3915c5976961",
+          "nativeQuery": Object {
+            "columns": Array [
+              "agent_type",
+              "v0",
+            ],
+            "context": Object {
+              "finalize": false,
+              "finalizeAggregations": false,
+              "groupByEnableMultiValueUnnesting": false,
+              "scanSignature": "[{\\"name\\":\\"agent_type\\",\\"type\\":\\"STRING\\"},{\\"name\\":\\"v0\\",\\"type\\":\\"LONG\\"}]",
+              "sqlInsertSegmentGranularity": "{\\"type\\":\\"all\\"}",
+              "sqlQueryId": "32ced762-7679-4a25-9220-3915c5976961",
+              "sqlReplaceTimeChunks": "all",
+            },
+            "dataSource": Object {
+              "inputFormat": Object {
+                "featureSpec": Object {},
+                "flattenSpec": null,
+                "keepNullColumns": false,
+                "type": "json",
+              },
+              "inputSource": Object {
+                "httpAuthenticationPassword": null,
+                "httpAuthenticationUsername": null,
+                "type": "http",
+                "uris": Array [
+                  "https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz",
+                ],
+              },
+              "signature": Array [
+                Object {
+                  "name": "timestamp",
+                  "type": "STRING",
+                },
+                Object {
+                  "name": "agent_type",
+                  "type": "STRING",
+                },
+              ],
+              "type": "external",
+            },
+            "granularity": Object {
+              "type": "all",
+            },
+            "intervals": Object {
+              "intervals": Array [
+                "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z",
+              ],
+              "type": "intervals",
+            },
+            "legacy": false,
+            "queryType": "scan",
+            "resultFormat": "compactedList",
+            "virtualColumns": Array [
+              Object {
+                "expression": "timestamp_parse(\\"timestamp\\",null,'UTC')",
+                "name": "v0",
+                "outputType": "LONG",
+                "type": "expression",
+              },
+            ],
+          },
+          "queryContext": Object {
+            "finalizeAggregations": false,
+            "groupByEnableMultiValueUnnesting": false,
+            "maxParseExceptions": 0,
+          },
+          "result": undefined,
+          "sqlQuery": "REPLACE INTO \\"kttm_simple\\" OVERWRITE ALL
+        SELECT TIME_PARSE(\\"timestamp\\") AS \\"__time\\", agent_type
+        FROM TABLE(
+          EXTERN(
+            '{\\"type\\":\\"http\\",\\"uris\\":[\\"https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz\\"]}',
+            '{\\"type\\":\\"json\\"}',
+            '[{\\"name\\":\\"timestamp\\",\\"type\\":\\"string\\"},{\\"name\\":\\"agent_type\\",\\"type\\":\\"string\\"}]'
+          )
+        )
+        PARTITIONED BY ALL TIME",
+          "stages": Stages {
+            "counters": Object {
+              "0": Object {
+                "0": Object {
+                  "input0": Object {
+                    "files": Array [
+                      1,
+                    ],
+                    "rows": Array [
+                      465346,
+                    ],
+                    "totalFiles": Array [
+                      1,
+                    ],
+                    "type": "channel",
+                  },
+                  "output": Object {
+                    "bytes": Array [
+                      25430674,
+                    ],
+                    "frames": Array [
+                      4,
+                    ],
+                    "rows": Array [
+                      465346,
+                    ],
+                    "type": "channel",
+                  },
+                  "shuffle": Object {
+                    "bytes": Array [
+                      23570446,
+                    ],
+                    "frames": Array [
+                      38,
+                    ],
+                    "rows": Array [
+                      465346,
+                    ],
+                    "type": "channel",
+                  },
+                  "sortProgress": Object {
+                    "levelToMergedBatches": Object {
+                      "0": 1,
+                      "1": 1,
+                      "2": 1,
+                    },
+                    "levelToTotalBatches": Object {
+                      "0": 1,
+                      "1": 1,
+                      "2": 1,
+                    },
+                    "progressDigest": 1,
+                    "totalMergersForUltimateLevel": 1,
+                    "totalMergingLevels": 3,
+                    "type": "sortProgress",
+                  },
+                },
+              },
+              "1": Object {
+                "0": Object {
+                  "input0": Object {
+                    "bytes": Array [
+                      23570446,
+                    ],
+                    "frames": Array [
+                      38,
+                    ],
+                    "rows": Array [
+                      465346,
+                    ],
+                    "type": "channel",
+                  },
+                },
+              },
+            },
+            "stages": Array [
+              Object {
+                "definition": Object {
+                  "id": "0b353011-6ea1-480a-8ca8-386771621672_0",
+                  "input": Array [
+                    Object {
+                      "inputFormat": Object {
+                        "featureSpec": Object {},
+                        "flattenSpec": null,
+                        "keepNullColumns": false,
+                        "type": "json",
+                      },
+                      "inputSource": Object {
+                        "httpAuthenticationPassword": null,
+                        "httpAuthenticationUsername": null,
+                        "type": "http",
+                        "uris": Array [
+                          "https://static.imply.io/example-data/kttm-v2/kttm-v2-2019-08-25.json.gz",
+                        ],
+                      },
+                      "signature": Array [
+                        Object {
+                          "name": "timestamp",
+                          "type": "STRING",
+                        },
+                        Object {
+                          "name": "agent_type",
+                          "type": "STRING",
+                        },
+                      ],
+                      "type": "external",
+                    },
+                  ],
+                  "maxWorkerCount": 1,
+                  "processor": Object {
+                    "query": Object {
+                      "columns": Array [
+                        "agent_type",
+                        "v0",
+                      ],
+                      "context": Object {
+                        "__timeColumn": "v0",
+                        "finalize": false,
+                        "finalizeAggregations": false,
+                        "groupByEnableMultiValueUnnesting": false,
+                        "scanSignature": "[{\\"name\\":\\"agent_type\\",\\"type\\":\\"STRING\\"},{\\"name\\":\\"v0\\",\\"type\\":\\"LONG\\"}]",
+                        "sqlInsertSegmentGranularity": "{\\"type\\":\\"all\\"}",
+                        "sqlQueryId": "32ced762-7679-4a25-9220-3915c5976961",
+                        "sqlReplaceTimeChunks": "all",
+                      },
+                      "dataSource": Object {
+                        "inputNumber": 0,
+                        "type": "inputNumber",
+                      },
+                      "granularity": Object {
+                        "type": "all",
+                      },
+                      "intervals": Object {
+                        "intervals": Array [
+                          "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z",
+                        ],
+                        "type": "intervals",
+                      },
+                      "legacy": false,
+                      "queryType": "scan",
+                      "resultFormat": "compactedList",
+                      "virtualColumns": Array [
+                        Object {
+                          "expression": "timestamp_parse(\\"timestamp\\",null,'UTC')",
+                          "name": "v0",
+                          "outputType": "LONG",
+                          "type": "expression",
+                        },
+                      ],
+                    },
+                    "type": "scan",
+                  },
+                  "shuffleCheckHasMultipleValues": true,
+                  "shuffleSpec": Object {
+                    "clusterBy": Object {
+                      "columns": Array [
+                        Object {
+                          "columnName": "__boost",
+                        },
+                      ],
+                    },
+                    "targetSize": 3000000,
+                    "type": "targetSize",
+                  },
+                  "signature": Array [
+                    Object {
+                      "name": "__boost",
+                      "type": "LONG",
+                    },
+                    Object {
+                      "name": "agent_type",
+                      "type": "STRING",
+                    },
+                    Object {
+                      "name": "v0",
+                      "type": "LONG",
+                    },
+                  ],
+                },
+                "duration": 20229,
+                "partitionCount": 1,
+                "phase": "FINISHED",
+                "sort": true,
+                "stageNumber": 0,
+                "startTime": "2022-08-22T20:12:53.790Z",
+                "workerCount": 1,
+              },
+              Object {
+                "definition": Object {
+                  "id": "0b353011-6ea1-480a-8ca8-386771621672_1",
+                  "input": Array [
+                    Object {
+                      "stage": 0,
+                      "type": "stage",
+                    },
+                  ],
+                  "maxWorkerCount": 1,
+                  "processor": Object {
+                    "columnMappings": Array [
+                      Object {
+                        "outputColumn": "__time",
+                        "queryColumn": "v0",
+                      },
+                      Object {
+                        "outputColumn": "agent_type",
+                        "queryColumn": "agent_type",
+                      },
+                    ],
+                    "dataSchema": Object {
+                      "dataSource": "kttm_simple",
+                      "dimensionsSpec": Object {
+                        "dimensionExclusions": Array [
+                          "__time",
+                        ],
+                        "dimensions": Array [
+                          Object {
+                            "createBitmapIndex": true,
+                            "multiValueHandling": "SORTED_ARRAY",
+                            "name": "agent_type",
+                            "type": "string",
+                          },
+                        ],
+                        "includeAllDimensions": false,
+                      },
+                      "granularitySpec": Object {
+                        "intervals": Array [
+                          "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z",
+                        ],
+                        "queryGranularity": Object {
+                          "type": "none",
+                        },
+                        "rollup": false,
+                        "type": "arbitrary",
+                      },
+                      "metricsSpec": Array [],
+                      "timestampSpec": Object {
+                        "column": "__time",
+                        "format": "millis",
+                        "missingValue": null,
+                      },
+                      "transformSpec": Object {
+                        "filter": null,
+                        "transforms": Array [],
+                      },
+                    },
+                    "tuningConfig": Object {
+                      "maxNumWorkers": 1,
+                      "maxRowsInMemory": 100000,
+                      "rowsPerSegment": 3000000,
+                    },
+                    "type": "segmentGenerator",
+                  },
+                  "signature": Array [],
+                },
+                "duration": 2497,
+                "partitionCount": 1,
+                "phase": "FINISHED",
+                "stageNumber": 1,
+                "startTime": "2022-08-22T20:13:13.991Z",
+                "workerCount": 1,
+              },
+            ],
+          },
+          "startTime": 2022-08-22T20:12:51.391Z,
+          "status": "SUCCESS",
+          "warnings": undefined,
+        }
+      `);
+    });
+  });
+});
diff --git a/web-console/src/druid-models/execution/execution.ts b/web-console/src/druid-models/execution/execution.ts
new file mode 100644
index 0000000000..5aeeb47a2a
--- /dev/null
+++ b/web-console/src/druid-models/execution/execution.ts
@@ -0,0 +1,467 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Column, QueryResult, SqlExpression, SqlQuery, SqlWithQuery } from 'druid-query-toolkit';
+
+import { deepGet, deleteKeys, nonEmptyArray, oneOf } from '../../utils';
+import { DruidEngine, validDruidEngine } from '../druid-engine/druid-engine';
+import { QueryContext } from '../query-context/query-context';
+import { Stages } from '../stages/stages';
+
+const IGNORE_CONTEXT_KEYS = [
+  '__asyncIdentity__',
+  '__timeColumn',
+  'queryId',
+  'sqlQueryId',
+  'sqlInsertSegmentGranularity',
+  'signature',
+  'scanSignature',
+  'sqlReplaceTimeChunks',
+];
+
+// Hack around the concept that we might get back a SqlWithQuery and will need to unpack it
+function parseSqlQuery(queryString: string): SqlQuery | undefined {
+  const q = SqlExpression.maybeParse(queryString);
+  if (!q) return;
+  if (q instanceof SqlWithQuery) return q.flattenWith();
+  if (q instanceof SqlQuery) return q;
+  return;
+}
+
+export interface ExecutionError {
+  error: {
+    errorCode: string;
+    errorMessage?: string;
+    [key: string]: any;
+  };
+  host?: string;
+  taskId?: string;
+  stageNumber?: number;
+  exceptionStackTrace?: string;
+}
+
+type ExecutionDestination =
+  | {
+      type: 'taskReport';
+    }
+  | { type: 'dataSource'; dataSource: string; exists?: boolean }
+  | { type: 'download' };
+
+export type ExecutionStatus = 'RUNNING' | 'FAILED' | 'SUCCESS';
+
+export interface LastExecution {
+  engine: DruidEngine;
+  id: string;
+}
+
+export function validateLastExecution(possibleLastExecution: any): LastExecution | undefined {
+  if (
+    !possibleLastExecution ||
+    !validDruidEngine(possibleLastExecution.engine) ||
+    typeof possibleLastExecution.id !== 'string'
+  ) {
+    return;
+  }
+
+  return {
+    engine: possibleLastExecution.engine,
+    id: possibleLastExecution.id,
+  };
+}
+
+export interface ExecutionValue {
+  engine: DruidEngine;
+  id: string;
+  sqlQuery?: string;
+  nativeQuery?: any;
+  queryContext?: QueryContext;
+  status?: ExecutionStatus;
+  startTime?: Date;
+  duration?: number;
+  stages?: Stages;
+  destination?: ExecutionDestination;
+  result?: QueryResult;
+  error?: ExecutionError;
+  warnings?: ExecutionError[];
+  _payload?: { payload: any; task: string };
+}
+
+export class Execution {
+  static validAsyncStatus(
+    status: string | undefined,
+  ): status is 'INITIALIZED' | 'RUNNING' | 'COMPLETE' | 'FAILED' | 'UNDETERMINED' {
+    return oneOf(status, 'INITIALIZED', 'RUNNING', 'COMPLETE', 'FAILED', 'UNDETERMINED');
+  }
+
+  static validTaskStatus(
+    status: string | undefined,
+  ): status is 'WAITING' | 'PENDING' | 'RUNNING' | 'FAILED' | 'SUCCESS' {
+    return oneOf(status, 'WAITING', 'PENDING', 'RUNNING', 'FAILED', 'SUCCESS');
+  }
+
+  static normalizeAsyncStatus(
+    state: 'INITIALIZED' | 'RUNNING' | 'COMPLETE' | 'FAILED' | 'UNDETERMINED',
+  ): ExecutionStatus {
+    switch (state) {
+      case 'COMPLETE':
+        return 'SUCCESS';
+
+      case 'INITIALIZED':
+      case 'UNDETERMINED':
+        return 'RUNNING';
+
+      default:
+        return state;
+    }
+  }
+
+  // Treat WAITING as PENDING since they are all the same as far as the UI is concerned
+  static normalizeTaskStatus(
+    status: 'WAITING' | 'PENDING' | 'RUNNING' | 'FAILED' | 'SUCCESS',
+  ): ExecutionStatus {
+    switch (status) {
+      case 'SUCCESS':
+      case 'FAILED':
+        return status;
+
+      default:
+        return 'RUNNING';
+    }
+  }
+
+  static fromTaskSubmit(
+    taskSubmitResult: { state: any; taskId: string; error: any },
+    sqlQuery?: string,
+    queryContext?: QueryContext,
+  ): Execution {
+    const status = Execution.normalizeTaskStatus(taskSubmitResult.state);
+    return new Execution({
+      engine: 'sql-msq-task',
+      id: taskSubmitResult.taskId,
+      status: taskSubmitResult.error ? 'FAILED' : status,
+      sqlQuery,
+      queryContext,
+      error: taskSubmitResult.error
+        ? {
+            error: {
+              errorCode: 'AsyncError',
+              errorMessage: JSON.stringify(taskSubmitResult.error),
+            },
+          }
+        : status === 'FAILED'
+        ? {
+            error: {
+              errorCode: 'UnknownError',
+              errorMessage:
+                'Execution failed, there is no detail information, and there is no error in the status response',
+            },
+          }
+        : undefined,
+      destination: undefined,
+    });
+  }
+
+  static fromTaskStatus(
+    taskStatus: { status: any; task: string },
+    sqlQuery?: string,
+    queryContext?: QueryContext,
+  ): Execution {
+    const status = Execution.normalizeTaskStatus(taskStatus.status.status);
+    return new Execution({
+      engine: 'sql-msq-task',
+      id: taskStatus.task,
+      status: taskStatus.status.error ? 'FAILED' : status,
+      sqlQuery,
+      queryContext,
+      error: taskStatus.status.error
+        ? {
+            error: {
+              errorCode: 'AsyncError',
+              errorMessage: JSON.stringify(taskStatus.status.error),
+            },
+          }
+        : status === 'FAILED'
+        ? {
+            error: {
+              errorCode: 'UnknownError',
+              errorMessage:
+                'Execution failed, there is no detail information, and there is no error in the status response',
+            },
+          }
+        : undefined,
+      destination: undefined,
+    });
+  }
+
+  static fromTaskPayloadAndReport(
+    taskPayload: { payload: any; task: string },
+    taskReport: {
+      multiStageQuery: { payload: any; taskId: string };
+      error?: any;
+    },
+  ): Execution {
+    // Must have status set for a valid report
+    const id = deepGet(taskReport, 'multiStageQuery.taskId');
+    const status = deepGet(taskReport, 'multiStageQuery.payload.status.status');
+    const warnings = deepGet(taskReport, 'multiStageQuery.payload.status.warningReports');
+
+    if (typeof id !== 'string' || !Execution.validTaskStatus(status)) {
+      throw new Error('Invalid payload');
+    }
+
+    let error: ExecutionError | undefined;
+    if (status === 'FAILED') {
+      error =
+        deepGet(taskReport, 'multiStageQuery.payload.status.errorReport') ||
+        (typeof taskReport.error === 'string'
+          ? { error: { errorCode: 'UnknownError', errorMessage: taskReport.error } }
+          : undefined);
+    }
+
+    const stages = deepGet(taskReport, 'multiStageQuery.payload.stages');
+    const startTime = new Date(deepGet(taskReport, 'multiStageQuery.payload.status.startTime'));
+    const durationMs = deepGet(taskReport, 'multiStageQuery.payload.status.durationMs');
+
+    let result: QueryResult | undefined;
+    const resultsPayload: {
+      signature: { name: string; type: string }[];
+      sqlTypeNames: string[];
+      results: any[];
+    } = deepGet(taskReport, 'multiStageQuery.payload.results');
+    if (resultsPayload) {
+      const { signature, sqlTypeNames, results } = resultsPayload;
+      result = new QueryResult({
+        header: signature.map(
+          (sig, i: number) =>
+            new Column({ name: sig.name, nativeType: sig.type, sqlType: sqlTypeNames?.[i] }),
+        ),
+        rows: results,
+      }).inflateDatesFromSqlTypes();
+    }
+
+    let res = new Execution({
+      engine: 'sql-msq-task',
+      id,
+      status: Execution.normalizeTaskStatus(status),
+      startTime: isNaN(startTime.getTime()) ? undefined : startTime,
+      duration: typeof durationMs === 'number' ? durationMs : undefined,
+      stages: Array.isArray(stages)
+        ? new Stages(stages, deepGet(taskReport, 'multiStageQuery.payload.counters'))
+        : undefined,
+      error,
+      warnings: Array.isArray(warnings) ? warnings : undefined,
+      destination: deepGet(taskPayload, 'payload.spec.destination'),
+      result,
+      nativeQuery: deepGet(taskPayload, 'payload.spec.query'),
+
+      _payload: taskPayload,
+    });
+
+    if (deepGet(taskPayload, 'payload.sqlQuery')) {
+      res = res.changeSqlQuery(
+        deepGet(taskPayload, 'payload.sqlQuery'),
+        deleteKeys(deepGet(taskPayload, 'payload.sqlQueryContext'), IGNORE_CONTEXT_KEYS),
+      );
+    }
+
+    return res;
+  }
+
+  static fromResult(engine: DruidEngine, result: QueryResult): Execution {
+    return new Execution({
+      engine,
+      id: result.sqlQueryId || result.queryId || 'direct_result',
+      status: 'SUCCESS',
+      result,
+      duration: result.queryDuration,
+    });
+  }
+
+  public readonly engine: DruidEngine;
+  public readonly id: string;
+  public readonly sqlQuery?: string;
+  public readonly nativeQuery?: any;
+  public readonly queryContext?: QueryContext;
+  public readonly status?: ExecutionStatus;
+  public readonly startTime?: Date;
+  public readonly duration?: number;
+  public readonly stages?: Stages;
+  public readonly destination?: ExecutionDestination;
+  public readonly result?: QueryResult;
+  public readonly error?: ExecutionError;
+  public readonly warnings?: ExecutionError[];
+
+  public readonly _payload?: { payload: any; task: string };
+
+  constructor(value: ExecutionValue) {
+    this.engine = value.engine;
+    this.id = value.id;
+    if (!this.id) throw new Error('must have an id');
+    this.sqlQuery = value.sqlQuery;
+    this.nativeQuery = value.nativeQuery;
+    this.queryContext = value.queryContext;
+    this.status = value.status;
+    this.startTime = value.startTime;
+    this.duration = value.duration;
+    this.stages = value.stages;
+    this.destination = value.destination;
+    this.result = value.result;
+    this.error = value.error;
+    this.warnings = nonEmptyArray(value.warnings) ? value.warnings : undefined;
+
+    this._payload = value._payload;
+  }
+
+  valueOf(): ExecutionValue {
+    return {
+      engine: this.engine,
+      id: this.id,
+      sqlQuery: this.sqlQuery,
+      nativeQuery: this.nativeQuery,
+      queryContext: this.queryContext,
+      status: this.status,
+      startTime: this.startTime,
+      duration: this.duration,
+      stages: this.stages,
+      destination: this.destination,
+      result: this.result,
+      error: this.error,
+      warnings: this.warnings,
+
+      _payload: this._payload,
+    };
+  }
+
+  public changeSqlQuery(sqlQuery: string, queryContext?: QueryContext): Execution {
+    const value = this.valueOf();
+
+    value.sqlQuery = sqlQuery;
+    value.queryContext = queryContext;
+    const parsedQuery = parseSqlQuery(sqlQuery);
+    if (value.result && (parsedQuery || queryContext)) {
+      value.result = value.result.attachQuery({ context: queryContext }, parsedQuery);
+    }
+
+    return new Execution(value);
+  }
+
+  public changeDestination(destination: ExecutionDestination): Execution {
+    return new Execution({
+      ...this.valueOf(),
+      destination,
+    });
+  }
+
+  public changeResult(result: QueryResult): Execution {
+    return new Execution({
+      ...this.valueOf(),
+      result: result.attachQuery({}, this.sqlQuery ? parseSqlQuery(this.sqlQuery) : undefined),
+    });
+  }
+
+  public updateWith(newSummary: Execution): Execution {
+    let nextSummary = newSummary;
+    if (this.sqlQuery && !nextSummary.sqlQuery) {
+      nextSummary = nextSummary.changeSqlQuery(this.sqlQuery, this.queryContext);
+    }
+    if (this.destination && !nextSummary.destination) {
+      nextSummary = nextSummary.changeDestination(this.destination);
+    }
+
+    return nextSummary;
+  }
+
+  public attachErrorFromStatus(status: any): Execution {
+    const errorMsg = deepGet(status, 'status.errorMsg');
+
+    return new Execution({
+      ...this.valueOf(),
+      error: {
+        error: {
+          errorCode: 'UnknownError',
+          errorMessage: errorMsg,
+        },
+      },
+    });
+  }
+
+  public markDestinationDatasourceExists(): Execution {
+    const { destination } = this;
+    if (destination?.type !== 'dataSource') return this;
+
+    return new Execution({
+      ...this.valueOf(),
+      destination: {
+        ...destination,
+        exists: true,
+      },
+    });
+  }
+
+  public isProcessingData(): boolean {
+    const { status, stages } = this;
+    return Boolean(
+      status === 'RUNNING' &&
+        stages &&
+        stages.getTotalInputForStage(stages.getStage(0), 'rows') > 0,
+    );
+  }
+
+  public isWaitingForQuery(): boolean {
+    const { status } = this;
+    return status !== 'SUCCESS' && status !== 'FAILED';
+  }
+
+  public isFullyComplete(): boolean {
+    if (this.isWaitingForQuery()) return false;
+
+    const { status, destination } = this;
+    if (status === 'SUCCESS' && destination?.type === 'dataSource') {
+      return Boolean(destination.exists);
+    }
+
+    return true;
+  }
+
+  public getIngestDatasource(): string | undefined {
+    const { destination } = this;
+    if (destination?.type !== 'dataSource') return;
+    return destination.dataSource;
+  }
+
+  public isSuccessfulInsert(): boolean {
+    return Boolean(
+      this.isFullyComplete() && this.getIngestDatasource() && this.status === 'SUCCESS',
+    );
+  }
+
+  public getErrorMessage(): string | undefined {
+    const { error } = this;
+    if (!error) return;
+    return (
+      (error.error.errorCode ? `${error.error.errorCode}: ` : '') +
+      (error.error.errorMessage || (error.exceptionStackTrace || '').split('\n')[0])
+    );
+  }
+
+  public getEndTime(): Date | undefined {
+    const { startTime, duration } = this;
+    if (!startTime || !duration) return;
+    return new Date(startTime.valueOf() + duration);
+  }
+}
diff --git a/web-console/src/druid-models/external-config/external-config.ts b/web-console/src/druid-models/external-config/external-config.ts
new file mode 100644
index 0000000000..d9dcb97cd1
--- /dev/null
+++ b/web-console/src/druid-models/external-config/external-config.ts
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  filterMap,
+  SqlExpression,
+  SqlFunction,
+  SqlLiteral,
+  SqlQuery,
+  SqlRef,
+  SqlStar,
+} from 'druid-query-toolkit';
+import * as JSONBig from 'json-bigint-native';
+
+import { nonEmptyArray } from '../../utils';
+import { InputFormat } from '../input-format/input-format';
+import { InputSource } from '../input-source/input-source';
+
+export const MULTI_STAGE_QUERY_MAX_COLUMNS = 2000;
+const MAX_LINES = 10;
+
+function joinLinesMax(lines: string[], max: number) {
+  if (lines.length > max) {
+    lines = lines.slice(0, max).concat(`(and ${lines.length - max} more)`);
+  }
+  return lines.join('\n');
+}
+
+export interface ExternalConfig {
+  inputSource: InputSource;
+  inputFormat: InputFormat;
+  signature: SignatureColumn[];
+}
+
+export interface SignatureColumn {
+  name: string;
+  type: string;
+}
+
+export function summarizeInputSource(inputSource: InputSource, multiline: boolean): string {
+  switch (inputSource.type) {
+    case 'inline':
+      return `inline data`;
+
+    case 'local':
+      // ToDo: make this official
+      if (nonEmptyArray((inputSource as any).files)) {
+        let lines: string[] = (inputSource as any).files;
+        if (!multiline) lines = lines.slice(0, 1);
+        return joinLinesMax(lines, MAX_LINES);
+      }
+      return `${inputSource.baseDir || '?'}{${inputSource.filter || '?'}}`;
+
+    case 'http':
+      if (nonEmptyArray(inputSource.uris)) {
+        let lines: string[] = inputSource.uris;
+        if (!multiline) lines = lines.slice(0, 1);
+        return joinLinesMax(lines, MAX_LINES);
+      }
+      return '?';
+
+    case 's3':
+    case 'google':
+    case 'azure': {
+      const possibleLines = inputSource.uris || inputSource.prefixes;
+      if (nonEmptyArray(possibleLines)) {
+        let lines: string[] = possibleLines;
+        if (!multiline) lines = lines.slice(0, 1);
+        return joinLinesMax(lines, MAX_LINES);
+      }
+      if (nonEmptyArray(inputSource.objects)) {
+        let lines: string[] = inputSource.objects.map(({ bucket, path }) => `${bucket}:${path}`);
+        if (!multiline) lines = lines.slice(0, 1);
+        return joinLinesMax(lines, MAX_LINES);
+      }
+      return '?';
+    }
+
+    case 'hdfs': {
+      const paths =
+        typeof inputSource.paths === 'string' ? inputSource.paths.split(',') : inputSource.paths;
+      if (nonEmptyArray(paths)) {
+        let lines: string[] = paths;
+        if (!multiline) lines = lines.slice(0, 1);
+        return joinLinesMax(lines, MAX_LINES);
+      }
+      return '?';
+    }
+
+    default:
+      return String(inputSource.type);
+  }
+}
+
+export function summarizeInputFormat(inputFormat: InputFormat): string {
+  return String(inputFormat.type);
+}
+
+export function summarizeExternalConfig(externalConfig: ExternalConfig): string {
+  return `${summarizeInputSource(externalConfig.inputSource, false)} [${summarizeInputFormat(
+    externalConfig.inputFormat,
+  )}]`;
+}
+
+export function externalConfigToTableExpression(config: ExternalConfig): SqlExpression {
+  return SqlExpression.parse(`TABLE(
+  EXTERN(
+    ${SqlLiteral.create(JSONBig.stringify(config.inputSource))},
+    ${SqlLiteral.create(JSONBig.stringify(config.inputFormat))},
+    ${SqlLiteral.create(JSONBig.stringify(config.signature))}
+  )
+)`);
+}
+
+export function externalConfigToInitDimensions(
+  config: ExternalConfig,
+  isArrays: boolean[],
+  timeExpression: SqlExpression | undefined,
+): SqlExpression[] {
+  return (timeExpression ? [timeExpression.as('__time')] : [])
+    .concat(
+      filterMap(config.signature, ({ name }, i) => {
+        if (timeExpression && timeExpression.containsColumn(name)) return;
+        return SqlRef.column(name).applyIf(
+          isArrays[i],
+          ex => SqlFunction.simple('MV_TO_ARRAY', [ex]).as(name) as any,
+        );
+      }),
+    )
+    .slice(0, MULTI_STAGE_QUERY_MAX_COLUMNS);
+}
+
+export function fitExternalConfigPattern(query: SqlQuery): ExternalConfig {
+  if (!(query.getSelectExpressionForIndex(0) instanceof SqlStar)) {
+    throw new Error(`External SELECT must only be a star`);
+  }
+
+  const tableFn = query.fromClause?.expressions?.first();
+  if (!(tableFn instanceof SqlFunction) || tableFn.functionName !== 'TABLE') {
+    throw new Error(`External FROM must be a TABLE function`);
+  }
+
+  const externFn = tableFn.getArg(0);
+  if (!(externFn instanceof SqlFunction) || externFn.functionName !== 'EXTERN') {
+    throw new Error(`Within the TABLE function there must be an extern function`);
+  }
+
+  let inputSource: any;
+  try {
+    const arg0 = externFn.getArg(0);
+    inputSource = JSONBig.parse(arg0 instanceof SqlLiteral ? String(arg0.value) : '#');
+  } catch {
+    throw new Error(`The first argument to the extern function must be a string embedding JSON`);
+  }
+
+  let inputFormat: any;
+  try {
+    const arg1 = externFn.getArg(1);
+    inputFormat = JSONBig.parse(arg1 instanceof SqlLiteral ? String(arg1.value) : '#');
+  } catch {
+    throw new Error(`The second argument to the extern function must be a string embedding JSON`);
+  }
+
+  let signature: any;
+  try {
+    const arg2 = externFn.getArg(2);
+    signature = JSONBig.parse(arg2 instanceof SqlLiteral ? String(arg2.value) : '#');
+  } catch {
+    throw new Error(`The third argument to the extern function must be a string embedding JSON`);
+  }
+
+  return {
+    inputSource,
+    inputFormat,
+    signature,
+  };
+}
diff --git a/web-console/src/druid-models/filter.tsx b/web-console/src/druid-models/filter/filter.tsx
similarity index 95%
rename from web-console/src/druid-models/filter.tsx
rename to web-console/src/druid-models/filter/filter.tsx
index 89cb2b65b1..35b09a68c4 100644
--- a/web-console/src/druid-models/filter.tsx
+++ b/web-console/src/druid-models/filter/filter.tsx
@@ -18,11 +18,10 @@
 
 import React from 'react';
 
-import { ExternalLink, Field } from '../components';
-import { getLink } from '../links';
-import { deepGet, EMPTY_ARRAY, oneOf, typeIs } from '../utils';
-
-import { IngestionSpec } from './ingestion-spec';
+import { ExternalLink, Field } from '../../components';
+import { getLink } from '../../links';
+import { deepGet, EMPTY_ARRAY, oneOf, typeIs } from '../../utils';
+import { IngestionSpec } from '../ingestion-spec/ingestion-spec';
 
 export interface DruidFilter {
   readonly type: string;
diff --git a/web-console/src/druid-models/flatten-spec.spec.ts b/web-console/src/druid-models/flatten-spec/flatten-spec.spec.ts
similarity index 91%
rename from web-console/src/druid-models/flatten-spec.spec.ts
rename to web-console/src/druid-models/flatten-spec/flatten-spec.spec.ts
index a475190125..a35c185381 100644
--- a/web-console/src/druid-models/flatten-spec.spec.ts
+++ b/web-console/src/druid-models/flatten-spec/flatten-spec.spec.ts
@@ -18,11 +18,11 @@
 
 import { computeFlattenExprsForData } from './flatten-spec';
 
-describe('spec-utils', () => {
+describe('flatten-spec', () => {
   describe('computeFlattenExprsForData', () => {
     const data = [
       {
-        context: { host: 'clarity', topic: 'moon', bonus: { foo: 'bar' } },
+        context: { host: 'cla', topic: 'moon', bonus: { foo: 'bar' } },
         tags: ['a', 'b', 'c'],
         messages: [
           { metric: 'request/time', value: 122 },
@@ -32,7 +32,7 @@ describe('spec-utils', () => {
         value: 5,
       },
       {
-        context: { host: 'pivot', popic: 'sun' },
+        context: { host: 'piv', popic: 'sun' },
         tags: ['a', 'd'],
         messages: [
           { metric: 'request/time', value: 44 },
@@ -41,7 +41,7 @@ describe('spec-utils', () => {
         value: 4,
       },
       {
-        context: { host: 'imply', dopik: 'fun' },
+        context: { host: 'imp', dopik: 'fun' },
         tags: ['x', 'y'],
         messages: [
           { metric: 'request/time', value: 4 },
diff --git a/web-console/src/druid-models/flatten-spec.tsx b/web-console/src/druid-models/flatten-spec/flatten-spec.tsx
similarity index 96%
rename from web-console/src/druid-models/flatten-spec.tsx
rename to web-console/src/druid-models/flatten-spec/flatten-spec.tsx
index 68b8184ced..3845a83916 100644
--- a/web-console/src/druid-models/flatten-spec.tsx
+++ b/web-console/src/druid-models/flatten-spec/flatten-spec.tsx
@@ -18,9 +18,9 @@
 
 import React from 'react';
 
-import { ExternalLink, Field } from '../components';
-import { getLink } from '../links';
-import { typeIs } from '../utils';
+import { ExternalLink, Field } from '../../components';
+import { getLink } from '../../links';
+import { typeIs } from '../../utils';
 
 export interface FlattenSpec {
   useFieldDiscovery?: boolean;
diff --git a/web-console/src/druid-models/index.ts b/web-console/src/druid-models/index.ts
index d5e80d3764..359ba70440 100644
--- a/web-console/src/druid-models/index.ts
+++ b/web-console/src/druid-models/index.ts
@@ -16,18 +16,26 @@
  * limitations under the License.
  */
 
-export * from './compaction-config';
-export * from './compaction-status';
-export * from './coordinator-dynamic-config';
-export * from './dimension-spec';
-export * from './filter';
-export * from './flatten-spec';
-export * from './ingestion-spec';
-export * from './input-format';
-export * from './input-source';
-export * from './lookup-spec';
-export * from './metric-spec';
-export * from './overlord-dynamic-config';
-export * from './time';
-export * from './timestamp-spec';
-export * from './transform-spec';
+export * from './compaction-config/compaction-config';
+export * from './compaction-status/compaction-status';
+export * from './coordinator-dynamic-config/coordinator-dynamic-config';
+export * from './dimension-spec/dimension-spec';
+export * from './druid-engine/druid-engine';
+export * from './execution/execution';
+export * from './external-config/external-config';
+export * from './filter/filter';
+export * from './flatten-spec/flatten-spec';
+export * from './ingest-query-pattern/ingest-query-pattern';
+export * from './ingestion-spec/ingestion-spec';
+export * from './input-format/input-format';
+export * from './input-source/input-source';
+export * from './lookup-spec/lookup-spec';
+export * from './metric-spec/metric-spec';
+export * from './overlord-dynamic-config/overlord-dynamic-config';
+export * from './query-context/query-context';
+export * from './stages/stages';
+export * from './time/time';
+export * from './timestamp-spec/timestamp-spec';
+export * from './transform-spec/transform-spec';
+export * from './workbench-query/workbench-query';
+export * from './workbench-query/workbench-query-part';
diff --git a/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.spec.ts b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.spec.ts
new file mode 100644
index 0000000000..d8ec89f172
--- /dev/null
+++ b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.spec.ts
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { sane, SqlQuery } from 'druid-query-toolkit';
+
+import { fitIngestQueryPattern, ingestQueryPatternToQuery } from './ingest-query-pattern';
+
+describe('ingest-query-pattern', () => {
+  it('works', () => {
+    const query = SqlQuery.parse(sane`
+      INSERT INTO "kttm-2019"
+      WITH ext AS (SELECT *
+      FROM TABLE(
+        EXTERN(
+          '{"type":"http","uris":["https://example.com/data.json.gz"]}',
+          '{"type":"json"}',
+          '[{"name":"timestamp","type":"string"},{"name":"agent_category","type":"string"},{"name":"agent_type","type":"string"},{"name":"browser","type":"string"},{"name":"browser_version","type":"string"},{"name":"city","type":"string"},{"name":"continent","type":"string"},{"name":"country","type":"string"},{"name":"version","type":"string"},{"name":"event_type","type":"string"},{"name":"event_subtype","type":"string"},{"name":"loaded_image","type":"string"},{"name":"adblock_list","typ [...]
+        )
+      ))
+      SELECT
+        TIME_PARSE("timestamp") AS __time,
+        agent_category,
+        agent_type,
+        browser,
+        browser_version
+      FROM ext
+      PARTITIONED BY HOUR
+      CLUSTERED BY 4
+    `);
+
+    const insertQueryPattern = fitIngestQueryPattern(query);
+    expect(insertQueryPattern.partitionedBy).toEqual('hour');
+
+    const query2 = ingestQueryPatternToQuery(insertQueryPattern);
+
+    expect(query2.toString()).toEqual(query.toString());
+  });
+});
diff --git a/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.ts b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.ts
new file mode 100644
index 0000000000..53a6f2b6b8
--- /dev/null
+++ b/web-console/src/druid-models/ingest-query-pattern/ingest-query-pattern.ts
@@ -0,0 +1,314 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  SqlExpression,
+  SqlFunction,
+  SqlLiteral,
+  SqlPartitionedByClause,
+  SqlQuery,
+  SqlReplaceClause,
+  SqlTableRef,
+  SqlWithPart,
+} from 'druid-query-toolkit';
+
+import { filterMap, oneOf } from '../../utils';
+import {
+  ExternalConfig,
+  externalConfigToInitDimensions,
+  externalConfigToTableExpression,
+  fitExternalConfigPattern,
+} from '../external-config/external-config';
+import { guessDataSourceNameFromInputSource } from '../ingestion-spec/ingestion-spec';
+
+export type IngestMode = 'insert' | 'replace';
+
+function removeMvToArray(dimension: SqlExpression): SqlExpression {
+  return dimension.walk(ex =>
+    ex instanceof SqlFunction && ex.getEffectiveFunctionName() === 'MV_TO_ARRAY'
+      ? ex.getArg(0)
+      : ex,
+  ) as SqlExpression;
+}
+
+export interface IngestQueryPattern {
+  destinationTableName: string;
+  mode: IngestMode;
+  overwriteWhere?: SqlExpression;
+  mainExternalName: string;
+  mainExternalConfig: ExternalConfig;
+  filters: readonly SqlExpression[];
+  dimensions: readonly SqlExpression[];
+  metrics?: readonly SqlExpression[];
+  partitionedBy: string;
+  clusteredBy: readonly number[];
+}
+
+export function externalConfigToIngestQueryPattern(
+  config: ExternalConfig,
+  isArrays: boolean[],
+  timeExpression: SqlExpression | undefined,
+): IngestQueryPattern {
+  return {
+    destinationTableName: guessDataSourceNameFromInputSource(config.inputSource) || 'data',
+    mode: 'replace',
+    mainExternalName: 'ext',
+    mainExternalConfig: config,
+    filters: [],
+    dimensions: externalConfigToInitDimensions(config, isArrays, timeExpression),
+    partitionedBy: timeExpression ? 'day' : 'all',
+    clusteredBy: [],
+  };
+}
+
+export function getQueryPatternExpression(
+  pattern: IngestQueryPattern,
+  index: number,
+): SqlExpression | undefined {
+  const { dimensions, metrics } = pattern;
+  if (index < dimensions.length) {
+    return dimensions[index];
+  } else if (metrics) {
+    return metrics[index - dimensions.length];
+  }
+  return;
+}
+
+export function getQueryPatternExpressionType(
+  pattern: IngestQueryPattern,
+  index: number,
+): 'dimension' | 'metric' | undefined {
+  const { dimensions, metrics } = pattern;
+  if (index < dimensions.length) {
+    return 'dimension';
+  } else if (metrics) {
+    return 'metric';
+  }
+  return;
+}
+
+export function changeQueryPatternExpression(
+  pattern: IngestQueryPattern,
+  index: number,
+  type: 'dimension' | 'metric',
+  ex: SqlExpression | undefined,
+): IngestQueryPattern {
+  let { dimensions, metrics } = pattern;
+  if (index === -1) {
+    if (ex) {
+      if (type === 'dimension') {
+        dimensions = dimensions.concat(ex);
+      } else if (metrics) {
+        metrics = metrics.concat(ex);
+      }
+    }
+  } else if (index < dimensions.length) {
+    dimensions = filterMap(dimensions, (d, i) => (i === index ? ex : d));
+  } else if (metrics) {
+    const metricIndex = index - dimensions.length;
+    metrics = filterMap(metrics, (m, i) => (i === metricIndex ? ex : m));
+  }
+  return { ...pattern, dimensions, metrics };
+}
+
+function verifyHasOutputName(expression: SqlExpression): void {
+  if (expression.getOutputName()) return;
+  throw new Error(`${expression} must have an AS alias`);
+}
+
+export function fitIngestQueryPattern(query: SqlQuery): IngestQueryPattern {
+  if (query.explainClause) throw new Error(`Can not use EXPLAIN in the data loader flow`);
+  if (query.havingClause) throw new Error(`Can not use HAVING in the data loader flow`);
+  if (query.orderByClause) throw new Error(`Can not USE ORDER BY in the data loader flow`);
+  if (query.limitClause) throw new Error(`Can not use LIMIT in the data loader flow`);
+  if (query.offsetClause) throw new Error(`Can not use OFFSET in the data loader flow`);
+  if (query.unionQuery) throw new Error(`Can not use UNION in the data loader flow`);
+
+  if (query.hasStarInSelect()) {
+    throw new Error(
+      `Can not have * in SELECT in the data loader flow, the columns need to be explicitly listed out`,
+    );
+  }
+
+  let destinationTableName: string;
+  let mode: IngestMode;
+  let overwriteWhere: SqlExpression | undefined;
+  if (query.insertClause) {
+    mode = 'insert';
+    destinationTableName = query.insertClause.table.getTable();
+  } else if (query.replaceClause) {
+    mode = 'replace';
+    destinationTableName = query.replaceClause.table.getTable();
+    overwriteWhere = query.replaceClause.whereClause?.expression;
+  } else {
+    throw new Error(`Must have an INSERT or REPLACE clause`);
+  }
+
+  const withParts = query.getWithParts();
+  if (withParts.length !== 1) {
+    throw new Error(`Must have exactly one with part`);
+  }
+
+  const { table: withTable, columns: withColumns, query: withQuery } = withParts[0];
+  if (withColumns) {
+    throw new Error(`Can not have columns in the WITH expression`);
+  }
+  if (!(withQuery instanceof SqlQuery)) {
+    throw new Error(`The body of the WITH expression must be a query`);
+  }
+
+  const mainExternalName = withTable.name;
+  const mainExternalConfig = fitExternalConfigPattern(withQuery);
+
+  const filters = query.getWhereExpression()?.decomposeViaAnd() || [];
+
+  let dimensions: readonly SqlExpression[];
+  let metrics: readonly SqlExpression[] | undefined;
+  if (query.hasGroupBy()) {
+    dimensions = query.getGroupedSelectExpressions();
+    metrics = query.getAggregateSelectExpressions();
+    metrics.forEach(verifyHasOutputName);
+  } else {
+    dimensions = query.getSelectExpressionsArray();
+  }
+
+  dimensions.forEach(verifyHasOutputName);
+
+  const partitionedByClause = query.partitionedByClause;
+  if (!partitionedByClause) {
+    throw new Error(`Must have a PARTITIONED BY clause`);
+  }
+  let partitionedBy: string;
+  if (partitionedByClause.expression) {
+    if (partitionedByClause.expression instanceof SqlLiteral) {
+      partitionedBy = String(partitionedByClause.expression.value).toLowerCase();
+    } else {
+      partitionedBy = '';
+    }
+  } else {
+    partitionedBy = 'all';
+  }
+  if (!oneOf(partitionedBy, 'hour', 'day', 'month', 'year', 'all')) {
+    throw new Error(`Must partition by HOUR, DAY, MONTH, YEAR, or ALL TIME`);
+  }
+
+  const clusteredByExpressions = query.clusteredByClause
+    ? query.clusteredByClause.expressions.values
+    : [];
+  const clusteredBy = clusteredByExpressions.map(clusteredByExpression => {
+    const selectIndex = query.getSelectIndexForExpression(clusteredByExpression, true);
+    if (selectIndex === -1) {
+      throw new Error(
+        `Invalid CLUSTERED BY expression ${clusteredByExpression}, can only partition on a dimension`,
+      );
+    }
+    return selectIndex;
+  });
+
+  return {
+    destinationTableName,
+    mode,
+    overwriteWhere,
+    mainExternalName,
+    mainExternalConfig,
+    filters,
+    dimensions,
+    metrics,
+    partitionedBy,
+    clusteredBy,
+  };
+}
+
+export function ingestQueryPatternToQuery(
+  ingestQueryPattern: IngestQueryPattern,
+  preview?: boolean,
+  sampleDataQuery?: SqlQuery,
+): SqlQuery {
+  const {
+    destinationTableName,
+    mode,
+    overwriteWhere,
+    mainExternalName,
+    mainExternalConfig,
+    filters,
+    dimensions,
+    metrics,
+    partitionedBy,
+    clusteredBy,
+  } = ingestQueryPattern;
+  return SqlQuery.from(SqlTableRef.create(mainExternalName))
+    .applyIf(!preview, q =>
+      mode === 'insert'
+        ? q.changeInsertIntoTable(destinationTableName)
+        : q.changeReplaceClause(SqlReplaceClause.create(destinationTableName, overwriteWhere)),
+    )
+    .changeWithParts([
+      SqlWithPart.simple(
+        mainExternalName,
+        sampleDataQuery ||
+          SqlQuery.create(externalConfigToTableExpression(mainExternalConfig)).applyIf(preview, q =>
+            q.changeLimitValue(10000),
+          ),
+      ),
+    ])
+    .applyForEach(dimensions, (query, ex) =>
+      query.addSelect(preview ? removeMvToArray(ex) : ex, metrics ? { addToGroupBy: 'end' } : {}),
+    )
+    .applyForEach(metrics || [], (query, ex) => query.addSelect(ex))
+    .applyIf(filters.length, query => query.changeWhereExpression(SqlExpression.and(...filters)))
+    .applyIf(!preview, q =>
+      q
+        .changePartitionedByClause(
+          SqlPartitionedByClause.create(
+            partitionedBy !== 'all'
+              ? new SqlLiteral({
+                  value: partitionedBy.toUpperCase(),
+                  stringValue: partitionedBy.toUpperCase(),
+                })
+              : undefined,
+          ),
+        )
+        .changeClusteredByExpressions(clusteredBy.map(p => SqlLiteral.index(p))),
+    );
+}
+
+export type DestinationMode = 'new' | 'replace' | 'insert';
+
+export interface DestinationInfo {
+  mode: DestinationMode;
+  table: string;
+}
+
+export function getDestinationMode(
+  ingestQueryPattern: IngestQueryPattern,
+  existingTables: string[],
+): DestinationMode {
+  return existingTables.includes(ingestQueryPattern.destinationTableName)
+    ? ingestQueryPattern.mode
+    : 'new';
+}
+
+export function getDestinationInfo(
+  ingestQueryPattern: IngestQueryPattern,
+  existingTables: string[],
+): DestinationInfo {
+  return {
+    mode: getDestinationMode(ingestQueryPattern, existingTables),
+    table: ingestQueryPattern.destinationTableName,
+  };
+}
diff --git a/web-console/src/druid-models/ingestion-spec.spec.ts b/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
similarity index 95%
rename from web-console/src/druid-models/ingestion-spec.spec.ts
rename to web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
index 768a061fd9..a846d29a81 100644
--- a/web-console/src/druid-models/ingestion-spec.spec.ts
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.spec.ts
@@ -16,6 +16,8 @@
  * limitations under the License.
  */
 
+import { CSV_SAMPLE } from '../../utils/sampler.mock';
+
 import {
   adjustId,
   cleanSpec,
@@ -26,7 +28,6 @@ import {
   updateSchemaWithSample,
   upgradeSpec,
 } from './ingestion-spec';
-import { CSV_SAMPLE } from './test-fixtures';
 
 describe('ingestion-spec', () => {
   it('upgrades / downgrades task spec 1', () => {
@@ -170,6 +171,39 @@ describe('ingestion-spec', () => {
     });
   });
 
+  it('does not mangle a custom parser', () => {
+    expect(() =>
+      upgradeSpec({
+        type: 'index_parallel',
+        spec: {
+          ioConfig: {
+            type: 'index_parallel',
+            firehose: {
+              type: 'http',
+              uris: ['https://website.com/wikipedia.json.gz'],
+            },
+          },
+          tuningConfig: {
+            type: 'index_parallel',
+          },
+          dataSchema: {
+            dataSource: 'wikipedia',
+            granularitySpec: {
+              segmentGranularity: 'day',
+              queryGranularity: 'hour',
+              rollup: true,
+            },
+            parser: {
+              type: 'super_cool_custom_parser',
+            },
+          },
+        },
+      }),
+    ).toThrow(
+      "Can not rewrite parser of type 'super_cool_custom_parser', only 'string' is supported",
+    );
+  });
+
   it('upgrades / downgrades task spec (without parser)', () => {
     const oldTaskSpec = {
       type: 'index_parallel',
@@ -693,8 +727,12 @@ describe('spec utils', () => {
       expect(guessColumnTypeFromInput([1, [2], 3], false)).toEqual('string');
     });
 
+    it('works for strange json', () => {
+      expect(guessColumnTypeFromInput([1, { hello: 'world' }, 3], false)).toEqual('COMPLEX<json>');
+    });
+
     it('works for strange input (object with no prototype)', () => {
-      expect(guessColumnTypeFromInput([1, Object.create(null), 3], false)).toEqual('string');
+      expect(guessColumnTypeFromInput([1, Object.create(null), 3], false)).toEqual('COMPLEX<json>');
     });
   });
 
diff --git a/web-console/src/druid-models/ingestion-spec.tsx b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
similarity index 96%
rename from web-console/src/druid-models/ingestion-spec.tsx
rename to web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
index 29881dfe0a..d7bf509163 100644
--- a/web-console/src/druid-models/ingestion-spec.tsx
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
@@ -20,8 +20,8 @@ import { Code } from '@blueprintjs/core';
 import { range } from 'd3-array';
 import React from 'react';
 
-import { AutoForm, ExternalLink, Field } from '../components';
-import { getLink } from '../links';
+import { AutoForm, ExternalLink, Field } from '../../components';
+import { getLink } from '../../links';
 import {
   allowKeys,
   deepDelete,
@@ -35,25 +35,28 @@ import {
   oneOf,
   parseCsvLine,
   typeIs,
-} from '../utils';
-import { SampleHeaderAndRows } from '../utils/sampler';
-
+} from '../../utils';
+import { SampleHeaderAndRows } from '../../utils/sampler';
 import {
   DimensionsSpec,
   getDimensionSpecName,
   getDimensionSpecs,
   getDimensionSpecType,
-} from './dimension-spec';
-import { InputFormat, issueWithInputFormat } from './input-format';
-import { InputSource, issueWithInputSource } from './input-source';
+} from '../dimension-spec/dimension-spec';
+import { InputFormat, issueWithInputFormat } from '../input-format/input-format';
+import {
+  FILTER_SUGGESTIONS,
+  InputSource,
+  issueWithInputSource,
+} from '../input-source/input-source';
 import {
   getMetricSpecOutputType,
   getMetricSpecs,
   getMetricSpecSingleFieldName,
   MetricSpec,
-} from './metric-spec';
-import { TimestampSpec } from './timestamp-spec';
-import { TransformSpec } from './transform-spec';
+} from '../metric-spec/metric-spec';
+import { TimestampSpec } from '../timestamp-spec/timestamp-spec';
+import { TransformSpec } from '../transform-spec/transform-spec';
 
 export const MAX_INLINE_DATA_LENGTH = 65536;
 
@@ -349,7 +352,7 @@ export function cleanSpec(
   ) as IngestionSpec;
 }
 
-export function upgradeSpec(spec: any): Partial<IngestionSpec> {
+export function upgradeSpec(spec: any, yolo = false): Partial<IngestionSpec> {
   spec = nestSpecIfNeeded(spec);
 
   // Upgrade firehose if exists
@@ -370,6 +373,15 @@ export function upgradeSpec(spec: any): Partial<IngestionSpec> {
 
   // Decompose parser if exists
   if (deepGet(spec, 'spec.dataSchema.parser')) {
+    if (!yolo) {
+      const parserType = deepGet(spec, 'spec.dataSchema.parser.type');
+      if (parserType !== 'string') {
+        throw new Error(
+          `Can not rewrite parser of type '${parserType}', only 'string' is supported`,
+        );
+      }
+    }
+
     spec = deepMove(
       spec,
       'spec.dataSchema.parser.parseSpec.timestampSpec',
@@ -442,6 +454,24 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
     ),
   };
 
+  const inputSourceFilter: Field<IoConfig> = {
+    name: 'inputSource.filter',
+    label: 'File filter',
+    type: 'string',
+    suggestions: FILTER_SUGGESTIONS,
+    placeholder: '*',
+    info: (
+      <p>
+        A wildcard filter for files. See{' '}
+        <ExternalLink href="https://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/filefilter/WildcardFileFilter.html">
+          here
+        </ExternalLink>{' '}
+        for format information. Files matching the filter criteria are considered for ingestion.
+        Files not matching the filter criteria are ignored.
+      </p>
+    ),
+  };
+
   switch (ingestionComboType) {
     case 'index_parallel:http':
       return [
@@ -499,16 +529,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
           label: 'File filter',
           type: 'string',
           required: true,
-          suggestions: [
-            '*',
-            '*.json',
-            '*.json.gz',
-            '*.csv',
-            '*.tsv',
-            '*.parquet',
-            '*.orc',
-            '*.avro',
-          ],
+          suggestions: FILTER_SUGGESTIONS,
           info: (
             <>
               <ExternalLink
@@ -521,7 +542,8 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
                 <ExternalLink href="https://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/filefilter/WildcardFileFilter.html">
                   here
                 </ExternalLink>{' '}
-                for format information.
+                for format information. Files matching the filter criteria are considered for
+                ingestion. Files not matching the filter criteria are ignored.
               </p>
             </>
           ),
@@ -629,7 +651,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
             </>
           ),
         },
-
+        inputSourceFilter,
         {
           name: 'inputSource.properties.accessKeyId.type',
           label: 'Access key ID type',
@@ -687,7 +709,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
 
         {
           name: 'inputSource.properties.secretAccessKey.type',
-          label: 'Secret key type',
+          label: 'Secret access key type',
           type: 'string',
           suggestions: [undefined, 'environment', 'default'],
           placeholder: '(none)',
@@ -708,7 +730,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
         },
         {
           name: 'inputSource.properties.secretAccessKey.variable',
-          label: 'Secret key value',
+          label: 'Secret access key environment variable',
           type: 'string',
           placeholder: '(environment variable name)',
           defined: ioConfig =>
@@ -717,7 +739,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
         },
         {
           name: 'inputSource.properties.secretAccessKey.password',
-          label: 'Secret key value',
+          label: 'Secret access key value',
           type: 'string',
           placeholder: '(secret key)',
           defined: ioConfig =>
@@ -791,6 +813,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
             </>
           ),
         },
+        inputSourceFilter,
       ];
 
     case 'index_parallel:google':
@@ -849,6 +872,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
             </>
           ),
         },
+        inputSourceFilter,
       ];
 
     case 'index_parallel:hdfs':
@@ -1277,9 +1301,9 @@ function filterIsFilename(filter: string): boolean {
 }
 
 function filenameFromPath(path: string): string | undefined {
-  const m = /([^/.]+)[^/]*?\/?$/.exec(path);
-  if (!m) return;
-  return m[1];
+  const parts = path.split('/');
+  while (parts.length && !parts[parts.length - 1]) parts.pop();
+  return parts.length ? basenameFromFilename(parts[parts.length - 1]) : undefined;
 }
 
 function basenameFromFilename(filename: string): string | undefined {
@@ -1751,6 +1775,24 @@ const TUNING_FORM_FIELDS: Field<IngestionSpec>[] = [
     placeholder: 'Default: 1/6 of max JVM memory',
     info: <>Used in determining when intermediate persists to disk should occur.</>,
   },
+  {
+    name: 'spec.tuningConfig.maxColumnsToMerge',
+    type: 'number',
+    defaultValue: -1,
+    min: -1,
+    hideInMore: true,
+    info: (
+      <>
+        <p>
+          Limit of the number of segments to merge in a single phase when merging segments for
+          publishing. This limit affects the total number of columns present in a set of segments to
+          merge. If the limit is exceeded, segment merging occurs in multiple phases. Druid merges
+          at least 2 segments per phase, regardless of this setting.
+        </p>
+        <p>Default: -1 (unlimited)</p>
+      </>
+    ),
+  },
   {
     name: 'spec.tuningConfig.resetOffsetAutomatically',
     type: 'boolean',
@@ -2303,6 +2345,9 @@ export function guessColumnTypeFromInput(
   // If we see any arrays in the input this is a multi-value dimension that must be a string
   if (definedValues.some(v => Array.isArray(v))) return 'string';
 
+  // If we see any JSON objects in the input assume COMPLEX<json>
+  if (definedValues.some(v => v && typeof v === 'object')) return 'COMPLEX<json>';
+
   if (
     definedValues.every(v => {
       return (
diff --git a/web-console/src/druid-models/input-format.tsx b/web-console/src/druid-models/input-format/input-format.tsx
similarity index 93%
rename from web-console/src/druid-models/input-format.tsx
rename to web-console/src/druid-models/input-format/input-format.tsx
index 90fb8b800d..61ac6eda14 100644
--- a/web-console/src/druid-models/input-format.tsx
+++ b/web-console/src/druid-models/input-format/input-format.tsx
@@ -19,11 +19,10 @@
 import { Code } from '@blueprintjs/core';
 import React from 'react';
 
-import { AutoForm, ExternalLink, Field } from '../components';
-import { getLink } from '../links';
-import { oneOf, typeIs } from '../utils';
-
-import { FlattenSpec } from './flatten-spec';
+import { AutoForm, ExternalLink, Field } from '../../components';
+import { getLink } from '../../links';
+import { oneOf, typeIs } from '../../utils';
+import { FlattenSpec } from '../flatten-spec/flatten-spec';
 
 export interface InputFormat {
   readonly type: string;
@@ -31,7 +30,7 @@ export interface InputFormat {
   readonly skipHeaderRows?: number;
   readonly columns?: string[];
   readonly delimiter?: string;
-  readonly listDelimiter?: string;
+  readonly listDelimiter?: string | null;
   readonly pattern?: string;
   readonly function?: string;
   readonly flattenSpec?: FlattenSpec | null;
@@ -44,7 +43,7 @@ export const INPUT_FORMAT_FIELDS: Field<InputFormat>[] = [
     name: 'type',
     label: 'Input format',
     type: 'string',
-    suggestions: ['json', 'csv', 'tsv', 'regex', 'parquet', 'orc', 'avro_ocf', 'avro_stream'],
+    suggestions: ['json', 'csv', 'tsv', 'parquet', 'orc', 'avro_ocf', 'avro_stream', 'regex'],
     required: true,
     info: (
       <>
diff --git a/web-console/src/druid-models/input-source.tsx b/web-console/src/druid-models/input-source.tsx
deleted file mode 100644
index eb989de4f0..0000000000
--- a/web-console/src/druid-models/input-source.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { nonEmptyArray } from '../utils';
-
-export interface InputSource {
-  type: string;
-  baseDir?: string;
-  filter?: any;
-  uris?: string[];
-  prefixes?: string[];
-  objects?: { bucket: string; path: string }[];
-  fetchTimeout?: number;
-
-  // druid
-  dataSource?: string;
-  interval?: string;
-  dimensions?: string[];
-  metrics?: string[];
-  maxInputSegmentBytesPerTask?: number;
-
-  // inline
-  data?: string;
-
-  // hdfs
-  paths?: string;
-
-  // http
-  httpAuthenticationUsername?: any;
-  httpAuthenticationPassword?: any;
-}
-
-export function issueWithInputSource(inputSource: InputSource | undefined): string | undefined {
-  if (!inputSource) return 'does not exist';
-  if (!inputSource.type) return 'missing a type';
-  switch (inputSource.type) {
-    case 'local':
-      if (!inputSource.baseDir) return `must have a 'baseDir'`;
-      if (!inputSource.filter) return `must have a 'filter'`;
-      break;
-
-    case 'http':
-      if (!nonEmptyArray(inputSource.uris)) {
-        return 'must have at least one uri';
-      }
-      break;
-
-    case 'druid':
-      if (!inputSource.dataSource) return `must have a 'dataSource'`;
-      if (!inputSource.interval) return `must have an 'interval'`;
-      break;
-
-    case 'inline':
-      if (!inputSource.data) return `must have 'data'`;
-      break;
-
-    case 's3':
-    case 'azure':
-    case 'google':
-      if (
-        !nonEmptyArray(inputSource.uris) &&
-        !nonEmptyArray(inputSource.prefixes) &&
-        !nonEmptyArray(inputSource.objects)
-      ) {
-        return 'must have at least one uri or prefix or object';
-      }
-      break;
-
-    case 'hdfs':
-      if (!inputSource.paths) {
-        return 'must have paths';
-      }
-      break;
-  }
-  return;
-}
diff --git a/web-console/src/druid-models/input-source/input-source.tsx b/web-console/src/druid-models/input-source/input-source.tsx
new file mode 100644
index 0000000000..7f5ba63793
--- /dev/null
+++ b/web-console/src/druid-models/input-source/input-source.tsx
@@ -0,0 +1,590 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+import { ExternalLink, Field } from '../../components';
+import { getLink } from '../../links';
+import { deepGet, deepSet, nonEmptyArray, typeIs } from '../../utils';
+
+export const FILTER_SUGGESTIONS: string[] = [
+  '*',
+  '*.json',
+  '*.json.gz',
+  '*.csv',
+  '*.tsv',
+  '*.parquet',
+  '*.orc',
+  '*.avro',
+];
+
+export interface InputSource {
+  type: string;
+  baseDir?: string;
+  filter?: any;
+  uris?: string[];
+  prefixes?: string[];
+  objects?: { bucket: string; path: string }[];
+  fetchTimeout?: number;
+
+  // druid
+  dataSource?: string;
+  interval?: string;
+  dimensions?: string[];
+  metrics?: string[];
+  maxInputSegmentBytesPerTask?: number;
+
+  // inline
+  data?: string;
+
+  // hdfs
+  paths?: string | string[];
+
+  // http
+  httpAuthenticationUsername?: any;
+  httpAuthenticationPassword?: any;
+}
+
+export type InputSourceDesc =
+  | {
+      type: 'inline';
+      data: string;
+    }
+  | {
+      type: 'local';
+      filter?: any;
+      baseDir?: string;
+      files?: string[];
+    }
+  | {
+      type: 'druid';
+      dataSource: string;
+      interval: string;
+      filter?: any;
+      dimensions?: string[]; // ToDo: these are not in the docs https://druid.apache.org/docs/latest/ingestion/native-batch-input-sources.html
+      metrics?: string[];
+      maxInputSegmentBytesPerTask?: number;
+    }
+  | {
+      type: 'http';
+      uris: string[];
+      httpAuthenticationUsername?: any;
+      httpAuthenticationPassword?: any;
+    }
+  | {
+      type: 's3';
+      uris?: string[];
+      prefixes?: string[];
+      objects?: { bucket: string; path: string }[];
+      properties?: {
+        accessKeyId?: any;
+        secretAccessKey?: any;
+        assumeRoleArn?: any;
+        assumeRoleExternalId?: any;
+      };
+    }
+  | {
+      type: 'google' | 'azure';
+      uris?: string[];
+      prefixes?: string[];
+      objects?: { bucket: string; path: string }[];
+    }
+  | {
+      type: 'hdfs';
+      paths?: string | string[];
+    }
+  | {
+      type: 'sql';
+      database: any;
+      foldCase?: boolean;
+      sqls: string[];
+    }
+  | {
+      type: 'combining';
+      delegates: InputSource[];
+    };
+
+export function issueWithInputSource(inputSource: InputSource | undefined): string | undefined {
+  if (!inputSource) return 'does not exist';
+  if (!inputSource.type) return 'missing a type';
+  switch (inputSource.type) {
+    case 'local':
+      if (!inputSource.baseDir) return `must have a 'baseDir'`;
+      if (!inputSource.filter) return `must have a 'filter'`;
+      return;
+
+    case 'http':
+      if (!nonEmptyArray(inputSource.uris)) {
+        return 'must have at least one uri';
+      }
+      return;
+
+    case 'druid':
+      if (!inputSource.dataSource) return `must have a 'dataSource'`;
+      if (!inputSource.interval) return `must have an 'interval'`;
+      return;
+
+    case 'inline':
+      if (!inputSource.data) return `must have 'data'`;
+      return;
+
+    case 's3':
+    case 'azure':
+    case 'google':
+      if (
+        !nonEmptyArray(inputSource.uris) &&
+        !nonEmptyArray(inputSource.prefixes) &&
+        !nonEmptyArray(inputSource.objects)
+      ) {
+        return 'must have at least one uri or prefix or object';
+      }
+      return;
+
+    case 'hdfs':
+      if (!inputSource.paths) {
+        return 'must have paths';
+      }
+      return;
+
+    default:
+      return;
+  }
+}
+
+export const INPUT_SOURCE_FIELDS: Field<InputSource>[] = [
+  // inline
+
+  {
+    name: 'data',
+    label: 'Inline data',
+    type: 'string',
+    defined: typeIs('inline'),
+    required: true,
+    placeholder: 'Paste your data here',
+    multiline: true,
+    info: <p>Put you inline data here</p>,
+  },
+
+  // http
+
+  {
+    name: 'uris',
+    label: 'URIs',
+    type: 'string-array',
+    placeholder: 'https://example.com/path/to/file1.ext, https://example.com/path/to/file2.ext',
+    defined: typeIs('http'),
+    required: true,
+    info: (
+      <p>
+        The full URI of your file. To ingest from multiple URIs, use commas to separate each
+        individual URI.
+      </p>
+    ),
+  },
+  {
+    name: 'httpAuthenticationUsername',
+    label: 'HTTP auth username',
+    type: 'string',
+    defined: typeIs('http'),
+    placeholder: '(optional)',
+    info: <p>Username to use for authentication with specified URIs</p>,
+  },
+  {
+    name: 'httpAuthenticationPassword',
+    label: 'HTTP auth password',
+    type: 'string',
+    defined: typeIs('http'),
+    placeholder: '(optional)',
+    info: <p>Password to use for authentication with specified URIs</p>,
+  },
+
+  // local
+
+  {
+    name: 'baseDir',
+    label: 'Base directory',
+    type: 'string',
+    placeholder: '/path/to/files/',
+    defined: typeIs('local'),
+    required: true,
+    info: (
+      <>
+        <ExternalLink href={`${getLink('DOCS')}/ingestion/native-batch.html#input-sources`}>
+          baseDir
+        </ExternalLink>
+        <p>Specifies the directory to search recursively for files to be ingested.</p>
+      </>
+    ),
+  },
+  {
+    name: 'filter',
+    label: 'File filter',
+    type: 'string',
+    defined: typeIs('local'),
+    required: true,
+    suggestions: FILTER_SUGGESTIONS,
+    info: (
+      <>
+        <ExternalLink href={`${getLink('DOCS')}/ingestion/native-batch.html#local-input-source`}>
+          filter
+        </ExternalLink>
+        <p>
+          A wildcard filter for files. See{' '}
+          <ExternalLink href="https://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/filefilter/WildcardFileFilter.html">
+            here
+          </ExternalLink>{' '}
+          for format information.
+        </p>
+      </>
+    ),
+  },
+
+  // s3
+
+  {
+    name: 'uris',
+    label: 'S3 URIs',
+    type: 'string-array',
+    placeholder: 's3://your-bucket/some-file1.ext, s3://your-bucket/some-file2.ext',
+    defined: inputSource =>
+      inputSource.type === 's3' &&
+      !deepGet(inputSource, 'prefixes') &&
+      !deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>
+          The full S3 URI of your file. To ingest from multiple URIs, use commas to separate each
+          individual URI.
+        </p>
+        <p>Either S3 URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+  {
+    name: 'prefixes',
+    label: 'S3 prefixes',
+    type: 'string-array',
+    placeholder: 's3://your-bucket/some-path1, s3://your-bucket/some-path2',
+    defined: inputSource =>
+      inputSource.type === 's3' &&
+      !deepGet(inputSource, 'uris') &&
+      !deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>A list of paths (with bucket) where your files are stored.</p>
+        <p>Either S3 URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+  {
+    name: 'objects',
+    label: 'S3 objects',
+    type: 'json',
+    placeholder: '{"bucket":"your-bucket", "path":"some-file.ext"}',
+    defined: inputSource => inputSource.type === 's3' && deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>
+          JSON array of{' '}
+          <ExternalLink href={`${getLink('DOCS')}/development/extensions-core/s3.html`}>
+            S3 Objects
+          </ExternalLink>
+          .
+        </p>
+        <p>Either S3 URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+
+  // azure
+
+  {
+    name: 'uris',
+    label: 'Azure URIs',
+    type: 'string-array',
+    placeholder: 'azure://your-container/some-file1.ext, azure://your-container/some-file2.ext',
+    defined: inputSource =>
+      inputSource.type === 'azure' &&
+      !deepGet(inputSource, 'prefixes') &&
+      !deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>
+          The full Azure URI of your file. To ingest from multiple URIs, use commas to separate each
+          individual URI.
+        </p>
+        <p>Either Azure URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+  {
+    name: 'prefixes',
+    label: 'Azure prefixes',
+    type: 'string-array',
+    placeholder: 'azure://your-container/some-path1, azure://your-container/some-path2',
+    defined: inputSource =>
+      inputSource.type === 'azure' &&
+      !deepGet(inputSource, 'uris') &&
+      !deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>A list of paths (with bucket) where your files are stored.</p>
+        <p>Either Azure URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+  {
+    name: 'objects',
+    label: 'Azure objects',
+    type: 'json',
+    placeholder: '{"bucket":"your-container", "path":"some-file.ext"}',
+    defined: inputSource => inputSource.type === 'azure' && deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>
+          JSON array of{' '}
+          <ExternalLink href={`${getLink('DOCS')}/development/extensions-core/azure.html`}>
+            S3 Objects
+          </ExternalLink>
+          .
+        </p>
+        <p>Either Azure URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+
+  // google
+  {
+    name: 'uris',
+    label: 'Google Cloud Storage URIs',
+    type: 'string-array',
+    placeholder: 'gs://your-bucket/some-file1.ext, gs://your-bucket/some-file2.ext',
+    defined: inputSource =>
+      inputSource.type === 'google' &&
+      !deepGet(inputSource, 'prefixes') &&
+      !deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>
+          The full Google Cloud Storage URI of your file. To ingest from multiple URIs, use commas
+          to separate each individual URI.
+        </p>
+        <p>Either Google Cloud Storage URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+  {
+    name: 'prefixes',
+    label: 'Google Cloud Storage prefixes',
+    type: 'string-array',
+    placeholder: 'gs://your-bucket/some-path1, gs://your-bucket/some-path2',
+    defined: inputSource =>
+      inputSource.type === 'google' &&
+      !deepGet(inputSource, 'uris') &&
+      !deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>A list of paths (with bucket) where your files are stored.</p>
+        <p>Either Google Cloud Storage URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+  {
+    name: 'objects',
+    label: 'Google Cloud Storage objects',
+    type: 'json',
+    placeholder: '{"bucket":"your-bucket", "path":"some-file.ext"}',
+    defined: inputSource => inputSource.type === 'google' && deepGet(inputSource, 'objects'),
+    required: true,
+    info: (
+      <>
+        <p>
+          JSON array of{' '}
+          <ExternalLink href={`${getLink('DOCS')}/development/extensions-core/google.html`}>
+            Google Cloud Storage Objects
+          </ExternalLink>
+          .
+        </p>
+        <p>Either Google Cloud Storage URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+
+  // Cloud common
+  {
+    name: 'filter',
+    label: 'File filter',
+    type: 'string',
+    suggestions: FILTER_SUGGESTIONS,
+    placeholder: '*',
+    defined: typeIs('s3', 'azure', 'google'),
+    info: (
+      <p>
+        A wildcard filter for files. See{' '}
+        <ExternalLink href="https://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/filefilter/WildcardFileFilter.html">
+          here
+        </ExternalLink>{' '}
+        for format information. Files matching the filter criteria are considered for ingestion.
+        Files not matching the filter criteria are ignored.
+      </p>
+    ),
+  },
+
+  // S3 auth extra
+  {
+    name: 'properties.accessKeyId.type',
+    label: 'Access key ID type',
+    type: 'string',
+    suggestions: [undefined, 'environment', 'default'],
+    placeholder: '(none)',
+    defined: typeIs('s3'),
+    info: (
+      <>
+        <p>S3 access key type.</p>
+        <p>Setting this will override the default configuration provided in the config.</p>
+        <p>
+          The access key can be pulled from an environment variable or inlined in the ingestion spec
+          (default).
+        </p>
+        <p>
+          Note: Inlining the access key into the ingestion spec is dangerous as it might appear in
+          server log files and can be seen by anyone accessing this console.
+        </p>
+      </>
+    ),
+    adjustment: inputSource => {
+      return deepSet(
+        inputSource,
+        'properties.secretAccessKey.type',
+        deepGet(inputSource, 'properties.accessKeyId.type'),
+      );
+    },
+  },
+  {
+    name: 'properties.accessKeyId.variable',
+    label: 'Access key ID environment variable',
+    type: 'string',
+    placeholder: '(environment variable name)',
+    defined: inputSource =>
+      inputSource.type === 's3' &&
+      deepGet(inputSource, 'properties.accessKeyId.type') === 'environment',
+    info: <p>The environment variable containing the S3 access key for this S3 bucket.</p>,
+  },
+  {
+    name: 'properties.accessKeyId.password',
+    label: 'Access key ID value',
+    type: 'string',
+    placeholder: '(access key)',
+    defined: inputSource =>
+      inputSource.type === 's3' &&
+      deepGet(inputSource, 'properties.accessKeyId.type') === 'default',
+    info: (
+      <>
+        <p>S3 access key for this S3 bucket.</p>
+        <p>
+          Note: Inlining the access key into the ingestion spec is dangerous as it might appear in
+          server log files and can be seen by anyone accessing this console.
+        </p>
+      </>
+    ),
+  },
+  {
+    name: 'properties.secretAccessKey.type',
+    label: 'Secret access key type',
+    type: 'string',
+    suggestions: [undefined, 'environment', 'default'],
+    placeholder: '(none)',
+    defined: typeIs('s3'),
+    info: (
+      <>
+        <p>S3 secret key type.</p>
+        <p>Setting this will override the default configuration provided in the config.</p>
+        <p>
+          The secret key can be pulled from an environment variable or inlined in the ingestion spec
+          (default).
+        </p>
+        <p>
+          Note: Inlining the secret key into the ingestion spec is dangerous as it might appear in
+          server log files and can be seen by anyone accessing this console.
+        </p>
+      </>
+    ),
+  },
+  {
+    name: 'properties.secretAccessKey.variable',
+    label: 'Secret access key environment variable',
+    type: 'string',
+    placeholder: '(environment variable name)',
+    defined: inputSource =>
+      deepGet(inputSource, 'properties.secretAccessKey.type') === 'environment',
+    info: <p>The environment variable containing the S3 secret key for this S3 bucket.</p>,
+  },
+  {
+    name: 'properties.secretAccessKey.password',
+    label: 'Secret access key value',
+    type: 'string',
+    placeholder: '(secret key)',
+    defined: inputSource => deepGet(inputSource, 'properties.secretAccessKey.type') === 'default',
+    info: (
+      <>
+        <p>S3 secret key for this S3 bucket.</p>
+        <p>
+          Note: Inlining the access key into the ingestion spec is dangerous as it might appear in
+          server log files and can be seen by anyone accessing this console.
+        </p>
+      </>
+    ),
+  },
+
+  // hdfs
+  {
+    name: 'paths',
+    label: 'Paths',
+    type: 'string',
+    placeholder: '/path/to/file.ext',
+    defined: typeIs('hdfs'),
+    required: true,
+  },
+
+  // sql
+  {
+    name: 'database.type',
+    label: 'Database type',
+    type: 'string',
+    suggestions: ['mysql', 'postgresql'],
+    defined: typeIs('sql'),
+    required: true,
+    info: (
+      <>
+        <p>
+          The full Google Cloud Storage URI of your file. To ingest from multiple URIs, use commas
+          to separate each individual URI.
+        </p>
+        <p>Either Google Cloud Storage URIs or prefixes or objects must be set.</p>
+      </>
+    ),
+  },
+];
diff --git a/web-console/src/druid-models/lookup-spec.spec.ts b/web-console/src/druid-models/lookup-spec/lookup-spec.spec.ts
similarity index 100%
rename from web-console/src/druid-models/lookup-spec.spec.ts
rename to web-console/src/druid-models/lookup-spec/lookup-spec.spec.ts
diff --git a/web-console/src/druid-models/lookup-spec.tsx b/web-console/src/druid-models/lookup-spec/lookup-spec.tsx
similarity index 99%
rename from web-console/src/druid-models/lookup-spec.tsx
rename to web-console/src/druid-models/lookup-spec/lookup-spec.tsx
index 67f12b6d39..b1328a8f12 100644
--- a/web-console/src/druid-models/lookup-spec.tsx
+++ b/web-console/src/druid-models/lookup-spec/lookup-spec.tsx
@@ -19,8 +19,8 @@
 import { Code } from '@blueprintjs/core';
 import React from 'react';
 
-import { AutoForm, Field } from '../components';
-import { deepGet, deepSet, oneOf, pluralIfNeeded, typeIs } from '../utils';
+import { AutoForm, Field } from '../../components';
+import { deepGet, deepSet, oneOf, pluralIfNeeded, typeIs } from '../../utils';
 
 export interface ExtractionNamespaceSpec {
   readonly type: string;
diff --git a/web-console/src/druid-models/metric-spec.spec.ts b/web-console/src/druid-models/metric-spec/metric-spec.spec.ts
similarity index 97%
rename from web-console/src/druid-models/metric-spec.spec.ts
rename to web-console/src/druid-models/metric-spec/metric-spec.spec.ts
index 70e0d5051c..e30b806344 100644
--- a/web-console/src/druid-models/metric-spec.spec.ts
+++ b/web-console/src/druid-models/metric-spec/metric-spec.spec.ts
@@ -16,8 +16,9 @@
  * limitations under the License.
  */
 
+import { JSON_SAMPLE } from '../../utils/sampler.mock';
+
 import { getMetricSpecs } from './metric-spec';
-import { JSON_SAMPLE } from './test-fixtures';
 
 describe('metric-spec', () => {
   describe('getMetricSecs', () => {
diff --git a/web-console/src/druid-models/metric-spec.tsx b/web-console/src/druid-models/metric-spec/metric-spec.tsx
similarity index 97%
rename from web-console/src/druid-models/metric-spec.tsx
rename to web-console/src/druid-models/metric-spec/metric-spec.tsx
index 3aa9c7b19c..6b3290272e 100644
--- a/web-console/src/druid-models/metric-spec.tsx
+++ b/web-console/src/druid-models/metric-spec/metric-spec.tsx
@@ -19,12 +19,11 @@
 import { Code } from '@blueprintjs/core';
 import React from 'react';
 
-import { ExternalLink, Field } from '../components';
-import { getLink } from '../links';
-import { filterMap, typeIs } from '../utils';
-import { SampleHeaderAndRows } from '../utils/sampler';
-
-import { guessColumnTypeFromHeaderAndRows } from './ingestion-spec';
+import { ExternalLink, Field } from '../../components';
+import { getLink } from '../../links';
+import { filterMap, typeIs } from '../../utils';
+import { SampleHeaderAndRows } from '../../utils/sampler';
+import { guessColumnTypeFromHeaderAndRows } from '../ingestion-spec/ingestion-spec';
 
 export interface MetricSpec {
   readonly type: string;
diff --git a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss b/web-console/src/druid-models/mocks.ts
similarity index 87%
copy from web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
copy to web-console/src/druid-models/mocks.ts
index 43efe5085b..a07429577e 100644
--- a/web-console/src/components/table-cell-unparseable/table-cell-unparseable.scss
+++ b/web-console/src/druid-models/mocks.ts
@@ -16,6 +16,5 @@
  * limitations under the License.
  */
 
-.table-cell-unparseable {
-  color: #9e2b0e;
-}
+export * from './execution/execution-ingest-complete.mock';
+export * from './execution/execution-ingest-error.mock';
diff --git a/web-console/src/druid-models/overlord-dynamic-config.tsx b/web-console/src/druid-models/overlord-dynamic-config/overlord-dynamic-config.tsx
similarity index 96%
rename from web-console/src/druid-models/overlord-dynamic-config.tsx
rename to web-console/src/druid-models/overlord-dynamic-config/overlord-dynamic-config.tsx
index 14cac82951..2b090acd9d 100644
--- a/web-console/src/druid-models/overlord-dynamic-config.tsx
+++ b/web-console/src/druid-models/overlord-dynamic-config/overlord-dynamic-config.tsx
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { Field } from '../components';
+import { Field } from '../../components';
 
 export interface OverlordDynamicConfig {
   selectStrategy?: Record<string, any>;
diff --git a/web-console/src/druid-models/query-context/query-context.tsx b/web-console/src/druid-models/query-context/query-context.tsx
new file mode 100644
index 0000000000..f915186285
--- /dev/null
+++ b/web-console/src/druid-models/query-context/query-context.tsx
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { deepDelete, deepSet } from '../../utils';
+
+export interface QueryContext {
+  useCache?: boolean;
+  populateCache?: boolean;
+  useApproximateCountDistinct?: boolean;
+  useApproximateTopN?: boolean;
+  sqlTimeZone?: string;
+
+  // Multi-stage query
+  maxNumTasks?: number;
+  finalizeAggregations?: boolean;
+  durableShuffleStorage?: boolean;
+  maxParseExceptions?: number;
+  groupByEnableMultiValueUnnesting?: boolean;
+
+  [key: string]: any;
+}
+
+export interface QueryWithContext {
+  queryString: string;
+  queryContext?: QueryContext;
+  wrapQueryLimit?: number;
+}
+
+export function isEmptyContext(context: QueryContext | undefined): boolean {
+  return !context || Object.keys(context).length === 0;
+}
+
+// -----------------------------
+
+export function getUseCache(context: QueryContext): boolean {
+  const { useCache } = context;
+  return typeof useCache === 'boolean' ? useCache : true;
+}
+
+export function changeUseCache(context: QueryContext, useCache: boolean): QueryContext {
+  let newContext = context;
+  if (useCache) {
+    newContext = deepDelete(newContext, 'useCache');
+    newContext = deepDelete(newContext, 'populateCache');
+  } else {
+    newContext = deepSet(newContext, 'useCache', false);
+    newContext = deepSet(newContext, 'populateCache', false);
+  }
+  return newContext;
+}
+
+// -----------------------------
+
+export function getUseApproximateCountDistinct(context: QueryContext): boolean {
+  const { useApproximateCountDistinct } = context;
+  return typeof useApproximateCountDistinct === 'boolean' ? useApproximateCountDistinct : true;
+}
+
+export function changeUseApproximateCountDistinct(
+  context: QueryContext,
+  useApproximateCountDistinct: boolean,
+): QueryContext {
+  if (useApproximateCountDistinct) {
+    return deepDelete(context, 'useApproximateCountDistinct');
+  } else {
+    return deepSet(context, 'useApproximateCountDistinct', false);
+  }
+}
+
+// -----------------------------
+
+export function getUseApproximateTopN(context: QueryContext): boolean {
+  const { useApproximateTopN } = context;
+  return typeof useApproximateTopN === 'boolean' ? useApproximateTopN : true;
+}
+
+export function changeUseApproximateTopN(
+  context: QueryContext,
+  useApproximateTopN: boolean,
+): QueryContext {
+  if (useApproximateTopN) {
+    return deepDelete(context, 'useApproximateTopN');
+  } else {
+    return deepSet(context, 'useApproximateTopN', false);
+  }
+}
+
+// sqlTimeZone
+
+export function getTimezone(context: QueryContext): string | undefined {
+  return context.sqlTimeZone;
+}
+
+export function changeTimezone(context: QueryContext, timezone: string | undefined): QueryContext {
+  if (timezone) {
+    return deepSet(context, 'sqlTimeZone', timezone);
+  } else {
+    return deepDelete(context, 'sqlTimeZone');
+  }
+}
+
+// maxNumTasks
+
+export function getMaxNumTasks(context: QueryContext): number {
+  const { maxNumTasks } = context;
+  return Math.max(typeof maxNumTasks === 'number' ? maxNumTasks : 0, 2);
+}
+
+export function changeMaxNumTasks(
+  context: QueryContext,
+  maxNumTasks: number | undefined,
+): QueryContext {
+  return typeof maxNumTasks === 'number'
+    ? deepSet(context, 'maxNumTasks', maxNumTasks)
+    : deepDelete(context, 'maxNumTasks');
+}
+
+// taskAssignment
+
+export function getTaskAssigment(context: QueryContext): string {
+  const { taskAssignment } = context;
+  return taskAssignment ?? 'max';
+}
+
+export function changeTaskAssigment(
+  context: QueryContext,
+  taskAssignment: string | undefined,
+): QueryContext {
+  return typeof taskAssignment === 'string'
+    ? deepSet(context, 'taskAssignment', taskAssignment)
+    : deepDelete(context, 'taskAssignment');
+}
+
+// finalizeAggregations
+
+export function getFinalizeAggregations(context: QueryContext): boolean | undefined {
+  const { finalizeAggregations } = context;
+  return typeof finalizeAggregations === 'boolean' ? finalizeAggregations : undefined;
+}
+
+export function changeFinalizeAggregations(
+  context: QueryContext,
+  finalizeAggregations: boolean | undefined,
+): QueryContext {
+  return typeof finalizeAggregations === 'boolean'
+    ? deepSet(context, 'finalizeAggregations', finalizeAggregations)
+    : deepDelete(context, 'finalizeAggregations');
+}
+
+// finalizeAggregations
+
+export function getGroupByEnableMultiValueUnnesting(context: QueryContext): boolean | undefined {
+  const { groupByEnableMultiValueUnnesting } = context;
+  return typeof groupByEnableMultiValueUnnesting === 'boolean'
+    ? groupByEnableMultiValueUnnesting
+    : undefined;
+}
+
+export function changeGroupByEnableMultiValueUnnesting(
+  context: QueryContext,
+  groupByEnableMultiValueUnnesting: boolean | undefined,
+): QueryContext {
+  return typeof groupByEnableMultiValueUnnesting === 'boolean'
+    ? deepSet(context, 'groupByEnableMultiValueUnnesting', groupByEnableMultiValueUnnesting)
+    : deepDelete(context, 'groupByEnableMultiValueUnnesting');
+}
+
+// durableShuffleStorage
+
+export function getDurableShuffleStorage(context: QueryContext): boolean {
+  const { durableShuffleStorage } = context;
+  return Boolean(durableShuffleStorage);
+}
+
+export function changeDurableShuffleStorage(
+  context: QueryContext,
+  durableShuffleStorage: boolean,
+): QueryContext {
+  if (durableShuffleStorage) {
+    return deepSet(context, 'durableShuffleStorage', true);
+  } else {
+    return deepDelete(context, 'durableShuffleStorage');
+  }
+}
+
+// maxParseExceptions
+
+export function getMaxParseExceptions(context: QueryContext): number {
+  const { maxParseExceptions } = context;
+  return Number(maxParseExceptions) || 0;
+}
+
+export function changeMaxParseExceptions(
... 22691 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@druid.apache.org
For additional commands, e-mail: commits-help@druid.apache.org