You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@druid.apache.org by kf...@apache.org on 2024/10/01 03:37:09 UTC

(druid) branch 31.0.0 updated: [Backport] console work to 31 (#16991, #17132, #17133, #17135, #17180) (#17200)

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

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


The following commit(s) were added to refs/heads/31.0.0 by this push:
     new 20629487212 [Backport] console work to 31 (#16991, #17132, #17133, #17135, #17180) (#17200)
20629487212 is described below

commit 2062948721217893e20568bc2d6cde656e57108d
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Mon Sep 30 20:37:03 2024 -0700

    [Backport] console work to 31 (#16991, #17132, #17133, #17135, #17180) (#17200)
    
    * Web console query view improvements (#16991)
    * Made maxNumTaskOptions configurable in the Query view
    * Updated the copy for taskAssignment options
    * Reordered options in engine menu for msq engine
    * fixed snapshot
    * maxNumTaskOptions -> maxTasksOptions
    * added back select destination item
    * fixed duplicate menu item
    * snapshot
    * Added the ability to hide certain engine menu options
    * Added the ability to hide/show more menu items
    * Make the tooltip better and improve structure (#17132)
    * switch to using arrays by default (#17133)
    * Web console: add stage graph (#17135)
    * Web console: revamp the experimental explore view (#17180)
    * explore revamp
    * remove ToDo
    * fix CodeQL
    * add tooltips
    * show issue on echart chars
    * fix: browser back does not refresh chart
    * fix maxRows 0
    * be more resiliant to missing __time
    ---------
    Co-authored-by: Sébastien <se...@imply.io>
---
 licenses.yaml                                      |    47 +-
 web-console/README.md                              |     3 +-
 web-console/package-lock.json                      |   112 +-
 web-console/package.json                           |     4 +-
 .../src/components/braced-text/braced-text.tsx     |     4 +-
 .../__snapshots__/click-to-copy.spec.tsx.snap      |     2 +-
 .../src/components/click-to-copy/click-to-copy.tsx |     2 +-
 .../__snapshots__/header-bar.spec.tsx.snap         |     5 +-
 .../src/components/header-bar/header-bar.tsx       |     7 +-
 web-console/src/components/index.ts                |     1 +
 .../record-table-pane/record-table-pane.tsx        |     2 +-
 .../__snapshots__/rule-editor.spec.tsx.snap        |     4 -
 .../src/components/rule-editor/rule-editor.tsx     |     2 +-
 .../supervisor-history-panel.tsx                   |     6 +-
 .../__snapshots__/table-cell.spec.tsx.snap         |     8 +-
 .../src/components/table-cell/table-cell.tsx       |     6 +-
 .../table-clickable-cell/table-clickable-cell.tsx  |     4 +-
 web-console/src/console-application.tsx            |    15 +-
 .../compaction-config-dialog.spec.tsx.snap         |    21 +-
 .../compaction-config-dialog.tsx                   |    11 +-
 .../compaction-history-dialog.tsx                  |     4 +-
 .../src/dialogs/history-dialog/history-dialog.tsx  |     4 +-
 .../__snapshots__/retention-dialog.spec.tsx.snap   |     2 +-
 .../src/druid-models/execution/execution.ts        |    16 +-
 .../druid-models/ingestion-spec/ingestion-spec.tsx |     3 +
 .../druid-models/query-context/query-context.tsx   |     2 +
 web-console/src/druid-models/stages/graph-info.ts  |    77 +
 web-console/src/druid-models/stages/stages.spec.ts |    92 +
 web-console/src/druid-models/stages/stages.ts      |   194 +-
 web-console/src/druid-models/task/task.ts          |     6 +
 .../workbench-query/workbench-query.ts             |     2 +-
 web-console/src/entry.scss                         |     8 +
 web-console/src/entry.tsx                          |     4 +-
 web-console/src/helpers/capabilities.ts            |    42 +-
 web-console/src/hooks/index.ts                     |     1 +
 .../use-hash-and-local-storage-hybrid-state.ts     |    83 +
 web-console/src/hooks/use-query-manager.ts         |    23 +-
 .../src/singletons/workbench-running-promises.ts   |     7 +-
 .../filter-menu.scss => utils/base64-url.spec.ts}  |    39 +-
 .../source-pane.scss => utils/base64-url.ts}       |    22 +-
 web-console/src/utils/basic-action.tsx             |     2 +-
 web-console/src/utils/date.ts                      |    30 +-
 web-console/src/utils/general.tsx                  |    74 +-
 web-console/src/utils/index.tsx                    |     2 +
 web-console/src/utils/local-storage-keys.tsx       |     3 +-
 .../mouse-tooltip/mouse-tooltip.scss}              |    20 +-
 .../src/utils/mouse-tooltip/mouse-tooltip.ts       |    77 +
 web-console/src/utils/table-helpers.ts             |     3 +-
 web-console/src/utils/types.ts                     |     6 +
 .../explore-view/column-picker/column-picker.tsx   |    47 -
 .../column-picker-menu/column-picker-menu.scss     |     0
 .../column-picker-menu/column-picker-menu.tsx      |    26 +-
 .../components/column-picker/column-picker.tsx     |    84 +
 .../control-pane/control-pane.scss                 |     4 +-
 .../components/control-pane/control-pane.tsx       |   386 +
 .../control-pane/expression-menu.scss}             |    37 +-
 .../components/control-pane/expression-menu.tsx    |   212 +
 .../control-pane/measure-menu.scss}                |    46 +-
 .../components/control-pane/measure-menu.tsx       |   291 +
 .../control-pane/named-expressions-input.scss}     |     6 +-
 .../control-pane/named-expressions-input.tsx}      |   122 +-
 .../control-pane/options-input.tsx                 |    68 +-
 .../droppable-container/droppable-container.scss   |     2 +-
 .../droppable-container/droppable-container.tsx    |    74 +
 .../filter-pane/column-value/column-value.scss     |     0
 .../filter-pane/column-value/column-value.tsx      |     0
 .../contains-filter-control.scss                   |     0
 .../contains-filter-control.tsx                    |    97 +
 .../filter-pane/filter-menu/filter-menu.scss       |    25 +-
 .../filter-pane/filter-menu/filter-menu.tsx        |   425 +
 .../number-range-filter-control.tsx                |    69 +
 .../regexp-filter-control.scss                     |     0
 .../regexp-filter-control.tsx                      |    92 +-
 .../time-interval-filter-control.scss}             |    11 +-
 .../time-interval-filter-control.tsx               |    53 +
 .../time-relative-filter-control.tsx               |    61 +-
 .../values-filter-control.scss                     |     0
 .../values-filter-control.tsx                      |   120 +
 .../{ => components}/filter-pane/filter-pane.scss  |     4 +-
 .../{ => components}/filter-pane/filter-pane.tsx   |    80 +-
 .../generic-output-table/generic-output-table.scss |     8 +-
 .../generic-output-table/generic-output-table.tsx  |   246 +-
 .../highlight-bubble/highlight-bubble.scss         |     0
 .../highlight-bubble/highlight-bubble.tsx          |     4 +-
 .../explore-view/{modules => }/components/index.ts |    12 +
 .../issue/issue.scss}                              |    12 +-
 .../issue/issue.tsx}                               |    37 +-
 .../module-pane/module-pane.scss}                  |    53 +-
 .../components/module-pane/module-pane.tsx         |   100 +
 .../module-picker/module-picker.scss}              |     9 +-
 .../module-picker/module-picker.tsx}               |    28 +-
 .../preview-pane/preview-pane.scss}                |    33 +-
 .../components/preview-pane/preview-pane.tsx       |    72 +
 .../column-dialog/column-dialog.scss}              |    41 +-
 .../resource-pane/column-dialog/column-dialog.tsx  |   169 +
 .../measure-dialog/measure-dialog.scss}            |    41 +-
 .../measure-dialog/measure-dialog.tsx              |   176 +
 .../resource-pane/resource-pane.scss               |    42 +-
 .../components/resource-pane/resource-pane.tsx     |   283 +
 .../source-pane/source-pane.scss}                  |     5 +-
 .../{ => components}/source-pane/source-pane.tsx   |    47 +-
 .../source-query-pane/source-query-pane.scss}      |    30 +-
 .../source-query-pane/source-query-pane.tsx        |    83 +
 .../components/sql-input/sql-input.tsx             |   167 +
 .../components/utc-date-input/utc-date-input.tsx   |    86 +
 .../explore-view/control-pane/aggregate-menu.tsx   |    85 -
 .../explore-view/control-pane/aggregates-input.tsx |    69 -
 .../explore-view/control-pane/control-pane.tsx     |   348 -
 .../src/views/explore-view/control-pane/helpers.ts |   111 -
 web-console/src/views/explore-view/drag-helper.ts  |     7 +-
 .../droppable-container/droppable-container.tsx    |    67 -
 .../src/views/explore-view/explore-state.ts        |   232 +
 .../src/views/explore-view/explore-view.scss       |    81 +-
 .../src/views/explore-view/explore-view.tsx        |   685 +-
 .../contains-filter-control.tsx                    |   132 -
 .../custom-filter-control.tsx                      |    67 -
 .../filter-pane/filter-menu/filter-menu.tsx        |   191 -
 .../time-interval-filter-control.tsx               |   131 -
 .../values-filter-control.tsx                      |   156 -
 .../highlight-store/highlight-store.ts             |    43 +-
 .../views/explore-view/models/expression-meta.ts   |   102 +
 .../highlight-store.ts => models/highlight.ts}     |    47 +-
 .../{modules/components => models}/index.ts        |     8 +-
 .../views/explore-view/models/measure-pattern.ts   |   148 +
 .../src/views/explore-view/models/measure.ts       |   234 +
 .../src/views/explore-view/models/parameter.ts     |   273 +
 .../src/views/explore-view/models/query-source.ts  |   364 +
 .../tile-picker.scss => models/stage.ts}           |    14 +-
 .../module-repository/module-repository.ts         |    57 +
 .../modules/bar-chart-echarts-module.ts            |   155 -
 .../explore-view/modules/bar-chart-module.tsx      |   209 +
 ...eact-module.scss => grouping-table-module.scss} |     5 +-
 .../explore-view/modules/grouping-table-module.tsx |   303 +
 .../src/views/explore-view/modules/index.ts        |    11 +-
 .../modules/multi-axis-chart-echarts-module.ts     |   266 -
 .../modules/multi-axis-chart-module.tsx            |   333 +
 .../modules/pie-chart-echarts-module.ts            |   201 -
 .../explore-view/modules/pie-chart-module.tsx      |   257 +
 .../record-table-module.scss}                      |    11 +-
 .../explore-view/modules/record-table-module.tsx   |    96 +
 .../explore-view/modules/table-react-module.tsx    |   304 -
 .../modules/time-chart-echarts-module.ts           |   368 -
 .../explore-view/modules/time-chart-module.tsx     |   435 +
 .../utils/__snapshots__/table-query.spec.ts.snap   | 39502 ----------------
 .../views/explore-view/modules/utils/utils.spec.ts |   101 -
 .../src/views/explore-view/modules/utils/utils.ts  |   111 -
 .../views/explore-view/query-macros/aggregate.ts   |    58 +
 .../{modules/components => query-macros}/index.ts  |     3 +-
 .../max-data-time.ts}                              |    34 +-
 .../explore-view/resource-pane/resource-pane.tsx   |    93 -
 .../utils/__snapshots__/table-query.spec.ts.snap   | 46048 +++++++++++++++++++
 .../src/views/explore-view/utils/date-format.ts    |     1 +
 .../explore-view/utils/duration.spec.ts}           |    41 +-
 .../src/views/explore-view/utils/duration.ts       |    46 +
 .../filter-pattern-helpers.ts}                     |    38 +-
 .../src/views/explore-view/utils/general.ts        |    54 +
 web-console/src/views/explore-view/utils/index.ts  |     6 +
 .../views/explore-view/utils/max-time-for-table.ts |    52 +
 web-console/src/views/explore-view/utils/misc.ts   |    48 +-
 .../{modules => }/utils/table-query.spec.ts        |   345 +-
 .../{modules => }/utils/table-query.ts             |   400 +-
 .../explore-view/utils/time-manipulation.spec.ts   |   225 +
 .../views/explore-view/utils/time-manipulation.ts  |   193 +
 .../__snapshots__/home-view.spec.tsx.snap          |    30 +-
 web-console/src/views/home-view/home-view.scss     |     4 +
 .../views/home-view/status-card/status-card.tsx    |    22 +-
 .../__snapshots__/filter-table.spec.tsx.snap       |    12 +-
 .../src/views/load-data-view/load-data-view.tsx    |     6 +-
 .../__snapshots__/parse-time-table.spec.tsx.snap   |    12 +-
 .../__snapshots__/schema-table.spec.tsx.snap       |    12 +-
 .../__snapshots__/transform-table.spec.tsx.snap    |    12 +-
 .../destination-form/destination-form.tsx          |    10 +-
 .../schema-step/preview-table/preview-table.tsx    |     2 +-
 .../views/supervisors-view/supervisors-view.tsx    |    14 +-
 web-console/src/views/tasks-view/tasks-view.tsx    |    11 +-
 .../__snapshots__/column-tree.spec.tsx.snap        |     7 +-
 .../workbench-view/column-tree/column-tree.tsx     |     4 +-
 .../execution-details-pane.spec.tsx.snap           |    52 +-
 .../execution-details-pane.tsx                     |    27 +-
 .../execution-stages-pane.spec.tsx.snap            |    52 +-
 .../execution-stages-pane.scss                     |    48 +-
 .../execution-stages-pane.tsx                      |   347 +-
 .../execution-summary-panel.tsx                    |    16 +-
 .../explain-dialog/explain-dialog.tsx              |    25 +-
 .../ingest-success-pane/ingest-success-pane.tsx    |     6 +-
 .../input-format-step/input-format-step.tsx        |     3 +-
 .../__snapshots__/max-tasks-button.spec.tsx.snap   |    29 +-
 .../max-tasks-button/max-tasks-button.tsx          |    81 +-
 .../views/workbench-view/query-tab/query-tab.tsx   |    74 +-
 .../recent-query-task-panel.tsx                    |    16 +-
 .../result-table-pane/result-table-pane.tsx        |     2 +-
 .../views/workbench-view/run-panel/run-panel.tsx   |   495 +-
 .../workbench-history-dialog.tsx                   |    13 +-
 .../src/views/workbench-view/workbench-view.scss   |    14 +-
 .../src/views/workbench-view/workbench-view.tsx    |   170 +-
 195 files changed, 56255 insertions(+), 44718 deletions(-)

diff --git a/licenses.yaml b/licenses.yaml
index c4e2fa52300..e1e7fe2e890 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5089,24 +5089,6 @@ version: 0.22.22
 
 ---
 
-name: "@druid-toolkit/visuals-core"
-license_category: binary
-module: web-console
-license_name: Apache License version 2.0
-copyright: Imply Data
-version: 0.3.3
-
----
-
-name: "@druid-toolkit/visuals-react"
-license_category: binary
-module: web-console
-license_name: Apache License version 2.0
-copyright: Imply Data
-version: 0.3.3
-
----
-
 name: "@emotion/cache"
 license_category: binary
 module: web-console
@@ -5207,15 +5189,6 @@ license_file_path: licenses/bin/@fontsource-open-sans.OFL
 
 ---
 
-name: "@juggle/resize-observer"
-license_category: binary
-module: web-console
-license_name: Apache License version 2.0
-copyright: Juggle
-version: 3.4.0
-
----
-
 name: "@popperjs/core"
 license_category: binary
 module: web-console
@@ -5575,6 +5548,16 @@ license_file_path: licenses/bin/d3-interpolate.BSD3
 
 ---
 
+name: "d3-scale-chromatic"
+license_category: binary
+module: web-console
+license_name: ISC License
+copyright: Mike Bostock
+version: 3.1.0
+license_file_path: licenses/bin/d3-scale-chromatic.ISC
+
+---
+
 name: "d3-scale"
 license_category: binary
 module: web-console
@@ -6561,16 +6544,6 @@ license_file_path: licenses/bin/upper-case.MIT
 
 ---
 
-name: "use-resize-observer"
-license_category: binary
-module: web-console
-license_name: MIT License
-copyright: Viktor Hubert
-version: 9.1.0
-license_file_path: licenses/bin/use-resize-observer.MIT
-
----
-
 name: "use-sync-external-store"
 license_category: binary
 module: web-console
diff --git a/web-console/README.md b/web-console/README.md
index ff5012344bd..f9a7f7abbb5 100644
--- a/web-console/README.md
+++ b/web-console/README.md
@@ -59,7 +59,8 @@ The console relies on [eslint](https://eslint.org) (and various plugins), [sass-
 
 - Install `dbaeumer.vscode-eslint` extension
 - Install `esbenp.prettier-vscode` extension
-- Open User Settings (JSON) and set the following:
+- Select `Open User Settings (JSON)` from the editor commnads (`Ctrl+Shift+P` or `Comand+Shift+P`) and set the following:
+
   ```json
     "editor.defaultFormatter": "esbenp.prettier-vscode",
     "editor.formatOnSave": true,
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 44d3cbae2a4..e77b282c002 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -15,8 +15,6 @@
         "@blueprintjs/icons": "^5.10.0",
         "@blueprintjs/select": "^5.2.1",
         "@druid-toolkit/query": "^0.22.22",
-        "@druid-toolkit/visuals-core": "^0.3.3",
-        "@druid-toolkit/visuals-react": "^0.3.3",
         "@fontsource/open-sans": "^5.0.28",
         "ace-builds": "~1.4.14",
         "axios": "^1.7.4",
@@ -28,6 +26,7 @@
         "d3-axis": "^2.1.0",
         "d3-dsv": "^2.0.0",
         "d3-scale": "^3.3.0",
+        "d3-scale-chromatic": "^3.1.0",
         "d3-selection": "^2.0.0",
         "date-fns": "^2.28.0",
         "echarts": "^5.4.3",
@@ -62,6 +61,7 @@
         "@types/d3-axis": "^2.1.3",
         "@types/d3-dsv": "^2.0.0",
         "@types/d3-scale": "^3.3.2",
+        "@types/d3-scale-chromatic": "^3.0.3",
         "@types/d3-selection": "^2.0.1",
         "@types/enzyme": "^3.10.17",
         "@types/enzyme-adapter-react-16": "^1.0.9",
@@ -996,30 +996,6 @@
         "tslib": "^2.5.2"
       }
     },
-    "node_modules/@druid-toolkit/visuals-core": {
-      "version": "0.3.3",
-      "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-core/-/visuals-core-0.3.3.tgz",
-      "integrity": "sha512-Oze2M6LBxNIstFQTI68qayZs6vchtRiTAtIvuyvvalh3RGUqblJ91stMvh+9FtnHUBkr6J7J2C30L3VpDd0LTQ==",
-      "dependencies": {
-        "@druid-toolkit/query": "*",
-        "json-bigint-native": "^1.2.0",
-        "zustand": "^4.3.2"
-      }
-    },
-    "node_modules/@druid-toolkit/visuals-react": {
-      "version": "0.3.3",
-      "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-react/-/visuals-react-0.3.3.tgz",
-      "integrity": "sha512-1WKTA7y2bd2LWA1as9bdAk7tPHkKWkgtcH6P7yZZDzooi1wVhgLWhREpvJFHsyIsau2ZHMYDiZkiDESrc90lIA==",
-      "dependencies": {
-        "@druid-toolkit/query": "*",
-        "@druid-toolkit/visuals-core": "*",
-        "use-resize-observer": "^9.1.0",
-        "zustand": "^4.3.2"
-      },
-      "peerDependencies": {
-        "react": "^18.1.0"
-      }
-    },
     "node_modules/@emotion/cache": {
       "version": "10.0.29",
       "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz",
@@ -2120,11 +2096,6 @@
         "@jridgewell/sourcemap-codec": "^1.4.10"
       }
     },
-    "node_modules/@juggle/resize-observer": {
-      "version": "3.4.0",
-      "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
-      "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
-    },
     "node_modules/@leichtgewicht/ip-codec": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -2503,6 +2474,12 @@
         "@types/d3-time": "^2"
       }
     },
+    "node_modules/@types/d3-scale-chromatic": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz",
+      "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==",
+      "dev": true
+    },
     "node_modules/@types/d3-selection": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.1.tgz",
@@ -5437,6 +5414,18 @@
         "d3-time-format": "2 - 3"
       }
     },
+    "node_modules/d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-interpolate": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-scale/node_modules/d3-time": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
@@ -17477,18 +17466,6 @@
       "integrity": "sha1-iS/pWWCAXoVRnxzUOJ8stMu3ZS8=",
       "dev": true
     },
-    "node_modules/use-resize-observer": {
-      "version": "9.1.0",
-      "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
-      "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==",
-      "dependencies": {
-        "@juggle/resize-observer": "^3.3.1"
-      },
-      "peerDependencies": {
-        "react": "16.8.0 - 18",
-        "react-dom": "16.8.0 - 18"
-      }
-    },
     "node_modules/use-sync-external-store": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
@@ -19100,27 +19077,6 @@
         "tslib": "^2.5.2"
       }
     },
-    "@druid-toolkit/visuals-core": {
-      "version": "0.3.3",
-      "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-core/-/visuals-core-0.3.3.tgz",
-      "integrity": "sha512-Oze2M6LBxNIstFQTI68qayZs6vchtRiTAtIvuyvvalh3RGUqblJ91stMvh+9FtnHUBkr6J7J2C30L3VpDd0LTQ==",
-      "requires": {
-        "@druid-toolkit/query": "*",
-        "json-bigint-native": "^1.2.0",
-        "zustand": "^4.3.2"
-      }
-    },
-    "@druid-toolkit/visuals-react": {
-      "version": "0.3.3",
-      "resolved": "https://registry.npmjs.org/@druid-toolkit/visuals-react/-/visuals-react-0.3.3.tgz",
-      "integrity": "sha512-1WKTA7y2bd2LWA1as9bdAk7tPHkKWkgtcH6P7yZZDzooi1wVhgLWhREpvJFHsyIsau2ZHMYDiZkiDESrc90lIA==",
-      "requires": {
-        "@druid-toolkit/query": "*",
-        "@druid-toolkit/visuals-core": "*",
-        "use-resize-observer": "^9.1.0",
-        "zustand": "^4.3.2"
-      }
-    },
     "@emotion/cache": {
       "version": "10.0.29",
       "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz",
@@ -19980,11 +19936,6 @@
         "@jridgewell/sourcemap-codec": "^1.4.10"
       }
     },
-    "@juggle/resize-observer": {
-      "version": "3.4.0",
-      "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
-      "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
-    },
     "@leichtgewicht/ip-codec": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -20309,6 +20260,12 @@
         "@types/d3-time": "^2"
       }
     },
+    "@types/d3-scale-chromatic": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz",
+      "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==",
+      "dev": true
+    },
     "@types/d3-selection": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.1.tgz",
@@ -22606,6 +22563,15 @@
         }
       }
     },
+    "d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+      "requires": {
+        "d3-color": "1 - 3",
+        "d3-interpolate": "1 - 3"
+      }
+    },
     "d3-selection": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
@@ -31520,14 +31486,6 @@
       "integrity": "sha1-iS/pWWCAXoVRnxzUOJ8stMu3ZS8=",
       "dev": true
     },
-    "use-resize-observer": {
-      "version": "9.1.0",
-      "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
-      "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==",
-      "requires": {
-        "@juggle/resize-observer": "^3.3.1"
-      }
-    },
     "use-sync-external-store": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
diff --git a/web-console/package.json b/web-console/package.json
index 9c9f38d24cb..175fc9415f3 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -69,8 +69,6 @@
     "@blueprintjs/icons": "^5.10.0",
     "@blueprintjs/select": "^5.2.1",
     "@druid-toolkit/query": "^0.22.22",
-    "@druid-toolkit/visuals-core": "^0.3.3",
-    "@druid-toolkit/visuals-react": "^0.3.3",
     "@fontsource/open-sans": "^5.0.28",
     "ace-builds": "~1.4.14",
     "axios": "^1.7.4",
@@ -82,6 +80,7 @@
     "d3-axis": "^2.1.0",
     "d3-dsv": "^2.0.0",
     "d3-scale": "^3.3.0",
+    "d3-scale-chromatic": "^3.1.0",
     "d3-selection": "^2.0.0",
     "date-fns": "^2.28.0",
     "echarts": "^5.4.3",
@@ -116,6 +115,7 @@
     "@types/d3-axis": "^2.1.3",
     "@types/d3-dsv": "^2.0.0",
     "@types/d3-scale": "^3.3.2",
+    "@types/d3-scale-chromatic": "^3.0.3",
     "@types/d3-selection": "^2.0.1",
     "@types/enzyme": "^3.10.17",
     "@types/enzyme-adapter-react-16": "^1.0.9",
diff --git a/web-console/src/components/braced-text/braced-text.tsx b/web-console/src/components/braced-text/braced-text.tsx
index 69aafe08b02..c0fb0b314ee 100644
--- a/web-console/src/components/braced-text/braced-text.tsx
+++ b/web-console/src/components/braced-text/braced-text.tsx
@@ -84,7 +84,7 @@ function hideThousandsSeparator(text: string) {
 }
 
 export const BracedText = React.memo(function BracedText(props: BracedTextProps) {
-  const { className, text, braces, padFractionalPart, unselectableThousandsSeparator, title } =
+  const { className, text, braces, padFractionalPart, unselectableThousandsSeparator, ...rest } =
     props;
 
   let effectiveBraces = braces.concat(text);
@@ -115,7 +115,7 @@ export const BracedText = React.memo(function BracedText(props: BracedTextProps)
   }
 
   return (
-    <span className={classNames('braced-text', className)} title={title}>
+    <span className={classNames('braced-text', className)} {...rest}>
       <span className="brace-text">{findMostNumbers(effectiveBraces)}</span>
       <span className="real-text">
         {unselectableThousandsSeparator ? hideThousandsSeparator(text) : 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
index a53da72f6d1..7c8496b481a 100644
--- 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
@@ -3,7 +3,7 @@
 exports[`ClickToCopy matches snapshot 1`] = `
 <a
   class="click-to-copy"
-  title="Click to copy:
+  data-tooltip="Click to copy:
 Hello world"
 >
   Hello world
diff --git a/web-console/src/components/click-to-copy/click-to-copy.tsx b/web-console/src/components/click-to-copy/click-to-copy.tsx
index 5d1d2a8cf47..6ba17344b50 100644
--- a/web-console/src/components/click-to-copy/click-to-copy.tsx
+++ b/web-console/src/components/click-to-copy/click-to-copy.tsx
@@ -31,7 +31,7 @@ export const ClickToCopy = React.memo(function ClickToCopy(props: ClickToCopyPro
   return (
     <a
       className="click-to-copy"
-      title={`Click to copy:\n${text}`}
+      data-tooltip={`Click to copy:\n${text}`}
       onClick={() => {
         copy(text, { format: 'text/plain' });
         AppToaster.show({
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 fe1e8c92a41..9aac8421b3f 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
@@ -199,6 +199,7 @@ exports[`HeaderBar matches snapshot 1`] = `
       <Blueprint5.Button
         active={false}
         className="header-entry"
+        data-tooltip="More views"
         icon="more"
         minimal={true}
       />
@@ -212,7 +213,7 @@ exports[`HeaderBar matches snapshot 1`] = `
         Capabilities {
           "coordinator": true,
           "maxTaskSlots": undefined,
-          "multiStageQuery": true,
+          "multiStageQueryTask": true,
           "overlord": true,
           "queryType": "nativeAndSql",
         }
@@ -333,6 +334,7 @@ exports[`HeaderBar matches snapshot 1`] = `
     >
       <Blueprint5.Button
         className="header-entry"
+        data-tooltip="Settings"
         icon="cog"
         minimal={true}
       />
@@ -418,6 +420,7 @@ exports[`HeaderBar matches snapshot 1`] = `
     >
       <Blueprint5.Button
         className="header-entry"
+        data-tooltip="Help"
         icon="help"
         minimal={true}
       />
diff --git a/web-console/src/components/header-bar/header-bar.tsx b/web-console/src/components/header-bar/header-bar.tsx
index aed66798299..7cdc8cf1d31 100644
--- a/web-console/src/components/header-bar/header-bar.tsx
+++ b/web-console/src/components/header-bar/header-bar.tsx
@@ -106,7 +106,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
   const [overlordDynamicConfigDialogOpen, setOverlordDynamicConfigDialogOpen] = useState(false);
   const [compactionDynamicConfigDialogOpen, setCompactionDynamicConfigDialogOpen] = useState(false);
 
-  const showSplitDataLoaderMenu = capabilities.hasMultiStageQuery();
+  const showSplitDataLoaderMenu = capabilities.hasMultiStageQueryTask();
 
   const loadDataViewsMenuActive = oneOf(
     active,
@@ -360,6 +360,7 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
             minimal
             icon={IconNames.MORE}
             active={moreViewsMenuActive}
+            data-tooltip="More views"
           />
         </Popover>
       </NavbarGroup>
@@ -407,10 +408,10 @@ export const HeaderBar = React.memo(function HeaderBar(props: HeaderBarProps) {
           </Popover>
         )}
         <Popover content={configMenu} position={Position.BOTTOM_RIGHT}>
-          <Button className="header-entry" minimal icon={IconNames.COG} />
+          <Button className="header-entry" minimal icon={IconNames.COG} data-tooltip="Settings" />
         </Popover>
         <Popover content={helpMenu} position={Position.BOTTOM_RIGHT}>
-          <Button className="header-entry" minimal icon={IconNames.HELP} />
+          <Button className="header-entry" minimal icon={IconNames.HELP} data-tooltip="Help" />
         </Popover>
       </NavbarGroup>
       {aboutDialogOpen && <AboutDialog onClose={() => setAboutDialogOpen(false)} />}
diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts
index bbae88a5d43..437017a8c76 100644
--- a/web-console/src/components/index.ts
+++ b/web-console/src/components/index.ts
@@ -27,6 +27,7 @@ export * from './clearable-input/clearable-input';
 export * from './click-to-copy/click-to-copy';
 export * from './deferred/deferred';
 export * from './external-link/external-link';
+export * from './fancy-numeric-input/fancy-numeric-input';
 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';
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
index 262298c1052..e44eab35560 100644
--- a/web-console/src/components/record-table-pane/record-table-pane.tsx
+++ b/web-console/src/components/record-table-pane/record-table-pane.tsx
@@ -140,7 +140,7 @@ export const RecordTablePane = React.memo(function RecordTablePane(props: Record
               Header() {
                 return (
                   <div className="clickable-cell">
-                    <div className="output-name" title={columnToSummary(column)}>
+                    <div className="output-name" data-tooltip={columnToSummary(column)}>
                       {icon && <Icon className="type-icon" icon={icon} size={12} />}
                       {h}
                       {hasFilterOnHeader(h, i) && <Icon icon={IconNames.FILTER} size={14} />}
diff --git a/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap b/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap
index c3c43799667..2769c8af739 100644
--- a/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap
+++ b/web-console/src/components/rule-editor/__snapshots__/rule-editor.spec.tsx.snap
@@ -348,7 +348,6 @@ exports[`RuleEditor matches snapshot no tier in rule 1`] = `
           >
             <button
               class="bp5-button bp5-minimal"
-              title=""
               type="button"
             >
               <span
@@ -1088,7 +1087,6 @@ exports[`RuleEditor matches snapshot with existing tier and non existing tier in
           >
             <button
               class="bp5-button bp5-minimal"
-              title=""
               type="button"
             >
               <span
@@ -1480,7 +1478,6 @@ exports[`RuleEditor matches snapshot with existing tier in rule 1`] = `
           >
             <button
               class="bp5-button bp5-minimal"
-              title=""
               type="button"
             >
               <span
@@ -1877,7 +1874,6 @@ exports[`RuleEditor matches snapshot with non existing tier in rule 1`] = `
           >
             <button
               class="bp5-button bp5-minimal"
-              title=""
               type="button"
             >
               <span
diff --git a/web-console/src/components/rule-editor/rule-editor.tsx b/web-console/src/components/rule-editor/rule-editor.tsx
index 8c40ede1ac5..c5dfb24ddf0 100644
--- a/web-console/src/components/rule-editor/rule-editor.tsx
+++ b/web-console/src/components/rule-editor/rule-editor.tsx
@@ -144,7 +144,7 @@ export const RuleEditor = React.memo(function RuleEditor(props: RuleEditorProps)
           minimal
           icon={IconNames.PLUS}
           disabled={disabled}
-          title={disabled ? 'There are no tiers left to assign' : ''}
+          data-tooltip={disabled ? 'There are no tiers left to assign' : undefined}
         >
           Add historical tier replication
         </Button>
diff --git a/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx b/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx
index 0e1043a5681..8af5c2df03f 100644
--- a/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx
+++ b/web-console/src/components/supervisor-history-panel/supervisor-history-panel.tsx
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { Tab, Tabs } from '@blueprintjs/core';
+import { Tab, Tabs, TabsExpander } from '@blueprintjs/core';
 import * as JSONBig from 'json-bigint-native';
 import React, { useState } from 'react';
 
@@ -68,7 +68,7 @@ export const SupervisorHistoryPanel = React.memo(function SupervisorHistoryPanel
           <Tab
             id={i}
             key={i}
-            title={pastSupervisor.version}
+            data-tooltip={pastSupervisor.version}
             panelClassName="panel"
             panel={
               <ShowValue
@@ -79,7 +79,7 @@ export const SupervisorHistoryPanel = React.memo(function SupervisorHistoryPanel
             }
           />
         ))}
-        <Tabs.Expander />
+        <TabsExpander />
       </Tabs>
       {diffIndex !== -1 && (
         <DiffDialog
diff --git a/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap b/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
index 29b4091d7ba..75814e8c7f0 100644
--- a/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
+++ b/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
@@ -3,18 +3,18 @@
 exports[`TableCell matches snapshot Date (invalid) 1`] = `
 <div
   class="table-cell timestamp"
-  title="NaN"
+  data-tooltip="NaN"
 >
-  Unusable date
+  Invalid date
 </div>
 `;
 
 exports[`TableCell matches snapshot Date 1`] = `
 <div
   class="table-cell timestamp"
-  title="1645664523000"
+  data-tooltip="1645664523000"
 >
-  2022-02-24T01:02:03.000Z
+  2022-02-24 01:02:03
 </div>
 `;
 
diff --git a/web-console/src/components/table-cell/table-cell.tsx b/web-console/src/components/table-cell/table-cell.tsx
index 3b31caa85f2..6bd53271d6a 100644
--- a/web-console/src/components/table-cell/table-cell.tsx
+++ b/web-console/src/components/table-cell/table-cell.tsx
@@ -21,7 +21,7 @@ import * as JSONBig from 'json-bigint-native';
 import React, { useState } from 'react';
 
 import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
-import { isSimpleArray } from '../../utils';
+import { isSimpleArray, prettyFormatIsoDateWithMsIfNeeded } from '../../utils';
 import { ActionIcon } from '../action-icon/action-icon';
 
 import './table-cell.scss';
@@ -97,8 +97,8 @@ export const TableCell = React.memo(function TableCell(props: TableCellProps) {
   } else if (value instanceof Date) {
     const dateValue = value.valueOf();
     return (
-      <div className="table-cell timestamp" title={String(value.valueOf())}>
-        {isNaN(dateValue) ? 'Unusable date' : value.toISOString()}
+      <div className="table-cell timestamp" data-tooltip={String(dateValue)}>
+        {isNaN(dateValue) ? 'Invalid date' : prettyFormatIsoDateWithMsIfNeeded(value.toISOString())}
       </div>
     );
   } else if (isSimpleArray(value)) {
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 fa3d56982a9..aec706a50fc 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
@@ -36,13 +36,13 @@ export interface TableClickableCellProps {
 export const TableClickableCell = React.memo(function TableClickableCell(
   props: TableClickableCellProps,
 ) {
-  const { className, onClick, hoverIcon, title, disabled, children } = props;
+  const { className, onClick, hoverIcon, disabled, children, ...rest } = props;
 
   return (
     <div
       className={classNames('table-clickable-cell', className, { disabled })}
-      title={title}
       onClick={disabled ? undefined : onClick}
+      {...rest}
     >
       {children}
       {hoverIcon && !disabled && <Icon className="hover-icon" icon={hoverIcon} />}
diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx
index 36a0b8aa392..95336713dbb 100644
--- a/web-console/src/console-application.tsx
+++ b/web-console/src/console-application.tsx
@@ -28,7 +28,7 @@ import type { Filter } from 'react-table';
 
 import type { HeaderActiveTab } from './components';
 import { HeaderBar, Loader } from './components';
-import type { DruidEngine, QueryContext, QueryWithContext } from './druid-models';
+import type { QueryContext, QueryWithContext } from './druid-models';
 import { Capabilities, maybeGetClusterCapacity } from './helpers';
 import { stringToTableFilters, tableFiltersToString } from './react-table';
 import { AppToaster } from './singletons';
@@ -307,14 +307,6 @@ export class ConsoleApplication extends React.PureComponent<
       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
@@ -326,8 +318,7 @@ export class ConsoleApplication extends React.PureComponent<
         mandatoryQueryContext={mandatoryQueryContext}
         baseQueryContext={baseQueryContext}
         serverQueryContext={serverQueryContext}
-        queryEngines={queryEngines}
-        allowExplain
+        queryEngines={capabilities.getSupportedQueryEngines()}
         goToTask={this.goToTasksWithTaskId}
         getClusterCapacity={maybeGetClusterCapacity}
       />,
@@ -470,7 +461,7 @@ export class ConsoleApplication extends React.PureComponent<
                   component={this.wrappedClassicBatchDataLoaderView}
                 />
               )}
-              {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQuery() && (
+              {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQueryTask() && (
                 <Route path="/sql-data-loader" component={this.wrappedSqlDataLoaderView} />
               )}
 
diff --git a/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap b/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap
index d122f33fbd2..09531d03eac 100644
--- a/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/compaction-config-dialog/__snapshots__/compaction-config-dialog.spec.tsx.snap
@@ -1620,21 +1620,12 @@ exports[`CompactionConfigDialog matches snapshot without compactionConfig 1`] =
         onClick={[Function]}
         text="History"
       />
-      <Blueprint5.Tooltip
-        compact={false}
-        content="There is no compaction config currently set for this datasource"
-        hoverCloseDelay={0}
-        hoverOpenDelay={100}
-        interactionKind="hover-target"
-        minimal={false}
-        transitionDuration={100}
-      >
-        <Blueprint5.Button
-          disabled={true}
-          intent="danger"
-          text="Delete"
-        />
-      </Blueprint5.Tooltip>
+      <Blueprint5.Button
+        data-tooltip="There is no compaction config currently set for this datasource"
+        disabled={true}
+        intent="danger"
+        text="Delete"
+      />
       <Blueprint5.Button
         onClick={[Function]}
         text="Close"
diff --git a/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx b/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx
index ee286c00760..800d88f78b2 100644
--- a/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx
+++ b/web-console/src/dialogs/compaction-config-dialog/compaction-config-dialog.tsx
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { Button, Callout, Classes, Code, Dialog, Intent, Switch, Tooltip } from '@blueprintjs/core';
+import { Button, Callout, Classes, Code, Dialog, Intent, Switch } from '@blueprintjs/core';
 import React, { useState } from 'react';
 
 import type { FormJsonTabs } from '../../components';
@@ -163,9 +163,12 @@ export const CompactionConfigDialog = React.memo(function CompactionConfigDialog
           {compactionConfig ? (
             <Button text="Delete" intent={Intent.DANGER} onClick={onDelete} />
           ) : (
-            <Tooltip content="There is no compaction config currently set for this datasource">
-              <Button text="Delete" disabled intent={Intent.DANGER} />
-            </Tooltip>
+            <Button
+              text="Delete"
+              disabled
+              intent={Intent.DANGER}
+              data-tooltip="There is no compaction config currently set for this datasource"
+            />
           )}
           <Button text="Close" onClick={onClose} />
           <Button
diff --git a/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx b/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx
index 45234c68ad2..c654a1f9b77 100644
--- a/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx
+++ b/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { Button, Callout, Classes, Dialog, Tab, Tabs, Tag } from '@blueprintjs/core';
+import { Button, Callout, Classes, Dialog, Tab, Tabs, TabsExpander, Tag } from '@blueprintjs/core';
 import * as JSONBig from 'json-bigint-native';
 import React, { useState } from 'react';
 
@@ -113,7 +113,7 @@ export const CompactionHistoryDialog = React.memo(function CompactionHistoryDial
                   }
                 />
               ))}
-              <Tabs.Expander />
+              <TabsExpander />
             </Tabs>
           ) : (
             <div>
diff --git a/web-console/src/dialogs/history-dialog/history-dialog.tsx b/web-console/src/dialogs/history-dialog/history-dialog.tsx
index 404e2fd273e..b17b8c2e93c 100644
--- a/web-console/src/dialogs/history-dialog/history-dialog.tsx
+++ b/web-console/src/dialogs/history-dialog/history-dialog.tsx
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { Button, Classes, Dialog, Tab, Tabs } from '@blueprintjs/core';
+import { Button, Classes, Dialog, Tab, Tabs, TabsExpander } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
 import * as JSONBig from 'json-bigint-native';
@@ -83,7 +83,7 @@ export const HistoryDialog = React.memo(function HistoryDialog(props: HistoryDia
             />
           );
         })}
-        <Tabs.Expander />
+        <TabsExpander />
       </Tabs>
     );
   }
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 5a064c5ddd1..e7a3c48569d 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
@@ -516,9 +516,9 @@ exports[`RetentionDialog matches snapshot 1`] = `
                             >
                               <button
                                 class="bp5-button bp5-disabled bp5-minimal"
+                                data-tooltip="There are no tiers left to assign"
                                 disabled=""
                                 tabindex="-1"
-                                title="There are no tiers left to assign"
                                 type="button"
                               >
                                 <span
diff --git a/web-console/src/druid-models/execution/execution.ts b/web-console/src/druid-models/execution/execution.ts
index 45bfc517538..f218acc0386 100644
--- a/web-console/src/druid-models/execution/execution.ts
+++ b/web-console/src/druid-models/execution/execution.ts
@@ -255,12 +255,13 @@ export class Execution {
       };
     }
 
+    const { createdAt, durationMs, state } = asyncSubmitResult;
     return new Execution({
       engine: 'sql-msq-task',
       id: queryId,
-      startTime: new Date(asyncSubmitResult.createdAt),
-      duration: asyncSubmitResult.durationMs,
-      status: Execution.normalizeAsyncState(asyncSubmitResult.state),
+      startTime: new Date(createdAt),
+      duration: durationMs >= 0 ? durationMs : undefined,
+      status: Execution.normalizeAsyncState(state),
       sqlQuery,
       queryContext,
       stages: Array.isArray(stages) && counters ? new Stages(stages, counters) : undefined,
@@ -335,7 +336,7 @@ export class Execution {
       status: Execution.normalizeTaskStatus(status),
       segmentStatus: segmentLoaderStatus,
       startTime: isNaN(startTime.getTime()) ? undefined : startTime,
-      duration: typeof durationMs === 'number' ? durationMs : undefined,
+      duration: typeof durationMs === 'number' && durationMs >= 0 ? durationMs : undefined,
       usageInfo: getUsageInfoFromStatusPayload(
         deepGet(taskReport, 'multiStageQuery.payload.status'),
       ),
@@ -441,6 +442,13 @@ export class Execution {
     };
   }
 
+  public changeEngine(engine: DruidEngine): Execution {
+    return new Execution({
+      ...this.valueOf(),
+      engine,
+    });
+  }
+
   public changeSqlQuery(sqlQuery: string, queryContext?: QueryContext): Execution {
     const value = this.valueOf();
 
diff --git a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
index 46d6a3a4cbb..2071aa2c377 100644
--- a/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
+++ b/web-console/src/druid-models/ingestion-spec/ingestion-spec.tsx
@@ -296,6 +296,9 @@ export type SchemaMode = 'fixed' | 'string-only-discovery' | 'type-aware-discove
 export type ArrayMode = 'arrays' | 'multi-values';
 
 export const DEFAULT_FORCE_SEGMENT_SORT_BY_TIME = true;
+export const DEFAULT_SCHEMA_MODE: SchemaMode = 'fixed';
+export const DEFAULT_ARRAY_MODE: ArrayMode = 'arrays';
+
 export function getForceSegmentSortByTime(spec: Partial<IngestionSpec>): boolean {
   return (
     deepGet(spec, 'spec.dataSchema.dimensionsSpec.forceSegmentSortByTime') ??
diff --git a/web-console/src/druid-models/query-context/query-context.tsx b/web-console/src/druid-models/query-context/query-context.tsx
index ee467577510..b702c233457 100644
--- a/web-console/src/druid-models/query-context/query-context.tsx
+++ b/web-console/src/druid-models/query-context/query-context.tsx
@@ -42,6 +42,7 @@ export interface QueryContext {
   waitUntilSegmentsLoad?: boolean;
   useConcurrentLocks?: boolean;
   forceSegmentSortByTime?: boolean;
+  includeAllCounters?: boolean;
 
   [key: string]: any;
 }
@@ -65,6 +66,7 @@ export const DEFAULT_SERVER_QUERY_CONTEXT: QueryContext = {
   waitUntilSegmentsLoad: false,
   useConcurrentLocks: false,
   forceSegmentSortByTime: true,
+  includeAllCounters: false,
 };
 
 export interface QueryWithContext {
diff --git a/web-console/src/druid-models/stages/graph-info.ts b/web-console/src/druid-models/stages/graph-info.ts
new file mode 100644
index 00000000000..98f41d709c6
--- /dev/null
+++ b/web-console/src/druid-models/stages/graph-info.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 } from '../../utils';
+
+import type { StageDefinition } from './stages';
+
+export type GraphInfoLane =
+  | { type: 'stage'; stageNumber: number; fromLanes: number[]; hasOut: boolean }
+  | { type: 'line'; stageNumber: number; fromLane: number };
+
+export type GraphInfo = GraphInfoLane[];
+
+export function computeNextInfo(
+  stage: StageDefinition,
+  prevInfo: GraphInfo,
+  isLastStage: boolean,
+): GraphInfo {
+  const inputStageNumbers = filterMap(stage.definition.input, i =>
+    i.type === 'stage' ? i.stage : undefined,
+  );
+
+  const myInfo: GraphInfo = [];
+  let addedSelf = false;
+  for (let i = 0; i < prevInfo.length; i++) {
+    const prevLane = prevInfo[i];
+    if (addedSelf) {
+      if (inputStageNumbers.includes(prevLane.stageNumber)) {
+        // Do nothing because this was already recorded in the 'stage' lane
+      } else {
+        myInfo.push({ type: 'line', stageNumber: prevLane.stageNumber, fromLane: i });
+      }
+    } else {
+      if (inputStageNumbers.includes(prevLane.stageNumber)) {
+        // Add self
+        myInfo.push({
+          type: 'stage',
+          stageNumber: stage.stageNumber,
+          fromLanes: filterMap(prevInfo, (lane, i) =>
+            inputStageNumbers.includes(lane.stageNumber) ? i : undefined,
+          ),
+          hasOut: !isLastStage,
+        });
+        addedSelf = true;
+      } else {
+        myInfo.push({ type: 'line', stageNumber: prevLane.stageNumber, fromLane: i });
+      }
+    }
+  }
+
+  // If the stage was not already added then it must be a new source, start a new lane
+  if (!addedSelf) {
+    myInfo.push({
+      type: 'stage',
+      stageNumber: stage.stageNumber,
+      fromLanes: [], // We know the stage is a source
+      hasOut: !isLastStage,
+    });
+  }
+
+  return myInfo;
+}
diff --git a/web-console/src/druid-models/stages/stages.spec.ts b/web-console/src/druid-models/stages/stages.spec.ts
index ad8f2e77408..2c0a6f5ec18 100644
--- a/web-console/src/druid-models/stages/stages.spec.ts
+++ b/web-console/src/druid-models/stages/stages.spec.ts
@@ -16,8 +16,53 @@
  * limitations under the License.
  */
 
+import { aggregateSortProgressCounters } from './stages';
 import { STAGES } from './stages.mock';
 
+describe('aggregateSortProgressCounters', () => {
+  it('works', () => {
+    expect(
+      aggregateSortProgressCounters([
+        {
+          type: 'sortProgress',
+          totalMergingLevels: 2,
+          levelToTotalBatches: { 0: 3, 1: 4, 2: 4 },
+          levelToMergedBatches: { 0: 3, 1: 4, 2: 4 },
+          totalMergersForUltimateLevel: 1,
+        },
+        {
+          type: 'sortProgress',
+          totalMergingLevels: 4,
+          levelToTotalBatches: { 0: 2, 1: 4, 2: 6, 3: 5 },
+          levelToMergedBatches: { 0: 2, 1: 4, 2: 6, 3: 5 },
+          totalMergersForUltimateLevel: 1,
+        },
+      ]),
+    ).toEqual({
+      totalMergingLevels: {
+        '2': 1,
+        '4': 1,
+      },
+      levelToBatches: {
+        '0': {
+          '2': 1,
+          '3': 1,
+        },
+        '1': {
+          '4': 2,
+        },
+        '2': {
+          '4': 1,
+          '6': 1,
+        },
+        '3': {
+          '5': 1,
+        },
+      },
+    });
+  });
+});
+
 describe('Stages', () => {
   describe('#overallProgress', () => {
     it('works when finished', () => {
@@ -290,4 +335,51 @@ describe('Stages', () => {
       `);
     });
   });
+
+  describe('#getGraphInfos', () => {
+    it('works for mock', () => {
+      expect(STAGES.getGraphInfos()).toMatchInlineSnapshot(`
+        [
+          [
+            {
+              "fromLanes": [],
+              "hasOut": true,
+              "stageNumber": 0,
+              "type": "stage",
+            },
+          ],
+          [
+            {
+              "fromLanes": [
+                0,
+              ],
+              "hasOut": true,
+              "stageNumber": 1,
+              "type": "stage",
+            },
+          ],
+          [
+            {
+              "fromLanes": [
+                0,
+              ],
+              "hasOut": true,
+              "stageNumber": 2,
+              "type": "stage",
+            },
+          ],
+          [
+            {
+              "fromLanes": [
+                0,
+              ],
+              "hasOut": false,
+              "stageNumber": 3,
+              "type": "stage",
+            },
+          ],
+        ]
+      `);
+    });
+  });
 });
diff --git a/web-console/src/druid-models/stages/stages.ts b/web-console/src/druid-models/stages/stages.ts
index 0d263a40553..fbb2c1cd3d4 100644
--- a/web-console/src/druid-models/stages/stages.ts
+++ b/web-console/src/druid-models/stages/stages.ts
@@ -18,15 +18,48 @@
 
 import { max, sum } from 'd3-array';
 
-import { deleteKeys, filterMap, oneOf, zeroDivide } from '../../utils';
+import { countBy, deleteKeys, filterMap, groupByAsMap, oneOf, zeroDivide } from '../../utils';
 import type { InputFormat } from '../input-format/input-format';
 import type { InputSource } from '../input-source/input-source';
 
+import type { GraphInfo } from './graph-info';
+import { computeNextInfo } from './graph-info';
+
 const SHUFFLE_WEIGHT = 0.5;
 const READING_INPUT_WITH_SHUFFLE_WEIGHT = 1 - SHUFFLE_WEIGHT;
 
 export type InOut = 'in' | 'out';
 
+function simpleSum(xs: number[]) {
+  return sum(xs);
+}
+function aggregateThings<T>(
+  things: T[],
+  aggregators: {
+    [Property in keyof T]: T[Property] | ((vs: T[Property][], ts: T[]) => T[Property]);
+  },
+): T {
+  return Object.fromEntries(
+    Object.entries(aggregators).map(([k, f]) => [
+      k,
+      typeof f === 'function'
+        ? f(
+            filterMap(things, t => (t as any)[k]),
+            things,
+          )
+        : f,
+    ]),
+  ) as any;
+}
+
+function filterToExistingKeys<T>(keys: (keyof T)[], ts: T[]): (keyof T)[] {
+  return keys.filter(k => ts.some(t => Object.hasOwn(t as any, k)));
+}
+
+function objectFromKeys<T>(keys: string[], value: T): Record<string, T> {
+  return Object.fromEntries(keys.map(k => [k, value]));
+}
+
 export type StageInput =
   | {
       type: 'stage';
@@ -63,6 +96,7 @@ export interface StageDefinition {
       targetSize?: number;
       partitions?: number;
       aggregate?: boolean;
+      limitHint?: number;
     };
     maxWorkerCount: number;
     shuffleCheckHasMultipleValues?: boolean;
@@ -86,26 +120,25 @@ export interface ClusterBy {
   bucketByCount?: number;
 }
 
-export function formatClusterBy(
-  clusterBy: ClusterBy | undefined,
-  part: 'all' | 'partition' | 'cluster' = 'all',
-): string {
-  if (!clusterBy) return '';
-
-  let { columns, bucketByCount } = clusterBy;
-  if (bucketByCount) {
-    if (part === 'partition') {
-      columns = columns.slice(0, bucketByCount);
-    } else {
-      columns = columns.slice(bucketByCount);
-    }
-  }
-
+function formatClusterByColumns(columns: ClusterBy['columns']): string {
   return columns
     .map(part => part.columnName + (part.order === 'DESCENDING' ? ' DESC' : ''))
     .join(', ');
 }
 
+export function formatClusterBy(clusterBy: ClusterBy): string {
+  const { columns, bucketByCount } = clusterBy;
+
+  if (clusterBy.bucketByCount) {
+    return [
+      `Partition by: ${formatClusterByColumns(columns.slice(0, bucketByCount))}`,
+      `Cluster by: ${formatClusterByColumns(columns.slice(bucketByCount))}`,
+    ].join('\n');
+  } else {
+    return `Cluster by: ${formatClusterByColumns(columns)}`;
+  }
+}
+
 export interface StageWorkerCounter {
   [k: `input${number}`]: ChannelCounter | undefined;
   output?: ChannelCounter;
@@ -113,6 +146,7 @@ export interface StageWorkerCounter {
   sortProgress?: SortProgressCounter;
   segmentGenerationProgress?: SegmentGenerationProgressCounter;
   warnings?: WarningCounter;
+  cpu?: CpusCounter;
 }
 
 export type ChannelCounterName = `input${number}` | 'output' | 'shuffle';
@@ -156,6 +190,29 @@ export interface SortProgressCounter {
   triviallyComplete?: boolean;
 }
 
+export interface AggregatedSortProgress {
+  totalMergingLevels: Record<string, number>;
+  levelToBatches: Record<string, Record<string, number>>;
+}
+
+export function aggregateSortProgressCounters(
+  sortProgressCounters: SortProgressCounter[],
+): AggregatedSortProgress {
+  return {
+    totalMergingLevels: countBy(sortProgressCounters, c => c.totalMergingLevels),
+    levelToBatches: groupByAsMap(
+      sortProgressCounters.flatMap(c =>
+        Object.entries(c.levelToMergedBatches).map(([level, merged]) => [
+          level,
+          Math.max(merged, c.levelToTotalBatches[level as any] || 0),
+        ]),
+      ),
+      ([level]) => level,
+      entities => countBy(entities, x => x[1]),
+    ),
+  };
+}
+
 export interface SegmentGenerationProgressCounter {
   type: 'segmentGenerationProgress';
   rowsProcessed: number;
@@ -176,12 +233,79 @@ export interface WarningCounter {
   // More types of warnings might be added later
 }
 
+export type CpusCounterFields =
+  | 'main'
+  | 'collectKeyStatistics'
+  | 'mergeInput'
+  | 'hashPartitionOutput'
+  | 'mixOutput'
+  | 'sortOutput';
+
+export const CPUS_COUNTER_FIELDS: CpusCounterFields[] = [
+  'main',
+  'collectKeyStatistics',
+  'mergeInput',
+  'hashPartitionOutput',
+  'mixOutput',
+  'sortOutput',
+];
+
+export function cpusCounterFieldTitle(k: CpusCounterFields) {
+  switch (k) {
+    case 'main':
+      return 'Main';
+
+    case 'collectKeyStatistics':
+      return 'Collect key stats';
+
+    case 'mergeInput':
+      return 'Merge input';
+
+    case 'hashPartitionOutput':
+      return 'Hash partition out';
+
+    case 'mixOutput':
+      return 'Mix output';
+
+    case 'sortOutput':
+      return 'Sort output';
+
+    default:
+      return k;
+  }
+}
+
+export interface CpusCounter {
+  type: 'cpus';
+  main?: CpuCounter;
+  collectKeyStatistics?: CpuCounter;
+  mergeInput?: CpuCounter;
+  hashPartitionOutput?: CpuCounter;
+  mixOutput?: CpuCounter;
+  sortOutput?: CpuCounter;
+}
+
+export interface CpuCounter {
+  type: 'cpu';
+  cpu: number;
+  wall: number;
+}
+
+function sumCpuCounters(cs: CpuCounter[]): CpuCounter {
+  return aggregateThings(cs, {
+    type: 'cpu',
+    cpu: simpleSum,
+    wall: simpleSum,
+  });
+}
+
 export interface SimpleWideCounter {
   index: number;
   [k: `input${number}`]: Record<ChannelFields, number> | undefined;
   output?: Record<ChannelFields, number>;
   shuffle?: Record<ChannelFields, number>;
   segmentGenerationProgress?: SegmentGenerationProgressCounter;
+  cpu?: CpusCounter;
 }
 
 function zeroChannelFields(): Record<ChannelFields, number> {
@@ -347,6 +471,14 @@ export class Stages {
     return this.stages.findIndex(({ phase }) => oneOf(phase, 'READING_INPUT', 'POST_READING'));
   }
 
+  hasCounter(counterName: CounterName): boolean {
+    const { counters } = this;
+    if (!counters) return false;
+    return Object.values(counters).some(counter =>
+      Object.values(counter).some(c => Boolean(c[counterName])),
+    );
+  }
+
   hasCounterForStage(stage: StageDefinition, counterName: CounterName): boolean {
     return this.getCountersForStage(stage).some(c => Boolean(c[counterName]));
   }
@@ -395,6 +527,14 @@ export class Stages {
     );
   }
 
+  getCpuTotalsForStage(stage: StageDefinition): CpusCounter {
+    const cpusCounters = filterMap(this.getCountersForStage(stage), c => c.cpu);
+    return aggregateThings(cpusCounters, {
+      type: 'cpus',
+      ...objectFromKeys(filterToExistingKeys(CPUS_COUNTER_FIELDS, cpusCounters), sumCpuCounters),
+    });
+  }
+
   getTotalCounterForStage(
     stage: StageDefinition,
     counterName: CounterName,
@@ -478,6 +618,7 @@ export class Stages {
           : zeroChannelFields();
       }
       newWideCounter.segmentGenerationProgress = stageCounters.segmentGenerationProgress;
+      newWideCounter.cpu = stageCounters.cpu;
       return newWideCounter;
     });
   }
@@ -549,6 +690,12 @@ export class Stages {
     return simpleCounters;
   }
 
+  getAggregatedSortProgressForStage(stage: StageDefinition): AggregatedSortProgress | undefined {
+    const sortProgressCounters = filterMap(this.getCountersForStage(stage), c => c.sortProgress);
+    if (!sortProgressCounters.length) return;
+    return aggregateSortProgressCounters(sortProgressCounters);
+  }
+
   getRateFromStage(stage: StageDefinition, field: ChannelFields): number | undefined {
     if (!stage.duration) return;
     if (field === 'bytes' && stage.definition.input.some(input => input.type !== 'stage')) {
@@ -578,4 +725,19 @@ export class Stages {
 
     return potentiallyStuckIndex;
   }
+
+  getGraphInfos(): GraphInfo[] {
+    const { stages } = this;
+
+    let prevInfo: GraphInfo = [];
+    const ret: GraphInfo[] = [];
+
+    for (const stage of stages) {
+      ret.push(
+        (prevInfo = computeNextInfo(stage, prevInfo, stage.stageNumber === stages.length - 1)),
+      );
+    }
+
+    return ret;
+  }
 }
diff --git a/web-console/src/druid-models/task/task.ts b/web-console/src/druid-models/task/task.ts
index 1a245508e8d..3445cc5fb45 100644
--- a/web-console/src/druid-models/task/task.ts
+++ b/web-console/src/druid-models/task/task.ts
@@ -113,6 +113,12 @@ export interface MsqTaskReportResponse {
       };
       stages: StageDefinition[];
       counters: Counters;
+      results?: {
+        signature: { name: string; type: string }[];
+        sqlTypeNames: string[];
+        results: any[];
+        resultsTruncated?: boolean;
+      };
     };
   };
   error?: any;
diff --git a/web-console/src/druid-models/workbench-query/workbench-query.ts b/web-console/src/druid-models/workbench-query/workbench-query.ts
index 76bd29a4448..dd75c94b75e 100644
--- a/web-console/src/druid-models/workbench-query/workbench-query.ts
+++ b/web-console/src/druid-models/workbench-query/workbench-query.ts
@@ -153,7 +153,7 @@ export class WorkbenchQuery {
 
   static fromTaskQueryAndContext(queryString: string, context: QueryContext): WorkbenchQuery {
     const noSqlOuterLimit = typeof context['sqlOuterLimit'] === 'undefined';
-    const cleanContext = deleteKeys(context, ['sqlOuterLimit']);
+    const cleanContext = deleteKeys(context, ['sqlOuterLimit', '__resultFormat']);
 
     let retQuery = WorkbenchQuery.blank()
       .changeEngine('sql-msq-task')
diff --git a/web-console/src/entry.scss b/web-console/src/entry.scss
index c980decba1e..73cbb238eb1 100644
--- a/web-console/src/entry.scss
+++ b/web-console/src/entry.scss
@@ -36,6 +36,7 @@ body {
   overflow: hidden;
   font-size: 13px;
   overscroll-behavior-x: none;
+  cursor: default;
 }
 
 body {
@@ -74,3 +75,10 @@ body {
   max-height: 47vh;
   overflow: auto;
 }
+
+// Make buttons stretch nicely
+.#{$bp-ns}-button.#{$bp-ns}-fill {
+  .#{$bp-ns}-button-text {
+    flex: 1;
+  }
+}
diff --git a/web-console/src/entry.tsx b/web-console/src/entry.tsx
index 0e698a3f84b..dc1d2f56da3 100644
--- a/web-console/src/entry.tsx
+++ b/web-console/src/entry.tsx
@@ -32,7 +32,7 @@ import type { QueryContext } from './druid-models';
 import type { Links } from './links';
 import { setLinkOverrides } from './links';
 import { Api, UrlBaser } from './singletons';
-import { setLocalStorageNamespace } from './utils';
+import { initMouseTooltip, setLocalStorageNamespace } from './utils';
 
 import './entry.scss';
 
@@ -118,6 +118,8 @@ createRoot(container).render(
   </OverlaysProvider>,
 );
 
+initMouseTooltip();
+
 // ---------------------------------
 // Taken from https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2
 
diff --git a/web-console/src/helpers/capabilities.ts b/web-console/src/helpers/capabilities.ts
index 4b10a49c965..fe125b67231 100644
--- a/web-console/src/helpers/capabilities.ts
+++ b/web-console/src/helpers/capabilities.ts
@@ -16,6 +16,7 @@
  * limitations under the License.
  */
 
+import type { DruidEngine } from '../druid-models';
 import { Api } from '../singletons';
 
 import { maybeGetClusterCapacity } from './index';
@@ -35,7 +36,7 @@ export type QueryType = 'none' | 'nativeOnly' | 'nativeAndSql';
 
 export interface CapabilitiesValue {
   queryType: QueryType;
-  multiStageQuery: boolean;
+  multiStageQueryTask: boolean;
   coordinator: boolean;
   overlord: boolean;
   maxTaskSlots?: number;
@@ -51,7 +52,7 @@ export class Capabilities {
   static NO_PROXY: Capabilities;
 
   private readonly queryType: QueryType;
-  private readonly multiStageQuery: boolean;
+  private readonly multiStageQueryTask: boolean;
   private readonly coordinator: boolean;
   private readonly overlord: boolean;
   private readonly maxTaskSlots?: number;
@@ -129,7 +130,7 @@ export class Capabilities {
     return true;
   }
 
-  static async detectMultiStageQuery(): Promise<boolean> {
+  static async detectMultiStageQueryTask(): Promise<boolean> {
     try {
       const resp = await Api.instance.get(`/druid/v2/sql/task/enabled?capabilities`);
       return Boolean(resp.data.enabled);
@@ -153,11 +154,11 @@ export class Capabilities {
       coordinator = overlord = await Capabilities.detectManagementProxy();
     }
 
-    const multiStageQuery = await Capabilities.detectMultiStageQuery();
+    const multiStageQueryTask = await Capabilities.detectMultiStageQueryTask();
 
     return new Capabilities({
       queryType,
-      multiStageQuery,
+      multiStageQueryTask,
       coordinator,
       overlord,
     });
@@ -177,7 +178,7 @@ export class Capabilities {
 
   constructor(value: CapabilitiesValue) {
     this.queryType = value.queryType;
-    this.multiStageQuery = value.multiStageQuery;
+    this.multiStageQueryTask = value.multiStageQueryTask;
     this.coordinator = value.coordinator;
     this.overlord = value.overlord;
     this.maxTaskSlots = value.maxTaskSlots;
@@ -186,7 +187,7 @@ export class Capabilities {
   public valueOf(): CapabilitiesValue {
     return {
       queryType: this.queryType,
-      multiStageQuery: this.multiStageQuery,
+      multiStageQueryTask: this.multiStageQueryTask,
       coordinator: this.coordinator,
       overlord: this.overlord,
       maxTaskSlots: this.maxTaskSlots,
@@ -243,8 +244,19 @@ export class Capabilities {
     return this.queryType === 'nativeAndSql';
   }
 
-  public hasMultiStageQuery(): boolean {
-    return this.multiStageQuery;
+  public hasMultiStageQueryTask(): boolean {
+    return this.multiStageQueryTask;
+  }
+
+  public getSupportedQueryEngines(): DruidEngine[] {
+    const queryEngines: DruidEngine[] = ['native'];
+    if (this.hasSql()) {
+      queryEngines.push('sql-native');
+    }
+    if (this.hasMultiStageQueryTask()) {
+      queryEngines.push('sql-msq-task');
+    }
+    return queryEngines;
   }
 
   public hasCoordinatorAccess(): boolean {
@@ -269,37 +281,37 @@ export class Capabilities {
 }
 Capabilities.FULL = new Capabilities({
   queryType: 'nativeAndSql',
-  multiStageQuery: true,
+  multiStageQueryTask: true,
   coordinator: true,
   overlord: true,
 });
 Capabilities.NO_SQL = new Capabilities({
   queryType: 'nativeOnly',
-  multiStageQuery: false,
+  multiStageQueryTask: false,
   coordinator: true,
   overlord: true,
 });
 Capabilities.COORDINATOR_OVERLORD = new Capabilities({
   queryType: 'none',
-  multiStageQuery: false,
+  multiStageQueryTask: false,
   coordinator: true,
   overlord: true,
 });
 Capabilities.COORDINATOR = new Capabilities({
   queryType: 'none',
-  multiStageQuery: false,
+  multiStageQueryTask: false,
   coordinator: true,
   overlord: false,
 });
 Capabilities.OVERLORD = new Capabilities({
   queryType: 'none',
-  multiStageQuery: false,
+  multiStageQueryTask: false,
   coordinator: false,
   overlord: true,
 });
 Capabilities.NO_PROXY = new Capabilities({
   queryType: 'nativeAndSql',
-  multiStageQuery: true,
+  multiStageQueryTask: true,
   coordinator: false,
   overlord: false,
 });
diff --git a/web-console/src/hooks/index.ts b/web-console/src/hooks/index.ts
index 62b22443f3b..515fbe0db36 100644
--- a/web-console/src/hooks/index.ts
+++ b/web-console/src/hooks/index.ts
@@ -19,6 +19,7 @@
 export * from './use-clock';
 export * from './use-constant';
 export * from './use-global-event-listener';
+export * from './use-hash-and-local-storage-hybrid-state';
 export * from './use-interval';
 export * from './use-last-defined';
 export * from './use-local-storage-state';
diff --git a/web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts b/web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts
new file mode 100644
index 00000000000..10911c7798b
--- /dev/null
+++ b/web-console/src/hooks/use-hash-and-local-storage-hybrid-state.ts
@@ -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.
+ */
+
+import * as JSONBig from 'json-bigint-native';
+import type { Dispatch, SetStateAction } from 'react';
+import { useEffect, useState } from 'react';
+
+import type { LocalStorageKeys } from '../utils';
+import {
+  base64UrlDecode,
+  base64UrlEncode,
+  localStorageGetJson,
+  localStorageSetJson,
+} from '../utils';
+
+function encodeHashState(prefix: string, x: unknown): string {
+  return prefix + base64UrlEncode(JSONBig.stringify(x));
+}
+
+function decodeHashState(prefix: string, x: string): any {
+  if (!x.startsWith(prefix)) return;
+  try {
+    return JSONBig.parse(base64UrlDecode(x.slice(prefix.length)));
+  } catch {
+    return;
+  }
+}
+
+export function useHashAndLocalStorageHybridState<T>(
+  prefix: string,
+  key: LocalStorageKeys,
+  initialValue?: T,
+  inflateFn?: (x: any) => T,
+): [T, Dispatch<SetStateAction<T>>] {
+  const [state, setState] = useState(() => {
+    // Try to read state from hash and fallback to local storage
+    const valueToInflate =
+      decodeHashState(prefix, window.location.hash) ?? localStorageGetJson(key);
+    if (typeof valueToInflate === 'undefined') return initialValue;
+    return inflateFn ? inflateFn(valueToInflate) : valueToInflate;
+  });
+
+  const setValue: Dispatch<SetStateAction<T>> = (value: T | ((prevState: T) => T)) => {
+    const valueToStore = value instanceof Function ? value(state) : value;
+    window.history.pushState(null, '', encodeHashState(prefix, value));
+    setState(valueToStore);
+    localStorageSetJson(key, valueToStore);
+  };
+
+  // Listen for "popstate" event (triggered by browser back/forward navigation)
+  useEffect(() => {
+    const handlePopState = () => {
+      const valueToInflate = decodeHashState(prefix, window.location.hash);
+      if (typeof valueToInflate === 'undefined') return;
+      const value = inflateFn ? inflateFn(valueToInflate) : valueToInflate;
+      setState(value);
+      localStorageSetJson(key, value);
+    };
+
+    window.addEventListener('popstate', handlePopState);
+
+    return () => {
+      window.removeEventListener('popstate', handlePopState);
+    };
+  }, []);
+
+  return [state, setValue];
+}
diff --git a/web-console/src/hooks/use-query-manager.ts b/web-console/src/hooks/use-query-manager.ts
index 38444374583..36bf83f6ecd 100644
--- a/web-console/src/hooks/use-query-manager.ts
+++ b/web-console/src/hooks/use-query-manager.ts
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 
 import type { QueryManagerOptions } from '../utils';
 import { QueryManager, QueryState } from '../utils';
@@ -39,7 +39,8 @@ export function useQueryManager<Q, R, I = never, E extends Error = Error>(
     backgroundStatusCheck || ((() => {}) as any),
   );
 
-  const [resultState, setResultState] = useState<QueryState<R, E, I>>(initState || QueryState.INIT);
+  const resultStateRef = useRef<QueryState<R, E, I>>(initState || QueryState.INIT);
+  const [_, setResultState] = useState<QueryState<R, E, I>>(initState || QueryState.INIT);
 
   function makeQueryManager() {
     return new QueryManager<Q, R, I, E>({
@@ -47,7 +48,10 @@ export function useQueryManager<Q, R, I = never, E extends Error = Error>(
       initState,
       processQuery: concreteProcessQuery,
       backgroundStatusCheck: backgroundStatusCheck ? concreteBackgroundStatusCheck : undefined,
-      onStateChange: setResultState,
+      onStateChange: s => {
+        resultStateRef.current = s;
+        setResultState(s);
+      },
     });
   }
 
@@ -75,12 +79,11 @@ export function useQueryManager<Q, R, I = never, E extends Error = Error>(
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
-  useEffect(() => {
-    if (typeof query !== 'undefined') {
-      queryManager.runQuery(query);
-    }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [query]);
+  const prevQuery = useRef<Q | undefined>(initQuery);
+  if (typeof query !== 'undefined' && query !== prevQuery.current) {
+    prevQuery.current = query;
+    queryManager.runQuery(query);
+  }
 
-  return [resultState, queryManager];
+  return [resultStateRef.current, queryManager];
 }
diff --git a/web-console/src/singletons/workbench-running-promises.ts b/web-console/src/singletons/workbench-running-promises.ts
index 996070537d3..2e4e34acf81 100644
--- a/web-console/src/singletons/workbench-running-promises.ts
+++ b/web-console/src/singletons/workbench-running-promises.ts
@@ -16,11 +16,10 @@
  * limitations under the License.
  */
 
-import type { QueryResult } from '@druid-toolkit/query';
+import type { Execution } from '../druid-models';
 
 export interface WorkbenchRunningPromise {
-  promise: Promise<QueryResult>;
-  prefixLines: number;
+  executionPromise: Promise<Execution>;
   startTime: Date;
 }
 
@@ -28,7 +27,7 @@ export class WorkbenchRunningPromises {
   private static readonly promises = new Map<string, WorkbenchRunningPromise>();
 
   static isWorkbenchRunningPromise(x: any): x is WorkbenchRunningPromise {
-    return Boolean(x.promise);
+    return Boolean(x.executionPromise);
   }
 
   static storePromise(id: string, promise: WorkbenchRunningPromise): void {
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/utils/base64-url.spec.ts
similarity index 55%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/utils/base64-url.spec.ts
index dbb850df020..e509779f4ae 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/utils/base64-url.spec.ts
@@ -16,29 +16,24 @@
  * limitations under the License.
  */
 
-@import '../../../../variables';
+import { base64UrlDecode, base64UrlEncode } from './base64-url';
 
-.filter-menu {
-  width: 400px;
+describe('base64-url', () => {
+  it('works in simple case', () => {
+    const originalString = 'Hello, World!';
+    const encoded = base64UrlEncode(originalString);
+    expect(encoded).toEqual('SGVsbG8sIFdvcmxkIQ');
 
-  &.main {
-    padding: 15px;
-  }
+    const decoded = base64UrlDecode(encoded);
+    expect(decoded).toEqual(originalString);
+  });
 
-  .controls .#{$ns}-form-content {
-    display: flex;
-    gap: 15px;
-
-    .type-selector {
-      flex: 1;
+  it('works for all ascii chars', () => {
+    for (let c = 0; c < 256; c++) {
+      const originalString = String.fromCharCode(c);
+      const encoded = base64UrlEncode(originalString);
+      const decoded = base64UrlDecode(encoded);
+      expect(decoded).toEqual(originalString);
     }
-  }
-
-  textarea {
-    width: 100%;
-  }
-
-  .button-bar {
-    text-align: right;
-  }
-}
+  });
+});
diff --git a/web-console/src/views/explore-view/source-pane/source-pane.scss b/web-console/src/utils/base64-url.ts
similarity index 57%
rename from web-console/src/views/explore-view/source-pane/source-pane.scss
rename to web-console/src/utils/base64-url.ts
index 741f16562fb..d93b9ff4897 100644
--- a/web-console/src/views/explore-view/source-pane/source-pane.scss
+++ b/web-console/src/utils/base64-url.ts
@@ -16,15 +16,19 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
-
-.source-pane {
-  .#{$ns}-button-text {
-    flex: 1 1 auto;
-  }
+export function base64UrlEncode(input: string): string {
+  const base64 = btoa(input); // Encode to base64
+  return base64
+    .replace(/\+/g, '-') // Replace '+' with '-'
+    .replace(/\//g, '_') // Replace '/' with '_'
+    .replace(/=+$/, ''); // Remove any trailing '='
 }
 
-.source-menu {
-  max-height: 80vh;
-  overflow: auto;
+export function base64UrlDecode(input: string): string {
+  const base64 = input
+    .replace(/-/g, '+') // Replace '-' with '+'
+    .replace(/_/g, '/'); // Replace '_' with '/'
+
+  // Add padding '=' if necessary
+  return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=')); // Decode from base64
 }
diff --git a/web-console/src/utils/basic-action.tsx b/web-console/src/utils/basic-action.tsx
index 5194d26fa1b..f98b5b66d7d 100644
--- a/web-console/src/utils/basic-action.tsx
+++ b/web-console/src/utils/basic-action.tsx
@@ -45,7 +45,7 @@ export function basicActionsToMenu(
           intent={intent}
           onClick={onAction}
           disabled={Boolean(disabledReason)}
-          title={disabledReason}
+          data-super-title={disabledReason}
         />
       ))}
     </Menu>
diff --git a/web-console/src/utils/date.ts b/web-console/src/utils/date.ts
index b6ace7d6dda..1d5aa4f61e9 100644
--- a/web-console/src/utils/date.ts
+++ b/web-console/src/utils/date.ts
@@ -27,9 +27,35 @@ export function isNonNullRange(range: DateRange): range is NonNullDateRange {
 export function dateToIsoDateString(date: Date): string {
   return date.toISOString().slice(0, 10);
 }
+export function prettyFormatIsoDateWithMsIfNeeded(isoDate: string | Date): string {
+  return (typeof isoDate === 'string' ? isoDate : isoDate.toISOString())
+    .replace('T', ' ')
+    .replace('Z', '')
+    .replace('.000', '');
+}
+
+export function prettyFormatIsoDate(isoDate: string | Date): string {
+  return prettyFormatIsoDateWithMsIfNeeded(isoDate).replace(/\.\d\d\d/, '');
+}
+
+export function prettyFormatIsoDateTick(date: Date): string {
+  // s like 2016-06-27T19:00:00.000Z
+  let s = date.toISOString();
+  if (!s.endsWith('.000Z')) {
+    return s.slice(19, 23); // => ".001"
+  }
+  s = s.slice(0, 19); // s like 2016-06-27T19:00:00
+
+  if (!s.endsWith(':00')) {
+    return s.slice(11); // => 00:00:01
+  }
+  s = s.slice(0, 16); // s like 2016-06-27T19:00
+
+  if (!s.endsWith('T00:00')) {
+    return s.slice(11); // => 00:00
+  }
 
-export function prettyFormatIsoDate(isoDate: string): string {
-  return isoDate.replace('T', ' ').replace(/\.\d\d\dZ$/, '');
+  return s.slice(0, 10); // s like 2016-06-27
 }
 
 export function utcToLocalDate(utcDate: Date): Date {
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 765f7e30f64..c6885ca4337 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -148,7 +148,7 @@ export function change<T>(xs: readonly T[], from: T, to: T): T[] {
 
 export function countBy<T>(
   array: readonly T[],
-  fn: (x: T, index: number) => string = String,
+  fn: (x: T, index: number) => string | number = String,
 ): Record<string, number> {
   const counts: Record<string, number> = {};
   for (let i = 0; i < array.length; i++) {
@@ -184,11 +184,30 @@ export function mapRecord<T, Q>(
   const newRecord: Record<string, Q> = {};
   const keys = Object.keys(record);
   for (const key of keys) {
-    newRecord[key] = fn(record[key], key);
+    const mapped = fn(record[key], key);
+    if (typeof mapped === 'undefined') continue;
+    newRecord[key] = mapped;
   }
   return newRecord;
 }
 
+export function mapRecordIfChanged<T>(
+  record: Record<string, T>,
+  fn: (value: T, key: string) => T,
+): Record<string, T> {
+  const newRecord: Record<string, T> = {};
+  let changed = false;
+  const keys = Object.keys(record);
+  for (const key of keys) {
+    const v = record[key];
+    const mapped = fn(v, key);
+    if (v !== mapped) changed = true;
+    if (typeof mapped === 'undefined') continue;
+    newRecord[key] = mapped;
+  }
+  return changed ? newRecord : record;
+}
+
 export function groupBy<T, Q>(
   array: readonly T[],
   keyFn: (x: T, index: number) => string,
@@ -207,7 +226,7 @@ export function groupBy<T, Q>(
 
 export function groupByAsMap<T, Q>(
   array: readonly T[],
-  keyFn: (x: T, index: number) => string,
+  keyFn: (x: T, index: number) => string | number,
   aggregateFn: (xs: readonly T[], key: string) => Q,
 ): Record<string, Q> {
   const buckets: Record<string, T[]> = {};
@@ -235,6 +254,12 @@ export function uniq(array: readonly string[]): string[] {
 
 // ----------------------------
 
+export function formatEmpty(str: string): string {
+  return str === '' ? 'empty' : str;
+}
+
+// ----------------------------
+
 export function formatInteger(n: NumberLike): string {
   return numeral(n).format('0,0');
 }
@@ -293,6 +318,10 @@ export function forceSignInNumberFormatter(
   };
 }
 
+function sign(n: NumberLike): string {
+  return n < 0 ? '-' : '';
+}
+
 function pad2(str: string | number): string {
   return ('00' + str).slice(-2);
 }
@@ -302,35 +331,46 @@ function pad3(str: string | number): string {
 }
 
 export function formatDuration(ms: NumberLike): string {
-  const n = Number(ms);
+  const n = Math.abs(Number(ms));
   const timeInHours = Math.floor(n / 3600000);
   const timeInMin = Math.floor(n / 60000) % 60;
   const timeInSec = Math.floor(n / 1000) % 60;
-  return timeInHours + ':' + pad2(timeInMin) + ':' + pad2(timeInSec);
+  return sign(ms) + timeInHours + ':' + pad2(timeInMin) + ':' + pad2(timeInSec);
 }
 
 export function formatDurationWithMs(ms: NumberLike): string {
-  const n = Number(ms);
+  const n = Math.abs(Number(ms));
   const timeInHours = Math.floor(n / 3600000);
   const timeInMin = Math.floor(n / 60000) % 60;
   const timeInSec = Math.floor(n / 1000) % 60;
   return (
-    timeInHours + ':' + pad2(timeInMin) + ':' + pad2(timeInSec) + '.' + pad3(Math.floor(n) % 1000)
+    sign(ms) +
+    timeInHours +
+    ':' +
+    pad2(timeInMin) +
+    ':' +
+    pad2(timeInSec) +
+    '.' +
+    pad3(Math.floor(n) % 1000)
   );
 }
 
+export function formatDurationWithMsIfNeeded(ms: NumberLike): string {
+  return Number(ms) < 1000 ? formatDurationWithMs(ms) : formatDuration(ms);
+}
+
 export function formatDurationHybrid(ms: NumberLike): string {
-  const n = Number(ms);
+  const n = Math.abs(Number(ms));
   if (n < 600000) {
     // anything that looks like 1:23.45 (max 9:59.99)
     const timeInMin = Math.floor(n / 60000);
     const timeInSec = Math.floor(n / 1000) % 60;
     const timeInMs = Math.floor(n) % 1000;
-    return `${timeInMin ? `${timeInMin}:` : ''}${timeInMin ? pad2(timeInSec) : timeInSec}.${pad3(
-      timeInMs,
-    ).slice(0, 2)}s`;
+    return `${sign(ms)}${timeInMin ? `${timeInMin}:` : ''}${
+      timeInMin ? pad2(timeInSec) : timeInSec
+    }.${pad3(timeInMs).slice(0, 2)}s`;
   } else {
-    return formatDuration(n);
+    return formatDuration(ms);
   }
 }
 
@@ -374,6 +414,16 @@ export function filterMap<T, Q>(xs: readonly T[], f: (x: T, i: number) => Q | un
   return xs.map(f).filter((x: Q | undefined) => typeof x !== 'undefined') as Q[];
 }
 
+export function filterMapIfChanged<T>(xs: T[], f: (x: T, i: number) => T | undefined): T[] {
+  let changed = false;
+  const newXs = filterMap(xs, (x, i) => {
+    const newX = f(x, i);
+    if (typeof newX === 'undefined' || x !== newX) changed = true;
+    return newX;
+  });
+  return changed ? newXs : xs;
+}
+
 export function findMap<T, Q>(
   xs: readonly T[],
   f: (x: T, i: number) => Q | undefined,
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index d9b6d62a66e..edea5ad0a52 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/index.tsx
@@ -16,6 +16,7 @@
  * limitations under the License.
  */
 
+export * from './base64-url';
 export * from './column-metadata';
 export * from './date';
 export * from './download';
@@ -26,6 +27,7 @@ export * from './formatter';
 export * from './general';
 export * from './local-storage-backed-visibility';
 export * from './local-storage-keys';
+export * from './mouse-tooltip/mouse-tooltip';
 export * from './null-mode-detection';
 export * from './object-change';
 export * from './query-action';
diff --git a/web-console/src/utils/local-storage-keys.tsx b/web-console/src/utils/local-storage-keys.tsx
index 7c797d2c89a..d4efec06e22 100644
--- a/web-console/src/utils/local-storage-keys.tsx
+++ b/web-console/src/utils/local-storage-keys.tsx
@@ -56,8 +56,7 @@ export const LocalStorageKeys = {
 
   SQL_DATA_LOADER_CONTENT: 'sql-data-loader-content' as const,
 
-  EXPLORE_CONTENT: 'explore-content' as const,
-  EXPLORE_ESSENCE: 'explore-essence' as const,
+  EXPLORE_STATE: 'explore-state' as const,
 };
 export type LocalStorageKeys = (typeof LocalStorageKeys)[keyof typeof LocalStorageKeys];
 
diff --git a/web-console/src/views/explore-view/control-pane/aggregate-menu.scss b/web-console/src/utils/mouse-tooltip/mouse-tooltip.scss
similarity index 75%
copy from web-console/src/views/explore-view/control-pane/aggregate-menu.scss
copy to web-console/src/utils/mouse-tooltip/mouse-tooltip.scss
index 754bd23858b..d2ebc813788 100644
--- a/web-console/src/views/explore-view/control-pane/aggregate-menu.scss
+++ b/web-console/src/utils/mouse-tooltip/mouse-tooltip.scss
@@ -16,13 +16,17 @@
  * limitations under the License.
  */
 
-.aggregate-menu {
-  .search-input {
-    margin: 4px;
-  }
+@import '../../variables';
 
-  .inner-menu {
-    height: 400px;
-    overflow: auto;
-  }
+.mouse-tooltip {
+  position: absolute;
+  background: $dark-gray1;
+  color: $light-gray5;
+  padding: 5px 8px;
+  border-radius: 4px;
+  pointer-events: none;
+  z-index: 1000;
+  font-size: 11px;
+  max-width: 75vw;
+  white-space: pre-wrap;
 }
diff --git a/web-console/src/utils/mouse-tooltip/mouse-tooltip.ts b/web-console/src/utils/mouse-tooltip/mouse-tooltip.ts
new file mode 100644
index 00000000000..bbea9050890
--- /dev/null
+++ b/web-console/src/utils/mouse-tooltip/mouse-tooltip.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 { clamp } from '../general';
+
+import './mouse-tooltip.scss';
+
+const OFFSET_X = 0;
+const OFFSET_Y = 30;
+
+function findDataTooltip(element: HTMLElement): string | undefined {
+  let e: HTMLElement | null = element;
+  while (e) {
+    const t = e.getAttribute('data-tooltip');
+    if (t) return t;
+    e = e.parentElement;
+  }
+  return;
+}
+
+export function initMouseTooltip() {
+  let tooltipDiv: HTMLDivElement | undefined;
+
+  // Mouse move handler to follow the mouse
+  const handleMouseMove = (event: MouseEvent) => {
+    if (!tooltipDiv) return;
+
+    tooltipDiv.style.left = `${clamp(
+      event.pageX + OFFSET_X,
+      0,
+      window.innerWidth - tooltipDiv.offsetWidth,
+    )}px`;
+    tooltipDiv.style.top = `${clamp(
+      event.pageY + OFFSET_Y,
+      0,
+      window.innerHeight - tooltipDiv.offsetHeight,
+    )}px`;
+  };
+
+  // Mouse over handler to show the tooltip for elements with the appropriate attribute
+  const handleMouseOver = (event: MouseEvent) => {
+    const title = findDataTooltip(event.target as HTMLElement);
+    if (title) {
+      if (!tooltipDiv) {
+        tooltipDiv = document.createElement('div');
+        tooltipDiv.className = 'mouse-tooltip';
+        document.body.appendChild(tooltipDiv);
+        document.addEventListener('mousemove', handleMouseMove);
+      }
+      if (tooltipDiv.innerText !== title) {
+        tooltipDiv.innerText = title;
+        handleMouseMove(event); // Position the div as it might be the first positioning
+      }
+    } else if (tooltipDiv) {
+      document.removeEventListener('mousemove', handleMouseMove);
+      tooltipDiv.remove();
+      tooltipDiv = undefined;
+    }
+  };
+
+  document.addEventListener('mouseover', handleMouseOver);
+}
diff --git a/web-console/src/utils/table-helpers.ts b/web-console/src/utils/table-helpers.ts
index 006d56dff48..90df7fa1064 100644
--- a/web-console/src/utils/table-helpers.ts
+++ b/web-console/src/utils/table-helpers.ts
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import type { QueryResult } from '@druid-toolkit/query';
+import type { QueryResult, SqlExpression } from '@druid-toolkit/query';
 import { C } from '@druid-toolkit/query';
 import type { Filter } from 'react-table';
 
@@ -35,6 +35,7 @@ export function changePage(pagination: Pagination, page: number): Pagination {
 export interface ColumnHint {
   displayName?: string;
   group?: string;
+  expressionForWhere?: SqlExpression;
   formatter?: (x: any) => string;
 }
 
diff --git a/web-console/src/utils/types.ts b/web-console/src/utils/types.ts
index 8ebbd6938b3..7f48655cb1c 100644
--- a/web-console/src/utils/types.ts
+++ b/web-console/src/utils/types.ts
@@ -111,6 +111,12 @@ export function dataTypeToIcon(dataType: string): IconName {
     case 'COMPLEX<COMPRESSEDBIGDECIMAL>':
       return IconNames.SORT_NUMERICAL_DESC;
 
+    case 'COMPLEX<TEXT>':
+      return IconNames.LABEL;
+
+    case 'COMPLEX<VECTOR>':
+      return IconNames.REGRESSION_CHART;
+
     default:
       if (typeUpper.startsWith('ARRAY')) return IconNames.ARRAY;
       if (typeUpper.startsWith('COMPLEX')) return IconNames.ASTERISK;
diff --git a/web-console/src/views/explore-view/column-picker/column-picker.tsx b/web-console/src/views/explore-view/column-picker/column-picker.tsx
deleted file mode 100644
index 237e84b99e4..00000000000
--- a/web-console/src/views/explore-view/column-picker/column-picker.tsx
+++ /dev/null
@@ -1,47 +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 { HTMLSelect } from '@blueprintjs/core';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-import React from 'react';
-
-export interface ColumnPickerProps {
-  availableColumns: ExpressionMeta[] | undefined;
-  selectedColumnName: string;
-  onSelectedColumnNameChange(selectedColumnName: string): void;
-}
-
-export const ColumnPicker = React.memo(function ColumnPicker(props: ColumnPickerProps) {
-  const { availableColumns, selectedColumnName, onSelectedColumnNameChange } = props;
-
-  return (
-    <HTMLSelect
-      className="column-picker"
-      value={selectedColumnName}
-      onChange={e => {
-        onSelectedColumnNameChange(e.target.value);
-      }}
-    >
-      {availableColumns?.map((column, i) => (
-        <option key={i} value={column.name}>
-          {column.name}
-        </option>
-      )) || <option value="loading">Loading...</option>}
-    </HTMLSelect>
-  );
-});
diff --git a/web-console/src/views/explore-view/column-picker-menu/column-picker-menu.scss b/web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.scss
similarity index 100%
rename from web-console/src/views/explore-view/column-picker-menu/column-picker-menu.scss
rename to web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.scss
diff --git a/web-console/src/views/explore-view/column-picker-menu/column-picker-menu.tsx b/web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.tsx
similarity index 80%
rename from web-console/src/views/explore-view/column-picker-menu/column-picker-menu.tsx
rename to web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.tsx
index 16d3cbee9b1..e25bbd6b788 100644
--- a/web-console/src/views/explore-view/column-picker-menu/column-picker-menu.tsx
+++ b/web-console/src/views/explore-view/components/column-picker-menu/column-picker-menu.tsx
@@ -19,26 +19,32 @@
 import type { IconName } from '@blueprintjs/core';
 import { Icon, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
+import type { Column } from '@druid-toolkit/query';
 import classNames from 'classnames';
 import React, { useState } from 'react';
 
-import { caseInsensitiveContains, dataTypeToIcon, filterMap } from '../../../utils';
+import { caseInsensitiveContains, columnToIcon, filterMap } from '../../../../utils';
 
 import './column-picker-menu.scss';
 
 export interface ColumnPickerMenuProps {
   className?: string;
-  columns: ExpressionMeta[];
-  onSelectColumn(column: ExpressionMeta): void;
-  iconForColumn?: (column: ExpressionMeta) => IconName | undefined;
+  columns: readonly Column[];
+  onSelectColumn(column: Column): void;
+  rightIconForColumn?: (column: Column) => IconName | undefined;
   onSelectNone?: () => void;
   shouldDismissPopover?: boolean;
 }
 
 export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMenuProps) {
-  const { className, columns, onSelectColumn, iconForColumn, onSelectNone, shouldDismissPopover } =
-    props;
+  const {
+    className,
+    columns,
+    onSelectColumn,
+    onSelectNone,
+    rightIconForColumn,
+    shouldDismissPopover,
+  } = props;
   const [columnSearch, setColumnSearch] = useState('');
 
   return (
@@ -47,7 +53,7 @@ export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMen
         className="search-input"
         value={columnSearch}
         onChange={e => setColumnSearch(e.target.value)}
-        placeholder="Search..."
+        placeholder="Search"
         autoFocus
       />
       <Menu className="column-menu">
@@ -61,11 +67,11 @@ export const ColumnPickerMenu = function ColumnPickerMenu(props: ColumnPickerMen
         )}
         {filterMap(columns, (c, i) => {
           if (!caseInsensitiveContains(c.name, columnSearch)) return;
-          const iconName = iconForColumn?.(c);
+          const iconName = rightIconForColumn?.(c);
           return (
             <MenuItem
               key={i}
-              icon={c.sqlType ? dataTypeToIcon(c.sqlType) : IconNames.BLANK}
+              icon={columnToIcon(c) || IconNames.BLANK}
               text={c.name}
               labelElement={iconName && <Icon icon={iconName} />}
               onClick={() => onSelectColumn(c)}
diff --git a/web-console/src/views/explore-view/components/column-picker/column-picker.tsx b/web-console/src/views/explore-view/components/column-picker/column-picker.tsx
new file mode 100644
index 00000000000..81c88fbf9b7
--- /dev/null
+++ b/web-console/src/views/explore-view/components/column-picker/column-picker.tsx
@@ -0,0 +1,84 @@
+/*
+ * 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, Menu, MenuItem, Popover, Position } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column } from '@druid-toolkit/query';
+import React, { useState } from 'react';
+
+import { columnToIcon } from '../../../../utils';
+
+export interface ColumnPickerProps {
+  availableColumns: readonly Column[] | undefined;
+  selectedColumnName: string;
+  onSelectedColumnNameChange(selectedColumnName: string): void;
+  fill?: boolean;
+  disabled?: boolean;
+  shouldDismissPopover?: boolean;
+}
+
+export const ColumnPicker = React.memo(function ColumnPicker(props: ColumnPickerProps) {
+  const {
+    availableColumns,
+    selectedColumnName,
+    onSelectedColumnNameChange,
+    fill,
+    disabled,
+    shouldDismissPopover,
+  } = props;
+  const [isOpen, setIsOpen] = useState(false);
+
+  const selectedColumn = availableColumns?.find(c => c.name === selectedColumnName);
+  return (
+    <Popover
+      className="column-picker"
+      position={Position.BOTTOM_LEFT}
+      isOpen={isOpen}
+      minimal
+      fill={fill}
+      disabled={disabled}
+      content={
+        <Menu style={{ maxHeight: '600px', overflowY: 'scroll' }}>
+          {availableColumns?.map((column, i) => (
+            <MenuItem
+              key={i}
+              icon={columnToIcon(column)}
+              text={column.name}
+              onClick={() => {
+                setIsOpen(false);
+                onSelectedColumnNameChange(column.name);
+              }}
+              shouldDismissPopover={shouldDismissPopover}
+            />
+          )) || <MenuItem text="Loading..." />}
+        </Menu>
+      }
+    >
+      <Button
+        icon={selectedColumn ? columnToIcon(selectedColumn) : undefined}
+        text={selectedColumnName}
+        rightIcon={IconNames.CARET_DOWN}
+        fill={fill}
+        disabled={disabled}
+        onClick={() => {
+          setIsOpen(true);
+        }}
+      />
+    </Popover>
+  );
+});
diff --git a/web-console/src/views/explore-view/control-pane/control-pane.scss b/web-console/src/views/explore-view/components/control-pane/control-pane.scss
similarity index 91%
rename from web-console/src/views/explore-view/control-pane/control-pane.scss
rename to web-console/src/views/explore-view/components/control-pane/control-pane.scss
index 52e30069bc4..c1608cdbf86 100644
--- a/web-console/src/views/explore-view/control-pane/control-pane.scss
+++ b/web-console/src/views/explore-view/components/control-pane/control-pane.scss
@@ -16,10 +16,10 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
+@import '../../../../variables';
 
 .control-pane {
-  .#{$ns}-tag-input-values .#{$ns}-tag {
+  .#{$bp-ns}-tag-input-values .#{$bp-ns}-tag {
     vertical-align: top;
   }
 }
diff --git a/web-console/src/views/explore-view/components/control-pane/control-pane.tsx b/web-console/src/views/explore-view/components/control-pane/control-pane.tsx
new file mode 100644
index 00000000000..7fcc6352f29
--- /dev/null
+++ b/web-console/src/views/explore-view/components/control-pane/control-pane.tsx
@@ -0,0 +1,386 @@
+/*
+ * 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,
+  InputGroup,
+  Intent,
+  Menu,
+  MenuItem,
+  Popover,
+  SegmentedControl,
+  Tag,
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column, SqlExpression } from '@druid-toolkit/query';
+import { SqlColumn } from '@druid-toolkit/query';
+import type { JSX } from 'react';
+import React from 'react';
+
+import {
+  AutoForm,
+  FancyNumericInput,
+  FormGroupWithInfo,
+  PopoverText,
+} from '../../../../components';
+import { AppToaster } from '../../../../singletons';
+import { filterMap } from '../../../../utils';
+import type { OptionValue, ParameterDefinition, QuerySource } from '../../models';
+import {
+  effectiveParameterDefault,
+  evaluateFunctor,
+  ExpressionMeta,
+  getModuleOptionLabel,
+  Measure,
+} from '../../models';
+import { changeOrAdd } from '../../utils';
+import { DroppableContainer } from '../droppable-container/droppable-container';
+
+import { ExpressionMenu } from './expression-menu';
+import { MeasureMenu } from './measure-menu';
+import { NamedExpressionsInput } from './named-expressions-input';
+import { OptionsInput } from './options-input';
+
+import './control-pane.scss';
+
+export interface ControlPaneProps {
+  querySource: QuerySource | undefined;
+  onUpdateParameterValues(params: Record<string, unknown>): void;
+  parameters: Record<string, ParameterDefinition>;
+  parameterValues: Record<string, unknown>;
+  onAddToSourceQueryAsColumn?(expression: SqlExpression): void;
+  onAddToSourceQueryAsMeasure?(measure: Measure): void;
+}
+
+export const ControlPane = function ControlPane(props: ControlPaneProps) {
+  const {
+    querySource,
+    onUpdateParameterValues,
+    parameters,
+    parameterValues,
+    onAddToSourceQueryAsColumn,
+    onAddToSourceQueryAsMeasure,
+  } = props;
+  const columns = querySource?.columns || [];
+  const measures = querySource?.measures || [];
+
+  function renderOptionsPropInput(
+    parameter: ParameterDefinition,
+    value: any,
+    onValueChange: (value: any) => void,
+  ): {
+    element: JSX.Element;
+    onDropColumn?: (column: Column) => void;
+    onDropMeasure?: (measure: Measure) => void;
+  } {
+    const effectiveValue = value ?? effectiveParameterDefault(parameter, querySource);
+    const required = evaluateFunctor(parameter.required, parameterValues);
+    switch (parameter.type) {
+      case 'boolean': {
+        return {
+          element: (
+            <SegmentedControl
+              value={String(effectiveValue)}
+              onValueChange={v => {
+                onValueChange(v === 'true');
+              }}
+              options={[
+                { value: 'false', label: 'False' },
+                { value: 'true', label: 'True' },
+              ]}
+              small
+            />
+          ),
+        };
+      }
+
+      case 'number':
+        return {
+          element: (
+            <FancyNumericInput
+              value={effectiveValue}
+              onValueChange={onValueChange}
+              placeholder={parameter.placeholder}
+              fill
+              min={parameter.min}
+              max={parameter.max}
+            />
+          ),
+        };
+
+      case 'string':
+        return {
+          element: (
+            <InputGroup
+              value={(effectiveValue as string) || ''}
+              onChange={e => onValueChange(e.target.value)}
+              placeholder={parameter.placeholder}
+              fill
+            />
+          ),
+        };
+
+      case 'option': {
+        const controlOptions = parameter.options || [];
+        const selectedOption: OptionValue | undefined = controlOptions.find(
+          o => o === effectiveValue,
+        );
+        return {
+          element: (
+            <Popover
+              fill
+              position="bottom-left"
+              minimal
+              content={
+                <Menu>
+                  {controlOptions.map((o, i) => (
+                    <MenuItem
+                      key={i}
+                      text={getModuleOptionLabel(o, parameter)}
+                      onClick={() => onValueChange(o)}
+                    />
+                  ))}
+                </Menu>
+              }
+            >
+              <InputGroup
+                value={
+                  typeof selectedOption === 'undefined'
+                    ? String(effectiveValue)
+                    : getModuleOptionLabel(selectedOption, parameter)
+                }
+                readOnly
+                fill
+                rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
+              />
+            </Popover>
+          ),
+        };
+      }
+
+      case 'options': {
+        return {
+          element: (
+            <OptionsInput
+              options={parameter.options || []}
+              value={(effectiveValue as OptionValue[]) || []}
+              onValueChange={onValueChange}
+              optionLabel={o => getModuleOptionLabel(o, parameter)}
+              allowDuplicates={parameter.allowDuplicates}
+              nonEmpty={parameter.nonEmpty}
+            />
+          ),
+        };
+      }
+
+      case 'expression': {
+        const handleSelectColumn = (c: Column) => {
+          onValueChange(ExpressionMeta.fromColumn(c));
+        };
+        return {
+          element: (
+            <NamedExpressionsInput
+              allowReordering
+              values={effectiveValue ? [effectiveValue] : []}
+              onValuesChange={vs => onValueChange(vs[0])}
+              singleton
+              nonEmpty={required}
+              itemMenu={(initExpression, onClose) => (
+                <ExpressionMenu
+                  columns={columns}
+                  initExpression={initExpression}
+                  onSelectExpression={onValueChange}
+                  onClose={onClose}
+                  onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+                />
+              )}
+            />
+          ),
+          onDropColumn: handleSelectColumn,
+        };
+      }
+
+      case 'expressions': {
+        const disabledColumnNames = parameter.allowDuplicates
+          ? []
+          : filterMap(effectiveValue as ExpressionMeta[], ({ expression }) =>
+              expression instanceof SqlColumn ? expression.getName() : undefined,
+            );
+        return {
+          element: (
+            <NamedExpressionsInput
+              allowReordering
+              values={effectiveValue as ExpressionMeta[]}
+              onValuesChange={onValueChange}
+              nonEmpty={parameter.nonEmpty}
+              itemMenu={(initExpression, onClose) => (
+                <ExpressionMenu
+                  columns={columns}
+                  initExpression={initExpression}
+                  onSelectExpression={c =>
+                    onValueChange(changeOrAdd(effectiveValue, initExpression, c))
+                  }
+                  disabledColumnNames={disabledColumnNames}
+                  onClose={onClose}
+                  onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+                />
+              )}
+            />
+          ),
+          onDropColumn: (column: Column) => {
+            const columnName = column.name;
+            if (
+              !parameter.allowDuplicates &&
+              effectiveValue.find((v: ExpressionMeta) => v.name === columnName)
+            ) {
+              AppToaster.show({
+                intent: Intent.WARNING,
+                message: (
+                  <>
+                    <Tag minimal>{columnName}</Tag> already selected
+                  </>
+                ),
+              });
+              return;
+            }
+            onValueChange(effectiveValue.concat(ExpressionMeta.fromColumn(column)));
+          },
+        };
+      }
+
+      case 'measure': {
+        return {
+          element: (
+            <NamedExpressionsInput
+              values={effectiveValue ? [effectiveValue] : []}
+              onValuesChange={vs => onValueChange(vs[0])}
+              singleton
+              nonEmpty={required}
+              itemMenu={(initMeasure, onClose) => (
+                <MeasureMenu
+                  columns={columns}
+                  measures={measures}
+                  initMeasure={initMeasure}
+                  onSelectMeasure={onValueChange}
+                  onClose={onClose}
+                  onAddToSourceQueryAsMeasure={onAddToSourceQueryAsMeasure}
+                />
+              )}
+            />
+          ),
+          onDropColumn: column => {
+            const measures = Measure.getPossibleMeasuresForColumn(column);
+            if (!measures.length) return;
+            onValueChange(measures[0]);
+          },
+          onDropMeasure: onValueChange,
+        };
+      }
+
+      case 'measures': {
+        return {
+          element: (
+            <NamedExpressionsInput<Measure>
+              values={effectiveValue}
+              onValuesChange={onValueChange}
+              allowReordering
+              nonEmpty={parameter.nonEmpty}
+              itemMenu={(initMeasure, onClose) => (
+                <MeasureMenu
+                  columns={columns}
+                  measures={measures}
+                  initMeasure={initMeasure}
+                  onSelectMeasure={m => onValueChange(changeOrAdd(effectiveValue, initMeasure, m))}
+                  onClose={onClose}
+                  onAddToSourceQueryAsMeasure={onAddToSourceQueryAsMeasure}
+                />
+              )}
+            />
+          ),
+          onDropColumn: column => {
+            const measures = Measure.getPossibleMeasuresForColumn(column).filter(
+              p => !effectiveValue.some((v: ExpressionMeta) => v.name === p.name),
+            );
+            if (!measures.length) return;
+            onValueChange(effectiveValue.concat(measures[0]));
+          },
+          onDropMeasure: measure => {
+            onValueChange(effectiveValue.concat(measure));
+          },
+        };
+      }
+
+      default:
+        return {
+          element: (
+            <Button
+              icon={IconNames.ERROR}
+              text={`Type not supported: ${(parameter as { type: string }).type}`}
+              disabled
+              fill
+            />
+          ),
+        };
+    }
+  }
+
+  const namedParameters = Object.entries(parameters ?? {});
+
+  return (
+    <div className="control-pane">
+      {namedParameters.map(([name, parameter], i) => {
+        const visible = evaluateFunctor(parameter.visible, parameterValues);
+        if (visible === false) return;
+
+        const value = parameterValues[name];
+        function onValueChange(newValue: unknown) {
+          onUpdateParameterValues({ [name]: newValue });
+        }
+
+        const { element, onDropColumn, onDropMeasure } = renderOptionsPropInput(
+          parameter,
+          value,
+          onValueChange,
+        );
+
+        const description = evaluateFunctor(parameter.description, parameterValues);
+        const formGroup = (
+          <FormGroupWithInfo
+            key={i}
+            label={
+              evaluateFunctor(parameter.label, parameterValues) || AutoForm.makeLabelName(name)
+            }
+            info={description && <PopoverText>{description}</PopoverText>}
+          >
+            {element}
+          </FormGroupWithInfo>
+        );
+
+        if (!onDropColumn) {
+          return formGroup;
+        }
+
+        return (
+          <DroppableContainer key={i} onDropColumn={onDropColumn} onDropMeasure={onDropMeasure}>
+            {formGroup}
+          </DroppableContainer>
+        );
+      })}
+    </div>
+  );
+};
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/control-pane/expression-menu.scss
similarity index 69%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/control-pane/expression-menu.scss
index dbb850df020..0c60d1f9f0a 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/control-pane/expression-menu.scss
@@ -18,27 +18,40 @@
 
 @import '../../../../variables';
 
-.filter-menu {
-  width: 400px;
+.expression-menu {
+  width: 500px;
+  height: 47vh;
+  padding: 15px;
+  display: flex;
+  flex-direction: column;
 
-  &.main {
-    padding: 15px;
+  .tab-bar {
+    & > * {
+      flex: 1 !important;
+    }
   }
 
-  .controls .#{$ns}-form-content {
-    display: flex;
-    gap: 15px;
+  & > .#{$bp-ns}-menu {
+    flex: 1;
+    padding: 0;
+    overflow: auto;
 
-    .type-selector {
-      flex: 1;
+    &:not(:last-child) {
+      margin-bottom: 15px;
     }
   }
 
-  textarea {
-    width: 100%;
+  .editor-container {
+    flex: 1;
+    margin-bottom: 15px;
   }
 
   .button-bar {
-    text-align: right;
+    display: flex;
+    gap: 10px;
+
+    .button-separator {
+      flex: 1;
+    }
   }
 }
diff --git a/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx b/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx
new file mode 100644
index 00000000000..407bf9ea329
--- /dev/null
+++ b/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx
@@ -0,0 +1,212 @@
+/*
+ * 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,
+  ButtonGroup,
+  FormGroup,
+  Icon,
+  InputGroup,
+  Intent,
+  Menu,
+  MenuItem,
+  Popover,
+  Position,
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column } from '@druid-toolkit/query';
+import { SqlColumn, SqlExpression } from '@druid-toolkit/query';
+import React, { useState } from 'react';
+
+import { AppToaster } from '../../../../singletons';
+import { columnToIcon } from '../../../../utils';
+import { ExpressionMeta } from '../../models';
+import { SqlInput } from '../sql-input/sql-input';
+
+import './expression-menu.scss';
+
+type ExpressionMenuTab = 'select' | 'sql';
+
+export interface ExpressionMenuProps {
+  columns: readonly Column[];
+  initExpression: ExpressionMeta | undefined;
+  onSelectExpression(measure: ExpressionMeta): void;
+  disabledColumnNames?: readonly string[];
+  onClose(): void;
+  onAddToSourceQueryAsColumn?(expression: SqlExpression): void;
+}
+
+function getColumnIndex(columns: readonly Column[], expression: SqlExpression): number {
+  if (!(expression instanceof SqlColumn)) return -1;
+  const columnName = expression.getName();
+  return columns.findIndex(column => column.name === columnName);
+}
+
+export const ExpressionMenu = function ExpressionMenu(props: ExpressionMenuProps) {
+  const {
+    columns,
+    initExpression,
+    onSelectExpression,
+    disabledColumnNames = [],
+    onClose,
+    onAddToSourceQueryAsColumn,
+  } = props;
+  const [tab, setTab] = useState<ExpressionMenuTab>(
+    initExpression && getColumnIndex(columns, initExpression.expression) === -1 ? 'sql' : 'select',
+  );
+  const [outputName, setOutputName] = useState<string>(initExpression?.as || '');
+  const [selectedColumnIndex, setSelectedColumnIndex] = useState<number>(
+    initExpression ? getColumnIndex(columns, initExpression.expression) : -1,
+  );
+  const [formula, setFormula] = useState<string>(
+    initExpression ? String(initExpression.expression) : '',
+  );
+
+  function getExpression(): SqlExpression | undefined {
+    if (!formula) {
+      AppToaster.show({
+        message: 'Formula is empty',
+        intent: Intent.DANGER,
+      });
+      return;
+    }
+
+    let parsedFormula: SqlExpression;
+    try {
+      parsedFormula = SqlExpression.parse(formula);
+    } catch (e) {
+      AppToaster.show({
+        message: `Could not parse formula: ${e.message}`,
+        intent: Intent.DANGER,
+      });
+      return;
+    }
+
+    return parsedFormula;
+  }
+
+  return (
+    <div className="expression-menu">
+      <FormGroup>
+        <ButtonGroup className="tab-bar" fill>
+          <Button
+            text="Select"
+            active={tab === 'select'}
+            onClick={() => {
+              if (tab === 'sql') {
+                const parsedFormula = SqlExpression.maybeParse(formula);
+                if (parsedFormula) {
+                  const selectedColumnIndex = getColumnIndex(columns, parsedFormula);
+                  if (selectedColumnIndex >= 0) {
+                    setSelectedColumnIndex(selectedColumnIndex);
+                  }
+                }
+              }
+              setTab('select');
+            }}
+          />
+          <Button
+            text="SQL"
+            active={tab === 'sql'}
+            onClick={() => {
+              setTab('sql');
+            }}
+          />
+        </ButtonGroup>
+      </FormGroup>
+      {tab === 'select' && (
+        <Menu>
+          {columns.map((c, i) => (
+            <MenuItem
+              key={i}
+              icon={columnToIcon(c)}
+              text={c.name}
+              disabled={i !== selectedColumnIndex && disabledColumnNames.includes(c.name)}
+              labelElement={i === selectedColumnIndex ? <Icon icon={IconNames.TICK} /> : undefined}
+              onClick={() => {
+                onSelectExpression(ExpressionMeta.fromColumn(c));
+              }}
+            />
+          ))}
+        </Menu>
+      )}
+      {tab === 'sql' && (
+        <>
+          <div className="editor-container">
+            <SqlInput
+              value={formula}
+              onValueChange={setFormula}
+              columns={columns}
+              placeholder="SQL expression"
+              autoFocus
+            />
+          </div>
+          <FormGroup label="Name">
+            <InputGroup
+              value={outputName}
+              onChange={e => {
+                setOutputName(e.target.value.slice(0, ExpressionMeta.MAX_NAME_LENGTH));
+              }}
+              placeholder="(default)"
+            />
+          </FormGroup>
+          <div className="button-bar">
+            {onAddToSourceQueryAsColumn && (
+              <Popover
+                position={Position.BOTTOM_LEFT}
+                content={
+                  <Menu>
+                    <MenuItem
+                      text="Add as column to the source query"
+                      onClick={() => {
+                        const expression = getExpression();
+                        if (!expression) return;
+                        onAddToSourceQueryAsColumn(expression.as(outputName || formula));
+                        onClose();
+                      }}
+                    />
+                  </Menu>
+                }
+              >
+                <Button icon={IconNames.TH_DERIVED} minimal />
+              </Popover>
+            )}
+            <div className="button-separator" />
+            <Button text="Cancel" onClick={onClose} />
+            <Button
+              intent={Intent.PRIMARY}
+              text="Apply"
+              onClick={() => {
+                const expression = getExpression();
+                if (!expression) return;
+
+                onSelectExpression(
+                  new ExpressionMeta({
+                    expression,
+                    as: outputName,
+                  }),
+                );
+                onClose();
+              }}
+            />
+          </div>
+        </>
+      )}
+    </div>
+  );
+};
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss
similarity index 63%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/control-pane/measure-menu.scss
index dbb850df020..f649a7c839c 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/control-pane/measure-menu.scss
@@ -18,27 +18,53 @@
 
 @import '../../../../variables';
 
-.filter-menu {
-  width: 400px;
+.measure-menu {
+  width: 500px;
+  height: 47vh;
+  padding: 15px;
+  display: flex;
+  flex-direction: column;
 
-  &.main {
-    padding: 15px;
+  .tab-bar {
+    & > * {
+      flex: 1 !important;
+    }
+  }
+
+  & > .#{$bp-ns}-menu {
+    flex: 1;
+    padding: 0;
+    overflow: auto;
+
+    &:not(:last-child) {
+      margin-bottom: 15px;
+    }
   }
 
-  .controls .#{$ns}-form-content {
+  .editor-container {
+    flex: 1;
+    margin-bottom: 15px;
+  }
+
+  .measure-column-line {
     display: flex;
-    gap: 15px;
+    gap: 8px;
 
-    .type-selector {
+    .column-group {
       flex: 1;
     }
   }
 
-  textarea {
-    width: 100%;
+  .expander {
+    flex: 1;
   }
 
   .button-bar {
-    text-align: right;
+    display: flex;
+    gap: 10px;
+
+    .button-separator {
+      flex: 1;
+    }
   }
 }
diff --git a/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx b/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx
new file mode 100644
index 00000000000..f1296dd8ffc
--- /dev/null
+++ b/web-console/src/views/explore-view/components/control-pane/measure-menu.tsx
@@ -0,0 +1,291 @@
+/*
+ * 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,
+  ButtonGroup,
+  FormGroup,
+  HTMLSelect,
+  Icon,
+  InputGroup,
+  Intent,
+  Menu,
+  MenuItem,
+  Popover,
+  Position,
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column } from '@druid-toolkit/query';
+import { SqlAlias, SqlExpression } from '@druid-toolkit/query';
+import React, { useState } from 'react';
+
+import { AppToaster } from '../../../../singletons';
+import { columnToIcon } from '../../../../utils';
+import { Measure, MeasurePattern } from '../../models';
+import { ColumnPicker } from '../column-picker/column-picker';
+import { SqlInput } from '../sql-input/sql-input';
+
+import './measure-menu.scss';
+
+type MeasureMenuTab = 'saved' | 'compose' | 'sql';
+
+export interface MeasureMenuProps {
+  columns: readonly Column[];
+  measures: readonly Measure[];
+  initMeasure: Measure | undefined;
+  onSelectMeasure(measure: Measure): void;
+  onClose(): void;
+  onAddToSourceQueryAsMeasure?(measure: Measure): void;
+}
+
+export const MeasureMenu = function MeasureMenu(props: MeasureMenuProps) {
+  const { columns, measures, initMeasure, onSelectMeasure, onClose, onAddToSourceQueryAsMeasure } =
+    props;
+
+  const [tab, setTab] = useState<MeasureMenuTab>(() => {
+    if (!initMeasure) return 'compose';
+    if (measures.some(measure => measure.equivalent(initMeasure))) return 'saved';
+    return MeasurePattern.fit(initMeasure.expression) ? 'compose' : 'sql';
+  });
+  const [outputName, setOutputName] = useState<string>(initMeasure?.as || '');
+  const [measurePattern, setMeasurePattern] = useState<MeasurePattern | undefined>(
+    initMeasure ? MeasurePattern.fit(initMeasure.expression) : undefined,
+  );
+  const [formula, setFormula] = useState<string>(initMeasure ? String(initMeasure.expression) : '');
+
+  function getMeasure(): Measure | undefined {
+    switch (tab) {
+      case 'saved':
+        return;
+
+      case 'compose': {
+        if (!measurePattern) throw new Error('no measure pattern');
+        const expression = measurePattern.toExpression();
+        return new Measure({
+          expression,
+          as: outputName,
+        });
+      }
+
+      case 'sql': {
+        if (!formula) {
+          AppToaster.show({
+            message: 'Formula is empty',
+            intent: Intent.DANGER,
+          });
+          return;
+        }
+
+        let parsedFormula: SqlExpression;
+        try {
+          parsedFormula = SqlExpression.parse(formula);
+        } catch (e) {
+          AppToaster.show({
+            message: `Could not parse formula: ${e.message}`,
+            intent: Intent.DANGER,
+          });
+          return;
+        }
+
+        if (parsedFormula instanceof SqlAlias) {
+          return new Measure({
+            expression: parsedFormula.getUnderlyingExpression(),
+            as: outputName || parsedFormula.getAliasName(),
+          });
+        }
+
+        return new Measure({
+          expression: parsedFormula,
+          as: outputName,
+        });
+      }
+    }
+  }
+
+  const actionDisabled =
+    (tab === 'compose' && !measurePattern) || (tab === 'sql' && formula === '');
+  return (
+    <div className="measure-menu">
+      <FormGroup>
+        <ButtonGroup className="tab-bar" fill>
+          <Button
+            text="Saved"
+            active={tab === 'saved'}
+            onClick={() => {
+              setTab('saved');
+            }}
+          />
+          <Button
+            text="Compose"
+            active={tab === 'compose'}
+            onClick={() => {
+              if (tab === 'sql') {
+                const parsedFormula = SqlExpression.maybeParse(formula);
+                if (parsedFormula) {
+                  const pattern = MeasurePattern.fit(parsedFormula);
+                  if (pattern) {
+                    setMeasurePattern(pattern);
+                  }
+                }
+              }
+              setTab('compose');
+            }}
+          />
+          <Button
+            text="SQL"
+            active={tab === 'sql'}
+            onClick={() => {
+              if (tab === 'compose' && measurePattern) {
+                setFormula(String(measurePattern));
+              }
+              setTab('sql');
+            }}
+          />
+        </ButtonGroup>
+      </FormGroup>
+      {tab === 'saved' && (
+        <Menu>
+          {measures.map((measure, i) => {
+            const aggregateMeasure = measure.toAggregateBasedMeasure();
+            return (
+              <MenuItem
+                key={i}
+                icon={IconNames.NUMERICAL}
+                text={aggregateMeasure.name}
+                labelElement={
+                  aggregateMeasure.equals(initMeasure) ? <Icon icon={IconNames.TICK} /> : undefined
+                }
+                onClick={() => {
+                  onSelectMeasure(aggregateMeasure);
+                }}
+              />
+            );
+          })}
+        </Menu>
+      )}
+      {tab === 'compose' &&
+        (measurePattern ? (
+          <div className="measure-column-line">
+            <FormGroup label="Aggregate">
+              <HTMLSelect
+                value={measurePattern.aggregate}
+                onChange={(e: any) =>
+                  setMeasurePattern(measurePattern.changeAggregate(e.target.value))
+                }
+              >
+                {MeasurePattern.AGGREGATES.map(measure => (
+                  <option key={measure} value={measure}>
+                    {measure}
+                  </option>
+                ))}
+              </HTMLSelect>
+            </FormGroup>
+            <FormGroup label="Column" className="column-group">
+              <ColumnPicker
+                availableColumns={columns}
+                disabled={measurePattern.aggregate === 'count'}
+                selectedColumnName={measurePattern.column}
+                onSelectedColumnNameChange={column => {
+                  setMeasurePattern(measurePattern.changeColumn(column));
+                }}
+                fill
+                shouldDismissPopover={false}
+              />
+            </FormGroup>
+          </div>
+        ) : (
+          <Menu>
+            {columns.map((c, i) => (
+              <MenuItem
+                key={i}
+                icon={columnToIcon(c)}
+                text={c.name}
+                shouldDismissPopover={false}
+                onClick={() => {
+                  setMeasurePattern(MeasurePattern.fromColumn(c));
+                }}
+              />
+            ))}
+          </Menu>
+        ))}
+      {tab === 'sql' && (
+        <div className="editor-container">
+          <SqlInput
+            value={formula}
+            onValueChange={setFormula}
+            columns={columns}
+            placeholder="SQL measure expression"
+            autoFocus
+          />
+        </div>
+      )}
+      {((tab === 'compose' && measurePattern) || tab === 'sql') && (
+        <FormGroup label="Name">
+          <InputGroup
+            value={outputName}
+            placeholder="(default)"
+            onChange={e => {
+              setOutputName(e.target.value.slice(0, Measure.MAX_NAME_LENGTH));
+            }}
+          />
+        </FormGroup>
+      )}
+      {tab === 'compose' && measurePattern && <div className="expander" />}
+      {tab !== 'saved' && (
+        <div className="button-bar">
+          {onAddToSourceQueryAsMeasure && (
+            <Popover
+              disabled={actionDisabled}
+              position={Position.BOTTOM_LEFT}
+              content={
+                <Menu>
+                  <MenuItem
+                    text="Add as measure to the source query"
+                    onClick={() => {
+                      const measure = getMeasure();
+                      if (!measure) return;
+
+                      onAddToSourceQueryAsMeasure(measure);
+                      onClose();
+                    }}
+                  />
+                </Menu>
+              }
+            >
+              <Button icon={IconNames.TH_DERIVED} minimal disabled={actionDisabled} />
+            </Popover>
+          )}
+          <div className="button-separator" />
+          <Button text="Cancel" onClick={onClose} />
+          <Button
+            intent={Intent.PRIMARY}
+            text="Apply"
+            disabled={actionDisabled}
+            onClick={() => {
+              const measure = getMeasure();
+              if (!measure) return;
+
+              onSelectMeasure(measure);
+              onClose();
+            }}
+          />
+        </div>
+      )}
+    </div>
+  );
+};
diff --git a/web-console/src/views/explore-view/control-pane/columns-input.scss b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss
similarity index 92%
copy from web-console/src/views/explore-view/control-pane/columns-input.scss
copy to web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss
index 2f7fc37e554..5dec6be7511 100644
--- a/web-console/src/views/explore-view/control-pane/columns-input.scss
+++ b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.scss
@@ -16,10 +16,10 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
+@import '../../../../variables';
 
-.columns-input {
-  .#{$ns}-tag-input-values .#{$ns}-tag {
+.named-expressions-input {
+  .#{$bp-ns}-tag-input-values .#{$bp-ns}-tag {
     &.drop-before::after {
       content: '';
       height: 24px;
diff --git a/web-console/src/views/explore-view/control-pane/columns-input.tsx b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx
similarity index 50%
rename from web-console/src/views/explore-view/control-pane/columns-input.tsx
rename to web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx
index 50e6b88c83e..ae498341ecd 100644
--- a/web-console/src/views/explore-view/control-pane/columns-input.tsx
+++ b/web-console/src/views/explore-view/components/control-pane/named-expressions-input.tsx
@@ -18,27 +18,13 @@
 
 import { Classes, Popover, Position, Tag } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
 import classNames from 'classnames';
 import type { JSX } from 'react';
 import React, { useCallback, useState } from 'react';
 
-import { ColumnPickerMenu } from '../column-picker-menu/column-picker-menu';
+import type { ExpressionMeta, Measure } from '../../models';
 
-import './columns-input.scss';
-
-export interface ColumnsInputProps {
-  columns: ExpressionMeta[];
-  value: ExpressionMeta[];
-  onValueChange(value: ExpressionMeta[]): void;
-  allowDuplicates?: boolean;
-  allowReordering?: boolean;
-
-  /**
-   * If you want to take control of the way new columns are picked and added
-   */
-  pickerMenu?: (columns: ExpressionMeta[]) => JSX.Element;
-}
+import './named-expressions-input.scss';
 
 function moveInArray(arr: any[], fromIndex: number, toIndex: number) {
   arr = arr.concat();
@@ -49,16 +35,24 @@ function moveInArray(arr: any[], fromIndex: number, toIndex: number) {
   return arr;
 }
 
-export const ColumnsInput = function ColumnsInput(props: ColumnsInputProps) {
-  const { columns, value, onValueChange, allowDuplicates, allowReordering, pickerMenu } = props;
+export interface NamesExpressionsInputProps<M extends ExpressionMeta | Measure> {
+  values: M[];
+  onValuesChange(value: M[]): void;
+  allowReordering?: boolean;
+  singleton?: boolean;
+  nonEmpty?: boolean;
+  itemMenu: (item: M | undefined, onClose: () => void) => JSX.Element;
+}
 
-  const availableColumns = allowDuplicates
-    ? columns
-    : columns.filter(o => !value.find(_ => _.name === o.name));
+export const NamedExpressionsInput = function NamedExpressionsInput<
+  M extends ExpressionMeta | Measure,
+>(props: NamesExpressionsInputProps<M>) {
+  const { values, onValuesChange, allowReordering, singleton, nonEmpty, itemMenu } = props;
 
   const [dragIndex, setDragIndex] = useState(-1);
   const [dropBefore, setDropBefore] = useState(false);
   const [dropIndex, setDropIndex] = useState(-1);
+  const [menuOpenOn, setMenuOpenOn] = useState<{ openOn?: M }>();
 
   const startDrag = useCallback((e: React.DragEvent, i: number) => {
     e.dataTransfer.effectAllowed = 'move';
@@ -89,52 +83,72 @@ export const ColumnsInput = function ColumnsInput(props: ColumnsInputProps) {
         if (correctedDropIndex > dragIndex) correctedDropIndex--;
 
         if (correctedDropIndex !== dragIndex) {
-          onValueChange(moveInArray(value, dragIndex, correctedDropIndex));
+          onValuesChange(moveInArray(values, dragIndex, correctedDropIndex));
         }
       }
       setDragIndex(-1);
       setDropIndex(-1);
       setDropBefore(false);
     },
-    [dropIndex, dragIndex, onValueChange, value, dropBefore],
+    [dropIndex, dragIndex, onValuesChange, values, dropBefore],
   );
 
+  const menuOnClose = () => {
+    setMenuOpenOn(undefined);
+  };
+
+  const canRemove = !nonEmpty || values.length > 1;
   return (
-    <div className={classNames('columns-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
+    <div
+      className={classNames(
+        'named-expressions-input',
+        Classes.INPUT,
+        Classes.TAG_INPUT,
+        Classes.FILL,
+      )}
+    >
       <div className={Classes.TAG_INPUT_VALUES} onDragEnd={onDrop}>
-        {value.map((c, i) => (
-          <Tag
-            className={classNames({
-              'drop-before': dropIndex === i && dropBefore,
-              'drop-after': dropIndex === i && !dropBefore,
-            })}
-            interactive
-            draggable={allowReordering}
-            onDragOver={e => onDragOver(e, i)}
+        {values.map((c, i) => (
+          <Popover
             key={i}
-            onDragStart={e => startDrag(e, i)}
-            onRemove={() => {
-              onValueChange(value.filter(v => v !== c));
-            }}
+            isOpen={Boolean(menuOpenOn && menuOpenOn.openOn === c)}
+            position={Position.BOTTOM}
+            onClose={menuOnClose}
+            content={itemMenu(c, menuOnClose)}
           >
-            {c.name}
-          </Tag>
+            <Tag
+              className={classNames({
+                'drop-before': dropIndex === i && dropBefore,
+                'drop-after': dropIndex === i && !dropBefore,
+              })}
+              title={`Expression: ${c.expression}`}
+              interactive
+              onClick={() => setMenuOpenOn({ openOn: c })}
+              draggable={allowReordering}
+              onDragOver={e => onDragOver(e, i)}
+              onDragStart={e => startDrag(e, i)}
+              onRemove={
+                canRemove
+                  ? () => {
+                      onValuesChange(values.filter(v => v !== c));
+                    }
+                  : undefined
+              }
+            >
+              {c.name}
+            </Tag>
+          </Popover>
         ))}
-        <Popover
-          position={Position.BOTTOM}
-          content={
-            pickerMenu ? (
-              pickerMenu(availableColumns)
-            ) : (
-              <ColumnPickerMenu
-                columns={availableColumns}
-                onSelectColumn={c => onValueChange(value.concat(c))}
-              />
-            )
-          }
-        >
-          <Tag icon={IconNames.PLUS} interactive />
-        </Popover>
+        {(!singleton || !values.length) && (
+          <Popover
+            isOpen={Boolean(menuOpenOn && !menuOpenOn.openOn)}
+            position={Position.BOTTOM}
+            onClose={menuOnClose}
+            content={itemMenu(undefined, menuOnClose)}
+          >
+            <Tag icon={IconNames.PLUS} interactive onClick={() => setMenuOpenOn({})} />
+          </Popover>
+        )}
       </div>
     </div>
   );
diff --git a/web-console/src/views/explore-view/control-pane/options-input.tsx b/web-console/src/views/explore-view/components/control-pane/options-input.tsx
similarity index 51%
rename from web-console/src/views/explore-view/control-pane/options-input.tsx
rename to web-console/src/views/explore-view/components/control-pane/options-input.tsx
index 9eec12da830..286889bf6b2 100644
--- a/web-console/src/views/explore-view/control-pane/options-input.tsx
+++ b/web-console/src/views/explore-view/components/control-pane/options-input.tsx
@@ -16,57 +16,83 @@
  * limitations under the License.
  */
 
-import { Classes, Menu, MenuItem, Popover, Position, Tag } from '@blueprintjs/core';
+import { Classes, Icon, Menu, MenuItem, Popover, Position, Tag } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import type { OptionValue, ParameterDefinition } from '@druid-toolkit/visuals-core';
-import { getPluginOptionLabel } from '@druid-toolkit/visuals-core';
 import classNames from 'classnames';
 import React from 'react';
 
+import type { OptionValue } from '../../models';
+
 export interface OptionsInputProps {
-  parameter: ParameterDefinition;
   options: readonly OptionValue[];
   value: OptionValue[];
   onValueChange(value: OptionValue[]): void;
+  optionLabel?(o: OptionValue): string;
+  allowDuplicates?: boolean;
+  nonEmpty?: boolean;
 }
 
 export const OptionsInput = function OptionsInput(props: OptionsInputProps) {
-  const { options, value, onValueChange, parameter } = props;
-
-  if (parameter.type !== 'options') {
-    return null;
-  }
+  const { options, value, onValueChange, optionLabel = String, allowDuplicates, nonEmpty } = props;
 
   const selectedOptions: OptionValue[] = value.filter(v => options.includes(v));
 
-  const availableOptions = parameter.allowDuplicates
+  const availableOptions = allowDuplicates
     ? options
     : options.filter(o => !value.find(v => v === o));
 
+  const canRemove = !nonEmpty || options.length > 1;
   return (
     <div className={classNames('options-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
       <div className={Classes.TAG_INPUT_VALUES}>
-        {selectedOptions.map((o, i) => (
-          <Tag
-            interactive
+        {selectedOptions.map((selectedOption, i) => (
+          <Popover
             key={i}
-            onRemove={() => {
-              onValueChange(value.filter(v => v !== o));
-            }}
+            position={Position.BOTTOM}
+            content={
+              <Menu>
+                {(allowDuplicates
+                  ? options
+                  : options.filter(o => o === selectedOption || !value.find(v => v === o))
+                ).map((ao, j) => (
+                  <MenuItem
+                    key={j}
+                    text={optionLabel(ao)}
+                    labelElement={
+                      ao === selectedOption ? <Icon icon={IconNames.TICK} /> : undefined
+                    }
+                    onClick={() => {
+                      onValueChange(value.map(v => (v === selectedOption ? ao : v)));
+                    }}
+                  />
+                ))}
+              </Menu>
+            }
           >
-            {getPluginOptionLabel(o, parameter)}
-          </Tag>
+            <Tag
+              interactive
+              onRemove={
+                canRemove
+                  ? () => {
+                      onValueChange(value.filter(v => v !== selectedOption));
+                    }
+                  : undefined
+              }
+            >
+              {optionLabel(selectedOption)}
+            </Tag>
+          </Popover>
         ))}
         <Popover
           position={Position.BOTTOM}
           content={
             <Menu>
-              {availableOptions.map((o, i) => (
+              {availableOptions.map((ao, i) => (
                 <MenuItem
                   key={i}
-                  text={getPluginOptionLabel(o, parameter)}
+                  text={optionLabel(ao)}
                   onClick={() => {
-                    onValueChange(value.concat(o));
+                    onValueChange(value.concat(ao));
                   }}
                 />
               ))}
diff --git a/web-console/src/views/explore-view/droppable-container/droppable-container.scss b/web-console/src/views/explore-view/components/droppable-container/droppable-container.scss
similarity index 97%
copy from web-console/src/views/explore-view/droppable-container/droppable-container.scss
copy to web-console/src/views/explore-view/components/droppable-container/droppable-container.scss
index 74b8a397384..0283c69be7b 100644
--- a/web-console/src/views/explore-view/droppable-container/droppable-container.scss
+++ b/web-console/src/views/explore-view/components/droppable-container/droppable-container.scss
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
+@import '../../../../variables';
 
 .droppable-container {
   position: relative;
diff --git a/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx b/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx
new file mode 100644
index 00000000000..ef8f36d25aa
--- /dev/null
+++ b/web-console/src/views/explore-view/components/droppable-container/droppable-container.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 type { Column } from '@druid-toolkit/query';
+import classNames from 'classnames';
+import React, { forwardRef, useState } from 'react';
+
+import { DragHelper } from '../../drag-helper';
+import type { Measure } from '../../models';
+
+import './droppable-container.scss';
+
+export interface DroppableContainerProps extends React.HTMLAttributes<HTMLDivElement> {
+  onDropColumn(column: Column): void;
+  onDropMeasure?(measure: Measure): void;
+  children?: React.ReactNode;
+}
+
+export const DroppableContainer = forwardRef<HTMLDivElement, DroppableContainerProps>(
+  function DroppableContainer(props, ref) {
+    const { className, onDropColumn, onDropMeasure, children, ...rest } = props;
+    const [dropHover, setDropHover] = useState(false);
+
+    return (
+      <div
+        ref={ref}
+        className={classNames('droppable-container', className, { 'drop-hover': dropHover })}
+        {...rest}
+        onDragOver={e => {
+          if (!DragHelper.dragColumn && !(onDropMeasure && DragHelper.dragMeasure)) return;
+          e.preventDefault();
+          e.dataTransfer.dropEffect = 'move';
+          setDropHover(true);
+        }}
+        onDragLeave={e => {
+          const currentTarget = e.currentTarget;
+          const relatedTarget = e.relatedTarget;
+          if (currentTarget.contains(relatedTarget as any)) return;
+          setDropHover(false);
+        }}
+        onDrop={() => {
+          if (!DragHelper.dragColumn && !(onDropMeasure && DragHelper.dragMeasure)) return;
+          const dragColumn = DragHelper.dragColumn;
+          const dragMeasure = DragHelper.dragMeasure;
+          setDropHover(false);
+          if (dragColumn) {
+            DragHelper.dragColumn = undefined;
+            onDropColumn(dragColumn);
+          } else if (dragMeasure && onDropMeasure) {
+            DragHelper.dragMeasure = undefined;
+            onDropMeasure(dragMeasure);
+          }
+        }}
+      >
+        {children}
+      </div>
+    );
+  },
+);
diff --git a/web-console/src/views/explore-view/filter-pane/column-value/column-value.scss b/web-console/src/views/explore-view/components/filter-pane/column-value/column-value.scss
similarity index 100%
copy from web-console/src/views/explore-view/filter-pane/column-value/column-value.scss
copy to web-console/src/views/explore-view/components/filter-pane/column-value/column-value.scss
diff --git a/web-console/src/views/explore-view/filter-pane/column-value/column-value.tsx b/web-console/src/views/explore-view/components/filter-pane/column-value/column-value.tsx
similarity index 100%
rename from web-console/src/views/explore-view/filter-pane/column-value/column-value.tsx
rename to web-console/src/views/explore-view/components/filter-pane/column-value/column-value.tsx
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss
similarity index 100%
rename from web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss
rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.scss
diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx
new file mode 100644
index 00000000000..353179c905e
--- /dev/null
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx
@@ -0,0 +1,97 @@
+/*
+ * 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 { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { ContainsFilterPattern, QueryResult } from '@druid-toolkit/query';
+import { C, F, filterPatternToExpression, SqlExpression, SqlQuery } from '@druid-toolkit/query';
+import React, { useMemo } from 'react';
+
+import { useQueryManager } from '../../../../../../hooks';
+import type { QuerySource } from '../../../../models';
+
+import './contains-filter-control.scss';
+
+export interface ContainsFilterControlProps {
+  querySource: QuerySource;
+  filter: SqlExpression | undefined;
+  filterPattern: ContainsFilterPattern;
+  setFilterPattern(filterPattern: ContainsFilterPattern): void;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+}
+
+export const ContainsFilterControl = React.memo(function ContainsFilterControl(
+  props: ContainsFilterControlProps,
+) {
+  const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props;
+  const { column, negated, contains } = filterPattern;
+
+  const previewQuery = useMemo(
+    () =>
+      SqlQuery.from(querySource.query)
+        .addSelect(F.cast(C(column), 'VARCHAR').as('c'), { addToGroupBy: 'end' })
+        .changeWhereExpression(
+          SqlExpression.and(
+            filter,
+            contains ? filterPatternToExpression(filterPattern) : undefined,
+          ),
+        )
+        .changeOrderByExpression(F.count().toOrderByExpression('DESC'))
+        .toString(),
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
+    [querySource.query, filter, column, contains, negated],
+  );
+
+  const [previewState] = useQueryManager<string, string[]>({
+    query: previewQuery,
+    debounceIdle: 100,
+    debounceLoading: 500,
+    processQuery: async query => {
+      const vs = await runSqlQuery(query);
+      return (vs.getColumnByName('c') || []).map(String);
+    },
+  });
+
+  return (
+    <div className="contains-filter-control">
+      <FormGroup>
+        <InputGroup
+          value={contains}
+          onChange={e => setFilterPattern({ ...filterPattern, contains: e.target.value })}
+          placeholder="Search string"
+        />
+      </FormGroup>
+      <FormGroup label="Preview">
+        <Menu className="preview-list">
+          {previewState.data?.map((v, i) => (
+            <MenuItem
+              key={i}
+              className="preview-item"
+              text={String(v)}
+              shouldDismissPopover={false}
+            />
+          ))}
+          {previewState.loading && <MenuItem disabled text="Loading..." />}
+          {previewState.error && (
+            <MenuItem icon={IconNames.ERROR} disabled text={previewState.getErrorMessage()} />
+          )}
+        </Menu>
+      </FormGroup>
+    </div>
+  );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss
similarity index 78%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss
index dbb850df020..b5659da421c 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.scss
@@ -16,20 +16,22 @@
  * limitations under the License.
  */
 
-@import '../../../../variables';
-
 .filter-menu {
-  width: 400px;
+  width: 500px;
+  padding: 15px;
 
-  &.main {
-    padding: 15px;
+  .tab-bar {
+    & > * {
+      flex: 1 !important;
+    }
   }
 
-  .controls .#{$ns}-form-content {
+  .controls {
     display: flex;
-    gap: 15px;
+    gap: 10px;
+    align-items: flex-end;
 
-    .type-selector {
+    .column-form-group {
       flex: 1;
     }
   }
@@ -39,6 +41,11 @@
   }
 
   .button-bar {
-    text-align: right;
+    display: flex;
+    gap: 10px;
+
+    .button-separator {
+      flex: 1;
+    }
   }
 }
diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx
new file mode 100644
index 00000000000..8cbca7e8d53
--- /dev/null
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/filter-menu.tsx
@@ -0,0 +1,425 @@
+/*
+ * 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,
+  ButtonGroup,
+  Callout,
+  FormGroup,
+  HTMLSelect,
+  Intent,
+  Menu,
+  MenuItem,
+  Popover,
+  Position,
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type {
+  Column,
+  FilterPattern,
+  FilterPatternType,
+  QueryResult,
+  SqlQuery,
+} from '@druid-toolkit/query';
+import {
+  C,
+  changeFilterPatternType,
+  filterPatternToExpression,
+  fitFilterPattern,
+  SqlExpression,
+} from '@druid-toolkit/query';
+import type { JSX } from 'react';
+import React, { useState } from 'react';
+
+import { AppToaster } from '../../../../../singletons';
+import type { QuerySource } from '../../../models';
+import { formatPatternWithoutNegation, initPatternForColumn } from '../../../utils';
+import { ColumnPicker } from '../../column-picker/column-picker';
+import { ColumnPickerMenu } from '../../column-picker-menu/column-picker-menu';
+import { SqlInput } from '../../sql-input/sql-input';
+
+import { ContainsFilterControl } from './contains-filter-control/contains-filter-control';
+import { NumberRangeFilterControl } from './number-range-filter-control/number-range-filter-control';
+import { RegexpFilterControl } from './regexp-filter-control/regexp-filter-control';
+import { TimeIntervalFilterControl } from './time-interval-filter-control/time-interval-filter-control';
+import { TimeRelativeFilterControl } from './time-relative-filter-control/time-relative-filter-control';
+import { ValuesFilterControl } from './values-filter-control/values-filter-control';
+
+import './filter-menu.scss';
+
+const DEFAULT_PATTERN_TYPES: FilterPatternType[] = [
+  'values',
+  'contains',
+  'regexp',
+  'numberRange',
+  'timeRelative',
+  'timeInterval',
+];
+
+function getPattenTypesForColumn(column: Column | undefined): FilterPatternType[] {
+  if (!column) return DEFAULT_PATTERN_TYPES;
+
+  switch (column.sqlType) {
+    case 'TIMESTAMP':
+      return ['timeRelative', 'timeInterval', 'numberRange', 'values', 'contains', 'regexp'];
+
+    case 'BOOLEAN':
+      return ['values', 'contains', 'regexp', 'numberRange'];
+
+    case 'BIGINT':
+    case 'DOUBLE':
+    case 'FLOAT':
+      return ['numberRange', 'values'];
+
+    default: //  VARCHAR also gets default
+      return DEFAULT_PATTERN_TYPES;
+  }
+}
+
+const PATTERN_TYPE_TO_NAME: Record<FilterPatternType, string> = {
+  values: 'Values',
+  contains: 'Contains',
+  regexp: 'Regular expression',
+  mvContains: 'Multi-value contains',
+  numberRange: 'Number range',
+  timeRelative: 'Time relative',
+  timeInterval: 'Time interval',
+  custom: 'Custom',
+};
+
+type FilterMenuTab = 'compose' | 'sql';
+
+export interface FilterMenuProps {
+  querySource: QuerySource;
+  filter: SqlExpression;
+  initPattern?: FilterPattern;
+  onPatternChange(newPattern: FilterPattern): void;
+  onClose(): void;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+  onAddToSourceQueryAsColumn?(expression: SqlExpression): void;
+  onMoveToSourceQueryAsClause?(expression: SqlExpression): void;
+}
+
+export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps) {
+  const {
+    querySource,
+    filter,
+    initPattern,
+    onPatternChange,
+    onClose,
+    runSqlQuery,
+    onAddToSourceQueryAsColumn,
+    onMoveToSourceQueryAsClause,
+  } = props;
+
+  const [tab, setTab] = useState<FilterMenuTab>(initPattern?.type === 'custom' ? 'sql' : 'compose');
+  const [formula, setFormula] = useState<string>(
+    initPattern?.type === 'custom' ? filterPatternToExpression(initPattern).toString() : '',
+  );
+  const [pattern, setPattern] = useState<FilterPattern | undefined>(initPattern);
+  const { columns } = querySource;
+
+  function onAcceptPattern(pattern: FilterPattern) {
+    onPatternChange(pattern);
+    onClose();
+  }
+
+  const negated = Boolean(pattern?.negated);
+
+  let cont: JSX.Element;
+  switch (pattern?.type) {
+    case 'values':
+      cont = (
+        <ValuesFilterControl
+          querySource={querySource}
+          filter={filter.removeColumnFromAnd(pattern.column)}
+          filterPattern={pattern}
+          setFilterPattern={setPattern}
+          runSqlQuery={runSqlQuery}
+        />
+      );
+      break;
+
+    case 'contains':
+      cont = (
+        <ContainsFilterControl
+          querySource={querySource}
+          filter={filter.removeColumnFromAnd(pattern.column)}
+          filterPattern={pattern}
+          setFilterPattern={setPattern}
+          runSqlQuery={runSqlQuery}
+        />
+      );
+      break;
+
+    case 'regexp':
+      cont = (
+        <RegexpFilterControl
+          querySource={querySource}
+          filter={filter.removeColumnFromAnd(pattern.column)}
+          filterPattern={pattern}
+          setFilterPattern={setPattern}
+          runSqlQuery={runSqlQuery}
+        />
+      );
+      break;
+
+    case 'numberRange':
+      cont = (
+        <NumberRangeFilterControl
+          querySource={querySource}
+          filterPattern={pattern}
+          setFilterPattern={setPattern}
+        />
+      );
+      break;
+
+    case 'timeInterval':
+      cont = (
+        <TimeIntervalFilterControl
+          querySource={querySource}
+          filterPattern={pattern}
+          setFilterPattern={setPattern}
+        />
+      );
+      break;
+
+    case 'timeRelative':
+      cont = (
+        <TimeRelativeFilterControl
+          querySource={querySource}
+          filterPattern={pattern}
+          setFilterPattern={setPattern}
+        />
+      );
+      break;
+
+    case 'mvContains':
+    case 'custom': {
+      const columnName: string | undefined =
+        pattern.type === 'custom'
+          ? pattern.expression?.getFirstColumnName()
+          : (pattern as any).column;
+      const column = columns.find(({ name }) => name === columnName);
+      cont = (
+        <FormGroup>
+          <Callout>
+            <p>The current filter can only be edited as SQL.</p>
+            <p>
+              <a onClick={() => setTab('sql')}>Continue editing in SQL.</a>
+            </p>
+            {column && (
+              <p>
+                <a onClick={() => setPattern(initPatternForColumn(column))}>{`Compose on column ${C(
+                  column.name,
+                )}.`}</a>
+              </p>
+            )}
+          </Callout>
+        </FormGroup>
+      );
+      break;
+    }
+
+    default:
+      cont = <div>Pattern no set</div>;
+      break;
+  }
+
+  function parseFormula(): SqlExpression | undefined {
+    try {
+      return SqlExpression.parse(formula);
+    } catch (e) {
+      AppToaster.show({
+        intent: Intent.DANGER,
+        message: e.message,
+      });
+      return;
+    }
+  }
+
+  return (
+    <div className="filter-menu">
+      <FormGroup>
+        <ButtonGroup className="tab-bar" fill>
+          <Button
+            text="Compose"
+            active={tab === 'compose'}
+            onClick={() => {
+              if (formula) {
+                const parsedFormula = parseFormula();
+                if (!parsedFormula) return;
+                setPattern(fitFilterPattern(parsedFormula));
+              } else {
+                setPattern(undefined);
+              }
+              setTab('compose');
+            }}
+          />
+          <Button
+            text="SQL"
+            active={tab === 'sql'}
+            onClick={() => {
+              setFormula(pattern ? filterPatternToExpression(pattern).toString() : '');
+              setTab('sql');
+            }}
+          />
+        </ButtonGroup>
+      </FormGroup>
+      {tab === 'compose' &&
+        (pattern ? (
+          <>
+            {pattern.type !== 'custom' && (
+              <div className="controls">
+                <FormGroup className="column-form-group" label="Column">
+                  <ColumnPicker
+                    availableColumns={querySource.columns}
+                    selectedColumnName={pattern.column}
+                    onSelectedColumnNameChange={selectedColumnName => {
+                      setPattern({ ...pattern, column: selectedColumnName });
+                    }}
+                    fill
+                  />
+                </FormGroup>
+                <FormGroup label="Filter type">
+                  <HTMLSelect
+                    className="type-selector"
+                    value={pattern.type}
+                    onChange={e =>
+                      setPattern(
+                        changeFilterPatternType(pattern, e.target.value as FilterPatternType),
+                      )
+                    }
+                  >
+                    {getPattenTypesForColumn(
+                      querySource.columns.find(c => c.name === pattern.column),
+                    ).map(type => (
+                      <option key={type} value={type}>
+                        {PATTERN_TYPE_TO_NAME[type]}
+                      </option>
+                    ))}
+                  </HTMLSelect>
+                </FormGroup>
+                <FormGroup>
+                  <ButtonGroup>
+                    <Button
+                      icon={IconNames.FILTER}
+                      active={!negated}
+                      onClick={() => setPattern({ ...pattern, negated: false })}
+                      data-tooltip="Include"
+                    />
+                    <Button
+                      icon={IconNames.FILTER_REMOVE}
+                      active={negated}
+                      onClick={() => setPattern({ ...pattern, negated: true })}
+                      data-tooltip="Exclude"
+                    />
+                  </ButtonGroup>
+                </FormGroup>
+              </div>
+            )}
+            {cont}
+          </>
+        ) : (
+          <ColumnPickerMenu
+            columns={columns}
+            onSelectColumn={c => setPattern(initPatternForColumn(c))}
+            rightIconForColumn={c =>
+              filter.containsColumnName(c.name) ? IconNames.FILTER : undefined
+            }
+            shouldDismissPopover={false}
+          />
+        ))}
+      {tab === 'sql' && (
+        <FormGroup>
+          <SqlInput
+            value={formula}
+            onValueChange={sql => setFormula(sql)}
+            columns={columns}
+            placeholder="SQL expression"
+            editorHeight={250}
+            autoFocus
+          />
+        </FormGroup>
+      )}
+      {(pattern || tab === 'sql') && (
+        <div className="button-bar">
+          {pattern && onAddToSourceQueryAsColumn && onMoveToSourceQueryAsClause && (
+            <Popover
+              position={Position.BOTTOM_LEFT}
+              content={
+                <Menu>
+                  <MenuItem
+                    text="Add as boolean column to the source query"
+                    onClick={() => {
+                      if (tab === 'compose') {
+                        onAddToSourceQueryAsColumn(
+                          filterPatternToExpression(pattern).as(
+                            formatPatternWithoutNegation(pattern),
+                          ),
+                        );
+                      } else {
+                        const parsedFormula = parseFormula();
+                        if (!parsedFormula) return;
+                        onAddToSourceQueryAsColumn(parsedFormula.as(formula));
+                      }
+                      onClose();
+                    }}
+                  />
+                  <MenuItem
+                    text="Move where clause to the source query"
+                    onClick={() => {
+                      if (tab === 'compose') {
+                        onMoveToSourceQueryAsClause(filterPatternToExpression(pattern));
+                      } else {
+                        const parsedFormula = parseFormula();
+                        if (!parsedFormula) return;
+                        onMoveToSourceQueryAsClause(parsedFormula);
+                      }
+                      onClose();
+                    }}
+                  />
+                </Menu>
+              }
+            >
+              <Button icon={IconNames.TH_DERIVED} minimal />
+            </Popover>
+          )}
+          <div className="button-separator" />
+          <Button text="Cancel" onClick={onClose} />
+          <Button
+            intent={Intent.PRIMARY}
+            text="Apply"
+            disabled={tab === 'sql' && formula === ''}
+            onClick={() => {
+              if (tab === 'compose') {
+                if (pattern) {
+                  onAcceptPattern(pattern);
+                }
+              } else {
+                const parsedFormula = parseFormula();
+                if (!parsedFormula) return;
+                onAcceptPattern({ type: 'custom', negated: false, expression: parsedFormula });
+              }
+            }}
+          />
+        </div>
+      )}
+    </div>
+  );
+});
diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/number-range-filter-control/number-range-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/number-range-filter-control/number-range-filter-control.tsx
new file mode 100644
index 00000000000..eb5adc9c4a7
--- /dev/null
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/number-range-filter-control/number-range-filter-control.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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, ControlGroup, FormGroup, NumericInput } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { NumberRangeFilterPattern } from '@druid-toolkit/query';
+import React from 'react';
+
+import type { QuerySource } from '../../../../models';
+
+export interface NumberRangeFilterControlProps {
+  querySource: QuerySource;
+  filterPattern: NumberRangeFilterPattern;
+  setFilterPattern(filterPattern: NumberRangeFilterPattern): void;
+}
+
+export const NumberRangeFilterControl = React.memo(function NumberRangeFilterControl(
+  props: NumberRangeFilterControlProps,
+) {
+  const { filterPattern, setFilterPattern } = props;
+  const { start, startBound, end, endBound } = filterPattern;
+
+  return (
+    <div className="number-range-filter-control">
+      <FormGroup label="Bounds">
+        <ControlGroup>
+          <NumericInput
+            value={start}
+            onValueChange={start => setFilterPattern({ ...filterPattern, start })}
+            fill
+          />
+          <Button
+            icon={startBound === '[' ? IconNames.LESS_THAN_OR_EQUAL_TO : IconNames.LESS_THAN}
+            onClick={() =>
+              setFilterPattern({ ...filterPattern, startBound: startBound === '[' ? '(' : '[' })
+            }
+          />
+          <Button text="X" disabled />
+          <Button
+            icon={endBound === ']' ? IconNames.LESS_THAN_OR_EQUAL_TO : IconNames.LESS_THAN}
+            onClick={() =>
+              setFilterPattern({ ...filterPattern, endBound: endBound === ']' ? ')' : ']' })
+            }
+          />
+          <NumericInput
+            value={end}
+            onValueChange={end => setFilterPattern({ ...filterPattern, end })}
+            fill
+          />
+        </ControlGroup>
+      </FormGroup>
+    </div>
+  );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss
similarity index 100%
rename from web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss
rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.scss
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx
similarity index 51%
rename from web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx
rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx
index 13358ac3427..ea6bdf74385 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/regexp-filter-control/regexp-filter-control.tsx
@@ -16,15 +16,14 @@
  * limitations under the License.
  */
 
-import { Button, FormGroup, InputGroup, Intent, Menu, MenuItem } from '@blueprintjs/core';
+import { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import type { RegexpFilterPattern } from '@druid-toolkit/query';
-import { C, filterPatternToExpression, SqlExpression, SqlLiteral } from '@druid-toolkit/query';
-import React, { useMemo, useState } from 'react';
+import type { QueryResult, RegexpFilterPattern } from '@druid-toolkit/query';
+import { C, F, filterPatternToExpression, SqlExpression, SqlQuery } from '@druid-toolkit/query';
+import React, { useMemo } from 'react';
 
-import { useQueryManager } from '../../../../../hooks';
-import { ColumnPicker } from '../../../column-picker/column-picker';
-import type { Dataset } from '../../../utils';
+import { useQueryManager } from '../../../../../../hooks';
+import type { QuerySource } from '../../../../models';
 
 import './regexp-filter-control.scss';
 
@@ -38,72 +37,51 @@ function regexpIssue(possibleRegexp: string): string | undefined {
 }
 
 export interface RegexpFilterControlProps {
-  dataset: Dataset;
+  querySource: QuerySource;
   filter: SqlExpression | undefined;
-  initFilterPattern: RegexpFilterPattern;
-  negated: boolean;
+  filterPattern: RegexpFilterPattern;
   setFilterPattern(filterPattern: RegexpFilterPattern): void;
-  queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
 }
 
 export const RegexpFilterControl = React.memo(function RegexpFilterControl(
   props: RegexpFilterControlProps,
 ) {
-  const { dataset, filter, initFilterPattern, negated, setFilterPattern, queryDruidSql } = props;
-  const [column, setColumn] = useState<string>(initFilterPattern.column);
-  const [regexp, setRegexp] = useState(initFilterPattern.regexp);
+  const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props;
+  const { column, negated, regexp } = filterPattern;
 
-  function makePattern(): RegexpFilterPattern {
-    return {
-      type: 'regexp',
-      negated,
-      column,
-      regexp: regexpIssue(regexp) ? '' : regexp,
-    };
-  }
-
-  const previewQuery = useMemo(() => {
-    const columnRef = C(column);
-    const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM ${dataset.table}`];
-
-    const filterEx = SqlExpression.and(
-      filter,
-      regexp ? filterPatternToExpression(makePattern()) : undefined,
-    );
-    if (!(filterEx instanceof SqlLiteral)) {
-      queryParts.push(`WHERE ${filterEx}`);
-    }
-
-    queryParts.push(`GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 101`);
-    return queryParts.join('\n');
+  const previewQuery = useMemo(
+    () =>
+      SqlQuery.from(querySource.query)
+        .addSelect(F.cast(C(column), 'VARCHAR').as('c'), { addToGroupBy: 'end' })
+        .changeWhereExpression(
+          SqlExpression.and(filter, regexp ? filterPatternToExpression(filterPattern) : undefined),
+        )
+        .changeOrderByExpression(F.count().toOrderByExpression('DESC'))
+        .toString(),
     // eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
-  }, [dataset.table, filter, column, regexp, negated]);
+    [querySource.query, filter, column, regexp, negated],
+  );
 
   const [previewState] = useQueryManager<string, string[]>({
     query: previewQuery,
     debounceIdle: 100,
     debounceLoading: 500,
     processQuery: async query => {
-      const vs = await queryDruidSql<{ c: any }>({
-        query,
-      });
-
-      return vs.map(d => String(d.c));
+      const vs = await runSqlQuery(query);
+      return (vs.getColumnByName('c') || []).map(String);
     },
   });
 
   const issue = regexpIssue(regexp);
   return (
     <div className="regexp-filter-control">
-      <FormGroup label="Column">
-        <ColumnPicker
-          availableColumns={dataset.columns}
-          selectedColumnName={column}
-          onSelectedColumnNameChange={setColumn}
-        />
-      </FormGroup>
       <FormGroup>
-        <InputGroup value={regexp} onChange={e => setRegexp(e.target.value)} placeholder="Regexp" />
+        <InputGroup
+          value={regexp}
+          onChange={e => setFilterPattern({ ...filterPattern, regexp: e.target.value })}
+          placeholder="Regexp"
+        />
       </FormGroup>
       <FormGroup label="Preview">
         <Menu className="preview-list">
@@ -127,18 +105,6 @@ export const RegexpFilterControl = React.memo(function RegexpFilterControl(
           )}
         </Menu>
       </FormGroup>
-      <div className="button-bar">
-        <Button
-          intent={Intent.PRIMARY}
-          text="OK"
-          onClick={() => {
-            const newPattern = makePattern();
-            // TODO: check if pattern is valid
-            // if (!isFilterPatternValid(newPattern)) return;
-            setFilterPattern(newPattern);
-          }}
-        />
-      </div>
     </div>
   );
 });
diff --git a/web-console/src/views/explore-view/filter-pane/column-value/column-value.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss
similarity index 90%
rename from web-console/src/views/explore-view/filter-pane/column-value/column-value.scss
rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss
index 9ef99b60b6f..51816400f82 100644
--- a/web-console/src/views/explore-view/filter-pane/column-value/column-value.scss
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.scss
@@ -16,10 +16,11 @@
  * limitations under the License.
  */
 
-.column-value {
-  &.empty,
-  &.null {
-    font-style: italic;
-    opacity: 0.9;
+.time-interval-filter-control {
+  display: flex;
+  gap: 10px;
+
+  & > * {
+    flex: 1;
   }
 }
diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
new file mode 100644
index 00000000000..acb21c309d2
--- /dev/null
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
@@ -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 { FormGroup } from '@blueprintjs/core';
+import type { TimeIntervalFilterPattern } from '@druid-toolkit/query';
+import React from 'react';
+
+import type { QuerySource } from '../../../../models';
+import { UtcDateInput } from '../../../utc-date-input/utc-date-input';
+
+import './time-interval-filter-control.scss';
+
+export interface TimeIntervalFilterControlProps {
+  querySource: QuerySource;
+  filterPattern: TimeIntervalFilterPattern;
+  setFilterPattern(filterPattern: TimeIntervalFilterPattern): void;
+}
+
+export const TimeIntervalFilterControl = React.memo(function TimeIntervalFilterControl(
+  props: TimeIntervalFilterControlProps,
+) {
+  const { filterPattern, setFilterPattern } = props;
+  const { start, end } = filterPattern;
+
+  return (
+    <div className="time-interval-filter-control">
+      <FormGroup label="Start">
+        <UtcDateInput
+          date={start}
+          onChange={start => setFilterPattern({ ...filterPattern, start })}
+        />
+      </FormGroup>
+      <FormGroup label="End">
+        <UtcDateInput date={end} onChange={end => setFilterPattern({ ...filterPattern, end })} />
+      </FormGroup>
+    </div>
+  );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx
similarity index 70%
rename from web-console/src/views/explore-view/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx
rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx
index b1ec8061117..32687c0c1b5 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/time-relative-filter-control/time-relative-filter-control.tsx
@@ -16,12 +16,11 @@
  * limitations under the License.
  */
 
-import { Button, FormGroup } from '@blueprintjs/core';
+import { Button, ButtonGroup, FormGroup } from '@blueprintjs/core';
 import type { TimeRelativeFilterPattern } from '@druid-toolkit/query';
-import React, { useState } from 'react';
+import React from 'react';
 
-import { ColumnPicker } from '../../../column-picker/column-picker';
-import type { Dataset } from '../../../utils';
+import type { QuerySource } from '../../../../models';
 
 interface PartialPattern {
   anchor: 'timestamp' | 'maxDataTime';
@@ -99,47 +98,41 @@ const GROUPS: GroupedNamedPartialPatterns[] = [
 ];
 
 export interface TimeRelativeFilterControlProps {
-  dataset: Dataset;
-  initFilterPattern: TimeRelativeFilterPattern;
-  negated: boolean;
+  querySource: QuerySource;
+  filterPattern: TimeRelativeFilterPattern;
   setFilterPattern(filterPattern: TimeRelativeFilterPattern): void;
 }
 
 export const TimeRelativeFilterControl = React.memo(function TimeRelativeFilterControl(
   props: TimeRelativeFilterControlProps,
 ) {
-  const { dataset, initFilterPattern, negated, setFilterPattern } = props;
-  const [column, setColumn] = useState<string>(initFilterPattern.column);
+  const { filterPattern, setFilterPattern } = props;
+  const { column, negated } = filterPattern;
 
-  const initKey = partialPatternToKey(initFilterPattern);
+  const initKey = partialPatternToKey(filterPattern);
   return (
     <div className="time-relative-filter-control">
-      <FormGroup label="Column">
-        <ColumnPicker
-          availableColumns={dataset.columns}
-          selectedColumnName={column}
-          onSelectedColumnNameChange={setColumn}
-        />
-      </FormGroup>
       {GROUPS.map(({ groupName, namedPartialPatterns }, i) => (
         <FormGroup key={i} label={groupName}>
-          {namedPartialPatterns.map(({ name, partialPattern }, i) => (
-            <Button
-              key={i}
-              text={name}
-              active={initKey === partialPatternToKey(partialPattern)}
-              onClick={() => {
-                setFilterPattern({
-                  type: 'timeRelative',
-                  negated,
-                  column,
-                  ...partialPattern,
-                  startBound: '[',
-                  endBound: ')',
-                });
-              }}
-            />
-          ))}
+          <ButtonGroup>
+            {namedPartialPatterns.map(({ name, partialPattern }, i) => (
+              <Button
+                key={i}
+                text={name}
+                active={initKey === partialPatternToKey(partialPattern)}
+                onClick={() => {
+                  setFilterPattern({
+                    type: 'timeRelative',
+                    negated,
+                    column,
+                    ...partialPattern,
+                    startBound: '[',
+                    endBound: ')',
+                  });
+                }}
+              />
+            ))}
+          </ButtonGroup>
         </FormGroup>
       ))}
     </div>
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/values-filter-control/values-filter-control.scss b/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.scss
similarity index 100%
rename from web-console/src/views/explore-view/filter-pane/filter-menu/values-filter-control/values-filter-control.scss
rename to web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.scss
diff --git a/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx
new file mode 100644
index 00000000000..a15d3daad76
--- /dev/null
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-menu/values-filter-control/values-filter-control.tsx
@@ -0,0 +1,120 @@
+/*
+ * 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 { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { QueryResult, SqlQuery, ValuesFilterPattern } from '@druid-toolkit/query';
+import { C, F, L, SqlExpression, SqlLiteral } from '@druid-toolkit/query';
+import React, { useMemo, useState } from 'react';
+
+import { useQueryManager } from '../../../../../../hooks';
+import { caseInsensitiveContains } from '../../../../../../utils';
+import type { QuerySource } from '../../../../models';
+import { toggle } from '../../../../utils';
+import { ColumnValue } from '../../column-value/column-value';
+
+import './values-filter-control.scss';
+
+export interface ValuesFilterControlProps {
+  querySource: QuerySource;
+  filter: SqlExpression | undefined;
+  filterPattern: ValuesFilterPattern;
+  setFilterPattern(filterPattern: ValuesFilterPattern): void;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+}
+
+export const ValuesFilterControl = React.memo(function ValuesFilterControl(
+  props: ValuesFilterControlProps,
+) {
+  const { querySource, filter, filterPattern, setFilterPattern, runSqlQuery } = props;
+  const { column, negated, values: selectedValues } = filterPattern;
+  const [initValues] = useState(selectedValues);
+  const [searchString, setSearchString] = useState('');
+
+  const valuesQuery = useMemo(() => {
+    const columnRef = C(column);
+    const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM (${querySource.query})`];
+
+    const filterEx = SqlExpression.and(
+      filter,
+      searchString ? F('ICONTAINS_STRING', columnRef, L(searchString)) : undefined,
+    );
+    if (!(filterEx instanceof SqlLiteral)) {
+      queryParts.push(`WHERE ${filterEx}`);
+    }
+
+    queryParts.push(`GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 101`);
+    return queryParts.join('\n');
+  }, [querySource.query, filter, column, searchString]);
+
+  const [valuesState] = useQueryManager<string, any[]>({
+    query: valuesQuery,
+    debounceIdle: 100,
+    debounceLoading: 500,
+    processQuery: async query => {
+      const vs = await runSqlQuery(query);
+      return vs.getColumnByName('c') || [];
+    },
+  });
+
+  let valuesToShow: any[] = initValues;
+  const values = valuesState.data;
+  if (values) {
+    valuesToShow = valuesToShow.concat(values.filter(v => !initValues.includes(v)));
+  }
+  if (searchString) {
+    valuesToShow = valuesToShow.filter(v => caseInsensitiveContains(v, searchString));
+  }
+
+  const showSearch = querySource.columns.find(c => c.name === column)?.sqlType !== 'BOOLEAN';
+
+  return (
+    <FormGroup className="values-filter-control">
+      {showSearch && (
+        <InputGroup
+          value={searchString}
+          onChange={e => setSearchString(e.target.value)}
+          placeholder="Search"
+        />
+      )}
+      <Menu className="value-list">
+        {valuesToShow.map((v, i) => (
+          <MenuItem
+            key={i}
+            icon={
+              selectedValues.includes(v)
+                ? negated
+                  ? IconNames.DELETE
+                  : IconNames.TICK_CIRCLE
+                : IconNames.CIRCLE
+            }
+            text={<ColumnValue value={v} />}
+            shouldDismissPopover={false}
+            onClick={e => {
+              setFilterPattern({
+                ...filterPattern,
+                values: e.altKey ? [v] : toggle(selectedValues, v),
+              });
+            }}
+          />
+        ))}
+        {valuesState.loading && <MenuItem icon={IconNames.BLANK} text="Loading..." disabled />}
+      </Menu>
+    </FormGroup>
+  );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-pane.scss b/web-console/src/views/explore-view/components/filter-pane/filter-pane.scss
similarity index 96%
rename from web-console/src/views/explore-view/filter-pane/filter-pane.scss
rename to web-console/src/views/explore-view/components/filter-pane/filter-pane.scss
index 9b117a9bc22..9be736495f0 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-pane.scss
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-pane.scss
@@ -16,14 +16,14 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
+@import '../../../../variables';
 
 .filter-pane {
   display: flex;
   flex-wrap: wrap;
   gap: 5px;
 
-  .filter-label {
+  .filter-icon-button {
     pointer-events: none;
   }
 
diff --git a/web-console/src/views/explore-view/filter-pane/filter-pane.tsx b/web-console/src/views/explore-view/components/filter-pane/filter-pane.tsx
similarity index 66%
rename from web-console/src/views/explore-view/filter-pane/filter-pane.tsx
rename to web-console/src/views/explore-view/components/filter-pane/filter-pane.tsx
index 4692cda170c..72ddd8f7046 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-pane.tsx
+++ b/web-console/src/views/explore-view/components/filter-pane/filter-pane.tsx
@@ -18,38 +18,51 @@
 
 import { Button, Popover } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import type { FilterPattern, SqlExpression } from '@druid-toolkit/query';
+import type {
+  Column,
+  FilterPattern,
+  QueryResult,
+  SqlExpression,
+  SqlQuery,
+} from '@druid-toolkit/query';
 import { filterPatternsToExpression, fitFilterPatterns } from '@druid-toolkit/query';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
 import classNames from 'classnames';
 import React, { forwardRef, useImperativeHandle, useState } from 'react';
 
+import type { QuerySource } from '../../models';
+import { formatPatternWithoutNegation, initPatternForColumn } from '../../utils';
 import { DroppableContainer } from '../droppable-container/droppable-container';
-import type { Dataset } from '../utils';
 
 import { FilterMenu } from './filter-menu/filter-menu';
-import { formatPatternWithoutNegation, initPatternForColumn } from './pattern-helpers';
 
 import './filter-pane.scss';
 
 export interface FilterPaneProps {
-  dataset: Dataset | undefined;
+  querySource: QuerySource | undefined;
   filter: SqlExpression;
   onFilterChange(filter: SqlExpression): void;
-  queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+  onAddToSourceQueryAsColumn?: (expression: SqlExpression) => void;
+  onMoveToSourceQueryAsClause?: (expression: SqlExpression, changeWhere?: SqlExpression) => void;
 }
 
 export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps, ref) {
-  const { dataset, filter, onFilterChange, queryDruidSql } = props;
+  const {
+    querySource,
+    filter,
+    onFilterChange,
+    runSqlQuery,
+    onAddToSourceQueryAsColumn,
+    onMoveToSourceQueryAsClause,
+  } = props;
   const patterns = fitFilterPatterns(filter);
 
   const [menuIndex, setMenuIndex] = useState<number>(-1);
-  const [menuNew, setMenuNew] = useState<{ column?: ExpressionMeta }>();
+  const [menuNew, setMenuNew] = useState<{ column?: Column }>();
 
-  function filterOn(column: ExpressionMeta) {
+  function filterOn(column: Column) {
     const relevantPatternIndex = patterns.findIndex(
-      pattern =>
-        pattern.type !== 'custom' && pattern.column === column.expression.getFirstColumnName(),
+      pattern => pattern.type !== 'custom' && pattern.column === column.name,
     );
     if (relevantPatternIndex < 0) {
       setMenuNew({ column });
@@ -72,17 +85,18 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
 
   return (
     <DroppableContainer className="filter-pane" onDropColumn={filterOn}>
-      <Button className="filter-label" minimal text="Filter:" />
+      <Button className="filter-icon-button" icon={IconNames.FILTER} minimal disabled />
       {patterns.map((pattern, i) => {
         return (
           <div className="filter-pill" key={i}>
-            {dataset ? (
+            {querySource ? (
               <Popover
                 isOpen={i === menuIndex}
                 onClose={() => setMenuIndex(-1)}
+                position="bottom"
                 content={
                   <FilterMenu
-                    dataset={dataset}
+                    querySource={querySource}
                     filter={filter}
                     initPattern={pattern}
                     onPatternChange={newPattern => {
@@ -91,10 +105,22 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
                     onClose={() => {
                       setMenuIndex(-1);
                     }}
-                    queryDruidSql={queryDruidSql}
+                    runSqlQuery={runSqlQuery}
+                    onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+                    onMoveToSourceQueryAsClause={
+                      onMoveToSourceQueryAsClause
+                        ? ex => {
+                            onMoveToSourceQueryAsClause(
+                              ex,
+                              filterPatternsToExpression(
+                                patterns.filter((_clause, idx) => idx !== i),
+                              ),
+                            );
+                          }
+                        : undefined
+                    }
                   />
                 }
-                position="bottom"
               >
                 <Button
                   className={classNames('filter-text-button', { negated: pattern.negated })}
@@ -121,7 +147,7 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
           </div>
         );
       })}
-      {dataset && (
+      {querySource ? (
         <Popover
           className="add-button"
           isOpen={Boolean(menuNew)}
@@ -129,7 +155,7 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
           onClose={() => setMenuNew(undefined)}
           content={
             <FilterMenu
-              dataset={dataset}
+              querySource={querySource}
               filter={filter}
               initPattern={menuNew?.column ? initPatternForColumn(menuNew?.column) : undefined}
               onPatternChange={newPattern => {
@@ -138,12 +164,26 @@ export const FilterPane = forwardRef(function FilterPane(props: FilterPaneProps,
               onClose={() => {
                 setMenuNew(undefined);
               }}
-              queryDruidSql={queryDruidSql}
+              runSqlQuery={runSqlQuery}
+              onAddToSourceQueryAsColumn={onAddToSourceQueryAsColumn}
+              onMoveToSourceQueryAsClause={onMoveToSourceQueryAsClause}
             />
           }
         >
-          <Button icon={IconNames.PLUS} onClick={() => setMenuNew({})} minimal />
+          <Button
+            icon={IconNames.PLUS}
+            text={patterns.length ? undefined : 'Add filter'}
+            onClick={() => setMenuNew({})}
+            minimal
+          />
         </Popover>
+      ) : (
+        <Button
+          icon={IconNames.PLUS}
+          text={patterns.length ? undefined : 'Add filter'}
+          disabled
+          minimal
+        />
       )}
     </DroppableContainer>
   );
diff --git a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.scss b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.scss
similarity index 94%
rename from web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.scss
rename to web-console/src/views/explore-view/components/generic-output-table/generic-output-table.scss
index 31733db7828..f30d6efd54d 100644
--- a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.scss
+++ b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.scss
@@ -16,8 +16,8 @@
  * limitations under the License.
  */
 
-@import '../../../../../variables';
-@import '../../../../../blueprint-overrides/common/colors';
+@import '../../../../variables';
+@import '../../../../blueprint-overrides/common/colors';
 
 .generic-output-table {
   position: relative;
@@ -55,7 +55,7 @@
       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 {
+        &.measure-header {
           background: rgba($druid-brand, 0.06);
         }
 
@@ -115,7 +115,7 @@
     width: 100%;
   }
 
-  .aggregate-column {
+  .measure-column {
     background-color: rgba($druid-brand, 0.06);
   }
 }
diff --git a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.tsx b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.tsx
similarity index 70%
rename from web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.tsx
rename to web-console/src/views/explore-view/components/generic-output-table/generic-output-table.tsx
index ebc56aefe6a..0050a34122c 100644
--- a/web-console/src/views/explore-view/modules/components/generic-output-table/generic-output-table.tsx
+++ b/web-console/src/views/explore-view/components/generic-output-table/generic-output-table.tsx
@@ -19,18 +19,18 @@
 import { Button, Icon, Intent, Menu, MenuItem, Popover } from '@blueprintjs/core';
 import type { IconName } from '@blueprintjs/icons';
 import { IconNames } from '@blueprintjs/icons';
-import type { Column, QueryResult, SqlExpression } from '@druid-toolkit/query';
-import { SqlColumn, SqlLiteral, trimString } from '@druid-toolkit/query';
+import type { Column, QueryResult } from '@druid-toolkit/query';
+import { SqlColumn, SqlExpression, SqlLiteral, trimString } from '@druid-toolkit/query';
 import classNames from 'classnames';
 import type { JSX } from 'react';
 import React, { useEffect, useState } from 'react';
 import type { Column as TableColumn } from 'react-table';
 import ReactTable from 'react-table';
 
-import { BracedText, Deferred, TableCell } from '../../../../../components';
-import { possibleDruidFormatForValues, TIME_COLUMN } from '../../../../../druid-models';
-import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../../../react-table';
-import type { ColumnHint, Pagination, QueryAction } from '../../../../../utils';
+import { BracedText, Deferred, TableCell } from '../../../../components';
+import { ShowValueDialog } from '../../../../dialogs/show-value-dialog/show-value-dialog';
+import { SMALL_TABLE_PAGE_SIZE, SMALL_TABLE_PAGE_SIZE_OPTIONS } from '../../../../react-table';
+import type { ColumnHint, Pagination } from '../../../../utils';
 import {
   columnToIcon,
   columnToWidth,
@@ -39,8 +39,7 @@ import {
   getNumericColumnBraces,
   prettyPrintSql,
   stringifyValue,
-  timeFormatToSql,
-} from '../../../../../utils';
+} from '../../../../utils';
 
 import './generic-output-table.scss';
 
@@ -94,12 +93,16 @@ function columnNester(
   return ret;
 }
 
+export function calculateInitPageSize(height: number): number {
+  return Math.ceil((height - 74) / 39);
+}
+
 export interface GenericOutputTableProps {
   queryResult: QueryResult;
-  onQueryAction(action: QueryAction): void;
+  onWhereChange?(where: SqlExpression): void;
+  onHavingChange?(having: SqlExpression): void;
   onOrderByChange?(columnName: string, desc: boolean): void;
   onExport?(): void;
-  runeMode: boolean;
   showTypeIcons: boolean;
   initPageSize?: number;
   columnHints?: Map<string, ColumnHint>;
@@ -110,10 +113,10 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
 ) {
   const {
     queryResult,
-    onQueryAction,
+    onWhereChange,
+    onHavingChange,
     onOrderByChange,
     onExport,
-    runeMode,
     showTypeIcons,
     initPageSize,
     columnHints,
@@ -123,6 +126,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
     page: 0,
     pageSize: initPageSize || 20,
   });
+  const [showValue, setShowValue] = useState<string>();
 
   // Reset page to 0 if number of results changes
   useEffect(() => {
@@ -147,9 +151,6 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
 
     const menuItems: JSX.Element[] = [];
     if (parsedQuery) {
-      const noStar = !parsedQuery.hasStarInSelect();
-      const selectExpression = parsedQuery.getSelectExpressionForIndex(headerIndex);
-
       if (onOrderByChange) {
         const orderBy = parsedQuery.getOrderByForSelectIndex(headerIndex);
 
@@ -189,139 +190,91 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
       }
 
       if (parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) {
-        const whereExpression = parsedQuery.getWhereExpression();
-        if (whereExpression && whereExpression.containsColumnName(header)) {
-          menuItems.push(
-            <MenuItem
-              key="remove_where"
-              icon={IconNames.FILTER_REMOVE}
-              text="Remove from WHERE clause"
-              onClick={() => {
-                onQueryAction(q =>
-                  q.changeWhereExpression(whereExpression.removeColumnFromAnd(header)),
-                );
-              }}
-            />,
-          );
-        }
-
-        const havingExpression = parsedQuery.getHavingExpression();
-        if (havingExpression && havingExpression.containsColumnName(header)) {
-          menuItems.push(
-            <MenuItem
-              key="remove_having"
-              icon={IconNames.FILTER_REMOVE}
-              text="Remove from HAVING clause"
-              onClick={() => {
-                onQueryAction(q =>
-                  q.changeHavingExpression(havingExpression.removeColumnFromAnd(header)),
-                );
-              }}
-            />,
-          );
+        if (onWhereChange) {
+          const whereExpression = parsedQuery.getWhereExpression();
+          if (whereExpression && whereExpression.containsColumnName(header)) {
+            menuItems.push(
+              <MenuItem
+                key="remove_where"
+                icon={IconNames.FILTER_REMOVE}
+                text="Remove from WHERE clause"
+                onClick={() => {
+                  const newWhere = whereExpression.removeColumnFromAnd(header);
+                  if (!newWhere) return;
+                  onWhereChange(newWhere);
+                }}
+              />,
+            );
+          }
         }
-      }
-
-      if (noStar && selectExpression) {
-        if (column.isTimeColumn()) {
-          // ToDo: clean
-        } else if (column.sqlType === 'TIMESTAMP') {
-          menuItems.push(
-            <MenuItem
-              key="declare_time"
-              icon={IconNames.TIME}
-              text="Use as the primary time column"
-              onClick={() => {
-                onQueryAction(q => q.changeSelect(headerIndex, selectExpression.as(TIME_COLUMN)));
-              }}
-            />,
-          );
-        } else {
-          // Not a time column -------------------------------------------
-          const values = queryResult.rows.map(row => row[headerIndex]);
-          const possibleDruidFormat = possibleDruidFormatForValues(values);
-          const formatSql = possibleDruidFormat ? timeFormatToSql(possibleDruidFormat) : undefined;
-
-          if (formatSql) {
-            const newSelectExpression = formatSql.fillPlaceholders([
-              selectExpression.getUnderlyingExpression(),
-            ]);
 
+        if (onHavingChange) {
+          const havingExpression = parsedQuery.getHavingExpression();
+          if (havingExpression && havingExpression.containsColumnName(header)) {
             menuItems.push(
               <MenuItem
-                key="parse_time"
-                icon={IconNames.TIME}
-                text={`Time parse as '${possibleDruidFormat}' and use as the primary time column`}
+                key="remove_having"
+                icon={IconNames.FILTER_REMOVE}
+                text="Remove from HAVING clause"
                 onClick={() => {
-                  onQueryAction(q =>
-                    q.changeSelect(headerIndex, newSelectExpression.as(TIME_COLUMN)),
-                  );
+                  const newHaving = havingExpression.removeColumnFromAnd(header);
+                  if (!newHaving) return;
+                  onHavingChange(newHaving);
                 }}
               />,
             );
           }
         }
       }
-    } else {
-      menuItems.push(
-        <MenuItem
-          key="copy_ref"
-          icon={IconNames.CLIPBOARD}
-          text={`Copy: ${prettyRef}`}
-          onClick={() => {
-            copyAndAlert(String(ref), `${prettyRef}' copied to clipboard`);
-          }}
-        />,
-      );
-
-      if (!runeMode) {
-        const orderByExpression = SqlColumn.create(header);
-        const descOrderBy = orderByExpression.toOrderByExpression('DESC');
-        const ascOrderBy = orderByExpression.toOrderByExpression('ASC');
-        const descOrderByPretty = prettyPrintSql(descOrderBy);
-        const ascOrderByPretty = prettyPrintSql(descOrderBy);
-
-        menuItems.push(
-          <MenuItem
-            key="copy_desc"
-            icon={IconNames.CLIPBOARD}
-            text={`Copy: ${descOrderByPretty}`}
-            onClick={() =>
-              copyAndAlert(descOrderBy.toString(), `'${descOrderByPretty}' copied to clipboard`)
-            }
-          />,
-          <MenuItem
-            key="copy_asc"
-            icon={IconNames.CLIPBOARD}
-            text={`Copy: ${ascOrderByPretty}`}
-            onClick={() =>
-              copyAndAlert(ascOrderBy.toString(), `'${ascOrderByPretty}' copied to clipboard`)
-            }
-          />,
-        );
-      }
     }
 
+    menuItems.push(
+      <MenuItem
+        key="copy_ref"
+        icon={IconNames.CLIPBOARD}
+        text={`Copy: ${prettyRef}`}
+        onClick={() => {
+          copyAndAlert(String(ref), `${prettyRef}' copied to clipboard`);
+        }}
+      />,
+    );
+
     return <Menu>{menuItems}</Menu>;
   }
 
   function filterOnMenuItem(icon: IconName, clause: SqlExpression, having: boolean) {
     if (!parsedQuery) return;
+    const columnName = clause.getUsedColumnNames()[0];
 
-    return (
-      <MenuItem
-        icon={icon}
-        text={`${having ? 'Having' : 'Filter on'}: ${prettyPrintSql(clause)}`}
-        onClick={() => {
-          const columnName = clause.getUsedColumnNames()[0];
-          onQueryAction(
-            having
-              ? q => q.removeFromHaving(columnName).addHaving(clause)
-              : q => q.removeColumnFromWhere(columnName).addWhere(clause),
-          );
-        }}
-      />
-    );
+    if (having) {
+      const havingExpression = parsedQuery.getEffectiveHavingExpression();
+      if (!onHavingChange) return;
+      return (
+        <MenuItem
+          icon={icon}
+          text={`Having: ${prettyPrintSql(clause)}`}
+          onClick={() => {
+            onHavingChange(
+              SqlExpression.and(havingExpression.removeColumnFromAnd(columnName), clause),
+            );
+          }}
+        />
+      );
+    } else {
+      const whereExpression = parsedQuery.getEffectiveWhereExpression();
+      if (!onWhereChange) return;
+      return (
+        <MenuItem
+          icon={icon}
+          text={`Filter on: ${prettyPrintSql(clause)}`}
+          onClick={() => {
+            onWhereChange(
+              SqlExpression.and(whereExpression.removeColumnFromAnd(columnName), clause),
+            );
+          }}
+        />
+      );
+    }
   }
 
   function clipboardMenuItem(clause: SqlExpression) {
@@ -341,7 +294,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
         icon={IconNames.EYE_OPEN}
         text="Show full value"
         onClick={() => {
-          // ToDo: clean up
+          setShowValue(stringifyValue(value));
         }}
       />
     );
@@ -349,19 +302,21 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
     const val = sqlLiteralForColumnValue(column, value);
 
     if (parsedQuery) {
-      let ex: SqlExpression | undefined;
+      let ex = columnHints?.get(column.name)?.expressionForWhere;
       let having = false;
-      if (parsedQuery.hasStarInSelect()) {
-        ex = SqlColumn.create(column.name);
-      } else {
-        const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
-        if (selectValue) {
-          const outputName = selectValue.getOutputName();
-          having = parsedQuery.isAggregateSelectIndex(headerIndex);
-          if (having && outputName) {
-            ex = SqlColumn.create(outputName);
-          } else {
-            ex = selectValue.getUnderlyingExpression();
+      if (!ex) {
+        if (parsedQuery.hasStarInSelect()) {
+          ex = SqlColumn.create(column.name);
+        } else {
+          const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
+          if (selectValue) {
+            const outputName = selectValue.getOutputName();
+            having = parsedQuery.isAggregateSelectIndex(headerIndex);
+            if (having && outputName) {
+              ex = SqlColumn.create(outputName);
+            } else {
+              ex = selectValue.getUnderlyingExpression();
+            }
           }
         }
       }
@@ -395,7 +350,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
             text={`Copy: ${trimmedValue}`}
             onClick={() => copyAndAlert(stringValue, `${trimmedValue} copied to clipboard`)}
           />
-          {!runeMode && val && (
+          {val && (
             <>
               {clipboardMenuItem(ref.equal(val))}
               {clipboardMenuItem(ref.unequal(val))}
@@ -418,7 +373,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
     }
 
     if (parsedQuery.isAggregateOutputColumn(header)) {
-      className.push('aggregate-header');
+      className.push('measure-header');
     }
 
     return className.join(' ');
@@ -514,7 +469,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
                 width: columnToWidth(column),
                 className:
                   parsedQuery && parsedQuery.isAggregateOutputColumn(h)
-                    ? 'aggregate-column'
+                    ? 'measure-column'
                     : undefined,
               };
             }),
@@ -523,6 +478,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
           )}
         />
       )}
+      {showValue && <ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} />}
     </div>
   );
 });
diff --git a/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.scss b/web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.scss
similarity index 100%
rename from web-console/src/views/explore-view/highlight-bubble/highlight-bubble.scss
rename to web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.scss
diff --git a/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx b/web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.tsx
similarity index 95%
rename from web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx
rename to web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.tsx
index cf26029957c..b3c74d7553c 100644
--- a/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx
+++ b/web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.tsx
@@ -21,8 +21,8 @@ import React, { useCallback, useEffect, useMemo } from 'react';
 import { createPortal } from 'react-dom';
 import { useStore } from 'zustand';
 
-import { useResizeObserver } from '../../../hooks';
-import { highlightStore } from '../highlight-store/highlight-store';
+import { useResizeObserver } from '../../../../hooks';
+import { highlightStore } from '../../highlight-store/highlight-store';
 
 import './highlight-bubble.scss';
 
diff --git a/web-console/src/views/explore-view/modules/components/index.ts b/web-console/src/views/explore-view/components/index.ts
similarity index 61%
copy from web-console/src/views/explore-view/modules/components/index.ts
copy to web-console/src/views/explore-view/components/index.ts
index 4b7eeff07ef..3cbeb6fe6d3 100644
--- a/web-console/src/views/explore-view/modules/components/index.ts
+++ b/web-console/src/views/explore-view/components/index.ts
@@ -16,4 +16,16 @@
  * limitations under the License.
  */
 
+export * from './control-pane/control-pane';
+export * from './droppable-container/droppable-container';
+export * from './filter-pane/filter-pane';
 export * from './generic-output-table/generic-output-table';
+export * from './highlight-bubble/highlight-bubble';
+export * from './issue/issue';
+export * from './module-pane/module-pane';
+export * from './module-picker/module-picker';
+export * from './resource-pane/resource-pane';
+export * from './source-pane/source-pane';
+export * from './source-query-pane/source-query-pane';
+export * from './sql-input/sql-input';
+export * from './utc-date-input/utc-date-input';
diff --git a/web-console/src/views/explore-view/modules/table-react-module.scss b/web-console/src/views/explore-view/components/issue/issue.scss
similarity index 88%
copy from web-console/src/views/explore-view/modules/table-react-module.scss
copy to web-console/src/views/explore-view/components/issue/issue.scss
index 26523b1c782..86c279bab1f 100644
--- a/web-console/src/views/explore-view/modules/table-react-module.scss
+++ b/web-console/src/views/explore-view/components/issue/issue.scss
@@ -16,11 +16,9 @@
  * limitations under the License.
  */
 
-.table-module {
-  width: 100%;
-  height: 100%;
-
-  .generic-output-table {
-    height: 100%;
-  }
+.issue {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: rgba(0, 0, 0, 0.5);
 }
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/issue/issue.tsx
similarity index 67%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/issue/issue.tsx
index dbb850df020..4374c626511 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/issue/issue.tsx
@@ -16,29 +16,22 @@
  * limitations under the License.
  */
 
-@import '../../../../variables';
+import classNames from 'classnames';
+import React from 'react';
 
-.filter-menu {
-  width: 400px;
+import './issue.scss';
 
-  &.main {
-    padding: 15px;
-  }
-
-  .controls .#{$ns}-form-content {
-    display: flex;
-    gap: 15px;
-
-    .type-selector {
-      flex: 1;
-    }
-  }
+export interface IssueProps {
+  className?: string;
+  issue: string;
+}
 
-  textarea {
-    width: 100%;
-  }
+export const Issue = React.memo(function Issue(props: IssueProps) {
+  const { className, issue } = props;
 
-  .button-bar {
-    text-align: right;
-  }
-}
+  return (
+    <div className={classNames('issue', className)}>
+      <div className="issue-inner">{issue}</div>
+    </div>
+  );
+});
diff --git a/web-console/src/views/explore-view/control-pane/columns-input.scss b/web-console/src/views/explore-view/components/module-pane/module-pane.scss
similarity index 63%
rename from web-console/src/views/explore-view/control-pane/columns-input.scss
rename to web-console/src/views/explore-view/components/module-pane/module-pane.scss
index 2f7fc37e554..d015fca9ecd 100644
--- a/web-console/src/views/explore-view/control-pane/columns-input.scss
+++ b/web-console/src/views/explore-view/components/module-pane/module-pane.scss
@@ -16,30 +16,43 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
-
-.columns-input {
-  .#{$ns}-tag-input-values .#{$ns}-tag {
-    &.drop-before::after {
-      content: '';
-      height: 24px;
-      width: 1px;
+@import '../../../../variables';
+
+.module-pane {
+  position: relative;
+  @include card-like;
+
+  & > .module {
+    width: 100%;
+    height: 100%;
+    position: relative;
+
+    & > .echart-container {
       position: absolute;
-      left: -3px;
-      top: 50%;
-      transform: translate(0, -50%);
-      background-color: white;
+      width: 100%;
+      height: 100%;
     }
 
-    &.drop-after::after {
-      content: '';
-      height: 24px;
-      width: 1px;
+    & > .issue {
       position: absolute;
-      right: -3px;
-      top: 50%;
-      transform: translate(0, -50%);
-      background-color: white;
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  .tile-content {
+    width: 100%;
+    height: 100%;
+
+    &.issue {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+
+    & > * {
+      width: 100%;
+      height: 100%;
     }
   }
 }
diff --git a/web-console/src/views/explore-view/components/module-pane/module-pane.tsx b/web-console/src/views/explore-view/components/module-pane/module-pane.tsx
new file mode 100644
index 00000000000..4e216e3ac38
--- /dev/null
+++ b/web-console/src/views/explore-view/components/module-pane/module-pane.tsx
@@ -0,0 +1,100 @@
+/*
+ * 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 { ResizeSensor } from '@blueprintjs/core';
+import type { QueryResult, SqlExpression, SqlQuery } from '@druid-toolkit/query';
+import React, { useState } from 'react';
+
+import type { ParameterDefinition, QuerySource } from '../../models';
+import { effectiveParameterDefault, Stage } from '../../models';
+import { ModuleRepository } from '../../module-repository/module-repository';
+import { Issue } from '../issue/issue';
+
+import './module-pane.scss';
+
+function fillInDefaults(
+  parameterValues: Record<string, any>,
+  parameters: Record<string, ParameterDefinition>,
+  querySource: QuerySource,
+): Record<string, any> {
+  const parameterValuesWithDefaults = { ...parameterValues };
+  Object.entries(parameters).forEach(([propName, propDefinition]) => {
+    if (typeof parameterValuesWithDefaults[propName] !== 'undefined') return;
+    parameterValuesWithDefaults[propName] = effectiveParameterDefault(propDefinition, querySource);
+  });
+  return parameterValuesWithDefaults;
+}
+
+export interface ModulePaneProps {
+  moduleId: string;
+  querySource: QuerySource;
+  where: SqlExpression;
+  setWhere(where: SqlExpression): void;
+
+  parameterValues: Record<string, any>;
+  setParameterValues(parameters: Record<string, any>): void;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+}
+
+export const ModulePane = function ModulePane(props: ModulePaneProps) {
+  const {
+    moduleId,
+    querySource,
+    where,
+    setWhere,
+    parameterValues,
+    setParameterValues,
+    runSqlQuery,
+  } = props;
+  const [stage, setStage] = useState<Stage | undefined>();
+
+  const module = ModuleRepository.getModule(moduleId);
+
+  let content: React.ReactNode;
+  if (module) {
+    const modelIssue = undefined; // AutoForm.issueWithModel(moduleTileConfig.config, module.configFields);
+    if (modelIssue) {
+      content = <Issue issue={modelIssue} />;
+    } else if (stage) {
+      content = React.createElement(module.component, {
+        stage,
+        querySource,
+        where,
+        setWhere,
+        parameterValues: fillInDefaults(parameterValues, module.parameters, querySource),
+        setParameterValues,
+        runSqlQuery,
+      });
+    }
+  } else {
+    content = <Issue issue={`Unknown module id: ${moduleId}`} />;
+  }
+
+  return (
+    <ResizeSensor
+      onResize={entries => {
+        if (entries.length !== 1) return;
+        const newStage = new Stage(entries[0].contentRect.width, entries[0].contentRect.height);
+        if (newStage.equals(stage)) return;
+        setStage(newStage);
+      }}
+    >
+      <div className="module-pane">{content}</div>
+    </ResizeSensor>
+  );
+};
diff --git a/web-console/src/views/explore-view/modules/table-react-module.scss b/web-console/src/views/explore-view/components/module-picker/module-picker.scss
similarity index 88%
copy from web-console/src/views/explore-view/modules/table-react-module.scss
copy to web-console/src/views/explore-view/components/module-picker/module-picker.scss
index 26523b1c782..0979ba3789f 100644
--- a/web-console/src/views/explore-view/modules/table-react-module.scss
+++ b/web-console/src/views/explore-view/components/module-picker/module-picker.scss
@@ -16,11 +16,10 @@
  * limitations under the License.
  */
 
-.table-module {
-  width: 100%;
-  height: 100%;
+@import '../../../../variables';
 
-  .generic-output-table {
-    height: 100%;
+.module-picker {
+  .more-button.#{$bp-ns}-popover-target {
+    flex: 0;
   }
 }
diff --git a/web-console/src/views/explore-view/tile-picker/tile-picker.tsx b/web-console/src/views/explore-view/components/module-picker/module-picker.tsx
similarity index 71%
rename from web-console/src/views/explore-view/tile-picker/tile-picker.tsx
rename to web-console/src/views/explore-view/components/module-picker/module-picker.tsx
index cefb37b0f5d..d4561e1431f 100644
--- a/web-console/src/views/explore-view/tile-picker/tile-picker.tsx
+++ b/web-console/src/views/explore-view/components/module-picker/module-picker.tsx
@@ -22,25 +22,21 @@ import { IconNames } from '@blueprintjs/icons';
 import type { JSX } from 'react';
 import React from 'react';
 
-import './tile-picker.scss';
+import './module-picker.scss';
 
-export interface TilePickerProps<Name extends string> {
-  modules: readonly { moduleName: Name; icon: IconName; label: string }[];
-  selectedTileName: Name | undefined;
-  onSelectedTileNameChange(newSelectedTileName: Name): void;
+export interface ModulePickerProps {
+  modules: readonly { id: string; icon: IconName; label: string }[];
+  selectedModuleId: string | undefined;
+  onSelectedModuleIdChange(newSelectedModuleId: string): void;
   moreMenu?: JSX.Element;
 }
 
-declare function TilePickerComponent<Name extends string>(
-  props: TilePickerProps<Name>,
-): JSX.Element;
+export const ModulePicker = React.memo(function ModulePicker(props: ModulePickerProps) {
+  const { modules, selectedModuleId, onSelectedModuleIdChange, moreMenu } = props;
 
-export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<string>) {
-  const { modules, selectedTileName, onSelectedTileNameChange, moreMenu } = props;
-
-  const selectedTileManifest = modules.find(module => module.moduleName === selectedTileName);
+  const selectedTileManifest = modules.find(module => module.id === selectedModuleId);
   return (
-    <ButtonGroup className="tile-picker" fill>
+    <ButtonGroup className="module-picker" fill>
       <Popover
         className="picker-button"
         minimal
@@ -53,7 +49,7 @@ export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<
                 key={i}
                 icon={module.icon}
                 text={module.label}
-                onClick={() => onSelectedTileNameChange(module.moduleName)}
+                onClick={() => onSelectedModuleIdChange(module.id)}
               />
             ))}
           </Menu>
@@ -61,7 +57,7 @@ export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<
       >
         <Button
           icon={selectedTileManifest ? selectedTileManifest.icon : IconNames.BOX}
-          text={selectedTileManifest ? selectedTileManifest.label : 'Please select something'}
+          text={selectedTileManifest ? selectedTileManifest.label : 'Select module'}
           fill
           minimal
           rightIcon={IconNames.CARET_DOWN}
@@ -74,4 +70,4 @@ export const TilePicker = React.memo(function TilePicker(props: TilePickerProps<
       )}
     </ButtonGroup>
   );
-}) as typeof TilePickerComponent;
+});
diff --git a/web-console/src/views/explore-view/droppable-container/droppable-container.scss b/web-console/src/views/explore-view/components/preview-pane/preview-pane.scss
similarity index 69%
rename from web-console/src/views/explore-view/droppable-container/droppable-container.scss
rename to web-console/src/views/explore-view/components/preview-pane/preview-pane.scss
index 74b8a397384..03624ee8a68 100644
--- a/web-console/src/views/explore-view/droppable-container/droppable-container.scss
+++ b/web-console/src/views/explore-view/components/preview-pane/preview-pane.scss
@@ -16,23 +16,28 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
-
-.droppable-container {
+.preview-pane {
   position: relative;
-  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+
+  .preview-values-wrapper {
+    flex: 1;
+    position: relative;
 
-  &.drop-hover {
-    &::after {
+    .preview-values {
       position: absolute;
-      top: 0;
-      left: 0;
-      right: 0;
-      bottom: 0;
-      pointer-events: none;
-      border: 1px solid $druid-brand;
-      border-radius: 3px;
-      content: '';
+      overflow: hidden;
+      height: 100%;
+      width: 100%;
+
+      .preview-value {
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+        padding: 1px 0;
+        opacity: 0.8;
+      }
     }
   }
 }
diff --git a/web-console/src/views/explore-view/components/preview-pane/preview-pane.tsx b/web-console/src/views/explore-view/components/preview-pane/preview-pane.tsx
new file mode 100644
index 00000000000..faaf9dcd65c
--- /dev/null
+++ b/web-console/src/views/explore-view/components/preview-pane/preview-pane.tsx
@@ -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 { Callout } from '@blueprintjs/core';
+import type { SqlQuery } from '@druid-toolkit/query';
+import { type QueryResult } from '@druid-toolkit/query';
+import React from 'react';
+
+import { useQueryManager } from '../../../../hooks';
+
+import './preview-pane.scss';
+
+function getPreviewValues(queryResult: QueryResult): any[] {
+  const headerNames = queryResult.getHeaderNames();
+  if (headerNames.length === 1) {
+    return queryResult.getColumnByName(headerNames[0]) || [];
+  } else {
+    return queryResult.rows[0] || [];
+  }
+}
+
+export interface PreviewPaneProps {
+  previewQuery: string | undefined;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+}
+
+export const PreviewPane = React.memo(function PreviewPane(props: PreviewPaneProps) {
+  const { previewQuery, runSqlQuery } = props;
+
+  const [previewState] = useQueryManager({
+    query: previewQuery,
+    processQuery: runSqlQuery,
+    debounceIdle: 1000,
+  });
+
+  const previewValues = previewState.data ? getPreviewValues(previewState.data) : undefined;
+  return (
+    <Callout className="preview-pane" title="Preview">
+      {previewState.loading && 'Loading...'}
+      {previewState.error && <div className="preview-error">{previewState.getErrorMessage()}</div>}
+      {previewValues &&
+        (previewValues.length ? (
+          <div className="preview-values-wrapper">
+            <div className="preview-values">
+              {previewValues.map((v, i) => (
+                <div className="preview-value" key={i}>
+                  {String(v)}
+                </div>
+              ))}
+            </div>
+          </div>
+        ) : (
+          'No preview values'
+        ))}
+    </Callout>
+  );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.scss
similarity index 62%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.scss
index dbb850df020..b1147dd1abc 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.scss
@@ -16,29 +16,42 @@
  * limitations under the License.
  */
 
-@import '../../../../variables';
+@import '../../../../../variables';
 
-.filter-menu {
-  width: 400px;
-
-  &.main {
-    padding: 15px;
+.column-dialog {
+  &.#{$bp-ns}-dialog {
+    width: 80vw;
   }
 
-  .controls .#{$ns}-form-content {
+  .#{$bp-ns}-dialog-body {
     display: flex;
-    gap: 15px;
+    gap: 12px;
 
-    .type-selector {
+    .controls {
       flex: 1;
+      display: flex;
+      flex-direction: column;
+
+      .sql-expression-form-group {
+        flex: 1;
+        margin: 0;
+
+        .#{$bp-ns}-form-content {
+          flex: 1;
+
+          .flexible-query-input {
+            height: 100%;
+          }
+        }
+      }
     }
-  }
 
-  textarea {
-    width: 100%;
+    .preview-pane {
+      width: 300px;
+    }
   }
 
-  .button-bar {
-    text-align: right;
+  .#{$bp-ns}-dialog-footer {
+    margin-top: 0;
   }
 }
diff --git a/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.tsx b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.tsx
new file mode 100644
index 00000000000..4cc4fcd674c
--- /dev/null
+++ b/web-console/src/views/explore-view/components/resource-pane/column-dialog/column-dialog.tsx
@@ -0,0 +1,169 @@
+/*
+ * 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, FormGroup, InputGroup, Intent, Tag } from '@blueprintjs/core';
+import { type QueryResult, F, sql, SqlExpression, SqlQuery } from '@druid-toolkit/query';
+import React, { useMemo, useState } from 'react';
+
+import { AppToaster } from '../../../../../singletons';
+import { ExpressionMeta, QuerySource } from '../../../models';
+import type { Rename } from '../../../utils';
+import { PreviewPane } from '../../preview-pane/preview-pane';
+import { SqlInput } from '../../sql-input/sql-input';
+
+import './column-dialog.scss';
+
+export interface ColumnDialogProps {
+  initExpression: SqlExpression | undefined;
+  columnToDuplicate?: string;
+  onApply(newQuery: SqlQuery, rename: Rename | undefined): void;
+  querySource: QuerySource;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+  onClose(): void;
+}
+
+export const ColumnDialog = React.memo(function ColumnDialog(props: ColumnDialogProps) {
+  const { initExpression, columnToDuplicate, onApply, querySource, runSqlQuery, onClose } = props;
+
+  const [outputName, setOutputName] = useState(initExpression?.getOutputName() || '');
+  const [formula, setFormula] = useState(String(initExpression?.getUnderlyingExpression() || ''));
+
+  const previewQuery = useMemo(() => {
+    const expression = SqlExpression.maybeParse(formula);
+    if (!expression) return;
+    return SqlQuery.from(QuerySource.stripToBaseSource(querySource.query))
+      .addSelect(F.cast(expression, 'VARCHAR').as('v'), { addToGroupBy: 'end' })
+      .applyIf(
+        querySource.baseColumns.find(column => column.isTimeColumn()),
+        q => q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
+      )
+      .changeLimitValue(100)
+      .toString();
+  }, [querySource, formula]);
+
+  return (
+    <Dialog
+      className="column-dialog"
+      isOpen
+      onClose={onClose}
+      title={initExpression ? 'Edit column' : 'Add column'}
+    >
+      <div className={Classes.DIALOG_BODY}>
+        <div className="controls">
+          <FormGroup label="Name">
+            <InputGroup
+              value={outputName}
+              onChange={e => {
+                setOutputName(e.target.value.slice(0, ExpressionMeta.MAX_NAME_LENGTH));
+              }}
+              placeholder="Column name"
+            />
+          </FormGroup>
+          <FormGroup label="SQL expression" className="sql-expression-form-group">
+            <SqlInput
+              value={formula}
+              onValueChange={formula => {
+                setFormula(formula);
+              }}
+              columns={querySource.baseColumns}
+              placeholder="SQL expression"
+              editorHeight={400}
+              autoFocus
+              showGutter={false}
+            />
+          </FormGroup>
+        </div>
+        <PreviewPane previewQuery={previewQuery} runSqlQuery={runSqlQuery} />
+      </div>
+      <div className={Classes.DIALOG_FOOTER}>
+        <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+          <div className="edit-column-dialog-buttons">
+            <Button text="Cancel" onClick={onClose} />
+            <Button
+              text="Apply"
+              intent={Intent.PRIMARY}
+              onClick={() => {
+                if (!outputName) {
+                  AppToaster.show({
+                    message: 'Must have a name',
+                    intent: Intent.DANGER,
+                  });
+                  return;
+                }
+
+                if (
+                  initExpression?.getOutputName() !== outputName &&
+                  querySource.nameInUse(outputName)
+                ) {
+                  const availableName = querySource.getAvailableName(outputName);
+                  AppToaster.show({
+                    message: (
+                      <>
+                        The name <Tag minimal>{outputName}</Tag> is already in use.
+                      </>
+                    ),
+                    intent: Intent.WARNING,
+                    action: {
+                      text: (
+                        <>
+                          Change to <Tag minimal>{availableName}</Tag>
+                        </>
+                      ),
+                      onClick: () => setOutputName(availableName),
+                    },
+                  });
+                  return;
+                }
+
+                let newExpression: SqlExpression;
+                try {
+                  newExpression = SqlExpression.parse(formula).as(outputName);
+                } catch (e) {
+                  AppToaster.show({
+                    message: e.message,
+                    intent: Intent.DANGER,
+                  });
+                  return;
+                }
+
+                if (initExpression) {
+                  const initExpressionName = initExpression.getOutputName()!;
+                  if (columnToDuplicate) {
+                    onApply(
+                      querySource.addColumnAfter(columnToDuplicate, newExpression),
+                      undefined,
+                    );
+                  } else {
+                    onApply(
+                      querySource.changeColumn(initExpressionName, newExpression),
+                      new Map([[initExpression.getOutputName()!, newExpression.getOutputName()!]]),
+                    );
+                  }
+                } else {
+                  onApply(querySource.addColumn(newExpression), undefined);
+                }
+
+                onClose();
+              }}
+            />
+          </div>
+        </div>
+      </div>
+    </Dialog>
+  );
+});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss
similarity index 62%
copy from web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
copy to web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss
index dbb850df020..c7edf7eeafc 100644
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.scss
+++ b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.scss
@@ -16,29 +16,42 @@
  * limitations under the License.
  */
 
-@import '../../../../variables';
+@import '../../../../../variables';
 
-.filter-menu {
-  width: 400px;
-
-  &.main {
-    padding: 15px;
+.measure-dialog {
+  &.#{$bp-ns}-dialog {
+    width: 80vw;
   }
 
-  .controls .#{$ns}-form-content {
+  .#{$bp-ns}-dialog-body {
     display: flex;
-    gap: 15px;
+    gap: 12px;
 
-    .type-selector {
+    .controls {
       flex: 1;
+      display: flex;
+      flex-direction: column;
+
+      .sql-expression-form-group {
+        flex: 1;
+        margin: 0;
+
+        .#{$bp-ns}-form-content {
+          flex: 1;
+
+          .flexible-query-input {
+            height: 100%;
+          }
+        }
+      }
     }
-  }
 
-  textarea {
-    width: 100%;
+    .preview-pane {
+      width: 300px;
+    }
   }
 
-  .button-bar {
-    text-align: right;
+  .#{$bp-ns}-dialog-footer {
+    margin-top: 0;
   }
 }
diff --git a/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx
new file mode 100644
index 00000000000..f02facf61f0
--- /dev/null
+++ b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx
@@ -0,0 +1,176 @@
+/*
+ * 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, FormGroup, InputGroup, Intent, Tag } from '@blueprintjs/core';
+import {
+  type QueryResult,
+  L,
+  sql,
+  SqlExpression,
+  SqlQuery,
+  SqlWithPart,
+} from '@druid-toolkit/query';
+import React, { useMemo, useState } from 'react';
+
+import { AppToaster } from '../../../../../singletons';
+import { Measure, QuerySource } from '../../../models';
+import type { Rename } from '../../../utils';
+import { PreviewPane } from '../../preview-pane/preview-pane';
+import { SqlInput } from '../../sql-input/sql-input';
+
+import './measure-dialog.scss';
+
+export interface MeasureDialogProps {
+  initMeasure: Measure | undefined;
+  measureToDuplicate?: string;
+  onApply(newQuery: SqlQuery, rename: Rename | undefined): void;
+  querySource: QuerySource;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+  onClose(): void;
+}
+
+export const MeasureDialog = React.memo(function MeasureDialog(props: MeasureDialogProps) {
+  const { initMeasure, measureToDuplicate, onApply, querySource, runSqlQuery, onClose } = props;
+
+  const [outputName, setOutputName] = useState(initMeasure?.name || '');
+  const [formula, setFormula] = useState(String(initMeasure?.expression || ''));
+
+  const previewQuery = useMemo(() => {
+    const expression = SqlExpression.maybeParse(formula);
+    if (!expression) return;
+    return SqlQuery.from('t')
+      .changeWithParts([SqlWithPart.simple('t', QuerySource.stripToBaseSource(querySource.query))])
+      .addSelect(L('Overall').as('label'))
+      .addSelect(expression.as('value'))
+      .applyIf(
+        querySource.baseColumns.find(column => column.isTimeColumn()),
+        q => q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
+      )
+      .toString();
+  }, [querySource.query, formula]);
+
+  return (
+    <Dialog
+      className="measure-dialog"
+      isOpen
+      onClose={onClose}
+      title={initMeasure ? 'Edit measure' : 'Add measure'}
+    >
+      <div className={Classes.DIALOG_BODY}>
+        <div className="controls">
+          <FormGroup label="Name">
+            <InputGroup
+              value={outputName}
+              onChange={e => {
+                setOutputName(e.target.value.slice(0, Measure.MAX_NAME_LENGTH));
+              }}
+              placeholder="Measure name"
+            />
+          </FormGroup>
+          <FormGroup label="SQL expression" className="sql-expression-form-group">
+            <SqlInput
+              value={formula}
+              onValueChange={formula => {
+                setFormula(formula);
+              }}
+              columns={querySource.baseColumns}
+              placeholder="SQL expression"
+              editorHeight={400}
+              autoFocus
+              showGutter={false}
+            />
+          </FormGroup>
+        </div>
+        <PreviewPane previewQuery={previewQuery} runSqlQuery={runSqlQuery} />
+      </div>
+      <div className={Classes.DIALOG_FOOTER}>
+        <div className={Classes.DIALOG_FOOTER_ACTIONS}>
+          <div className="edit-column-dialog-buttons">
+            <Button text="Cancel" onClick={onClose} />
+            <Button
+              text="Apply"
+              intent={Intent.PRIMARY}
+              onClick={() => {
+                if (!outputName) {
+                  AppToaster.show({
+                    message: 'Must have a name',
+                    intent: Intent.DANGER,
+                  });
+                  return;
+                }
+
+                if (initMeasure?.name !== outputName && querySource.nameInUse(outputName)) {
+                  const availableName = querySource.getAvailableName(outputName);
+                  AppToaster.show({
+                    message: (
+                      <>
+                        The name <Tag minimal>{outputName}</Tag> is already in use.
+                      </>
+                    ),
+                    intent: Intent.WARNING,
+                    action: {
+                      text: (
+                        <>
+                          Change to <Tag minimal>{availableName}</Tag>
+                        </>
+                      ),
+                      onClick: () => setOutputName(availableName),
+                    },
+                  });
+                  return;
+                }
+
+                let newExpression: SqlExpression;
+                try {
+                  newExpression = SqlExpression.parse(formula);
+                } catch (e) {
+                  AppToaster.show({
+                    message: e.message,
+                    intent: Intent.DANGER,
+                  });
+                  return;
+                }
+
+                const newMeasure: Measure = new Measure({
+                  as: outputName,
+                  expression: newExpression,
+                });
+
+                if (initMeasure) {
+                  const initMeasureName = initMeasure.name;
+                  if (measureToDuplicate) {
+                    onApply(querySource.addMeasureAfter(measureToDuplicate, newMeasure), undefined);
+                  } else {
+                    onApply(
+                      querySource.changeMeasure(initMeasureName, newMeasure),
+                      new Map([[initMeasureName, newExpression.getOutputName()!]]),
+                    );
+                  }
+                } else {
+                  onApply(querySource.addMeasure(newMeasure), undefined);
+                }
+
+                onClose();
+              }}
+            />
+          </div>
+        </div>
+      </div>
+    </Dialog>
+  );
+});
diff --git a/web-console/src/views/explore-view/resource-pane/resource-pane.scss b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
similarity index 61%
copy from web-console/src/views/explore-view/resource-pane/resource-pane.scss
copy to web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
index 81395dae874..89a16606263 100644
--- a/web-console/src/views/explore-view/resource-pane/resource-pane.scss
+++ b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
@@ -16,22 +16,48 @@
  * limitations under the License.
  */
 
+@import '../../../../variables';
+
 .resource-pane {
-  position: relative;
+  display: flex;
+  flex-direction: column;
 
   .search-input {
     margin: 4px;
   }
 
-  .resource-items {
-    position: absolute;
-    top: 38px;
-    bottom: 0;
-    left: 0;
-    right: 0;
+  .list-header {
+    padding: 6px;
+    text-transform: uppercase;
+    font-weight: bold;
+    position: relative;
+
+    .header-buttons {
+      position: absolute;
+      top: 0;
+      right: 0;
+    }
+  }
+
+  .column-resource-list {
+    flex: 3;
+    overflow: auto;
+    border-bottom: 2px solid $light-gray4;
+
+    .#{$bp-ns}-dark & {
+      border-bottom: 2px solid $dark-gray2;
+    }
+
+    .column-resource {
+      display: block;
+    }
+  }
+
+  .measure-resource-list {
+    flex: 1;
     overflow: auto;
 
-    .resource-item {
+    .measure-resource {
       display: block;
     }
   }
diff --git a/web-console/src/views/explore-view/components/resource-pane/resource-pane.tsx b/web-console/src/views/explore-view/components/resource-pane/resource-pane.tsx
new file mode 100644
index 00000000000..f6489eb17e2
--- /dev/null
+++ b/web-console/src/views/explore-view/components/resource-pane/resource-pane.tsx
@@ -0,0 +1,283 @@
+/*
+ * 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,
+  ButtonGroup,
+  Classes,
+  Icon,
+  Intent,
+  Menu,
+  MenuDivider,
+  MenuItem,
+  Popover,
+} from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import type { Column, QueryResult, SqlExpression, SqlQuery } from '@druid-toolkit/query';
+import classNames from 'classnames';
+import React, { useState } from 'react';
+
+import { ClearableInput } from '../../../../components';
+import { caseInsensitiveContains, columnToIcon, filterMap } from '../../../../utils';
+import { DragHelper } from '../../drag-helper';
+import type { Measure, QuerySource } from '../../models';
+import type { Rename } from '../../utils';
+
+import { ColumnDialog } from './column-dialog/column-dialog';
+import { MeasureDialog } from './measure-dialog/measure-dialog';
+
+import './resource-pane.scss';
+
+interface ColumnEditorOpenOn {
+  expression?: SqlExpression;
+  columnToDuplicate?: string;
+}
+
+interface MeasureEditorOpenOn {
+  measure?: Measure;
+  measureToDuplicate?: string;
+}
+
+export interface ResourcePaneProps {
+  querySource: QuerySource;
+  onQueryChange: (newQuery: SqlQuery, rename: Rename | undefined) => void;
+  onFilter?: (column: Column) => void;
+  onShowColumn(column: Column): void;
+  onShowMeasure(measure: Measure): void;
+  runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
+}
+
+export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
+  const { querySource, onQueryChange, onFilter, onShowColumn, onShowMeasure, runSqlQuery } = props;
+  const [columnSearch, setColumnSearch] = useState('');
+
+  const [columnEditorOpenOn, setColumnEditorOpenOn] = useState<ColumnEditorOpenOn | undefined>();
+  const [measureEditorOpenOn, setMeasureEditorOpenOn] = useState<MeasureEditorOpenOn | undefined>();
+
+  function applyUtil(nameTransform: (columnName: string) => string) {
+    if (!querySource) return;
+    const columnNameMap = querySource.getColumnNameMap(nameTransform);
+    onQueryChange(querySource.applyColumnNameMap(columnNameMap), columnNameMap);
+  }
+
+  return (
+    <div className="resource-pane">
+      <ClearableInput
+        className="search-input"
+        value={columnSearch}
+        onChange={setColumnSearch}
+        placeholder="Search"
+      />
+      <div className="list-header column-list-header">
+        Columns
+        <ButtonGroup className="header-buttons" minimal>
+          <Button
+            icon={IconNames.PLUS}
+            title="Add column"
+            onClick={() => setColumnEditorOpenOn({})}
+          />
+          <Popover
+            content={
+              <Menu>
+                <MenuItem
+                  text="Uppercase columns"
+                  onClick={() => applyUtil(x => x.toUpperCase())}
+                />
+                <MenuItem
+                  text="Lowercase columns"
+                  onClick={() => applyUtil(x => x.toLowerCase())}
+                />
+              </Menu>
+            }
+          >
+            <Button icon={IconNames.MORE} />
+          </Popover>
+        </ButtonGroup>
+      </div>
+      <div className="column-resource-list">
+        {filterMap(querySource.columns, (column, i) => {
+          const columnName = column.name;
+          if (!caseInsensitiveContains(columnName, columnSearch)) return;
+          return (
+            <Popover
+              className="column-resource"
+              key={i}
+              position="right"
+              content={
+                <Menu>
+                  {onFilter && (
+                    <MenuItem
+                      icon={IconNames.FILTER}
+                      text="Filter"
+                      onClick={() => onFilter(column)}
+                    />
+                  )}
+                  <MenuItem
+                    icon={IconNames.EYE_OPEN}
+                    text="Show"
+                    onClick={() => onShowColumn(column)}
+                  />
+                  <MenuDivider />
+                  <MenuItem
+                    icon={IconNames.EDIT}
+                    text="Edit"
+                    onClick={() =>
+                      setColumnEditorOpenOn({
+                        expression: querySource.getSourceExpressionForColumn(columnName),
+                      })
+                    }
+                  />
+                  <MenuItem
+                    icon={IconNames.DUPLICATE}
+                    text="Duplicate"
+                    onClick={() =>
+                      setColumnEditorOpenOn({
+                        columnToDuplicate: columnName,
+                        expression: querySource
+                          .getSourceExpressionForColumn(columnName)
+                          .as(querySource.getAvailableName(columnName)),
+                      })
+                    }
+                  />
+                  <MenuItem
+                    icon={IconNames.TRASH}
+                    text="Delete"
+                    intent={Intent.DANGER}
+                    onClick={() => onQueryChange(querySource.deleteColumn(columnName), undefined)}
+                  />
+                </Menu>
+              }
+            >
+              <div
+                className={Classes.MENU_ITEM}
+                draggable
+                onDragStart={e => {
+                  e.dataTransfer.effectAllowed = 'all';
+                  DragHelper.dragColumn = column;
+                  DragHelper.createDragGhost(e.dataTransfer, columnName);
+                }}
+              >
+                <Icon
+                  className={Classes.MENU_ITEM_ICON}
+                  icon={columnToIcon(column) || IconNames.BLANK}
+                  data-tooltip={`${columnName}\nSQL type: ${column.sqlType}\nNative type: ${column.nativeType}`}
+                />
+                <div className={classNames(Classes.FILL, Classes.TEXT_OVERFLOW_ELLIPSIS)}>
+                  {columnName}
+                </div>
+              </div>
+            </Popover>
+          );
+        })}
+      </div>
+      <div className="list-header measure-list-header">
+        Measures
+        <ButtonGroup className="header-buttons" minimal>
+          <Button
+            icon={IconNames.PLUS}
+            title="Add measure"
+            onClick={() => setMeasureEditorOpenOn({})}
+          />
+        </ButtonGroup>
+      </div>
+      <div className="measure-resource-list">
+        {filterMap(querySource.measures, (measure, i) => {
+          const measureName = measure.name;
+          if (!caseInsensitiveContains(measureName, columnSearch)) return;
+          return (
+            <Popover
+              className="measure-resource"
+              key={i}
+              position="right"
+              content={
+                <Menu>
+                  <MenuItem
+                    icon={IconNames.EYE_OPEN}
+                    text="Show"
+                    onClick={() => onShowMeasure(measure)}
+                  />
+                  <MenuDivider />
+                  <MenuItem
+                    icon={IconNames.EDIT}
+                    text="Edit"
+                    onClick={() =>
+                      setMeasureEditorOpenOn({
+                        measure,
+                      })
+                    }
+                  />
+                  <MenuItem
+                    icon={IconNames.DUPLICATE}
+                    text="Duplicate"
+                    onClick={() =>
+                      setMeasureEditorOpenOn({
+                        measureToDuplicate: measureName,
+                        measure: measure.changeAs(querySource.getAvailableName(measureName)),
+                      })
+                    }
+                  />
+                  <MenuItem
+                    icon={IconNames.TRASH}
+                    text="Delete"
+                    intent={Intent.DANGER}
+                    onClick={() => onQueryChange(querySource.deleteMeasure(measureName), undefined)}
+                  />
+                </Menu>
+              }
+            >
+              <div
+                className={Classes.MENU_ITEM}
+                draggable
+                onDragStart={e => {
+                  e.dataTransfer.effectAllowed = 'all';
+                  DragHelper.dragMeasure = measure.toAggregateBasedMeasure();
+                  DragHelper.createDragGhost(e.dataTransfer, measure.name);
+                }}
+              >
+                <Icon className={Classes.MENU_ITEM_ICON} icon={IconNames.PULSE} />
+                <div className={classNames(Classes.FILL, Classes.TEXT_OVERFLOW_ELLIPSIS)}>
+                  {measureName}
+                </div>
+              </div>
+            </Popover>
+          );
+        })}
+      </div>
+      {columnEditorOpenOn && (
+        <ColumnDialog
+          initExpression={columnEditorOpenOn.expression}
+          columnToDuplicate={columnEditorOpenOn.columnToDuplicate}
+          onApply={onQueryChange}
+          querySource={querySource}
+          runSqlQuery={runSqlQuery}
+          onClose={() => setColumnEditorOpenOn(undefined)}
+        />
+      )}
+      {measureEditorOpenOn && (
+        <MeasureDialog
+          initMeasure={measureEditorOpenOn.measure}
+          measureToDuplicate={measureEditorOpenOn.measureToDuplicate}
+          onApply={onQueryChange}
+          querySource={querySource}
+          runSqlQuery={runSqlQuery}
+          onClose={() => setMeasureEditorOpenOn(undefined)}
+        />
+      )}
+    </div>
+  );
+};
diff --git a/web-console/src/views/explore-view/modules/components/index.ts b/web-console/src/views/explore-view/components/source-pane/source-pane.scss
similarity index 92%
copy from web-console/src/views/explore-view/modules/components/index.ts
copy to web-console/src/views/explore-view/components/source-pane/source-pane.scss
index 4b7eeff07ef..e10b702cc48 100644
--- a/web-console/src/views/explore-view/modules/components/index.ts
+++ b/web-console/src/views/explore-view/components/source-pane/source-pane.scss
@@ -16,4 +16,7 @@
  * limitations under the License.
  */
 
-export * from './generic-output-table/generic-output-table';
+.source-pane-menu {
+  max-height: 80vh;
+  overflow: auto;
+}
diff --git a/web-console/src/views/explore-view/source-pane/source-pane.tsx b/web-console/src/views/explore-view/components/source-pane/source-pane.tsx
similarity index 52%
rename from web-console/src/views/explore-view/source-pane/source-pane.tsx
rename to web-console/src/views/explore-view/components/source-pane/source-pane.tsx
index 2208bc32a39..1524b24540f 100644
--- a/web-console/src/views/explore-view/source-pane/source-pane.tsx
+++ b/web-console/src/views/explore-view/components/source-pane/source-pane.tsx
@@ -18,23 +18,37 @@
 
 import { Button, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
+import { SqlQuery, SqlTable } from '@druid-toolkit/query';
+import type { JSX } from 'react';
 import React from 'react';
 
-import { useQueryManager } from '../../../hooks';
-import { queryDruidSql } from '../../../utils';
+import { useQueryManager } from '../../../../hooks';
+import { queryDruidSql } from '../../../../utils';
 
 import './source-pane.scss';
 
+function formatQuerySource(source: SqlQuery | undefined): string | JSX.Element {
+  if (!(source instanceof SqlQuery)) return 'No source selected';
+  const fromExpressions = source.getFromExpressions();
+  if (fromExpressions.length !== 1) return 'Multiple FROM expressions';
+  const fromExpression = fromExpressions[0];
+  if (!(fromExpression instanceof SqlTable)) return 'Complex FROM expression';
+  return fromExpression.getName();
+}
+
 export interface SourcePaneProps {
-  selectedTableName: string;
-  onSelectedTableNameChange(newSelectedSource: string): void;
+  selectedSource: SqlQuery | undefined;
+  onSelectTable(tableName: string): void;
+  onShowSourceQuery?: () => void;
+  fill?: boolean;
+  minimal?: boolean;
   disabled?: boolean;
 }
 
 export const SourcePane = React.memo(function SourcePane(props: SourcePaneProps) {
-  const { selectedTableName, onSelectedTableNameChange, disabled } = props;
+  const { selectedSource, onSelectTable, onShowSourceQuery, fill, minimal, disabled } = props;
 
-  const [sources] = useQueryManager<string, string[]>({
+  const [tables] = useQueryManager<string, string[]>({
     initQuery: '',
     processQuery: async () => {
       const tables = await queryDruidSql<{ TABLE_NAME: string }>({
@@ -52,20 +66,25 @@ export const SourcePane = React.memo(function SourcePane(props: SourcePaneProps)
       minimal
       position={Position.BOTTOM_LEFT}
       content={
-        <Menu className="source-menu">
-          {sources.loading && <MenuDivider title="Loading..." />}
-          {sources.data?.map((s, i) => (
-            <MenuItem key={i} text={s} onClick={() => onSelectedTableNameChange(s)} />
+        <Menu className="source-pane-menu">
+          {onShowSourceQuery && (
+            <MenuItem text="Show source query..." onClick={onShowSourceQuery} />
+          )}
+          {onShowSourceQuery && <MenuDivider />}
+          {tables.loading && <MenuDivider title="Loading..." />}
+          {tables.data?.map((table, i) => (
+            <MenuItem key={i} text={table} onClick={() => onSelectTable(table)} />
           ))}
-          {!sources.data?.length && <MenuItem text="No sources" disabled />}
+          {!tables.data?.length && <MenuItem text="No tables" disabled />}
         </Menu>
       }
     >
       <Button
-        text={`Source: ${selectedTableName}`}
+        icon={IconNames.TH}
+        text={formatQuerySource(selectedSource)}
         rightIcon={IconNames.CARET_DOWN}
-        fill
-        minimal
+        fill={fill}
+        minimal={minimal}
         disabled={disabled}
       />
     </Popover>
diff --git a/web-console/src/views/explore-view/resource-pane/resource-pane.scss b/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.scss
similarity index 73%
rename from web-console/src/views/explore-view/resource-pane/resource-pane.scss
rename to web-console/src/views/explore-view/components/source-query-pane/source-query-pane.scss
index 81395dae874..075dba60bd5 100644
--- a/web-console/src/views/explore-view/resource-pane/resource-pane.scss
+++ b/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.scss
@@ -16,23 +16,31 @@
  * limitations under the License.
  */
 
-.resource-pane {
+.source-query-pane {
   position: relative;
 
-  .search-input {
-    margin: 4px;
+  .column-tree {
+    position: absolute;
+    width: 200px;
+    height: 100%;
   }
 
-  .resource-items {
+  .flexible-query-input {
     position: absolute;
-    top: 38px;
-    bottom: 0;
-    left: 0;
+    left: 204px;
     right: 0;
-    overflow: auto;
+    top: 0;
+    bottom: 42px;
+  }
 
-    .resource-item {
-      display: block;
-    }
+  .button-bar {
+    position: absolute;
+    height: 34px;
+    left: 204px;
+    right: 0;
+    bottom: 0;
+    display: flex;
+    gap: 8px;
+    align-items: flex-start;
   }
 }
diff --git a/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.tsx b/web-console/src/views/explore-view/components/source-query-pane/source-query-pane.tsx
new file mode 100644
index 00000000000..dd3fbbd41dd
--- /dev/null
+++ b/web-console/src/views/explore-view/components/source-query-pane/source-query-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.
+ */
+
+import { Button, Intent } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import React, { useEffect, useState } from 'react';
+
+import { useQueryManager } from '../../../../hooks';
+import { type ColumnMetadata, queryDruidSql } from '../../../../utils';
+import { ColumnTree } from '../../../workbench-view/column-tree/column-tree';
+import { FlexibleQueryInput } from '../../../workbench-view/flexible-query-input/flexible-query-input';
+
+import './source-query-pane.scss';
+
+export interface SourceQueryPaneProps {
+  source: string;
+  onSourceChange(newSource: string): void;
+  onClose(): void;
+}
+
+export const SourceQueryPane = React.memo(function SourceQueryPane(props: SourceQueryPaneProps) {
+  const { source, onSourceChange, onClose } = props;
+  const [queryString, setQueryString] = useState(source?.toString() || '');
+
+  useEffect(() => {
+    if (source) {
+      setQueryString(source);
+    }
+  }, [source]);
+
+  const [columnMetadataState] = useQueryManager({
+    initQuery: '',
+    processQuery: async () => {
+      return await queryDruidSql<ColumnMetadata>({
+        query: `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS`,
+      });
+    },
+  });
+
+  return (
+    <div className="source-query-pane">
+      {!columnMetadataState.isError() && (
+        <ColumnTree
+          getParsedQuery={() => undefined}
+          columnMetadataLoading={columnMetadataState.loading}
+          columnMetadata={columnMetadataState.data}
+          onQueryChange={q => setQueryString(String(q))}
+        />
+      )}
+      <FlexibleQueryInput
+        queryString={queryString}
+        onQueryStringChange={setQueryString}
+        columnMetadata={columnMetadataState.data}
+      />
+      <div className="button-bar">
+        <Button
+          intent={Intent.PRIMARY}
+          icon={IconNames.CARET_RIGHT}
+          text="Set source query"
+          onClick={() => {
+            onSourceChange(queryString);
+          }}
+        />
+        <Button text="Hide" onClick={() => onClose()} />
+      </div>
+    </div>
+  );
+});
diff --git a/web-console/src/views/explore-view/components/sql-input/sql-input.tsx b/web-console/src/views/explore-view/components/sql-input/sql-input.tsx
new file mode 100644
index 00000000000..80534d36d6b
--- /dev/null
+++ b/web-console/src/views/explore-view/components/sql-input/sql-input.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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 type { Column } from '@druid-toolkit/query';
+import { C } from '@druid-toolkit/query';
+import type { Ace } from 'ace-builds';
+import ace from 'ace-builds';
+import React from 'react';
+import AceEditor from 'react-ace';
+
+import type { RowColumn } from '../../../../utils';
+import { uniq } from '../../../../utils';
+
+const langTools = ace.require('ace/ext/language_tools');
+
+const V_PADDING = 10;
+
+export interface SqlInputProps {
+  value: string;
+  onValueChange?: (newValue: string) => void;
+  placeholder?: string;
+  editorHeight?: number;
+  columns?: readonly Column[];
+  autoFocus?: boolean;
+  showGutter?: boolean;
+}
+
+export interface SqlInputState {
+  quotedCompletions: Ace.Completion[];
+  unquotedCompletions: Ace.Completion[];
+  prevColumns?: readonly Column[];
+}
+
+export class SqlInput extends React.PureComponent<SqlInputProps, SqlInputState> {
+  static aceTheme = 'solarized_dark';
+
+  private aceEditor: Ace.Editor | undefined;
+
+  private readonly aceCompleters: Ace.Completer[] = [
+    // Prepend with default completers to ensure completion data from
+    // editing mode (e.g. 'dsql') is included in addition to local completions
+    langTools.snippetCompleter,
+    langTools.keyWordCompleter,
+    langTools.textCompleter,
+    // Local completions
+    {
+      getCompletions: (_state, session, pos, prefix, callback) => {
+        const charBeforePrefix = session.getLine(pos.row)[pos.column - prefix.length - 1];
+        callback(
+          null,
+          charBeforePrefix === '"' ? this.state.unquotedCompletions : this.state.quotedCompletions,
+        );
+      },
+    },
+  ];
+
+  static getCompletions(columns: readonly Column[], quote: boolean): Ace.Completion[] {
+    return ([] as Ace.Completion[]).concat(
+      uniq(columns.map(column => column.name)).map(v => ({
+        value: quote ? String(C(v)) : v,
+        score: 50,
+        meta: 'column',
+      })),
+    );
+  }
+
+  static getDerivedStateFromProps(
+    props: SqlInputProps,
+    state: SqlInputState,
+  ): Partial<SqlInputState> | null {
+    const { columns } = props;
+
+    if (columns && columns !== state.prevColumns) {
+      return {
+        quotedCompletions: SqlInput.getCompletions(columns, true),
+        unquotedCompletions: SqlInput.getCompletions(columns, false),
+        prevColumns: columns,
+      };
+    }
+    return null;
+  }
+
+  constructor(props: SqlInputProps) {
+    super(props);
+    this.state = {
+      quotedCompletions: [],
+      unquotedCompletions: [],
+    };
+  }
+
+  componentWillUnmount() {
+    delete this.aceEditor;
+  }
+
+  private readonly handleChange = (value: string) => {
+    const { onValueChange } = this.props;
+    if (!onValueChange) return;
+    onValueChange(value);
+  };
+
+  public goToPosition(rowColumn: RowColumn) {
+    const { aceEditor } = this;
+    if (!aceEditor) return;
+    aceEditor.focus(); // Grab the focus
+    aceEditor.getSelection().moveCursorTo(rowColumn.row, rowColumn.column);
+    // If we had an end we could also do
+    // aceEditor.getSelection().selectToPosition({ row: endRow, column: endColumn });
+  }
+
+  render() {
+    const { value, onValueChange, placeholder, autoFocus, editorHeight, showGutter } = this.props;
+
+    return (
+      <AceEditor
+        mode="dsql"
+        theme={SqlInput.aceTheme}
+        className="sql-input placeholder-padding"
+        // 'react-ace' types are incomplete. Completion options can accept completers array.
+        enableBasicAutocompletion={this.aceCompleters as any}
+        enableLiveAutocompletion={this.aceCompleters as any}
+        name="ace-editor"
+        onChange={this.handleChange}
+        focus
+        fontSize={12}
+        width="100%"
+        height={editorHeight ? `${editorHeight}px` : '100%'}
+        showGutter={Boolean(showGutter)}
+        showPrintMargin={false}
+        tabSize={2}
+        value={value}
+        readOnly={!onValueChange}
+        editorProps={{
+          $blockScrolling: Infinity,
+        }}
+        setOptions={{
+          showLineNumbers: true,
+          newLineMode: 'unix' as any, // This type is specified incorrectly in AceEditor
+        }}
+        placeholder={placeholder || 'SELECT * FROM ...'}
+        onLoad={(editor: Ace.Editor) => {
+          editor.renderer.setPadding(V_PADDING);
+          editor.renderer.setScrollMargin(V_PADDING, V_PADDING, 0, 0);
+          this.aceEditor = editor;
+
+          if (autoFocus) {
+            editor.focus();
+          }
+        }}
+      />
+    );
+  }
+}
diff --git a/web-console/src/views/explore-view/components/utc-date-input/utc-date-input.tsx b/web-console/src/views/explore-view/components/utc-date-input/utc-date-input.tsx
new file mode 100644
index 00000000000..216dcaa2a18
--- /dev/null
+++ b/web-console/src/views/explore-view/components/utc-date-input/utc-date-input.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 { InputGroup } from '@blueprintjs/core';
+import React, { useState } from 'react';
+
+function utcParseDate(dateString: string): Date | undefined {
+  const dateParts = dateString.split(/[-T:. ]/g);
+
+  // Extract the individual date and time components
+  const year = parseInt(dateParts[0], 10);
+  if (!(1000 < year && year < 4000)) return;
+
+  const month = parseInt(dateParts[1], 10);
+  if (month > 12) return;
+
+  const day = parseInt(dateParts[2], 10);
+  if (day > 31) return;
+
+  const hour = parseInt(dateParts[3], 10);
+  if (hour > 23) return;
+
+  const minute = parseInt(dateParts[4], 10);
+  if (minute > 59) return;
+
+  const second = parseInt(dateParts[5], 10);
+  if (second > 59) return;
+
+  const millisecond = parseInt(dateParts[6], 10);
+  if (millisecond >= 1000) return;
+
+  const value = Date.UTC(year, month - 1, day, hour, minute, second); // Month is zero-based
+  if (isNaN(value)) return;
+
+  return new Date(value);
+}
+
+function normalizeDateString(dateString: string): string {
+  return dateString.replace(/[^\-0-9T:./Z ]/g, '');
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().replace(/Z$/, '').replace('.000', '').replace(/T/g, ' ');
+}
+
+export interface UtcDateInputProps {
+  date: Date;
+  onChange(newDate: Date): void;
+}
+
+export function UtcDateInput(props: UtcDateInputProps) {
+  const { date, onChange } = props;
+  const [dateString, setDateString] = useState<string | undefined>();
+
+  return (
+    <InputGroup
+      placeholder="yyyy-MM-dd HH:mm:ss"
+      value={dateString ?? formatDate(date)}
+      onChange={e => {
+        const v = normalizeDateString(e.target.value);
+        const parsedDate = utcParseDate(v);
+        if (parsedDate && formatDate(parsedDate) === v) {
+          onChange(parsedDate);
+          setDateString(undefined);
+        } else {
+          setDateString(v);
+        }
+      }}
+    />
+  );
+}
diff --git a/web-console/src/views/explore-view/control-pane/aggregate-menu.tsx b/web-console/src/views/explore-view/control-pane/aggregate-menu.tsx
deleted file mode 100644
index a91ac6374b7..00000000000
--- a/web-console/src/views/explore-view/control-pane/aggregate-menu.tsx
+++ /dev/null
@@ -1,85 +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 { InputGroup, Menu, MenuItem } from '@blueprintjs/core';
-import { SqlFunction } from '@druid-toolkit/query';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-import React, { useState } from 'react';
-
-import { caseInsensitiveContains } from '../../../utils';
-
-import { getPossibleAggregateForColumn } from './helpers';
-
-import './aggregate-menu.scss';
-
-const COUNT_AGG: ExpressionMeta = {
-  name: 'Count',
-  expression: SqlFunction.count(),
-  sqlType: 'BIGINT',
-};
-
-export interface AggregateMenuProps {
-  columns: ExpressionMeta[];
-  onSelectAggregate(aggregate: ExpressionMeta): void;
-  onSelectNone?: () => void;
-  shouldDismissPopover?: boolean;
-}
-
-export const AggregateMenu = function AggregateMenu(props: AggregateMenuProps) {
-  const { columns, onSelectAggregate, onSelectNone, shouldDismissPopover } = props;
-  const [columnSearch, setColumnSearch] = useState('');
-
-  return (
-    <div className="aggregate-menu">
-      <InputGroup
-        className="search-input"
-        value={columnSearch}
-        onChange={e => setColumnSearch(e.target.value)}
-        placeholder="Search..."
-        autoFocus
-      />
-      <Menu className="inner-menu">
-        {onSelectNone && (
-          <MenuItem
-            text="None"
-            onClick={onSelectNone}
-            shouldDismissPopover={shouldDismissPopover}
-          />
-        )}
-        <MenuItem text={COUNT_AGG.name} onClick={() => onSelectAggregate(COUNT_AGG)} />
-        {columns.map((c, i) => {
-          if (!caseInsensitiveContains(c.name, columnSearch)) return;
-          const possibleAggregateForColumn = getPossibleAggregateForColumn(c);
-          if (!possibleAggregateForColumn.length) return;
-          if (possibleAggregateForColumn.length === 1) {
-            const a = possibleAggregateForColumn[0];
-            return <MenuItem key={i} text={a.name} onClick={() => onSelectAggregate(a)} />;
-          } else {
-            return (
-              <MenuItem key={i} text={`${c.name}...`}>
-                {possibleAggregateForColumn.map((a, j) => (
-                  <MenuItem key={j} text={a.name} onClick={() => onSelectAggregate(a)} />
-                ))}
-              </MenuItem>
-            );
-          }
-        })}
-      </Menu>
-    </div>
-  );
-};
diff --git a/web-console/src/views/explore-view/control-pane/aggregates-input.tsx b/web-console/src/views/explore-view/control-pane/aggregates-input.tsx
deleted file mode 100644
index a2b2485e22f..00000000000
--- a/web-console/src/views/explore-view/control-pane/aggregates-input.tsx
+++ /dev/null
@@ -1,69 +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 { Classes, Popover, Position, Tag } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-import classNames from 'classnames';
-import React from 'react';
-
-import { AggregateMenu } from './aggregate-menu';
-
-export interface AggregatesInputProps {
-  columns: ExpressionMeta[];
-  value: ExpressionMeta[];
-  onValueChange(value: ExpressionMeta[]): void;
-  allowDuplicates?: boolean;
-}
-
-export const AggregatesInput = function AggregatesInput(props: AggregatesInputProps) {
-  const { columns, value, onValueChange, allowDuplicates } = props;
-
-  const availableColumn = allowDuplicates
-    ? columns
-    : columns.filter(o => !value.find(_ => _.name === o.name));
-
-  return (
-    <div className={classNames('aggregates-input', Classes.INPUT, Classes.TAG_INPUT, Classes.FILL)}>
-      <div className={Classes.TAG_INPUT_VALUES}>
-        {value.map((c, i) => (
-          <Tag
-            interactive
-            key={i}
-            onRemove={() => {
-              onValueChange(value.filter(v => v !== c));
-            }}
-          >
-            {c.name}
-          </Tag>
-        ))}
-        <Popover
-          position={Position.BOTTOM}
-          content={
-            <AggregateMenu
-              columns={availableColumn}
-              onSelectAggregate={c => onValueChange(value.concat(c))}
-            />
-          }
-        >
-          <Tag icon={IconNames.PLUS} interactive />
-        </Popover>
-      </div>
-    </div>
-  );
-};
diff --git a/web-console/src/views/explore-view/control-pane/control-pane.tsx b/web-console/src/views/explore-view/control-pane/control-pane.tsx
deleted file mode 100644
index 029f020cd76..00000000000
--- a/web-console/src/views/explore-view/control-pane/control-pane.tsx
+++ /dev/null
@@ -1,348 +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 {
-  Button,
-  ButtonGroup,
-  InputGroup,
-  Intent,
-  Menu,
-  MenuItem,
-  NumericInput,
-  Popover,
-} from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import type {
-  ExpressionMeta,
-  OptionValue,
-  ParameterDefinition,
-  RegisteredVisualModule,
-} from '@druid-toolkit/visuals-core';
-import { getPluginOptionLabel } from '@druid-toolkit/visuals-core';
-import type { JSX } from 'react';
-import React from 'react';
-
-import { AutoForm, FormGroupWithInfo, PopoverText } from '../../../components';
-import { AppToaster } from '../../../singletons';
-import { ColumnPickerMenu } from '../column-picker-menu/column-picker-menu';
-import { DroppableContainer } from '../droppable-container/droppable-container';
-
-import { AggregateMenu } from './aggregate-menu';
-import { ColumnsInput } from './columns-input';
-import { getPossibleAggregateForColumn } from './helpers';
-import { OptionsInput } from './options-input';
-
-import './control-pane.scss';
-
-export interface ControlPaneProps {
-  columns: ExpressionMeta[];
-  onUpdateParameterValues(params: Record<string, unknown>): void;
-  parameterValues: Record<string, unknown>;
-  visualModule: RegisteredVisualModule;
-}
-
-export const ControlPane = function ControlPane(props: ControlPaneProps) {
-  const { columns, onUpdateParameterValues, parameterValues, visualModule } = props;
-
-  function renderOptionsPropInput(
-    parameter: ParameterDefinition,
-    value: any,
-    onValueChange: (value: any) => void,
-  ): {
-    element: JSX.Element;
-    onDropColumn?: (column: ExpressionMeta) => void;
-  } {
-    switch (parameter.type) {
-      case 'boolean': {
-        return {
-          element: (
-            <ButtonGroup>
-              <Button
-                active={value === false}
-                onClick={() => {
-                  onValueChange(false);
-                }}
-              >
-                False
-              </Button>
-              <Button
-                active={value === true}
-                onClick={() => {
-                  onValueChange(true);
-                }}
-              >
-                True
-              </Button>
-            </ButtonGroup>
-          ),
-        };
-      }
-
-      case 'number':
-        return {
-          element: (
-            <NumericInput
-              value={(value as string) ?? ''}
-              onValueChange={v => onValueChange(v)}
-              placeholder={parameter.control?.placeholder}
-              fill
-              min={parameter.min}
-              max={parameter.max}
-            />
-          ),
-        };
-
-      case 'string':
-        return {
-          element: (
-            <InputGroup
-              value={(value as string) || ''}
-              onChange={e => onValueChange(e.target.value)}
-              placeholder={parameter.control?.placeholder}
-              fill
-            />
-          ),
-        };
-
-      case 'option': {
-        const controlOptions = parameter.options || [];
-        const selectedOption: OptionValue | undefined = controlOptions.find(o => o === value);
-        return {
-          element: (
-            <Popover
-              fill
-              position="bottom-left"
-              minimal
-              content={
-                <Menu>
-                  {controlOptions.map((o, i) => (
-                    <MenuItem
-                      key={i}
-                      text={getPluginOptionLabel(o, parameter)}
-                      onClick={() => onValueChange(o)}
-                    />
-                  ))}
-                </Menu>
-              }
-            >
-              <InputGroup
-                value={
-                  typeof selectedOption === 'undefined'
-                    ? String(value)
-                    : getPluginOptionLabel(selectedOption, parameter)
-                }
-                readOnly
-                fill
-                rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
-              />
-            </Popover>
-          ),
-        };
-      }
-
-      case 'options': {
-        return {
-          element: (
-            <OptionsInput
-              options={parameter.options || []}
-              value={(value as OptionValue[]) || []}
-              onValueChange={onValueChange}
-              parameter={parameter}
-            />
-          ),
-        };
-      }
-
-      case 'column':
-        return {
-          element: (
-            <Popover
-              fill
-              position="bottom-left"
-              minimal
-              content={
-                <ColumnPickerMenu
-                  columns={columns}
-                  onSelectNone={
-                    parameter.control?.required ? undefined : () => onValueChange(undefined)
-                  }
-                  onSelectColumn={onValueChange}
-                />
-              }
-            >
-              <InputGroup
-                value={(value as ExpressionMeta)?.name || 'None'}
-                readOnly
-                fill
-                rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
-              />
-            </Popover>
-          ),
-          onDropColumn: onValueChange,
-        };
-
-      case 'columns': {
-        return {
-          element: (
-            <ColumnsInput
-              columns={columns}
-              allowReordering
-              value={(value as ExpressionMeta[]) || []}
-              onValueChange={onValueChange}
-              allowDuplicates={parameter.allowDuplicates}
-            />
-          ),
-          onDropColumn: (column: ExpressionMeta) => {
-            value = value || [];
-            const columnName = column.name;
-            if (
-              !parameter.allowDuplicates &&
-              value.find((v: ExpressionMeta) => v.name === columnName)
-            ) {
-              AppToaster.show({
-                intent: Intent.WARNING,
-                message: `"${columnName}" already selected`,
-              });
-              return;
-            }
-            onValueChange(value.concat(column));
-          },
-        };
-      }
-
-      case 'aggregate': {
-        return {
-          element: (
-            <Popover
-              fill
-              position="bottom-left"
-              minimal
-              content={
-                <AggregateMenu
-                  columns={columns}
-                  onSelectAggregate={onValueChange}
-                  onSelectNone={
-                    parameter.control?.required ? undefined : () => onValueChange(undefined)
-                  }
-                />
-              }
-            >
-              <InputGroup
-                value={value ? (value as { name: string }).name : 'None'}
-                readOnly
-                fill
-                rightElement={<Button icon={IconNames.CARET_DOWN} minimal />}
-              />
-            </Popover>
-          ),
-          onDropColumn: column => {
-            const aggregates = getPossibleAggregateForColumn(column);
-            if (!aggregates.length) return;
-            onValueChange(aggregates[0]);
-          },
-        };
-      }
-
-      case 'aggregates': {
-        return {
-          element: (
-            <ColumnsInput
-              columns={columns}
-              value={(value as ExpressionMeta[]) || []}
-              onValueChange={onValueChange}
-              allowReordering
-              pickerMenu={availableColumns => (
-                <AggregateMenu
-                  columns={availableColumns}
-                  onSelectAggregate={c => onValueChange((value as ExpressionMeta[]).concat(c))}
-                />
-              )}
-            />
-          ),
-          onDropColumn: column => {
-            value = value || [];
-            const aggregates = getPossibleAggregateForColumn(column).filter(
-              p => !value.some((v: ExpressionMeta) => v.name === p.name),
-            );
-            if (!aggregates.length) return;
-            onValueChange(value.concat(aggregates[0]));
-          },
-        };
-      }
-
-      default:
-        return {
-          element: (
-            <Button
-              icon={IconNames.ERROR}
-              text={`Type not supported: ${(parameter as { type: string }).type}`}
-              disabled
-              fill
-            />
-          ),
-        };
-    }
-  }
-
-  const namedParameters = Object.entries(visualModule.parameterDefinitions ?? {});
-
-  return (
-    <div className="control-pane">
-      {namedParameters.map(([name, parameter], i) => {
-        const visible = parameter.control?.visible;
-        if (
-          visible === false ||
-          (typeof visible === 'function' && !visible({ params: parameterValues }))
-        ) {
-          return;
-        }
-
-        const value = parameterValues[name];
-        function onValueChange(newValue: unknown) {
-          onUpdateParameterValues({ [name]: newValue });
-        }
-
-        const { element, onDropColumn } = renderOptionsPropInput(parameter, value, onValueChange);
-
-        const formGroup = (
-          <FormGroupWithInfo
-            key={i}
-            label={parameter.control?.label || AutoForm.makeLabelName(name)}
-            info={
-              parameter.control?.description ? (
-                <PopoverText>{parameter.control.description}</PopoverText>
-              ) : undefined
-            }
-          >
-            {element}
-          </FormGroupWithInfo>
-        );
-
-        if (!onDropColumn) {
-          return formGroup;
-        }
-
-        return (
-          <DroppableContainer key={i} onDropColumn={onDropColumn}>
-            {formGroup}
-          </DroppableContainer>
-        );
-      })}
-    </div>
-  );
-};
diff --git a/web-console/src/views/explore-view/control-pane/helpers.ts b/web-console/src/views/explore-view/control-pane/helpers.ts
deleted file mode 100644
index 36d011190b2..00000000000
--- a/web-console/src/views/explore-view/control-pane/helpers.ts
+++ /dev/null
@@ -1,111 +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 { C, F, SqlFunction } from '@druid-toolkit/query';
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-
-export function getPossibleAggregateForColumn(column: ExpressionMeta): ExpressionMeta[] {
-  switch (column.sqlType) {
-    case 'TIMESTAMP':
-      return [
-        {
-          name: `Max ${column.name}`,
-          expression: F.max(C(column.name)),
-          sqlType: column.sqlType,
-        },
-        {
-          name: `Min ${column.name}`,
-          expression: F.min(C(column.name)),
-          sqlType: column.sqlType,
-        },
-      ];
-
-    case 'BIGINT':
-    case 'FLOAT':
-    case 'DOUBLE':
-      return [
-        {
-          name: `Sum ${column.name}`,
-          expression: F.sum(C(column.name)),
-          sqlType: column.sqlType,
-        },
-        {
-          name: `Max ${column.name}`,
-          expression: F.max(C(column.name)),
-          sqlType: column.sqlType,
-        },
-        {
-          name: `Min ${column.name}`,
-          expression: F.min(C(column.name)),
-          sqlType: column.sqlType,
-        },
-        {
-          name: `Unique ${column.name}`,
-          expression: SqlFunction.countDistinct(C(column.name)),
-          sqlType: 'BIGINT',
-        },
-        {
-          name: `P98 ${column.name}`,
-          expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
-          sqlType: 'DOUBLE',
-        },
-      ];
-
-    case 'VARCHAR':
-    case 'COMPLEX':
-    case 'COMPLEX<hyperUnique>':
-      return [
-        {
-          name: `Unique ${column.name}`,
-          expression: SqlFunction.countDistinct(C(column.name)),
-          sqlType: 'BIGINT',
-        },
-      ];
-
-    case 'COMPLEX<HLLSketch>':
-      return [
-        {
-          name: `Unique ${column.name}`,
-          expression: F('APPROX_COUNT_DISTINCT_DS_HLL', C(column.name)),
-          sqlType: 'BIGINT',
-        },
-      ];
-
-    case 'COMPLEX<quantilesDoublesSketch>':
-      return [
-        {
-          name: `Median ${column.name}`,
-          expression: F('APPROX_QUANTILE_DS', C(column.name), 0.5),
-          sqlType: 'DOUBLE',
-        },
-        {
-          name: `P95 ${column.name}`,
-          expression: F('APPROX_QUANTILE_DS', C(column.name), 0.95),
-          sqlType: 'DOUBLE',
-        },
-        {
-          name: `P98 ${column.name}`,
-          expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
-          sqlType: 'DOUBLE',
-        },
-      ];
-
-    default:
-      return [];
-  }
-}
diff --git a/web-console/src/views/explore-view/drag-helper.ts b/web-console/src/views/explore-view/drag-helper.ts
index 0df433efe4e..a9e1188e989 100644
--- a/web-console/src/views/explore-view/drag-helper.ts
+++ b/web-console/src/views/explore-view/drag-helper.ts
@@ -16,10 +16,13 @@
  * limitations under the License.
  */
 
-import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
+import type { Column } from '@druid-toolkit/query';
+
+import type { Measure } from './models';
 
 export class DragHelper {
-  static dragColumn: ExpressionMeta | undefined;
+  static dragColumn: Column | undefined;
+  static dragMeasure: Measure | undefined;
 
   static createDragGhost(dataTransfer: DataTransfer, text: string): void {
     const dragGhost = document.createElement('div');
diff --git a/web-console/src/views/explore-view/droppable-container/droppable-container.tsx b/web-console/src/views/explore-view/droppable-container/droppable-container.tsx
deleted file mode 100644
index 3e0d7dc4a88..00000000000
--- a/web-console/src/views/explore-view/droppable-container/droppable-container.tsx
+++ /dev/null
@@ -1,67 +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 type { ExpressionMeta } from '@druid-toolkit/visuals-core';
-import classNames from 'classnames';
-import React, { forwardRef, useState } from 'react';
-
-import { DragHelper } from '../drag-helper';
-
-import './droppable-container.scss';
-
-export interface DroppableContainerProps extends React.HTMLAttributes<HTMLDivElement> {
-  onDropColumn(column: ExpressionMeta): void;
-  children?: React.ReactNode;
-}
-
-export const DroppableContainer = forwardRef(function DroppableContainer(
-  props: DroppableContainerProps,
-  ref,
-) {
-  const { className, onDropColumn, children, ...rest } = props;
-  const [dropHover, setDropHover] = useState(false);
-
-  return (
-    <div
-      ref={ref as any}
-      className={classNames('droppable-container', className, { 'drop-hover': dropHover })}
-      {...rest}
-      onDragOver={e => {
-        if (!DragHelper.dragColumn) return;
-        e.preventDefault();
-        e.dataTransfer.dropEffect = 'move';
-        setDropHover(true);
-      }}
-      onDragLeave={e => {
-        const currentTarget = e.currentTarget;
-        const relatedTarget = e.relatedTarget;
-        if (currentTarget.contains(relatedTarget as any)) return;
-        setDropHover(false);
-      }}
-      onDrop={() => {
-        if (!DragHelper.dragColumn) return;
-        const dragColumn = DragHelper.dragColumn;
-        DragHelper.dragColumn = undefined;
-        setDropHover(false);
-        onDropColumn(dragColumn);
-      }}
-    >
-      {children}
-    </div>
-  );
-});
diff --git a/web-console/src/views/explore-view/explore-state.ts b/web-console/src/views/explore-view/explore-state.ts
new file mode 100644
index 00000000000..9ca74f5d7dd
--- /dev/null
+++ b/web-console/src/views/explore-view/explore-state.ts
@@ -0,0 +1,232 @@
+/*
+ * 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 type { Column } from '@druid-toolkit/query';
+import {
+  filterPatternToExpression,
+  SqlExpression,
+  SqlLiteral,
+  SqlQuery,
+} from '@druid-toolkit/query';
+
+import { isEmpty } from '../../utils';
+
+import type { Measure, ParameterValues } from './models';
+import {
+  ExpressionMeta,
+  inflateParameterValues,
+  QuerySource,
+  renameColumnsInParameterValues,
+} from './models';
+import { ModuleRepository } from './module-repository/module-repository';
+import type { Rename } from './utils';
+import { renameColumnsInExpression } from './utils';
+
+interface ExploreStateValue {
+  source: string;
+  showSourceQuery?: boolean;
+  where: SqlExpression;
+  moduleId: string;
+  parameterValues: Record<string, any>;
+}
+
+export class ExploreState {
+  static DEFAULT_STATE: ExploreState;
+
+  static fromJS(js: any) {
+    const inflatedParameterValues = inflateParameterValues(
+      js.parameterValues,
+      ModuleRepository.getModule(js.moduleId)?.parameters || {},
+    );
+    return new ExploreState({
+      ...js,
+      where: SqlExpression.maybeParse(js.where) || SqlLiteral.TRUE,
+      parameterValues: inflatedParameterValues,
+    });
+  }
+
+  public readonly source: string;
+  public readonly showSourceQuery: boolean;
+  public readonly where: SqlExpression;
+  public readonly moduleId: string;
+  public readonly parameterValues: Record<string, any>;
+
+  public readonly parsedSource: SqlQuery | undefined;
+  public readonly parseError: string | undefined;
+
+  constructor(value: ExploreStateValue) {
+    this.source = value.source;
+    this.showSourceQuery = Boolean(value.showSourceQuery);
+    this.where = value.where;
+    this.moduleId = value.moduleId;
+    this.parameterValues = value.parameterValues;
+
+    if (this.source === '') {
+      this.parseError = 'Please select source or enter a source query';
+    } else {
+      try {
+        this.parsedSource = SqlQuery.parse(this.source);
+      } catch (e) {
+        this.parseError = e.message;
+      }
+    }
+  }
+
+  valueOf(): ExploreStateValue {
+    return {
+      source: this.source,
+      showSourceQuery: this.showSourceQuery,
+      where: this.where,
+      moduleId: this.moduleId,
+      parameterValues: this.parameterValues,
+    };
+  }
+
+  public change(newValues: Partial<ExploreStateValue>): ExploreState {
+    return new ExploreState({
+      ...this.valueOf(),
+      ...newValues,
+    });
+  }
+
+  public changeSource(newSource: SqlQuery | string, rename: Rename | undefined): ExploreState {
+    const toChange: Partial<ExploreStateValue> = {
+      source: String(newSource),
+    };
+
+    if (rename) {
+      toChange.where = renameColumnsInExpression(this.where, rename);
+
+      const module = ModuleRepository.getModule(this.moduleId);
+      if (module) {
+        toChange.parameterValues = renameColumnsInParameterValues(
+          this.parameterValues,
+          module.parameters,
+          rename,
+        );
+      }
+    }
+
+    return this.change(toChange);
+  }
+
+  public changeToTable(tableName: string): ExploreState {
+    return this.changeSource(SqlQuery.create(tableName), undefined);
+  }
+
+  public addInitTimeFilterIfNeeded(columns: readonly Column[]): ExploreState {
+    if (!this.parsedSource) return this;
+    if (!QuerySource.isSingleStarQuery(this.parsedSource)) return this; // Only trigger for `SELECT * FROM ...` queries
+    if (!this.where.equal(SqlLiteral.TRUE)) return this;
+
+    const timeColumn = columns.find(c => c.isTimeColumn());
+    if (!timeColumn) return this;
+
+    return this.change({
+      where: filterPatternToExpression({
+        type: 'timeRelative',
+        column: timeColumn.name,
+        negated: false,
+        anchor: 'maxDataTime',
+        rangeDuration: 'P1D',
+        startBound: '[',
+        endBound: ')',
+      }),
+    });
+  }
+
+  public restrictToQuerySource(querySource: QuerySource): ExploreState {
+    const { where, moduleId, parameterValues } = this;
+    const module = ModuleRepository.getModule(moduleId);
+    if (!module) return this;
+    const newWhere = querySource.restrictWhere(where);
+    const newParameterValues = querySource.restrictParameterValues(
+      parameterValues,
+      module.parameters,
+    );
+    if (where === newWhere && parameterValues === newParameterValues) return this;
+
+    return this.change({
+      where: newWhere,
+      parameterValues: newParameterValues,
+    });
+  }
+
+  public applyShowColumn(column: Column): ExploreState {
+    let moduleId: string;
+    let parameterValues: ParameterValues;
+    if (column.sqlType === 'TIMESTAMP') {
+      moduleId = 'time-chart';
+      parameterValues = {};
+    } else {
+      moduleId = 'grouping-table';
+      parameterValues = this.moduleId === moduleId ? this.parameterValues : {};
+      parameterValues.splitColumns = [ExpressionMeta.fromColumn(column)];
+    }
+
+    return this.change({
+      moduleId,
+      parameterValues,
+    });
+  }
+
+  public applyShowMeasure(measure: Measure): ExploreState {
+    const module = ModuleRepository.getModule(this.moduleId);
+    if (module) {
+      const p = Object.entries(module.parameters).find(
+        ([_, def]) => def.type === 'measure' || def.type === 'measures',
+      );
+      if (p) {
+        const [paramName, def] = p;
+        const { parameterValues } = this;
+        return this.change({
+          parameterValues: {
+            ...parameterValues,
+            [paramName]:
+              def.type === 'measures'
+                ? (parameterValues[paramName] || []).concat(measure)
+                : measure,
+          },
+        });
+      }
+    }
+
+    return this.change({
+      moduleId: 'grouping-table',
+      parameterValues: {
+        measures: [measure],
+      },
+    });
+  }
+
+  public isInitState(): boolean {
+    return (
+      this.moduleId === 'record-table' &&
+      this.source === '' &&
+      this.where instanceof SqlLiteral &&
+      isEmpty(this.parameterValues)
+    );
+  }
+}
+
+ExploreState.DEFAULT_STATE = new ExploreState({
+  moduleId: 'record-table',
+  source: '',
+  where: SqlLiteral.TRUE,
+  parameterValues: {},
+});
diff --git a/web-console/src/views/explore-view/explore-view.scss b/web-console/src/views/explore-view/explore-view.scss
index d3ec7b0c5c0..09627e0c9bd 100644
--- a/web-console/src/views/explore-view/explore-view.scss
+++ b/web-console/src/views/explore-view/explore-view.scss
@@ -24,31 +24,78 @@ $resources-width: 240px;
   height: 100%;
   position: relative;
 
-  display: grid;
-  grid-template-columns: $resources-width 1fr $resources-width;
-  grid-template-rows: auto 1fr;
-  gap: 8px;
-
-  .source-pane,
-  .filter-pane,
-  .tile-picker,
-  .control-pane-cnt {
+  .source-query-pane {
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 0;
+    height: 192px;
     @include card-like;
-    padding: 8px;
-    height: 100%;
-    overflow: auto;
   }
 
-  .resource-pane-cnt,
-  .module-holder {
+  .source-error {
+    position: absolute;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    top: 0;
     @include card-like;
+    padding: 20px;
+
+    .source-pane {
+      margin-top: 15px;
+    }
+  }
+
+  &.show-source-query .explore-container {
+    top: 200px;
   }
+  &.show-source-query .source-error {
+    top: 200px;
+  }
+
+  .explore-container {
+    position: absolute;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    top: 0;
+
+    display: grid;
+    grid-template-columns: $resources-width 1fr $resources-width;
+    grid-template-rows: auto 1fr;
+    gap: 8px;
 
-  .resource-pane-cnt {
-    height: 100%;
+    .source-pane,
+    .filter-pane,
+    .module-picker,
+    .control-pane-cnt {
+      @include card-like;
+      padding: 8px;
+      height: 100%;
+      overflow: auto;
+    }
+
+    .resource-pane-cnt,
+    .module-holder {
+      @include card-like;
+    }
 
-    .resource-pane {
+    .resource-pane-cnt {
+      position: relative;
       height: 100%;
+
+      .resource-pane {
+        position: absolute;
+        width: 100%;
+        height: 100%;
+      }
+    }
+
+    .main-cnt {
+      .module-pane {
+        height: 100%;
+      }
     }
   }
 }
diff --git a/web-console/src/views/explore-view/explore-view.tsx b/web-console/src/views/explore-view/explore-view.tsx
index 885e272b411..9e80f3ae06a 100644
--- a/web-console/src/views/explore-view/explore-view.tsx
+++ b/web-console/src/views/explore-view/explore-view.tsx
@@ -18,83 +18,42 @@
 
 import './modules';
 
-import { Intent, Menu, MenuItem } from '@blueprintjs/core';
+import { Button, Intent, Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
-import type { SqlExpression } from '@druid-toolkit/query';
-import { C, L, sql, SqlLiteral, SqlQuery, SqlTable, T } from '@druid-toolkit/query';
-import type { ExpressionMeta, TransferValue } from '@druid-toolkit/visuals-core';
-import {
-  useModuleContainer,
-  useParameterValues,
-  useSingleHost,
-} from '@druid-toolkit/visuals-react';
+import type { Column, QueryResult, SqlExpression } from '@druid-toolkit/query';
+import { QueryRunner, SqlQuery } from '@druid-toolkit/query';
+import classNames from 'classnames';
 import copy from 'copy-to-clipboard';
 import React, { useEffect, useMemo, useRef, useState } from 'react';
 import { useStore } from 'zustand';
 
 import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
-import { useLocalStorageState, useQueryManager } from '../../hooks';
-import { AppToaster } from '../../singletons';
-import { deepGet, filterMap, LocalStorageKeys, oneOf, queryDruidSql } from '../../utils';
-
-import { ControlPane } from './control-pane/control-pane';
-import { DroppableContainer } from './droppable-container/droppable-container';
-import { FilterPane } from './filter-pane/filter-pane';
-import { HighlightBubble } from './highlight-bubble/highlight-bubble';
+import { useHashAndLocalStorageHybridState, useQueryManager } from '../../hooks';
+import { Api, AppToaster } from '../../singletons';
+import { DruidError, LocalStorageKeys, queryDruidSql } from '../../utils';
+
+import {
+  ControlPane,
+  DroppableContainer,
+  FilterPane,
+  HighlightBubble,
+  ModulePane,
+  ModulePicker,
+  ResourcePane,
+  SourcePane,
+  SourceQueryPane,
+} from './components';
+import { ExploreState } from './explore-state';
 import { highlightStore } from './highlight-store/highlight-store';
-import BarChartEcharts from './modules/bar-chart-echarts-module';
-import MultiAxisChartEcharts from './modules/multi-axis-chart-echarts-module';
-import PieChartEcharts from './modules/pie-chart-echarts-module';
-import TableReact from './modules/table-react-module';
-import TimeChartEcharts from './modules/time-chart-echarts-module';
-import { ResourcePane } from './resource-pane/resource-pane';
-import { SourcePane } from './source-pane/source-pane';
-import { TilePicker } from './tile-picker/tile-picker';
-import type { Dataset } from './utils';
+import type { Measure, ParameterValues } from './models';
+import { QuerySource } from './models';
+import { ModuleRepository } from './module-repository/module-repository';
+import { rewriteAggregate, rewriteMaxDataTime } from './query-macros';
+import type { Rename } from './utils';
 import { adjustTransferValue, normalizeType } from './utils';
 
 import './explore-view.scss';
 
-const VISUAL_MODULES = [
-  {
-    moduleName: 'time_chart_echarts',
-    icon: IconNames.TIMELINE_LINE_CHART,
-    label: 'Time chart',
-    module: TimeChartEcharts,
-    transfer: ['splitColumn', 'metric'],
-  },
-  {
-    moduleName: 'bar_chart_echarts',
-    icon: IconNames.TIMELINE_BAR_CHART,
-    label: 'Bar chart',
-    module: BarChartEcharts,
-    transfer: ['splitColumn', 'metric'],
-  },
-  {
-    moduleName: 'table_react',
-    icon: IconNames.TH,
-    label: 'Table',
-    module: TableReact,
-    transfer: ['splitColumns', 'metrics'],
-  },
-  {
-    moduleName: 'pie_chart_echarts',
-    icon: IconNames.PIE_CHART,
-    label: 'Pie chart',
-    module: PieChartEcharts,
-    transfer: ['splitColumn', 'metric'],
-  },
-  {
-    moduleName: 'multi-axis_chart_echarts',
-    icon: IconNames.SERIES_ADD,
-    label: 'Multi-axis chart',
-    module: MultiAxisChartEcharts,
-    transfer: ['metrics'],
-  },
-] as const;
-
-type ModuleType = (typeof VISUAL_MODULES)[number]['moduleName'];
-
 // ---------------------------------------
 
 interface QueryHistoryEntry {
@@ -118,297 +77,395 @@ function getFormattedQueryHistory(): string {
 
 // ---------------------------------------
 
-async function introspect(tableName: SqlTable): Promise<Dataset> {
-  const columns = await queryDruidSql({
-    query: `SELECT COLUMN_NAME AS "name", DATA_TYPE AS "sqlType" FROM INFORMATION_SCHEMA.COLUMNS
-          WHERE TABLE_SCHEMA = 'druid' AND TABLE_NAME = ${L(tableName.getName())}`,
-  });
-
-  return {
-    table: tableName,
-    columns: columns.map(({ name, sqlType }) => ({ name, expression: C(name), sqlType })),
-  };
-}
+const queryRunner = new QueryRunner({
+  inflateDateStrategy: 'fromSqlTypes',
+  executor: async (sqlQueryPayload, isSql, cancelToken) => {
+    if (!isSql) throw new Error('should never get here');
+    addQueryToHistory(sqlQueryPayload.query);
+    return Api.instance.post('/druid/v2/sql', sqlQueryPayload, { cancelToken });
+  },
+});
 
-// micro-cache
-const MAX_TIME_TTL = 60000;
-let lastMaxTimeTable: string | undefined;
-let lastMaxTimeValue: Date | undefined;
-let lastMaxTimeTimestamp = 0;
-
-async function getMaxTimeForTable(tableName: string): Promise<Date | undefined> {
-  // micro-cache get
-  if (
-    lastMaxTimeTable === tableName &&
-    lastMaxTimeValue &&
-    Date.now() < lastMaxTimeTimestamp + MAX_TIME_TTL
-  ) {
-    return lastMaxTimeValue;
+async function runSqlQuery(query: string | SqlQuery): Promise<QueryResult> {
+  try {
+    return await queryRunner.runQuery({
+      query,
+    });
+  } catch (e) {
+    throw new DruidError(e);
   }
-
-  const d = await queryDruidSql({
-    query: sql`SELECT MAX(__time) AS "maxTime" FROM ${T(tableName)}`,
-  });
-
-  let maxTime = new Date(deepGet(d, '0.maxTime'));
-  if (isNaN(maxTime.valueOf())) return;
-
-  // Add 1ms to the maxTime date so as to allow filters like `"__time" < {maxTime}" to capture the last event which might also be the only event
-  maxTime = new Date(maxTime.valueOf() + 1);
-
-  // micro-cache set
-  lastMaxTimeTable = tableName;
-  lastMaxTimeValue = maxTime;
-  lastMaxTimeTimestamp = Date.now();
-
-  return maxTime;
 }
 
-function getFirstTableName(q: SqlQuery): string | undefined {
-  let tableName: string | undefined;
-  q.walk(ex => {
-    if (ex instanceof SqlTable) {
-      tableName = ex.getName();
-      return;
-    }
-    return ex;
-  });
-  return tableName;
-}
+async function introspectSource(source: string): Promise<QuerySource> {
+  const query = SqlQuery.parse(source);
+  const introspectResult = await runSqlQuery(QuerySource.makeLimitZeroIntrospectionQuery(query));
 
-async function extendedQueryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]> {
-  if (sqlQueryPayload.query.includes('MAX_DATA_TIME()')) {
-    const parsed = SqlQuery.parse(sqlQueryPayload.query);
-    const tableName = getFirstTableName(parsed);
-    if (tableName) {
-      const maxTime = await getMaxTimeForTable(tableName);
-      if (maxTime) {
-        sqlQueryPayload = {
-          ...sqlQueryPayload,
-          query: sqlQueryPayload.query.replace(/MAX_DATA_TIME\(\)/g, L(maxTime)),
-        };
-      }
-    }
-  }
-
-  addQueryToHistory(sqlQueryPayload.query);
-  console.debug(`Running query:\n${sqlQueryPayload.query}`);
+  const baseIntrospectResult = QuerySource.isSingleStarQuery(query)
+    ? introspectResult
+    : await runSqlQuery(
+        QuerySource.makeLimitZeroIntrospectionQuery(QuerySource.stripToBaseSource(query)),
+      );
 
-  return queryDruidSql(sqlQueryPayload);
+  return QuerySource.fromIntrospectResult(
+    query,
+    baseIntrospectResult.header,
+    introspectResult.header,
+  );
 }
 
 export const ExploreView = React.memo(function ExploreView() {
   const [shownText, setShownText] = useState<string | undefined>();
-  const filterPane = useRef<{ filterOn(column: ExpressionMeta): void }>();
-
-  const [moduleName, setModuleName] = useLocalStorageState<ModuleType>(
-    LocalStorageKeys.EXPLORE_CONTENT,
-    VISUAL_MODULES[0].moduleName,
+  const filterPane = useRef<{ filterOn(column: Column): void }>();
+  const containerRef = useRef<HTMLDivElement | null>(null);
+
+  const [exploreState, setExploreState] = useHashAndLocalStorageHybridState<ExploreState>(
+    '#explore/v/',
+    LocalStorageKeys.EXPLORE_STATE,
+    ExploreState.DEFAULT_STATE,
+    s => {
+      return ExploreState.fromJS(s);
+    },
   );
 
   const { dropHighlight } = useStore(highlightStore);
 
-  const [timezone] = useState('Etc/UTC');
+  // -------------------------------------------------------
+  // If no table selected, change to first table if possible
+  async function initWithFirstTable() {
+    const tables = await queryDruidSql<{ TABLE_NAME: string }>({
+      query: `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' LIMIT 1`,
+    });
 
-  const [columns, setColumns] = useState<ExpressionMeta[]>([]);
+    const firstTableName = tables[0].TABLE_NAME;
+    if (firstTableName) {
+      setTable(firstTableName);
+    }
+  }
 
-  const { host, where, table, visualModule, updateWhere, updateTable } = useSingleHost({
-    sqlQuery: extendedQueryDruidSql,
-    persist: { name: LocalStorageKeys.EXPLORE_ESSENCE, storage: 'localStorage' },
-    visualModules: Object.fromEntries(VISUAL_MODULES.map(v => [v.moduleName, v.module])),
-    selectedModule: moduleName,
-    moduleState: {
-      parameterValues: {},
-      table: T('select source'),
-      where: SqlLiteral.TRUE,
-    },
+  useEffect(() => {
+    if (exploreState.isInitState()) {
+      void initWithFirstTable();
+    }
   });
 
-  useEffect(() => {
-    host.store.setState({ context: { timezone } });
-  }, [timezone, host.store]);
+  // -------------------------------------------------------
 
-  const { parameterValues, updateParameterValues, resetParameterValues } = useParameterValues({
-    host,
-    selectedModule: moduleName,
-    columns,
-  });
+  const { moduleId, source, parsedSource, parseError, where, parameterValues, showSourceQuery } =
+    exploreState;
+  const module = ModuleRepository.getModule(moduleId);
 
-  const [datasetState] = useQueryManager<SqlExpression, Dataset>({
-    query: table,
-    processQuery: tableName => introspect(tableName as SqlTable),
+  const [querySourceState] = useQueryManager<string, QuerySource>({
+    query: parsedSource ? String(parsedSource) : undefined,
+    processQuery: introspectSource,
   });
 
-  const onShow = useMemo(() => {
-    const currentShowTransfers =
-      VISUAL_MODULES.find(vm => vm.moduleName === moduleName)?.transfer || [];
-    if (currentShowTransfers.length) {
-      const paramName = currentShowTransfers[0];
-      const showControlType = visualModule?.parameterDefinitions?.[paramName]?.type;
-
-      if (paramName && oneOf(showControlType, 'column', 'columns')) {
-        return (column: ExpressionMeta) => {
-          updateParameterValues({ [paramName]: showControlType === 'column' ? column : [column] });
-        };
-      }
+  // -------------------------------------------------------
+  // If we have a __time::TIMESTAMP column and no filter add a filter
+
+  useEffect(() => {
+    const columns = querySourceState.data?.columns;
+    if (!columns) return;
+    const newExploreState = exploreState.addInitTimeFilterIfNeeded(columns);
+    if (exploreState !== newExploreState) {
+      setExploreState(newExploreState);
     }
-    return;
-  }, [updateParameterValues, moduleName, visualModule?.parameterDefinitions]);
+  }, [querySourceState.data]);
 
-  const dataset = datasetState.getSomeData();
+  // -------------------------------------------------------
 
   useEffect(() => {
-    setColumns(dataset?.columns ?? []);
-  }, [dataset?.columns]);
+    const querySource = querySourceState.data;
+    if (!querySource || !module) return;
+    const newExploreState = exploreState.restrictToQuerySource(querySource);
+    if (exploreState !== newExploreState) {
+      setExploreState(newExploreState);
+    }
+  }, [module, parameterValues, querySourceState.data]);
+
+  function setModuleId(moduleId: string, parameterValues: Record<string, any>) {
+    if (exploreState.moduleId === moduleId) return;
+    setExploreState(exploreState.change({ moduleId, parameterValues }));
+  }
 
-  const [containerRef] = useModuleContainer({ host, selectedModule: moduleName, columns });
+  function setParameterValues(newParameterValues: ParameterValues) {
+    if (newParameterValues === parameterValues) return;
+    setExploreState(exploreState.change({ parameterValues: newParameterValues }));
+  }
+
+  function resetParameterValues() {
+    setParameterValues({});
+  }
+
+  function updateParameterValues(newParameterValues: ParameterValues) {
+    setParameterValues({ ...parameterValues, ...newParameterValues });
+  }
+
+  function setSource(source: SqlQuery | string, rename?: Rename) {
+    setExploreState(exploreState.changeSource(source, rename));
+  }
+
+  function setTable(tableName: string) {
+    setExploreState(exploreState.changeToTable(tableName));
+  }
+
+  function setWhere(where: SqlExpression) {
+    setExploreState(exploreState.change({ where }));
+  }
+
+  function onShowColumn(column: Column) {
+    setExploreState(exploreState.applyShowColumn(column));
+  }
+
+  function onShowMeasure(measure: Measure) {
+    setExploreState(exploreState.applyShowMeasure(measure));
+  }
+
+  function onShowSourceQuery() {
+    setExploreState(exploreState.change({ showSourceQuery: true }));
+  }
+
+  const querySource = querySourceState.getSomeData();
+
+  const runSqlPlusQuery = useMemo(() => {
+    return async (query: string | SqlQuery) => {
+      if (!querySource) throw new Error('no querySource');
+      return await runSqlQuery(
+        await rewriteMaxDataTime(rewriteAggregate(SqlQuery.parse(query), querySource.measures)),
+      );
+    };
+  }, [querySource]);
 
   return (
-    <>
-      <div className="explore-view">
-        <SourcePane
-          selectedTableName={table ? (table as SqlTable).getName() : '-'}
-          onSelectedTableNameChange={t => updateTable(T(t))}
-          disabled={Boolean(dataset && datasetState.loading)}
-        />
-        <FilterPane
-          ref={filterPane}
-          dataset={dataset}
-          filter={where}
-          onFilterChange={updateWhere}
-          queryDruidSql={extendedQueryDruidSql}
+    <div className={classNames('explore-view', { 'show-source-query': showSourceQuery })}>
+      {showSourceQuery && (
+        <SourceQueryPane
+          source={source}
+          onSourceChange={setSource}
+          onClose={() => setExploreState(exploreState.change({ showSourceQuery: false }))}
         />
-        <TilePicker<ModuleType>
-          modules={VISUAL_MODULES}
-          selectedTileName={moduleName}
-          onSelectedTileNameChange={m => {
-            const currentParameterDefinitions = visualModule?.parameterDefinitions || {};
-            const valuesToTransfer: TransferValue[] = filterMap(
-              VISUAL_MODULES.find(vm => vm.moduleName === visualModule?.moduleName)?.transfer || [],
-              paramName => {
-                const parameterDefinition = currentParameterDefinitions[paramName];
-                if (!parameterDefinition) return;
-                const parameterValue = parameterValues[paramName];
-                if (typeof parameterValue === 'undefined') return;
-                return [parameterDefinition.type, parameterValue];
-              },
-            );
-
-            dropHighlight();
-            setModuleName(m);
-            resetParameterValues();
-
-            const newModuleDef = VISUAL_MODULES.find(vm => vm.moduleName === m);
-            if (newModuleDef) {
-              const newParameters: any = newModuleDef.module?.parameters || {};
-              const transferParameterValues: [name: string, value: any][] = filterMap(
-                newModuleDef.transfer || [],
-                t => {
-                  const p = newParameters[t];
-                  if (!p) return;
-                  const normalizedTargetType = normalizeType(p.type);
-                  const transferSource = valuesToTransfer.find(
-                    ([t]) => normalizeType(t) === normalizedTargetType,
-                  );
-                  if (!transferSource) return;
-                  const targetValue = adjustTransferValue(
-                    transferSource[1],
-                    transferSource[0],
-                    p.type,
-                  );
-                  if (typeof targetValue === 'undefined') return;
-                  return [t, targetValue];
-                },
+      )}
+      {parseError && (
+        <div className="source-error">
+          <p>{parseError}</p>
+          {source === '' && (
+            <p>
+              <SourcePane
+                selectedSource={undefined}
+                onSelectTable={setTable}
+                disabled={Boolean(querySource && querySourceState.loading)}
+              />
+            </p>
+          )}
+          {!showSourceQuery && (
+            <p>
+              <Button text="Show source query" onClick={onShowSourceQuery} />
+            </p>
+          )}
+        </div>
+      )}
+      {parsedSource && (
+        <div className="explore-container">
+          <SourcePane
+            selectedSource={parsedSource}
+            onSelectTable={setTable}
+            onShowSourceQuery={onShowSourceQuery}
+            fill
+            minimal
+            disabled={Boolean(querySource && querySourceState.loading)}
+          />
+          <FilterPane
+            ref={filterPane}
+            querySource={querySource}
+            filter={where}
+            onFilterChange={setWhere}
+            runSqlQuery={runSqlPlusQuery}
+            onAddToSourceQueryAsColumn={expression => {
+              if (!querySource) return;
+              setExploreState(
+                exploreState.changeSource(
+                  querySource.addColumn(querySource.transformToBaseColumns(expression)),
+                  undefined,
+                ),
+              );
+            }}
+            onMoveToSourceQueryAsClause={(expression, changeWhere) => {
+              if (!querySource) return;
+              setExploreState(
+                exploreState
+                  .change({ where: changeWhere })
+                  .changeSource(
+                    querySource.addWhereClause(querySource.transformToBaseColumns(expression)),
+                    undefined,
+                  ),
               );
+            }}
+          />
+          <ModulePicker
+            modules={[
+              { id: 'grouping-table', icon: IconNames.PANEL_TABLE, label: 'Grouping table' },
+              { id: 'record-table', icon: IconNames.TH, label: 'Record table' },
+              { id: 'time-chart', icon: IconNames.TIMELINE_LINE_CHART, label: 'Time chart' },
+              { id: 'bar-chart', icon: IconNames.VERTICAL_BAR_CHART_DESC, label: 'Bar chart' },
+              { id: 'pie-chart', icon: IconNames.PIE_CHART, label: 'Pie chart' },
+              { id: 'multi-axis-chart', icon: IconNames.SERIES_ADD, label: 'Multi-axis chart' },
+            ]}
+            selectedModuleId={moduleId}
+            onSelectedModuleIdChange={newModuleId => {
+              const newParameterValues: ParameterValues = {};
+              const oldModule = ModuleRepository.getModule(moduleId);
+              const newModule = ModuleRepository.getModule(newModuleId);
+              if (oldModule && newModule) {
+                const oldModuleParameters = oldModule.parameters || {};
+                const newModuleParameters = newModule.parameters || {};
+                for (const paramName in oldModuleParameters) {
+                  const parameterValue = parameterValues[paramName];
+                  if (typeof parameterValue === 'undefined') continue;
+
+                  const oldParameterDefinition = oldModuleParameters[paramName];
+                  const transferGroup = oldParameterDefinition.transferGroup;
+                  if (typeof transferGroup !== 'string') continue;
+
+                  const normalizedType = normalizeType(oldParameterDefinition.type);
+                  const target = Object.entries(newModuleParameters).find(
+                    ([_, def]) =>
+                      def.transferGroup === transferGroup &&
+                      normalizeType(def.type) === normalizedType,
+                  );
+                  if (!target) continue;
 
-              if (transferParameterValues.length) {
-                updateParameterValues(Object.fromEntries(transferParameterValues));
+                  newParameterValues[target[0]] = adjustTransferValue(
+                    parameterValue,
+                    oldParameterDefinition.type,
+                    target[1].type,
+                  );
+                }
               }
+
+              dropHighlight();
+              setModuleId(newModuleId, newParameterValues);
+            }}
+            moreMenu={
+              <Menu>
+                <MenuItem
+                  icon={IconNames.DUPLICATE}
+                  text="Copy last query"
+                  disabled={!QUERY_HISTORY.length}
+                  onClick={() => {
+                    copy(QUERY_HISTORY[0]?.sqlQuery, { format: 'text/plain' });
+                    AppToaster.show({
+                      message: `Copied query to clipboard`,
+                      intent: Intent.SUCCESS,
+                    });
+                  }}
+                />
+                <MenuItem
+                  icon={IconNames.HISTORY}
+                  text="Show query history"
+                  onClick={() => {
+                    setShownText(getFormattedQueryHistory());
+                  }}
+                />
+                <MenuItem
+                  icon={IconNames.RESET}
+                  text="Reset visualization parameters"
+                  onClick={() => {
+                    resetParameterValues();
+                  }}
+                />
+                <MenuDivider />
+                <MenuItem
+                  icon={IconNames.TRASH}
+                  text="Clear all view state"
+                  intent={Intent.DANGER}
+                  onClick={() => {
+                    localStorage.removeItem(LocalStorageKeys.EXPLORE_STATE);
+                    location.hash = '#explore';
+                    location.reload();
+                  }}
+                />
+              </Menu>
             }
-          }}
-          moreMenu={
-            <Menu>
-              <MenuItem
-                icon={IconNames.DUPLICATE}
-                text="Copy last query"
-                disabled={!QUERY_HISTORY.length}
-                onClick={() => {
-                  copy(QUERY_HISTORY[0]?.sqlQuery, { format: 'text/plain' });
-                  AppToaster.show({
-                    message: `Copied query to clipboard`,
-                    intent: Intent.SUCCESS,
-                  });
+          />
+          <div className="resource-pane-cnt">
+            {!querySource && querySourceState.loading && 'Loading...'}
+            {querySource && (
+              <ResourcePane
+                querySource={querySource}
+                onQueryChange={setSource}
+                onFilter={c => {
+                  filterPane.current?.filterOn(c);
                 }}
+                runSqlQuery={runSqlPlusQuery}
+                onShowColumn={onShowColumn}
+                onShowMeasure={onShowMeasure}
               />
-              <MenuItem
-                icon={IconNames.HISTORY}
-                text="Show query history"
-                onClick={() => {
-                  setShownText(getFormattedQueryHistory());
-                }}
+            )}
+          </div>
+          <DroppableContainer
+            className="main-cnt"
+            ref={containerRef}
+            onDropColumn={onShowColumn}
+            onDropMeasure={onShowMeasure}
+          >
+            {querySourceState.error && (
+              <div className="error-display">{querySourceState.getErrorMessage()}</div>
+            )}
+            {querySource && (
+              <ModulePane
+                moduleId={moduleId}
+                querySource={querySource}
+                where={where}
+                setWhere={setWhere}
+                parameterValues={parameterValues}
+                setParameterValues={updateParameterValues}
+                runSqlQuery={runSqlPlusQuery}
               />
-              <MenuItem
-                icon={IconNames.RESET}
-                text="Reset visualization state"
-                onClick={() => {
-                  resetParameterValues();
+            )}
+          </DroppableContainer>
+          <div className="control-pane-cnt">
+            {module && (
+              <ControlPane
+                querySource={querySource}
+                onUpdateParameterValues={updateParameterValues}
+                parameters={module.parameters}
+                parameterValues={parameterValues}
+                onAddToSourceQueryAsColumn={expression => {
+                  if (!querySource) return;
+                  setExploreState(
+                    exploreState.changeSource(
+                      querySource.addColumn(querySource.transformToBaseColumns(expression)),
+                      undefined,
+                    ),
+                  );
+                }}
+                onAddToSourceQueryAsMeasure={measure => {
+                  if (!querySource) return;
+                  setExploreState(
+                    exploreState.changeSource(
+                      querySource.addMeasure(
+                        measure.changeExpression(
+                          querySource.transformToBaseColumns(measure.expression),
+                        ),
+                      ),
+                      undefined,
+                    ),
+                  );
                 }}
               />
-            </Menu>
-          }
-        />
-        <div className="resource-pane-cnt">
-          {!dataset && datasetState.loading && 'Loading...'}
-          {dataset && (
-            <ResourcePane
-              dataset={dataset}
-              onFilter={c => {
-                filterPane.current?.filterOn(c);
+            )}
+          </div>
+          {shownText && (
+            <ShowValueDialog
+              title="Query history"
+              str={shownText}
+              onClose={() => {
+                setShownText(undefined);
               }}
-              onShow={onShow}
             />
           )}
         </div>
-        <DroppableContainer
-          ref={containerRef}
-          onDropColumn={column => {
-            let nextModuleName: ModuleType;
-            if (column.sqlType === 'TIMESTAMP') {
-              nextModuleName = 'time_chart_echarts';
-            } else {
-              nextModuleName = 'table_react';
-            }
-
-            setModuleName(nextModuleName);
-
-            if (column.sqlType === 'TIMESTAMP') {
-              resetParameterValues();
-            } else {
-              updateParameterValues({ splitColumns: [column] });
-            }
-          }}
-        />
-        <div className="control-pane-cnt">
-          {dataset && visualModule?.parameterDefinitions && (
-            <ControlPane
-              columns={dataset.columns}
-              onUpdateParameterValues={updateParameterValues}
-              parameterValues={parameterValues}
-              visualModule={visualModule}
-            />
-          )}
-        </div>
-        {shownText && (
-          <ShowValueDialog
-            title="Query history"
-            str={shownText}
-            onClose={() => {
-              setShownText(undefined);
-            }}
-          />
-        )}
-      </div>
+      )}
       <HighlightBubble referenceContainer={containerRef.current} />
-    </>
+    </div>
   );
 });
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx
deleted file mode 100644
index 6cec4234f68..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/contains-filter-control/contains-filter-control.tsx
+++ /dev/null
@@ -1,132 +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 { Button, FormGroup, InputGroup, Intent, Menu, MenuItem } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import type { ContainsFilterPattern } from '@druid-toolkit/query';
-import { C, filterPatternToExpression, SqlExpression, SqlLiteral } from '@druid-toolkit/query';
-import React, { useMemo, useState } from 'react';
-
-import { useQueryManager } from '../../../../../hooks';
-import { ColumnPicker } from '../../../column-picker/column-picker';
-import type { Dataset } from '../../../utils';
-
-import './contains-filter-control.scss';
-
-export interface ContainsFilterControlProps {
-  dataset: Dataset;
-  filter: SqlExpression | undefined;
-  initFilterPattern: ContainsFilterPattern;
-  negated: boolean;
-  setFilterPattern(filterPattern: ContainsFilterPattern): void;
-  queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
-}
-
-export const ContainsFilterControl = React.memo(function ContainsFilterControl(
-  props: ContainsFilterControlProps,
-) {
-  const { dataset, filter, initFilterPattern, negated, setFilterPattern, queryDruidSql } = props;
-  const [column, setColumn] = useState<string>(initFilterPattern.column);
-  const [contains, setContains] = useState(initFilterPattern.contains);
-
-  function makePattern(): ContainsFilterPattern {
-    return {
-      type: 'contains',
-      negated,
-      column,
-      contains,
-    };
-  }
-
-  const previewQuery = useMemo(() => {
-    const columnRef = C(column);
-    const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM ${dataset.table}`];
-
-    const filterEx = SqlExpression.and(
-      filter,
-      contains ? filterPatternToExpression(makePattern()) : undefined,
-    );
-    if (!(filterEx instanceof SqlLiteral)) {
-      queryParts.push(`WHERE ${filterEx}`);
-    }
-
-    queryParts.push(`GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 101`);
-    return queryParts.join('\n');
-    // eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
-  }, [dataset.table, filter, column, contains, negated]);
-
-  const [previewState] = useQueryManager<string, string[]>({
-    query: previewQuery,
-    debounceIdle: 100,
-    debounceLoading: 500,
-    processQuery: async query => {
-      const vs = await queryDruidSql<{ c: any }>({
-        query,
-      });
-
-      return vs.map(d => String(d.c));
-    },
-  });
-
-  return (
-    <div className="contains-filter-control">
-      <FormGroup label="Column">
-        <ColumnPicker
-          availableColumns={dataset.columns}
-          selectedColumnName={column}
-          onSelectedColumnNameChange={setColumn}
-        />
-      </FormGroup>
-      <FormGroup>
-        <InputGroup
-          value={contains}
-          onChange={e => setContains(e.target.value)}
-          placeholder="Search string"
-        />
-      </FormGroup>
-      <FormGroup label="Preview">
-        <Menu className="preview-list">
-          {previewState.data?.map((v, i) => (
-            <MenuItem
-              key={i}
-              className="preview-item"
-              text={String(v)}
-              shouldDismissPopover={false}
-            />
-          ))}
-          {previewState.loading && <MenuItem disabled text="Loading..." />}
-          {previewState.error && (
-            <MenuItem icon={IconNames.ERROR} disabled text={previewState.getErrorMessage()} />
-          )}
-        </Menu>
-      </FormGroup>
-      <div className="button-bar">
-        <Button
-          intent={Intent.PRIMARY}
-          text="OK"
-          onClick={() => {
-            const newPattern = makePattern();
-            // TODO check if valid
-            // if (!isFilterPatternValid(newPattern)) return;
-            setFilterPattern(newPattern);
-          }}
-        />
-      </div>
-    </div>
-  );
-});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/custom-filter-control/custom-filter-control.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/custom-filter-control/custom-filter-control.tsx
deleted file mode 100644
index b78a15b825e..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/custom-filter-control/custom-filter-control.tsx
+++ /dev/null
@@ -1,67 +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 { Button, FormGroup, Intent, TextArea } from '@blueprintjs/core';
-import type { CustomFilterPattern } from '@druid-toolkit/query';
-import { SqlExpression } from '@druid-toolkit/query';
-import React, { useState } from 'react';
-
-export interface CustomFilterControlProps {
-  initFilterPattern: CustomFilterPattern;
-  negated: boolean;
-  setFilterPattern(filterPattern: CustomFilterPattern): void;
-}
-
-export const CustomFilterControl = React.memo(function CustomFilterControl(
-  props: CustomFilterControlProps,
-) {
-  const { initFilterPattern, negated, setFilterPattern } = props;
-  const [formula, setFormula] = useState<string>(String(initFilterPattern.expression || ''));
-
-  function makePattern(): CustomFilterPattern {
-    return {
-      type: 'custom',
-      negated,
-      expression: SqlExpression.maybeParse(formula),
-    };
-  }
-
-  return (
-    <div className="custom-filter-control">
-      <FormGroup>
-        <TextArea
-          value={formula}
-          onChange={e => setFormula(e.target.value)}
-          placeholder="SQL expression"
-        />
-      </FormGroup>
-      <div className="button-bar">
-        <Button
-          intent={Intent.PRIMARY}
-          text="OK"
-          onClick={() => {
-            const newPattern = makePattern();
-            // TODO check if valid
-            // if (!isFilterPatternValid(newPattern)) return;
-            setFilterPattern(newPattern);
-          }}
-        />
-      </div>
-    </div>
-  );
-});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.tsx
deleted file mode 100644
index ccf07949f2f..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/filter-menu.tsx
+++ /dev/null
@@ -1,191 +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 { Button, ButtonGroup, FormGroup, HTMLSelect } from '@blueprintjs/core';
-import { IconNames } from '@blueprintjs/icons';
-import type { FilterPattern, FilterPatternType, SqlExpression } from '@druid-toolkit/query';
-import { changeFilterPatternType, FILTER_PATTERN_TYPES } from '@druid-toolkit/query';
-import type { JSX } from 'react';
-import React, { useState } from 'react';
-
-import { ColumnPickerMenu } from '../../column-picker-menu/column-picker-menu';
-import type { Dataset } from '../../utils';
-import { initPatternForColumn } from '../pattern-helpers';
-
-import { ContainsFilterControl } from './contains-filter-control/contains-filter-control';
-import { CustomFilterControl } from './custom-filter-control/custom-filter-control';
-import { RegexpFilterControl } from './regexp-filter-control/regexp-filter-control';
-import { TimeIntervalFilterControl } from './time-interval-filter-control/time-interval-filter-control';
-import { TimeRelativeFilterControl } from './time-relative-filter-control/time-relative-filter-control';
-import { ValuesFilterControl } from './values-filter-control/values-filter-control';
-
-import './filter-menu.scss';
-
-const PATTERN_TYPE_TO_NAME: Record<FilterPatternType, string> = {
-  values: 'Values',
-  contains: 'Contains',
-  custom: 'Custom',
-  mvContains: 'Multi-value contains',
-  numberRange: 'Number range',
-  regexp: 'Regular expression',
-  timeInterval: 'Time interval',
-  timeRelative: 'Time relative',
-};
-
-export interface FilterMenuProps {
-  dataset: Dataset;
-  filter: SqlExpression;
-  initPattern?: FilterPattern;
-  onPatternChange(newPattern: FilterPattern): void;
-  onClose(): void;
-  queryDruidSql<T = any>(sqlQueryPayload: Record<string, any>): Promise<T[]>;
-}
-
-export const FilterMenu = React.memo(function FilterMenu(props: FilterMenuProps) {
-  const { dataset, filter, initPattern, onPatternChange, onClose, queryDruidSql } = props;
-
-  const [pattern, setPattern] = useState<FilterPattern | undefined>(initPattern);
-  const [negated, setNegated] = useState(Boolean(pattern?.negated));
-
-  const { columns } = dataset;
-
-  if (!pattern) {
-    return (
-      <ColumnPickerMenu
-        className="filter-menu"
-        columns={columns}
-        onSelectColumn={c => setPattern(initPatternForColumn(c))}
-        iconForColumn={c => (filter.containsColumnName(c.name) ? IconNames.FILTER : undefined)}
-        shouldDismissPopover={false}
-      />
-    );
-  }
-
-  function onAcceptPattern(pattern: FilterPattern) {
-    onPatternChange({ ...pattern, negated });
-    onClose();
-  }
-
-  let cont: JSX.Element;
-  switch (pattern.type) {
-    case 'values':
-      cont = (
-        <ValuesFilterControl
-          dataset={dataset}
-          filter={filter.removeColumnFromAnd(pattern.column)}
-          initFilterPattern={pattern}
-          negated={negated}
-          setFilterPattern={onAcceptPattern}
-          onClose={onClose}
-          queryDruidSql={queryDruidSql}
-        />
-      );
-      break;
-
-    case 'contains':
-      cont = (
-        <ContainsFilterControl
-          dataset={dataset}
-          filter={filter.removeColumnFromAnd(pattern.column)}
-          initFilterPattern={pattern}
-          negated={negated}
-          setFilterPattern={onAcceptPattern}
-          queryDruidSql={queryDruidSql}
-        />
-      );
-      break;
-
-    case 'regexp':
-      cont = (
-        <RegexpFilterControl
-          dataset={dataset}
-          filter={filter.removeColumnFromAnd(pattern.column)}
-          initFilterPattern={pattern}
-          negated={negated}
-          setFilterPattern={onAcceptPattern}
-          queryDruidSql={queryDruidSql}
-        />
-      );
-      break;
-
-    case 'timeInterval':
-      cont = (
-        <TimeIntervalFilterControl
-          dataset={dataset}
-          initFilterPattern={pattern}
-          negated={negated}
-          setFilterPattern={onAcceptPattern}
-        />
-      );
-      break;
-
-    case 'timeRelative':
-      cont = (
-        <TimeRelativeFilterControl
-          dataset={dataset}
-          initFilterPattern={pattern}
-          negated={negated}
-          setFilterPattern={onAcceptPattern}
-        />
-      );
-      break;
-
-    case 'custom':
-      cont = (
-        <CustomFilterControl
-          initFilterPattern={pattern}
-          negated={negated}
-          setFilterPattern={onAcceptPattern}
-        />
-      );
-      break;
-
-    default:
-      cont = <div />; // TODO fix
-      break;
-  }
-
-  return (
-    <div className="filter-menu main">
-      <FormGroup className="controls">
-        <HTMLSelect
-          className="type-selector"
-          value={pattern.type}
-          onChange={e =>
-            setPattern(changeFilterPatternType(pattern, e.target.value as FilterPatternType))
-          }
-        >
-          {FILTER_PATTERN_TYPES.map(type => (
-            <option key={type} value={type}>
-              {PATTERN_TYPE_TO_NAME[type]}
-            </option>
-          ))}
-        </HTMLSelect>
-        <ButtonGroup>
-          <Button icon={IconNames.FILTER} active={!negated} onClick={() => setNegated(false)} />
-          <Button
-            icon={IconNames.FILTER_REMOVE}
-            active={negated}
-            onClick={() => setNegated(true)}
-          />
-        </ButtonGroup>
-      </FormGroup>
-      {cont}
-    </div>
-  );
-});
diff --git a/web-console/src/views/explore-view/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx b/web-console/src/views/explore-view/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
deleted file mode 100644
index 3a24bf69074..00000000000
--- a/web-console/src/views/explore-view/filter-pane/filter-menu/time-interval-filter-control/time-interval-filter-control.tsx
+++ /dev/null
@@ -1,131 +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 { Button, FormGroup, InputGroup, Intent } from '@blueprintjs/core';
-import type { TimeIntervalFilterPattern } from '@druid-toolkit/query';
-import React, { useState } from 'react';
-
-import { ColumnPicker } from '../../../column-picker/column-picker';
-import type { Dataset } from '../../../utils';
-
-function utcParseDate(dateString: string): Date | undefined {
-  const dateParts = dateString.split(/[-T:. ]/g);
-
-  // Extract the individual date and time components
-  const year = parseInt(dateParts[0], 10);
-  if (!(1000 < year && year < 4000)) return;
-
... 96081 lines suppressed ...


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