From 1db4557da158050f487de8ee8cb43af7f8b99903 Mon Sep 17 00:00:00 2001
From: Vadim Ogievetsky
Date: Wed, 15 Jan 2025 00:09:10 -0800
Subject: [PATCH] Web console: Explore view improvements (#17627)
* new Explore layout
* fix feedback comments
---
licenses.yaml | 37 +-
web-console/package-lock.json | 319 +++++---
web-console/package.json | 17 +-
.../clearable-input/clearable-input.spec.tsx | 2 +-
.../clearable-input/clearable-input.tsx | 16 +-
.../fancy-numeric-input.tsx | 4 +-
.../form-group-with-info.tsx | 4 +-
.../formatted-input/formatted-input.tsx | 4 +-
.../__snapshots__/header-bar.spec.tsx.snap | 3 +-
.../src/components/header-bar/header-bar.tsx | 2 +-
.../portal-bubble/portal-bubble.scss | 1 +
.../portal-bubble/portal-bubble.tsx | 17 +-
.../segment-timeline/chart-axis.tsx | 37 -
.../{common.ts => interval.ts} | 2 +-
.../segment-bar-chart-render.scss | 7 +-
.../segment-bar-chart-render.tsx | 421 +++++-----
.../segment-timeline/segment-bar-chart.tsx | 9 +-
.../segment-timeline/segment-timeline.tsx | 42 +-
.../__snapshots__/show-log.spec.tsx.snap | 1 -
.../__snapshots__/about-dialog.spec.tsx.snap | 1 -
.../src/druid-models/segment/segment.ts | 4 +-
web-console/src/hooks/index.ts | 2 +-
...use-hash-and-local-storage-hybrid-state.ts | 27 +-
.../use-memo-with-previous.ts} | 40 +-
web-console/src/hooks/use-resize-observer.ts | 80 --
.../auto-granularity.ts} | 72 +-
web-console/src/utils/base64-url.spec.ts | 15 +-
web-console/src/utils/base64-url.ts | 30 +-
.../date-floor-shift-ceil-utc.spec.ts | 169 ----
.../date-floor-shift-ceil.spec.ts | 181 -----
.../date-floor-shift-ceil.ts | 296 -------
web-console/src/utils/date-format.ts | 72 ++
.../src/utils/duration/duration.spec.ts | 505 ------------
web-console/src/utils/duration/duration.ts | 388 ---------
web-console/src/utils/general.tsx | 22 +-
web-console/src/utils/index.tsx | 5 +-
.../src/utils/mouse-tooltip/mouse-tooltip.ts | 33 +-
web-console/src/utils/stage.ts | 15 +
.../column-value/column-value.scss | 0
.../column-value/column-value.tsx | 6 +
.../components/control-pane/control-pane.scss | 5 +
.../components/control-pane/control-pane.tsx | 87 +-
.../control-pane/named-expressions-input.tsx | 2 +-
.../contains-filter-control.tsx | 9 +-
.../filter-pane/filter-menu/filter-menu.tsx | 27 +-
.../regexp-filter-control.tsx | 9 +-
.../time-relative-filter-control.tsx | 13 +-
.../values-filter-control.tsx | 13 +-
.../components/filter-pane/filter-pane.tsx | 53 +-
.../generic-output-table.tsx | 28 +-
.../components/helper-table/helper-table.scss | 85 ++
.../components/helper-table/helper-table.tsx | 191 +++++
.../highlight-bubble/highlight-bubble.tsx | 80 --
.../views/explore-view/components/index.ts | 2 +-
.../components/module-pane/module-pane.scss | 74 +-
.../components/module-pane/module-pane.tsx | 266 +++++-
.../module-picker/module-picker.tsx | 19 +-
.../nested-column-dialog.scss | 1 +
.../nested-column-dialog.tsx | 46 +-
.../resource-pane/resource-pane.scss | 71 +-
.../resource-pane/resource-pane.tsx | 389 ++++-----
.../source-query-pane/source-query-pane.tsx | 2 +-
.../src/views/explore-view/explore-state.ts | 237 ------
.../src/views/explore-view/explore-view.scss | 247 ++++--
.../src/views/explore-view/explore-view.tsx | 627 ++++++++-------
.../highlight-store/highlight-store.ts | 64 --
.../explore-view/models/explore-state.ts | 316 ++++++++
.../explore-view/models/expression-meta.ts | 18 +-
.../src/views/explore-view/models/index.ts | 2 +
.../src/views/explore-view/models/measure.ts | 10 +-
.../views/explore-view/models/module-state.ts | 173 ++++
.../views/explore-view/models/parameter.ts | 170 ++--
.../views/explore-view/models/query-source.ts | 56 +-
.../module-repository/module-repository.ts | 4 +-
.../bar-chart-module.tsx | 127 ++-
.../grouping-table-module.scss | 0
.../grouping-table-module.tsx | 27 +-
.../src/views/explore-view/modules/index.ts | 12 +-
.../multi-axis-chart-module.tsx | 187 +++--
.../pie-chart-module.tsx | 92 ++-
.../record-table-module.scss | 0
.../record-table-module.tsx | 12 +-
.../modules/time-chart-module.tsx | 451 -----------
.../continuous-chart-render.scss | 131 +++
.../continuous-chart-render.tsx | 754 ++++++++++++++++++
.../time-chart-module/time-chart-module.tsx | 348 ++++++++
.../query-macros/max-data-time.ts | 25 +-
.../views/explore-view/utils/date-format.ts | 28 -
.../utils/filter-pattern-helpers.ts | 89 ++-
.../src/views/explore-view/utils/index.ts | 2 -
.../views/explore-view/utils/table-query.ts | 5 +-
.../utils/time-manipulation.spec.ts | 51 +-
.../explore-view/utils/time-manipulation.ts | 58 ++
.../views/load-data-view/load-data-view.tsx | 10 +-
.../schema-step/schema-step.tsx | 2 +-
.../time-menu-items/time-menu-items.tsx | 19 +-
.../execution-details-pane.spec.tsx.snap | 4 -
.../execution-summary-panel.tsx | 9 +
.../timezone-menu-items.tsx | 3 +-
web-console/tsconfig.json | 2 +-
100 files changed, 4680 insertions(+), 4072 deletions(-)
delete mode 100644 web-console/src/components/segment-timeline/chart-axis.tsx
rename web-console/src/components/segment-timeline/{common.ts => interval.ts} (98%)
rename web-console/src/{views/explore-view/components/highlight-bubble/highlight-bubble.scss => hooks/use-memo-with-previous.ts} (52%)
delete mode 100644 web-console/src/hooks/use-resize-observer.ts
rename web-console/src/{views/explore-view/utils/get-auto-granularity.ts => utils/auto-granularity.ts} (55%)
delete mode 100755 web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil-utc.spec.ts
delete mode 100755 web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.spec.ts
delete mode 100755 web-console/src/utils/date-floor-shift-ceil/date-floor-shift-ceil.ts
create mode 100644 web-console/src/utils/date-format.ts
delete mode 100755 web-console/src/utils/duration/duration.spec.ts
delete mode 100755 web-console/src/utils/duration/duration.ts
rename web-console/src/views/explore-view/components/{filter-pane => }/column-value/column-value.scss (100%)
rename web-console/src/views/explore-view/components/{filter-pane => }/column-value/column-value.tsx (84%)
create mode 100644 web-console/src/views/explore-view/components/helper-table/helper-table.scss
create mode 100644 web-console/src/views/explore-view/components/helper-table/helper-table.tsx
delete mode 100644 web-console/src/views/explore-view/components/highlight-bubble/highlight-bubble.tsx
delete mode 100644 web-console/src/views/explore-view/explore-state.ts
delete mode 100644 web-console/src/views/explore-view/highlight-store/highlight-store.ts
create mode 100644 web-console/src/views/explore-view/models/explore-state.ts
create mode 100644 web-console/src/views/explore-view/models/module-state.ts
rename web-console/src/views/explore-view/modules/{ => bar-chart-module}/bar-chart-module.tsx (60%)
rename web-console/src/views/explore-view/modules/{ => grouping-table-module}/grouping-table-module.scss (100%)
rename web-console/src/views/explore-view/modules/{ => grouping-table-module}/grouping-table-module.tsx (92%)
rename web-console/src/views/explore-view/modules/{ => multi-axis-chart-module}/multi-axis-chart-module.tsx (70%)
rename web-console/src/views/explore-view/modules/{ => pie-chart-module}/pie-chart-module.tsx (74%)
rename web-console/src/views/explore-view/modules/{ => record-table-module}/record-table-module.scss (100%)
rename web-console/src/views/explore-view/modules/{ => record-table-module}/record-table-module.tsx (91%)
delete mode 100644 web-console/src/views/explore-view/modules/time-chart-module.tsx
create mode 100644 web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss
create mode 100644 web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx
create mode 100644 web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx
delete mode 100644 web-console/src/views/explore-view/utils/date-format.ts
diff --git a/licenses.yaml b/licenses.yaml
index e3f822300cfb..8d7457ec1dc3 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5072,7 +5072,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Palantir Technologies
-version: 5.1.2
+version: 5.1.4
---
@@ -5081,7 +5081,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Palantir Technologies
-version: 5.13.1
+version: 5.16.0
---
@@ -5090,7 +5090,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Palantir Technologies
-version: 2.3.11
+version: 2.3.17
---
@@ -5099,7 +5099,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Palantir Technologies
-version: 5.3.11
+version: 5.3.17
---
@@ -5108,7 +5108,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Palantir Technologies
-version: 5.13.0
+version: 5.15.0
---
@@ -5117,7 +5117,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Palantir Technologies
-version: 5.2.5
+version: 5.3.5
---
@@ -5419,6 +5419,15 @@ license_file_path: licenses/bin/change-case.MIT
---
+name: "chronoshift"
+license_category: binary
+module: web-console
+license_name: Apache License version 2.0
+copyright: Vadim Ogievetsky
+version: 1.1.0
+
+---
+
name: "classnames"
license_category: binary
module: web-console
@@ -5589,13 +5598,13 @@ license_file_path: licenses/bin/d3-interpolate.ISC
---
-name: "d3-scale-chromatic"
+name: "d3-path"
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
+license_file_path: licenses/bin/d3-path.ISC
---
@@ -5619,6 +5628,16 @@ license_file_path: licenses/bin/d3-selection.ISC
---
+name: "d3-shape"
+license_category: binary
+module: web-console
+license_name: ISC License
+copyright: Mike Bostock
+version: 3.2.0
+license_file_path: licenses/bin/d3-shape.ISC
+
+---
+
name: "d3-time-format"
license_category: binary
module: web-console
@@ -5713,7 +5732,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Imply Data
-version: 1.0.0
+version: 1.0.2
---
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 27a3d463aad2..d07f6e31dbf1 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -9,26 +9,27 @@
"version": "32.0.0",
"license": "Apache-2.0",
"dependencies": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/datetime": "^5.3.11",
- "@blueprintjs/datetime2": "^2.3.11",
- "@blueprintjs/icons": "^5.13.0",
- "@blueprintjs/select": "^5.2.5",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/datetime": "^5.3.17",
+ "@blueprintjs/datetime2": "^2.3.17",
+ "@blueprintjs/icons": "^5.15.0",
+ "@blueprintjs/select": "^5.3.5",
"@flatten-js/interval-tree": "^1.1.3",
"@fontsource/open-sans": "^5.0.30",
"@internationalized/date": "^3.5.6",
"ace-builds": "~1.5.3",
"axios": "^1.7.7",
+ "chronoshift": "^1.1.0",
"classnames": "^2.2.6",
"copy-to-clipboard": "^3.3.3",
"d3-array": "^3.2.4",
"d3-axis": "^3.0.0",
"d3-dsv": "^3.0.1",
"d3-scale": "^4.0.2",
- "d3-scale-chromatic": "^3.1.0",
"d3-selection": "^3.0.0",
+ "d3-shape": "^3.2.0",
"date-fns": "^2.28.0",
- "druid-query-toolkit": "^1.0.0",
+ "druid-query-toolkit": "^1.0.2",
"echarts": "^5.5.1",
"file-saver": "^2.0.5",
"hjson": "^3.2.2",
@@ -60,8 +61,8 @@
"@types/d3-axis": "^3.0.6",
"@types/d3-dsv": "^3.0.7",
"@types/d3-scale": "^4.0.8",
- "@types/d3-scale-chromatic": "^3.0.3",
"@types/d3-selection": "^3.0.11",
+ "@types/d3-shape": "^3.1.6",
"@types/enzyme": "^3.10.18",
"@types/enzyme-adapter-react-16": "^1.0.9",
"@types/file-saver": "^2.0.7",
@@ -785,9 +786,9 @@
"dev": true
},
"node_modules/@blueprintjs/colors": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/@blueprintjs/colors/-/colors-5.1.2.tgz",
- "integrity": "sha512-7CWwVsXK4YTN9Z/wkjnS3p7VE8YfIXXv2UaySAbtcw6rBkmoSHjLRtfohSA5yNy8xYTQ4KY2odKZSUW0W/Nltw==",
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/colors/-/colors-5.1.4.tgz",
+ "integrity": "sha512-OBRswl1v/AQXtx8PLP6PhZX+xY+Q/LP/eQATQi/ZUCrNbE0ZkMXQRS9PK/7ZVllnQqcACkC4x/JVthkzkLoG2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "~2.6.2"
@@ -800,13 +801,13 @@
"license": "0BSD"
},
"node_modules/@blueprintjs/core": {
- "version": "5.13.1",
- "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-5.13.1.tgz",
- "integrity": "sha512-PDZ9X/xGBetwU2AqQuCDGVWZZMmt6+/BCnmoKXxsBBZIuWQuHDiKcwvb9rzup0htsA6P7KGl5aw7ocmDvZPpBw==",
+ "version": "5.16.0",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-5.16.0.tgz",
+ "integrity": "sha512-umWvCL9iHP01AO11fILCLK8ZT8mkOouSC+MFwk4cVx8HElxLoUFCf64lUFSQpQMPyNKICYJ4qBrN/hKsez11lQ==",
"license": "Apache-2.0",
"dependencies": {
- "@blueprintjs/colors": "^5.1.2",
- "@blueprintjs/icons": "^5.13.0",
+ "@blueprintjs/colors": "^5.1.4",
+ "@blueprintjs/icons": "^5.15.0",
"@popperjs/core": "^2.11.8",
"classnames": "^2.3.1",
"normalize.css": "^8.0.1",
@@ -838,14 +839,14 @@
"license": "0BSD"
},
"node_modules/@blueprintjs/datetime": {
- "version": "5.3.11",
- "resolved": "https://registry.npmjs.org/@blueprintjs/datetime/-/datetime-5.3.11.tgz",
- "integrity": "sha512-6MMictO0OPc6gRoPt/gZ6V5tFTKj9d2M3Mpn4alNa74uZF/Y1FjBfOvSPOsV7JajBOVjqiaSEBRZIFHrhRr91Q==",
+ "version": "5.3.17",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/datetime/-/datetime-5.3.17.tgz",
+ "integrity": "sha512-Y6F3Md3OrEIqv3iF10WXpYwSc6Nhuk4i4g7GWIny/kZajLmLysPHFuzmidZ9IKrBYYwPBObAyWQmy9n3qR6mFw==",
"license": "Apache-2.0",
"dependencies": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/icons": "^5.13.0",
- "@blueprintjs/select": "^5.2.5",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/icons": "^5.15.0",
+ "@blueprintjs/select": "^5.3.5",
"classnames": "^2.3.1",
"date-fns": "^2.28.0",
"date-fns-tz": "^2.0.0",
@@ -870,14 +871,14 @@
"license": "0BSD"
},
"node_modules/@blueprintjs/datetime2": {
- "version": "2.3.11",
- "resolved": "https://registry.npmjs.org/@blueprintjs/datetime2/-/datetime2-2.3.11.tgz",
- "integrity": "sha512-o9NYfUAb8xYiGZQ56tn6CXm8jDNxwSxJvZgrzs6EVYCuCFrsBr9WAjy0p768ZbZlzuWwClbEjslr8s+E6etU/w==",
+ "version": "2.3.17",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/datetime2/-/datetime2-2.3.17.tgz",
+ "integrity": "sha512-HZNpQWCPTfTJfx8eZwZDnb/HoixyBqHPypWYs8DxWgfWIOVxpV0aaQUJl+Gni/wIgpf9g6qDs7x8WFdMt/G8Mg==",
"license": "Apache-2.0",
"dependencies": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/datetime": "^5.3.11",
- "@blueprintjs/icons": "^5.13.0",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/datetime": "^5.3.17",
+ "@blueprintjs/icons": "^5.15.0",
"classnames": "^2.3.1",
"date-fns": "^2.28.0",
"react-day-picker": "^8.10.0",
@@ -915,9 +916,9 @@
"license": "0BSD"
},
"node_modules/@blueprintjs/icons": {
- "version": "5.13.0",
- "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-5.13.0.tgz",
- "integrity": "sha512-L096dBjzfnWW7fWXM311S2C/5Zn0EuEK9q6G84QvWP0BZJOTowU1EIWLj90IgGtNajld/3ZUAj6eJf+ryt/kjQ==",
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-5.15.0.tgz",
+ "integrity": "sha512-5OiDY0hdQwfljfo9ynmmUBh3ibXTDuJ74WpwCakTr4hD9zGMhpzjnpEoZMfrMCsKZubGBiOFcT5riPljrDpdLw==",
"license": "Apache-2.0",
"dependencies": {
"change-case": "^4.1.2",
@@ -942,13 +943,13 @@
"license": "0BSD"
},
"node_modules/@blueprintjs/select": {
- "version": "5.2.5",
- "resolved": "https://registry.npmjs.org/@blueprintjs/select/-/select-5.2.5.tgz",
- "integrity": "sha512-mO9r5iQ4uxdgScTyNSNSLq3DUZgFtI3dAO1VRUNONBGPcSROVi+3ixYYynwcad18ftAZz/WuUD9ZHqVyDwXmbQ==",
+ "version": "5.3.5",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/select/-/select-5.3.5.tgz",
+ "integrity": "sha512-oULOvtnFHiaXPWahyQg0XFc2YhZrY1FPOuHq3fDKyb1MukYEYshy+L4gHUA5gnVBxv6543Oi7m7ErxGE3bcJmQ==",
"license": "Apache-2.0",
"dependencies": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/icons": "^5.13.0",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/icons": "^5.15.0",
"classnames": "^2.3.1",
"tslib": "~2.6.2"
},
@@ -3771,6 +3772,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/d3-scale": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
@@ -3781,12 +3789,6 @@
"@types/d3-time": "*"
}
},
- "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": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
@@ -3794,6 +3796,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
+ "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
"node_modules/@types/d3-time": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.1.tgz",
@@ -5818,6 +5830,16 @@
"node": ">=6.0"
}
},
+ "node_modules/chronoshift": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/chronoshift/-/chronoshift-1.1.0.tgz",
+ "integrity": "sha512-Mq72wZIn3lF8yyHo2LjOnWir8CXVafHalOXvYN1qvpYAYX9yOyUnlxLtd6W6g74xYDv9lfc/3sNZfY3EmkUwUw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@internationalized/date": "^3.5.6",
+ "tslib": "^2.8.1"
+ }
+ },
"node_modules/ci-info": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz",
@@ -6575,6 +6597,15 @@
"node": ">=12"
}
},
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -6591,18 +6622,6 @@
"node": ">=12"
}
},
- "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-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
@@ -6629,6 +6648,18 @@
"node": ">=12"
}
},
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/d3-time": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
@@ -7021,9 +7052,9 @@
}
},
"node_modules/druid-query-toolkit": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-1.0.0.tgz",
- "integrity": "sha512-yBQR4uDcks0lcsRSWoLQy16YQ4dx264m6i7TNQDFrACUKHlMtnw5l+4+UDZKbXbpUFLMLWCr/kLhmXzLJk50+Q==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-1.0.2.tgz",
+ "integrity": "sha512-TXu8io3oF04g0xhiGeXBB3xsGt2kJtp+95jrSja1a5Db8NFpkso6UPbfc6vlok24bky1aUKObTPJVVKOwSaBIw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.5.2"
@@ -8193,9 +8224,9 @@
}
},
"node_modules/express": {
- "version": "4.21.1",
- "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
- "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
+ "version": "4.21.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8218,7 +8249,7 @@
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "0.1.10",
+ "path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
@@ -8233,6 +8264,10 @@
},
"engines": {
"node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/array-flatten": {
@@ -8260,10 +8295,11 @@
}
},
"node_modules/express/node_modules/path-to-regexp": {
- "version": "0.1.10",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
- "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
- "dev": true
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/express/node_modules/safe-buffer": {
"version": "5.2.1",
@@ -12918,9 +12954,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "3.3.8",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+ "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true,
"funding": [
{
@@ -12928,6 +12964,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -17257,9 +17294,9 @@
}
},
"node_modules/tslib": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz",
- "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==",
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
@@ -19018,9 +19055,9 @@
"dev": true
},
"@blueprintjs/colors": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/@blueprintjs/colors/-/colors-5.1.2.tgz",
- "integrity": "sha512-7CWwVsXK4YTN9Z/wkjnS3p7VE8YfIXXv2UaySAbtcw6rBkmoSHjLRtfohSA5yNy8xYTQ4KY2odKZSUW0W/Nltw==",
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/colors/-/colors-5.1.4.tgz",
+ "integrity": "sha512-OBRswl1v/AQXtx8PLP6PhZX+xY+Q/LP/eQATQi/ZUCrNbE0ZkMXQRS9PK/7ZVllnQqcACkC4x/JVthkzkLoG2g==",
"requires": {
"tslib": "~2.6.2"
},
@@ -19033,12 +19070,12 @@
}
},
"@blueprintjs/core": {
- "version": "5.13.1",
- "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-5.13.1.tgz",
- "integrity": "sha512-PDZ9X/xGBetwU2AqQuCDGVWZZMmt6+/BCnmoKXxsBBZIuWQuHDiKcwvb9rzup0htsA6P7KGl5aw7ocmDvZPpBw==",
+ "version": "5.16.0",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-5.16.0.tgz",
+ "integrity": "sha512-umWvCL9iHP01AO11fILCLK8ZT8mkOouSC+MFwk4cVx8HElxLoUFCf64lUFSQpQMPyNKICYJ4qBrN/hKsez11lQ==",
"requires": {
- "@blueprintjs/colors": "^5.1.2",
- "@blueprintjs/icons": "^5.13.0",
+ "@blueprintjs/colors": "^5.1.4",
+ "@blueprintjs/icons": "^5.15.0",
"@popperjs/core": "^2.11.8",
"classnames": "^2.3.1",
"normalize.css": "^8.0.1",
@@ -19057,13 +19094,13 @@
}
},
"@blueprintjs/datetime": {
- "version": "5.3.11",
- "resolved": "https://registry.npmjs.org/@blueprintjs/datetime/-/datetime-5.3.11.tgz",
- "integrity": "sha512-6MMictO0OPc6gRoPt/gZ6V5tFTKj9d2M3Mpn4alNa74uZF/Y1FjBfOvSPOsV7JajBOVjqiaSEBRZIFHrhRr91Q==",
+ "version": "5.3.17",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/datetime/-/datetime-5.3.17.tgz",
+ "integrity": "sha512-Y6F3Md3OrEIqv3iF10WXpYwSc6Nhuk4i4g7GWIny/kZajLmLysPHFuzmidZ9IKrBYYwPBObAyWQmy9n3qR6mFw==",
"requires": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/icons": "^5.13.0",
- "@blueprintjs/select": "^5.2.5",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/icons": "^5.15.0",
+ "@blueprintjs/select": "^5.3.5",
"classnames": "^2.3.1",
"date-fns": "^2.28.0",
"date-fns-tz": "^2.0.0",
@@ -19079,13 +19116,13 @@
}
},
"@blueprintjs/datetime2": {
- "version": "2.3.11",
- "resolved": "https://registry.npmjs.org/@blueprintjs/datetime2/-/datetime2-2.3.11.tgz",
- "integrity": "sha512-o9NYfUAb8xYiGZQ56tn6CXm8jDNxwSxJvZgrzs6EVYCuCFrsBr9WAjy0p768ZbZlzuWwClbEjslr8s+E6etU/w==",
+ "version": "2.3.17",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/datetime2/-/datetime2-2.3.17.tgz",
+ "integrity": "sha512-HZNpQWCPTfTJfx8eZwZDnb/HoixyBqHPypWYs8DxWgfWIOVxpV0aaQUJl+Gni/wIgpf9g6qDs7x8WFdMt/G8Mg==",
"requires": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/datetime": "^5.3.11",
- "@blueprintjs/icons": "^5.13.0",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/datetime": "^5.3.17",
+ "@blueprintjs/icons": "^5.15.0",
"classnames": "^2.3.1",
"date-fns": "^2.28.0",
"react-day-picker": "^8.10.0",
@@ -19106,9 +19143,9 @@
}
},
"@blueprintjs/icons": {
- "version": "5.13.0",
- "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-5.13.0.tgz",
- "integrity": "sha512-L096dBjzfnWW7fWXM311S2C/5Zn0EuEK9q6G84QvWP0BZJOTowU1EIWLj90IgGtNajld/3ZUAj6eJf+ryt/kjQ==",
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-5.15.0.tgz",
+ "integrity": "sha512-5OiDY0hdQwfljfo9ynmmUBh3ibXTDuJ74WpwCakTr4hD9zGMhpzjnpEoZMfrMCsKZubGBiOFcT5riPljrDpdLw==",
"requires": {
"change-case": "^4.1.2",
"classnames": "^2.3.1",
@@ -19123,12 +19160,12 @@
}
},
"@blueprintjs/select": {
- "version": "5.2.5",
- "resolved": "https://registry.npmjs.org/@blueprintjs/select/-/select-5.2.5.tgz",
- "integrity": "sha512-mO9r5iQ4uxdgScTyNSNSLq3DUZgFtI3dAO1VRUNONBGPcSROVi+3ixYYynwcad18ftAZz/WuUD9ZHqVyDwXmbQ==",
+ "version": "5.3.5",
+ "resolved": "https://registry.npmjs.org/@blueprintjs/select/-/select-5.3.5.tgz",
+ "integrity": "sha512-oULOvtnFHiaXPWahyQg0XFc2YhZrY1FPOuHq3fDKyb1MukYEYshy+L4gHUA5gnVBxv6543Oi7m7ErxGE3bcJmQ==",
"requires": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/icons": "^5.13.0",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/icons": "^5.15.0",
"classnames": "^2.3.1",
"tslib": "~2.6.2"
},
@@ -20835,6 +20872,12 @@
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"dev": true
},
+ "@types/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==",
+ "dev": true
+ },
"@types/d3-scale": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
@@ -20844,18 +20887,21 @@
"@types/d3-time": "*"
}
},
- "@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": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"dev": true
},
+ "@types/d3-shape": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
+ "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
+ "dev": true,
+ "requires": {
+ "@types/d3-path": "*"
+ }
+ },
"@types/d3-time": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.1.tgz",
@@ -22405,6 +22451,15 @@
"integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
"dev": true
},
+ "chronoshift": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/chronoshift/-/chronoshift-1.1.0.tgz",
+ "integrity": "sha512-Mq72wZIn3lF8yyHo2LjOnWir8CXVafHalOXvYN1qvpYAYX9yOyUnlxLtd6W6g74xYDv9lfc/3sNZfY3EmkUwUw==",
+ "requires": {
+ "@internationalized/date": "^3.5.6",
+ "tslib": "^2.8.1"
+ }
+ },
"ci-info": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz",
@@ -22932,6 +22987,11 @@
"d3-color": "1 - 3"
}
},
+ "d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
+ },
"d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -22962,20 +23022,19 @@
}
}
},
- "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": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="
},
+ "d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "requires": {
+ "d3-path": "^3.1.0"
+ }
+ },
"d3-time": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
@@ -23261,9 +23320,9 @@
}
},
"druid-query-toolkit": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-1.0.0.tgz",
- "integrity": "sha512-yBQR4uDcks0lcsRSWoLQy16YQ4dx264m6i7TNQDFrACUKHlMtnw5l+4+UDZKbXbpUFLMLWCr/kLhmXzLJk50+Q==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-1.0.2.tgz",
+ "integrity": "sha512-TXu8io3oF04g0xhiGeXBB3xsGt2kJtp+95jrSja1a5Db8NFpkso6UPbfc6vlok24bky1aUKObTPJVVKOwSaBIw==",
"requires": {
"tslib": "^2.5.2"
}
@@ -24097,9 +24156,9 @@
}
},
"express": {
- "version": "4.21.1",
- "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
- "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
+ "version": "4.21.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dev": true,
"requires": {
"accepts": "~1.3.8",
@@ -24121,7 +24180,7 @@
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "0.1.10",
+ "path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
@@ -24157,9 +24216,9 @@
"dev": true
},
"path-to-regexp": {
- "version": "0.1.10",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
- "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==",
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"dev": true
},
"safe-buffer": {
@@ -27558,9 +27617,9 @@
}
},
"nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "3.3.8",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+ "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true
},
"natural-compare": {
@@ -30442,9 +30501,9 @@
}
},
"tslib": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz",
- "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA=="
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"type-check": {
"version": "0.4.0",
diff --git a/web-console/package.json b/web-console/package.json
index 39eae99e101b..1cb23e1b86ef 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -50,26 +50,27 @@
"not ie 11"
],
"dependencies": {
- "@blueprintjs/core": "^5.13.1",
- "@blueprintjs/datetime": "^5.3.11",
- "@blueprintjs/datetime2": "^2.3.11",
- "@blueprintjs/icons": "^5.13.0",
- "@blueprintjs/select": "^5.2.5",
+ "@blueprintjs/core": "^5.16.0",
+ "@blueprintjs/datetime": "^5.3.17",
+ "@blueprintjs/datetime2": "^2.3.17",
+ "@blueprintjs/icons": "^5.15.0",
+ "@blueprintjs/select": "^5.3.5",
"@flatten-js/interval-tree": "^1.1.3",
"@fontsource/open-sans": "^5.0.30",
"@internationalized/date": "^3.5.6",
"ace-builds": "~1.5.3",
"axios": "^1.7.7",
+ "chronoshift": "^1.1.0",
"classnames": "^2.2.6",
"copy-to-clipboard": "^3.3.3",
"d3-array": "^3.2.4",
"d3-axis": "^3.0.0",
"d3-dsv": "^3.0.1",
"d3-scale": "^4.0.2",
- "d3-scale-chromatic": "^3.1.0",
"d3-selection": "^3.0.0",
+ "d3-shape": "^3.2.0",
"date-fns": "^2.28.0",
- "druid-query-toolkit": "^1.0.0",
+ "druid-query-toolkit": "^1.0.2",
"echarts": "^5.5.1",
"file-saver": "^2.0.5",
"hjson": "^3.2.2",
@@ -101,8 +102,8 @@
"@types/d3-axis": "^3.0.6",
"@types/d3-dsv": "^3.0.7",
"@types/d3-scale": "^4.0.8",
- "@types/d3-scale-chromatic": "^3.0.3",
"@types/d3-selection": "^3.0.11",
+ "@types/d3-shape": "^3.1.6",
"@types/enzyme": "^3.10.18",
"@types/enzyme-adapter-react-16": "^1.0.9",
"@types/file-saver": "^2.0.7",
diff --git a/web-console/src/components/clearable-input/clearable-input.spec.tsx b/web-console/src/components/clearable-input/clearable-input.spec.tsx
index 3fcd0e0098f7..e209474d5213 100644
--- a/web-console/src/components/clearable-input/clearable-input.spec.tsx
+++ b/web-console/src/components/clearable-input/clearable-input.spec.tsx
@@ -27,7 +27,7 @@ describe('ClearableInput', () => {
className="testClassName"
value="testValue"
placeholder="testPlaceholder"
- onChange={() => {}}
+ onValueChange={() => {}}
/>
);
diff --git a/web-console/src/components/clearable-input/clearable-input.tsx b/web-console/src/components/clearable-input/clearable-input.tsx
index 9ea33a1bef61..de75d7dbdca7 100644
--- a/web-console/src/components/clearable-input/clearable-input.tsx
+++ b/web-console/src/components/clearable-input/clearable-input.tsx
@@ -16,30 +16,32 @@
* limitations under the License.
*/
+import type { InputGroupProps } from '@blueprintjs/core';
import { Button, InputGroup } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import React from 'react';
-export interface ClearableInputProps {
+export interface ClearableInputProps extends InputGroupProps {
className?: string;
value: string;
- onChange: (value: string) => void;
- placeholder: string;
+ onValueChange: (value: string) => void;
}
export const ClearableInput = React.memo(function ClearableInput(props: ClearableInputProps) {
- const { className, value, onChange, placeholder } = props;
+ const { className, value, onValueChange, ...otherProps } = props;
return (
onChange(e.target.value)}
+ onChange={(e: any) => onValueChange(e.target.value)}
rightElement={
- value ?
+
+ {
+ setNamingScheme(e.target.value.slice(0, ExpressionMeta.MAX_NAME_LENGTH));
+ }}
+ />
+
{pathsState.isLoading() && }
{pathsState.getErrorMessage()}
{paths && (
-
+
)}
-
- {
- setNamingScheme(e.target.value.slice(0, ExpressionMeta.MAX_NAME_LENGTH));
- }}
- />
-
@@ -152,17 +169,12 @@ export const NestedColumnDialog = React.memo(function NestedColumnDialog(
disabled={!selectedPaths.length}
intent={Intent.PRIMARY}
onClick={() => {
- const effectiveNamingScheme = namingScheme.includes('%')
- ? namingScheme
- : namingScheme + '[%]';
onApply(
querySource.addColumnAfter(
- nestedColumn.getOutputName()!,
+ nestedColumn.getOutputName() || '',
...selectedPaths.map(path =>
F('JSON_VALUE', nestedColumn, path).as(
- querySource.getAvailableName(
- effectiveNamingScheme.replaceAll('%', path.replace(/^\$\./, '')),
- ),
+ querySource.getAvailableName(getOutputName(path)),
),
),
),
diff --git a/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
index 89a166062637..65f08501c557 100644
--- a/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
+++ b/web-console/src/views/explore-view/components/resource-pane/resource-pane.scss
@@ -19,46 +19,61 @@
@import '../../../../variables';
.resource-pane {
- display: flex;
- flex-direction: column;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ grid-template-areas:
+ 'search'
+ 'resources';
.search-input {
+ grid-area: search;
margin: 4px;
}
- .list-header {
- padding: 6px;
- text-transform: uppercase;
- font-weight: bold;
- position: relative;
+ .splitter-layout {
+ grid-area: resources;
- .header-buttons {
- position: absolute;
- top: 0;
- right: 0;
+ .layout-splitter {
+ background-color: $light-gray4;
+
+ .#{$bp-ns}-dark & {
+ border-bottom: 2px solid $dark-gray2;
+ }
}
- }
- .column-resource-list {
- flex: 3;
- overflow: auto;
- border-bottom: 2px solid $light-gray4;
+ .resource-sub-pane {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ grid-template-areas:
+ 'header'
+ 'list';
- .#{$bp-ns}-dark & {
- border-bottom: 2px solid $dark-gray2;
- }
+ .list-header {
+ grid-area: header;
+ padding: 6px;
+ text-transform: uppercase;
+ font-weight: bold;
+ position: relative;
- .column-resource {
- display: block;
- }
- }
+ .header-buttons {
+ position: absolute;
+ top: 0;
+ right: 0;
+ }
+ }
- .measure-resource-list {
- flex: 1;
- overflow: auto;
+ .resource-list {
+ grid-area: list;
+ overflow: auto;
- .measure-resource {
- display: block;
+ .column-resource,
+ .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
index dda84fdbf05f..9c6146dfd1b1 100644
--- 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
@@ -32,7 +32,7 @@ import classNames from 'classnames';
import type { Column, QueryResult, SqlExpression, SqlQuery } from 'druid-query-toolkit';
import { useState } from 'react';
-import { ClearableInput } from '../../../../components';
+import { ClearableInput, SplitterLayout } from '../../../../components';
import { caseInsensitiveContains, columnToIcon, filterMap } from '../../../../utils';
import { DragHelper } from '../../drag-helper';
import type { Measure, QuerySource } from '../../models';
@@ -97,203 +97,220 @@ export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
-
- Columns
-
- setColumnEditorOpenOn({})}
- />
-
-
-
-
-
- {filterMap(querySource.columns, (column, i) => {
- const columnName = column.name;
- const isNestedColumn = column.nativeType === 'COMPLEX
';
- if (!caseInsensitiveContains(columnName, columnSearch)) return;
- return (
-
- {isNestedColumn ? (
+
+
+
+ Columns
+
+ setColumnEditorOpenOn({})}
+ />
+
- setNestedColumnEditorOpenOn(
- querySource.getSourceExpressionForColumn(columnName),
- )
- }
+ text="Make nice columns titles"
+ onClick={() => applyUtil(makeNiceTitle)}
/>
- ) : (
- <>
- {onFilter && (
+ applyUtil(x => x.toUpperCase())}
+ />
+ applyUtil(x => x.toLowerCase())}
+ />
+
+ }
+ >
+
+
+
+
+
+ {filterMap(querySource.columns, (column, i) => {
+ const columnName = column.name;
+ const isNestedColumn = column.nativeType === 'COMPLEX
';
+ if (!caseInsensitiveContains(columnName, columnSearch)) return;
+ return (
+
+ {isNestedColumn ? (
onFilter(column)}
+ icon={IconNames.EXPAND_ALL}
+ text="Expand nested column"
+ onClick={() =>
+ setNestedColumnEditorOpenOn(
+ querySource.getSourceExpressionForColumn(columnName),
+ )
+ }
/>
+ ) : (
+ <>
+ {onFilter && (
+ onFilter(column)}
+ />
+ )}
+ onShowColumn(column)}
+ />
+
+ >
)}
+
+ setColumnEditorOpenOn({
+ expression: querySource.getSourceExpressionForColumn(columnName),
+ })
+ }
+ />
+
+ setColumnEditorOpenOn({
+ columnToDuplicate: columnName,
+ expression: querySource
+ .getSourceExpressionForColumn(columnName)
+ .as(querySource.getAvailableName(columnName)),
+ })
+ }
+ />
+
+ onQueryChange(querySource.deleteColumn(columnName), undefined)
+ }
+ />
+
+ }
+ >
+ {
+ e.dataTransfer.effectAllowed = 'all';
+ DragHelper.dragColumn = column;
+ DragHelper.createDragGhost(e.dataTransfer, columnName);
+ }}
+ >
+
+
+ {columnName}
+
+
+
+ );
+ })}
+
+
+
+
+ Measures
+
+ setMeasureEditorOpenOn({})}
+ />
+
+
+
+ {filterMap(querySource.measures, (measure, i) => {
+ const measureName = measure.name;
+ if (!caseInsensitiveContains(measureName, columnSearch)) return;
+ return (
+
onShowColumn(column)}
+ onClick={() => onShowMeasure(measure)}
/>
- >
- )}
-
- setColumnEditorOpenOn({
- expression: querySource.getSourceExpressionForColumn(columnName),
- })
- }
- />
-
- setColumnEditorOpenOn({
- columnToDuplicate: columnName,
- expression: querySource
- .getSourceExpressionForColumn(columnName)
- .as(querySource.getAvailableName(columnName)),
- })
- }
- />
- onQueryChange(querySource.deleteColumn(columnName), undefined)}
- />
-
- }
- >
- {
- e.dataTransfer.effectAllowed = 'all';
- DragHelper.dragColumn = column;
- DragHelper.createDragGhost(e.dataTransfer, columnName);
- }}
- >
-
-
- {columnName}
-
-
-
- );
- })}
-
-
- Measures
-
- setMeasureEditorOpenOn({})}
- />
-
-
-
- {filterMap(querySource.measures, (measure, i) => {
- const measureName = measure.name;
- if (!caseInsensitiveContains(measureName, columnSearch)) return;
- return (
-
- onShowMeasure(measure)}
- />
-
-
- setMeasureEditorOpenOn({
- measure,
- })
- }
- />
-
- setMeasureEditorOpenOn({
- measureToDuplicate: measureName,
- measure: measure.changeAs(querySource.getAvailableName(measureName)),
- })
- }
- />
- onQueryChange(querySource.deleteMeasure(measureName), undefined)}
- />
-
- }
- >
- {
- e.dataTransfer.effectAllowed = 'all';
- DragHelper.dragMeasure = measure.toAggregateBasedMeasure();
- DragHelper.createDragGhost(e.dataTransfer, measure.name);
- }}
- >
-
-
- {measureName}
-
-
-
- );
- })}
-
+
+ setMeasureEditorOpenOn({
+ measure,
+ })
+ }
+ />
+
+ setMeasureEditorOpenOn({
+ measureToDuplicate: measureName,
+ measure: measure.changeAs(querySource.getAvailableName(measureName)),
+ })
+ }
+ />
+
+ onQueryChange(querySource.deleteMeasure(measureName), undefined)
+ }
+ />
+
+ }
+ >
+ {
+ e.dataTransfer.effectAllowed = 'all';
+ DragHelper.dragMeasure = measure.toAggregateBasedMeasure();
+ DragHelper.createDragGhost(e.dataTransfer, measure.name);
+ }}
+ >
+
+
+ {measureName}
+
+
+
+ );
+ })}
+
+
+
{columnEditorOpenOn && (
-
onClose()} />
+ onClose()} />
);
diff --git a/web-console/src/views/explore-view/explore-state.ts b/web-console/src/views/explore-view/explore-state.ts
deleted file mode 100644
index a5faba0a26f4..000000000000
--- a/web-console/src/views/explore-view/explore-state.ts
+++ /dev/null
@@ -1,237 +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 { Column } from 'druid-query-toolkit';
-import {
- filterPatternToExpression,
- SqlExpression,
- SqlLiteral,
- SqlQuery,
-} from 'druid-query-toolkit';
-
-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: ParameterValues;
-}
-
-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: ParameterValues;
-
- 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): ExploreState {
- return new ExploreState({
- ...this.valueOf(),
- ...newValues,
- });
- }
-
- public changeSource(newSource: SqlQuery | string, rename: Rename | undefined): ExploreState {
- const toChange: Partial = {
- 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;
-
- // Either find the `__time::TIMESTAMP` column or use the first column if it is a TIMESTAMP
- const timeColumn =
- columns.find(c => c.isTimeColumn()) ||
- (columns[0].sqlType === 'TIMESTAMP' ? columns[0] : undefined);
- 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 : {}),
- 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 09627e0c9bdf..54a976f780db 100644
--- a/web-console/src/views/explore-view/explore-view.scss
+++ b/web-console/src/views/explore-view/explore-view.scss
@@ -24,21 +24,24 @@ $resources-width: 240px;
height: 100%;
position: relative;
+ .splitter-layout {
+ height: 100%;
+ }
+
+ .layout-splitter:hover {
+ background: black;
+ opacity: 0.1;
+ border-radius: 2px;
+ }
+
.source-query-pane {
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- height: 192px;
+ @include pin-full;
@include card-like;
+ overflow: hidden;
}
.source-error {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- top: 0;
+ @include pin-full;
@include card-like;
padding: 20px;
@@ -47,56 +50,200 @@ $resources-width: 240px;
}
}
- &.show-source-query .explore-container {
- top: 200px;
- }
- &.show-source-query .source-error {
- top: 200px;
+ .filter-explore-wrapper {
+ @include pin-full;
+ display: grid;
+ gap: 8px;
+ grid-template-rows: auto 1fr;
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ 'fil'
+ 'mod';
+
+ & > .filter-pane-container {
+ grid-area: fil;
+ position: relative;
+ @include card-like;
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ align-content: flex-start;
+
+ .source-pane-container {
+ padding: 8px 0;
+ border-right: 1px solid $dark-gray2;
+ }
+
+ .filter-pane {
+ flex: 1;
+ padding: 8px 0;
+ }
+
+ .action-buttons {
+ padding: 8px 0;
+ }
+ }
+
+ & > .resource-explore-splitter {
+ grid-area: mod;
+
+ .module-helpers-splitter {
+ @include pin-full;
+ }
+ }
}
- .explore-container {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- top: 0;
+ .resource-pane-cnt {
+ @include pin-full;
+ @include card-like;
+
+ .resource-pane {
+ @include pin-full;
+ }
+ }
+ .modules-pane {
+ @include pin-full;
display: grid;
- grid-template-columns: $resources-width 1fr $resources-width;
- grid-template-rows: auto 1fr;
- gap: 8px;
+ gap: 5px;
- .source-pane,
- .filter-pane,
- .module-picker,
- .control-pane-cnt {
- @include card-like;
- padding: 8px;
- height: 100%;
- overflow: auto;
+ &.layout-single {
+ grid-template-areas: 'm0';
}
- .resource-pane-cnt,
- .module-holder {
- @include card-like;
+ &.layout-two-by-two {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0 m1'
+ 'm2 m3';
}
- .resource-pane-cnt {
- position: relative;
- height: 100%;
+ &.layout-two-rows {
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0'
+ 'm1';
+ }
- .resource-pane {
- position: absolute;
- width: 100%;
- height: 100%;
- }
+ &.layout-two-columns {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas: 'm0 m1';
+ }
+
+ &.layout-three-rows {
+ grid-template-rows: 1fr 1fr 1fr;
+ grid-template-areas:
+ 'm0'
+ 'm1'
+ 'm2';
+ }
+
+ &.layout-three-columns {
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-areas: 'm0 m1 m2';
+ }
+
+ &.layout-top-row-two-tiles {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0 m1'
+ 'm2 m2';
+ }
+
+ &.layout-bottom-row-two-tiles {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0 m0'
+ 'm1 m2';
+ }
+
+ &.layout-left-column-two-tiles {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0 m2'
+ 'm1 m2';
+ }
+
+ &.layout-right-column-two-tiles {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0 m1'
+ 'm0 m2';
+ }
+
+ &.layout-top-row-three-tiles {
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0 m1 m2'
+ 'm3 m3 m3';
+ }
+
+ &.layout-bottom-row-three-tiles {
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ grid-template-areas:
+ 'm0 m0 m0'
+ 'm1 m2 m3';
}
- .main-cnt {
- .module-pane {
- height: 100%;
+ &.layout-left-column-three-tiles {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr 1fr;
+ grid-template-areas:
+ 'm0 m3'
+ 'm1 m3'
+ 'm2 m3';
+ }
+
+ &.layout-right-column-three-tiles {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr 1fr;
+ grid-template-areas:
+ 'm0 m1'
+ 'm0 m2'
+ 'm0 m3';
+ }
+
+ @for $i from 0 through 3 {
+ & > .m#{$i} {
+ grid-area: m#{$i};
}
}
+
+ & > .no-module-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px gray dashed;
+ border-radius: 5px;
+ }
+ }
+
+ .helper-bar {
+ @include pin-full;
+ @include card-like;
+
+ .helper-tables {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .no-helper-message {
+ position: absolute;
+ left: 50%;
+ top: 42%;
+ transform: translate(-50%, 0);
+ white-space: nowrap;
+ }
}
}
@@ -115,3 +262,11 @@ $resources-width: 240px;
border-radius: 3px;
}
}
+
+.module-bubble {
+ .button-bar {
+ padding-top: 5px;
+ display: flex;
+ gap: 5px;
+ }
+}
diff --git a/web-console/src/views/explore-view/explore-view.tsx b/web-console/src/views/explore-view/explore-view.tsx
index 21c98b067fa8..703ca2bbd545 100644
--- a/web-console/src/views/explore-view/explore-view.tsx
+++ b/web-console/src/views/explore-view/explore-view.tsx
@@ -18,7 +18,18 @@
import './modules';
-import { Button, Intent, Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
+import {
+ Button,
+ ButtonGroup,
+ Icon,
+ Intent,
+ Menu,
+ MenuDivider,
+ MenuItem,
+ Popover,
+ Position,
+} from '@blueprintjs/core';
+import type { IconName } from '@blueprintjs/icons';
import { IconNames } from '@blueprintjs/icons';
import type { CancelToken } from 'axios';
import classNames from 'classnames';
@@ -26,49 +37,48 @@ import copy from 'copy-to-clipboard';
import type { Column, QueryResult, SqlExpression } from 'druid-query-toolkit';
import { QueryRunner, SqlQuery } from 'druid-query-toolkit';
import React, { useEffect, useMemo, useRef, useState } from 'react';
-import { useStore } from 'zustand';
-import { Loader } from '../../components';
+import { Loader, SplitterLayout } from '../../components';
import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
import { useHashAndLocalStorageHybridState, useQueryManager } from '../../hooks';
import { Api, AppToaster } from '../../singletons';
-import {
- DruidError,
- isEmpty,
- localStorageGetJson,
- LocalStorageKeys,
- localStorageSetJson,
- mapRecord,
- queryDruidSql,
-} from '../../utils';
+import { capitalizeFirst, DruidError, LocalStorageKeys, queryDruidSql } from '../../utils';
import {
- ControlPane,
DroppableContainer,
FilterPane,
- HighlightBubble,
+ HelperTable,
ModulePane,
- ModulePicker,
ResourcePane,
SourcePane,
SourceQueryPane,
} from './components';
-import { ExploreState } from './explore-state';
-import { highlightStore } from './highlight-store/highlight-store';
-import type { Measure, ParameterValues } from './models';
-import { QuerySource } from './models';
-import { ModuleRepository } from './module-repository/module-repository';
+import type { ExploreModuleLayout, Measure, ModuleState } from './models';
+import { ExploreState, ExpressionMeta, QuerySource } from './models';
import { rewriteAggregate, rewriteMaxDataTime } from './query-macros';
import type { Rename } from './utils';
-import { adjustTransferValue, normalizeType, QueryLog } from './utils';
+import { QueryLog } from './utils';
import './explore-view.scss';
const QUERY_LOG = new QueryLog();
-function getStickyParameterValuesForModule(moduleId: string): ParameterValues {
- return localStorageGetJson(LocalStorageKeys.EXPLORE_STICKY)?.[moduleId] || {};
-}
+const LAYOUT_TO_ICON: Record = {
+ 'single': IconNames.SYMBOL_RECTANGLE,
+ 'two-by-two': IconNames.GRID_VIEW,
+ 'two-rows': IconNames.LAYOUT_TWO_ROWS,
+ 'two-columns': IconNames.LAYOUT_TWO_COLUMNS,
+ 'three-rows': IconNames.LAYOUT_THREE_ROWS,
+ 'three-columns': IconNames.LAYOUT_THREE_COLUMNS,
+ 'top-row-two-tiles': IconNames.LAYOUT_TOP_ROW_TWO_TILES,
+ 'bottom-row-two-tiles': IconNames.LAYOUT_BOTTOM_ROW_TWO_TILES,
+ 'left-column-two-tiles': IconNames.LAYOUT_LEFT_COLUMN_TWO_TILES,
+ 'right-column-two-tiles': IconNames.LAYOUT_RIGHT_COLUMN_TWO_TILES,
+ 'top-row-three-tiles': IconNames.LAYOUT_TOP_ROW_THREE_TILES,
+ 'bottom-row-three-tiles': IconNames.LAYOUT_BOTTOM_ROW_THREE_TILES,
+ 'left-column-three-tiles': IconNames.LAYOUT_LEFT_COLUMN_THREE_TILES,
+ 'right-column-three-tiles': IconNames.LAYOUT_RIGHT_COLUMN_THREE_TILES,
+};
// ---------------------------------------
@@ -131,32 +141,28 @@ export const ExploreView = React.memo(function ExploreView() {
},
);
- const { dropHighlight } = useStore(highlightStore);
-
// -------------------------------------------------------
// If no table selected, change to first table if possible
- async function initWithFirstTable() {
+ async function initializeWithFirstTable() {
const tables = await queryDruidSql<{ TABLE_NAME: string }>({
query: `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' LIMIT 1`,
});
const firstTableName = tables[0].TABLE_NAME;
if (firstTableName) {
- setTable(firstTableName);
+ setExploreState(exploreState.initToTable(firstTableName));
}
}
useEffect(() => {
if (exploreState.isInitState()) {
- void initWithFirstTable();
+ void initializeWithFirstTable();
}
});
// -------------------------------------------------------
- const { moduleId, source, parsedSource, parseError, where, parameterValues, showSourceQuery } =
- exploreState;
- const module = ModuleRepository.getModule(moduleId);
+ const { parsedSource } = exploreState;
const [querySourceState] = useQueryManager({
query: parsedSource ? String(parsedSource) : undefined,
@@ -178,71 +184,43 @@ export const ExploreView = React.memo(function ExploreView() {
// -------------------------------------------------------
- useEffect(() => {
- const querySource = querySourceState.data;
- if (!querySource || !module) return;
- const newExploreState = exploreState.restrictToQuerySource(querySource);
- if (exploreState !== newExploreState) {
- setExploreState(newExploreState);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [module, parameterValues, querySourceState.data]);
-
- function setModuleId(moduleId: string, parameterValues: ParameterValues) {
- if (exploreState.moduleId === moduleId) return;
- setExploreState(exploreState.change({ moduleId, parameterValues }));
- }
-
- function setParameterValues(newParameterValues: ParameterValues) {
- if (newParameterValues === parameterValues) return;
- setExploreState(exploreState.change({ parameterValues: newParameterValues }));
- }
-
- function resetParameterValues() {
- setParameterValues(getStickyParameterValuesForModule(moduleId));
- }
+ const effectiveExploreState = useMemo(
+ () =>
+ querySourceState.data
+ ? exploreState.restrictToQuerySource(querySourceState.data)
+ : exploreState,
+ [exploreState, querySourceState.data],
+ );
- function updateParameterValues(newParameterValues: ParameterValues) {
- // Evaluate sticky-ness
- if (module) {
- const currentExploreSticky = localStorageGetJson(LocalStorageKeys.EXPLORE_STICKY) || {};
- const currentModuleSticky = currentExploreSticky[moduleId] || {};
- const newModuleSticky = {
- ...currentModuleSticky,
- ...mapRecord(newParameterValues, (v, k) => (module.parameters[k]?.sticky ? v : undefined)),
- };
-
- localStorageSetJson(LocalStorageKeys.EXPLORE_STICKY, {
- ...currentExploreSticky,
- [moduleId]: isEmpty(newModuleSticky) ? undefined : newModuleSticky,
- });
- }
+ const { source, parseError, where, showSourceQuery, hideResources, hideHelpers } =
+ effectiveExploreState;
- setParameterValues({ ...parameterValues, ...newParameterValues });
+ function setModuleState(index: number, moduleState: ModuleState) {
+ setExploreState(effectiveExploreState.changeModuleState(index, moduleState));
}
function setSource(source: SqlQuery | string, rename?: Rename) {
- setExploreState(exploreState.changeSource(source, rename));
+ setExploreState(effectiveExploreState.changeSource(source, rename));
}
function setTable(tableName: string) {
- setExploreState(exploreState.changeToTable(tableName));
+ setExploreState(effectiveExploreState.changeToTable(tableName));
}
function setWhere(where: SqlExpression) {
- setExploreState(exploreState.change({ where }));
+ setExploreState(effectiveExploreState.change({ where }));
}
function onShowColumn(column: Column) {
- setExploreState(exploreState.applyShowColumn(column));
+ setExploreState(effectiveExploreState.applyShowColumn(column, undefined));
}
function onShowMeasure(measure: Measure) {
- setExploreState(exploreState.applyShowMeasure(measure));
+ setExploreState(effectiveExploreState.applyShowMeasure(measure, undefined));
}
function onShowSourceQuery() {
- setExploreState(exploreState.change({ showSourceQuery: true }));
+ setExploreState(effectiveExploreState.change({ showSourceQuery: true }));
}
const querySource = querySourceState.getSomeData();
@@ -251,243 +229,316 @@ export const ExploreView = React.memo(function ExploreView() {
return async (query: string | SqlQuery, cancelToken?: CancelToken) => {
if (!querySource) throw new Error('no querySource');
const parsedQuery = SqlQuery.parse(query);
- return (
- await runSqlQuery(
- await rewriteMaxDataTime(rewriteAggregate(parsedQuery, querySource.measures)),
- cancelToken,
- )
- ).attachQuery({ query: '' }, parsedQuery);
+ const { query: rewrittenQuery, maxTime } = await rewriteMaxDataTime(
+ rewriteAggregate(parsedQuery, querySource.measures),
+ );
+ const results = await runSqlQuery(rewrittenQuery, cancelToken);
+
+ return results
+ .attachQuery({ query: '' }, parsedQuery)
+ .changeResultContext({ ...results.resultContext, maxTime });
};
}, [querySource]);
+ const selectedLayout = effectiveExploreState.getLayout();
return (
-
- {showSourceQuery && (
-
setExploreState(exploreState.change({ showSourceQuery: false }))}
- />
- )}
- {parseError && (
-
-
{parseError}
- {source === '' && (
-
-
-
- )}
- {!showSourceQuery && (
-
-
-
- )}
-
- )}
- {parsedSource && (
-
-
-
{
- 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,
- ),
- );
- }}
- />
- {
- let newParameterValues = getStickyParameterValuesForModule(newModuleId);
-
- 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;
-
- newParameterValues = {
- ...newParameterValues,
- [target[0]]: adjustTransferValue(
- parameterValue,
- oldParameterDefinition.type,
- target[1].type,
- ),
- };
- }
- }
-
- dropHighlight();
- setModuleId(newModuleId, newParameterValues);
- }}
- moreMenu={
-
+
+
+ {showSourceQuery && (
+
+ setExploreState(effectiveExploreState.change({ showSourceQuery: false }))
}
/>
-
- {!querySource && querySourceState.loading && 'Loading...'}
- {querySource && (
-
{
- filterPane.current?.filterOn(c);
- }}
- runSqlQuery={runSqlPlusQuery}
- onShowColumn={onShowColumn}
- onShowMeasure={onShowMeasure}
- />
+ )}
+ {parseError && (
+
+
{parseError}
+ {source === '' && (
+
+
+
+ )}
+ {!showSourceQuery && (
+
+
+
)}
-
- {querySourceState.error ? (
- {querySourceState.getErrorMessage()}
- ) : querySource ? (
-
+
+ {!showSourceQuery && (
+
+
+
+ )}
+
- ) : querySourceState.loading ? (
-
- ) : undefined}
-
-
- {module && (
-
{
if (!querySource) return;
setExploreState(
- exploreState.changeSource(
+ effectiveExploreState.changeSource(
querySource.addColumn(querySource.transformToBaseColumns(expression)),
undefined,
),
);
}}
- onAddToSourceQueryAsMeasure={measure => {
+ onMoveToSourceQueryAsClause={(expression, changeWhere) => {
if (!querySource) return;
setExploreState(
- exploreState.changeSource(
- querySource.addMeasure(
- measure.changeExpression(
- querySource.transformToBaseColumns(measure.expression),
- ),
+ effectiveExploreState
+ .change({ where: changeWhere })
+ .changeSource(
+ querySource.addWhereClause(querySource.transformToBaseColumns(expression)),
+ undefined,
),
- undefined,
- ),
);
}}
/>
- )}
+
+
+ {
+ copy(QUERY_LOG.getLastQuery()!, { format: 'text/plain' });
+ AppToaster.show({
+ message: `Copied query to clipboard`,
+ intent: Intent.SUCCESS,
+ });
+ }}
+ />
+ {
+ setShownText(QUERY_LOG.getFormatted());
+ }}
+ />
+
+ {
+ localStorage.removeItem(LocalStorageKeys.EXPLORE_STATE);
+ location.hash = '#explore';
+ location.reload();
+ }}
+ />
+
+ }
+ >
+
+
+
+ {ExploreState.LAYOUTS.map(layout => (
+ : undefined
+ }
+ onClick={() => {
+ setExploreState(effectiveExploreState.change({ layout }));
+ }}
+ />
+ ))}
+
+ }
+ >
+
+
+ {
+ if (e.altKey) {
+ setExploreState(
+ effectiveExploreState.change({ hideResources: !hideResources }),
+ );
+ } else {
+ setExploreState(effectiveExploreState.change({ hideHelpers: !hideHelpers }));
+ }
+ }}
+ />
+
+
+
+ {!hideResources && (
+
+ {!querySource && querySourceState.loading && 'Loading...'}
+ {querySource && (
+ {
+ filterPane.current?.filterOn(c);
+ }}
+ runSqlQuery={runSqlPlusQuery}
+ onShowColumn={onShowColumn}
+ onShowMeasure={onShowMeasure}
+ />
+ )}
+
+ )}
+
+ {querySourceState.error ? (
+ {querySourceState.getErrorMessage()}
+ ) : querySource ? (
+
+ {effectiveExploreState.getModuleStatesToShow().map((moduleState, i) =>
+ moduleState ? (
+ setModuleState(i, moduleState)}
+ onDelete={() => setExploreState(effectiveExploreState.removeModule(i))}
+ querySource={querySource}
+ where={where}
+ setWhere={setWhere}
+ runSqlQuery={runSqlPlusQuery}
+ onAddToSourceQueryAsColumn={expression => {
+ if (!querySource) return;
+ setExploreState(
+ effectiveExploreState.changeSource(
+ querySource.addColumn(
+ querySource.transformToBaseColumns(expression),
+ ),
+ undefined,
+ ),
+ );
+ }}
+ onAddToSourceQueryAsMeasure={measure => {
+ if (!querySource) return;
+ setExploreState(
+ effectiveExploreState.changeSource(
+ querySource.addMeasure(
+ measure.changeExpression(
+ querySource.transformToBaseColumns(measure.expression),
+ ),
+ ),
+ undefined,
+ ),
+ );
+ }}
+ />
+ ) : (
+
+ setExploreState(effectiveExploreState.applyShowColumn(column, i))
+ }
+ onDropMeasure={measure =>
+ setExploreState(effectiveExploreState.applyShowMeasure(measure, i))
+ }
+ >
+ Drag and drop a column or measure here
+
+ ),
+ )}
+
+ ) : querySourceState.loading ? (
+
+ ) : (
+ 'should never get here'
+ )}
+ {!hideHelpers && (
+
+ setExploreState(effectiveExploreState.addHelper(ExpressionMeta.fromColumn(c)))
+ }
+ >
+ {querySource && effectiveExploreState.helpers.length > 0 && (
+
+ {effectiveExploreState.helpers.map((ex, i) => (
+ setExploreState(effectiveExploreState.removeHelper(i))}
+ />
+ ))}
+
+ )}
+ {!effectiveExploreState.helpers.length && (
+ Drag columns here to see helpers
+ )}
+
+ )}
+
+
- {shownText && (
- {
- setShownText(undefined);
- }}
- />
- )}
-
+ )}
+
+ {shownText && (
+
{
+ setShownText(undefined);
+ }}
+ />
)}
-
);
});
diff --git a/web-console/src/views/explore-view/highlight-store/highlight-store.ts b/web-console/src/views/explore-view/highlight-store/highlight-store.ts
deleted file mode 100644
index 2c3d21c1b0b8..000000000000
--- a/web-console/src/views/explore-view/highlight-store/highlight-store.ts
+++ /dev/null
@@ -1,64 +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 { createStore } from 'zustand';
-
-import type { Highlight } from '../models';
-
-interface HighlightState {
- /**
- * The current highlight.
- */
- highlight: Highlight | undefined;
-
- /**
- * Sets the highlight.
- * @param highlight the highlight to set
- */
- setHighlight: (highlight: Highlight) => void;
-
- /**
- * Drops the highlight.
- */
- dropHighlight: () => void;
-
- /**
- * Updates the highlight.
- * @param highlight the highlight to update (only the properties to update)
- * @returns the updated highlight, or undefined if there's no highlight in the store
- */
- updateHighlight: (highlight: Partial) => Highlight | undefined;
-}
-
-/**
- * A lightweight store for the highlight.
- */
-export const highlightStore = createStore((set, get) => ({
- highlight: undefined,
- setHighlight: highlight => set({ highlight }),
- dropHighlight: () => set({ highlight: undefined }),
- updateHighlight: highlight => {
- set(state => {
- if (!state.highlight) return state;
-
- return { highlight: { ...state.highlight, ...highlight } };
- });
-
- return get().highlight;
- },
-}));
diff --git a/web-console/src/views/explore-view/models/explore-state.ts b/web-console/src/views/explore-view/models/explore-state.ts
new file mode 100644
index 000000000000..71d9ef54d5c7
--- /dev/null
+++ b/web-console/src/views/explore-view/models/explore-state.ts
@@ -0,0 +1,316 @@
+/*
+ * 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-query-toolkit';
+import {
+ filterPatternToExpression,
+ SqlExpression,
+ SqlLiteral,
+ SqlQuery,
+} from 'druid-query-toolkit';
+
+import {
+ changeByIndex,
+ deleteKeys,
+ filterOrReturn,
+ isEmpty,
+ mapRecord,
+ mapRecordOrReturn,
+} from '../../../utils';
+import type { Rename } from '../utils';
+import { renameColumnsInExpression } from '../utils';
+
+import { ExpressionMeta } from './expression-meta';
+import type { Measure } from './measure';
+import { ModuleState } from './module-state';
+import { QuerySource } from './query-source';
+
+export type ExploreModuleLayout =
+ | 'single'
+ | 'two-by-two'
+ | 'two-rows'
+ | 'two-columns'
+ | 'three-rows'
+ | 'three-columns'
+ | 'top-row-two-tiles'
+ | 'bottom-row-two-tiles'
+ | 'left-column-two-tiles'
+ | 'right-column-two-tiles'
+ | 'top-row-three-tiles'
+ | 'bottom-row-three-tiles'
+ | 'left-column-three-tiles'
+ | 'right-column-three-tiles';
+
+interface ExploreStateValue {
+ source: string;
+ showSourceQuery?: boolean;
+ where: SqlExpression;
+ moduleStates: Readonly>;
+ layout?: ExploreModuleLayout;
+ hideResources?: boolean;
+ helpers?: readonly ExpressionMeta[];
+ hideHelpers?: boolean;
+}
+
+export class ExploreState {
+ static DEFAULT_STATE: ExploreState;
+ static LAYOUTS: ExploreModuleLayout[] = [
+ 'single',
+ 'two-by-two',
+ 'two-rows',
+ 'two-columns',
+ 'three-rows',
+ 'three-columns',
+ 'top-row-two-tiles',
+ 'bottom-row-two-tiles',
+ 'left-column-two-tiles',
+ 'right-column-two-tiles',
+ 'top-row-three-tiles',
+ 'bottom-row-three-tiles',
+ 'left-column-three-tiles',
+ 'right-column-three-tiles',
+ ];
+
+ static LAYOUT_TO_NUM_TILES: Record = {
+ 'single': 1,
+ 'two-by-two': 4,
+ 'two-rows': 2,
+ 'two-columns': 2,
+ 'three-rows': 3,
+ 'three-columns': 3,
+ 'top-row-two-tiles': 3,
+ 'bottom-row-two-tiles': 3,
+ 'left-column-two-tiles': 3,
+ 'right-column-two-tiles': 3,
+ 'top-row-three-tiles': 4,
+ 'bottom-row-three-tiles': 4,
+ 'left-column-three-tiles': 4,
+ 'right-column-three-tiles': 4,
+ };
+
+ static fromJS(js: any) {
+ let moduleStatesJS: Readonly> = {};
+ if (js.moduleStates) {
+ moduleStatesJS = js.moduleStates;
+ } else if (js.moduleId && js.parameterValues) {
+ moduleStatesJS = { '0': js };
+ }
+ return new ExploreState({
+ ...js,
+ where: SqlExpression.maybeParse(js.where) || SqlLiteral.TRUE,
+ moduleStates: mapRecord(moduleStatesJS, ModuleState.fromJS),
+ helpers: ExpressionMeta.inflateArray(js.helpers || []),
+ });
+ }
+
+ public readonly source: string;
+ public readonly showSourceQuery: boolean;
+ public readonly where: SqlExpression;
+ public readonly moduleStates: Readonly>;
+ public readonly layout?: ExploreModuleLayout;
+ public readonly hideResources: boolean;
+ public readonly helpers: readonly ExpressionMeta[];
+ public readonly hideHelpers: boolean;
+
+ 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.moduleStates = value.moduleStates;
+ this.layout = value.layout;
+ this.hideResources = Boolean(value.hideResources);
+ this.helpers = value.helpers || [];
+ this.hideHelpers = Boolean(value.hideHelpers);
+
+ 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 {
+ const value: ExploreStateValue = {
+ source: this.source,
+ where: this.where,
+ moduleStates: this.moduleStates,
+ layout: this.layout,
+ };
+ if (this.showSourceQuery) value.showSourceQuery = true;
+ if (this.hideResources) value.hideResources = true;
+ if (this.helpers.length) value.helpers = this.helpers;
+ if (this.hideHelpers) value.hideHelpers = true;
+ return value;
+ }
+
+ public change(newValues: Partial): ExploreState {
+ return new ExploreState({
+ ...this.valueOf(),
+ ...newValues,
+ });
+ }
+
+ public changeSource(newSource: SqlQuery | string, rename: Rename | undefined): ExploreState {
+ const toChange: Partial = {
+ source: String(newSource),
+ };
+
+ if (rename) {
+ toChange.where = renameColumnsInExpression(this.where, rename);
+ toChange.moduleStates = mapRecordOrReturn(this.moduleStates, moduleState =>
+ moduleState.applyRename(rename),
+ );
+ toChange.helpers = this.helpers.map(helper => helper.applyRename(rename));
+ }
+
+ return this.change(toChange);
+ }
+
+ public getLayout(): ExploreModuleLayout {
+ return this.layout || 'single';
+ }
+
+ public changeToTable(tableName: string): ExploreState {
+ return this.changeSource(SqlQuery.create(tableName), undefined);
+ }
+
+ public initToTable(tableName: string): ExploreState {
+ const { moduleStates } = this;
+ return this.change({
+ source: SqlQuery.create(tableName).toString(),
+ moduleStates: isEmpty(moduleStates) ? { '0': ModuleState.INIT_STATE } : moduleStates,
+ });
+ }
+
+ 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.equals(SqlLiteral.TRUE)) return this;
+
+ // Either find the `__time::TIMESTAMP` column or use the first column if it is a TIMESTAMP
+ const timeColumn =
+ columns.find(c => c.isTimeColumn()) ||
+ (columns[0].sqlType === 'TIMESTAMP' ? columns[0] : undefined);
+ 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, moduleStates, helpers } = this;
+ const newWhere = querySource.restrictWhere(where);
+ const newModuleStates = mapRecordOrReturn(moduleStates, moduleState =>
+ moduleState.restrictToQuerySource(querySource, newWhere),
+ );
+ const newHelpers = filterOrReturn(helpers, helper =>
+ querySource.validateExpressionMeta(helper),
+ );
+ if (where === newWhere && moduleStates === newModuleStates && helpers === newHelpers)
+ return this;
+
+ return this.change({
+ where: newWhere,
+ moduleStates: newModuleStates,
+ helpers: newHelpers,
+ });
+ }
+
+ public changeModuleState(k: number, moduleState: ModuleState): ExploreState {
+ return this.change({
+ moduleStates: { ...this.moduleStates, [k]: moduleState },
+ });
+ }
+
+ public removeModule(k: number): ExploreState {
+ return this.change({
+ moduleStates: deleteKeys(this.moduleStates, [String(k)]),
+ });
+ }
+
+ public applyShowColumn(column: Column, k = 0): ExploreState {
+ const { moduleStates } = this;
+ return this.change({
+ moduleStates: {
+ ...moduleStates,
+ [k]: (moduleStates[k] || ModuleState.INIT_STATE).applyShowColumn(column),
+ },
+ });
+ }
+
+ public applyShowMeasure(measure: Measure, k = 0): ExploreState {
+ const { moduleStates } = this;
+ return this.change({
+ moduleStates: {
+ ...moduleStates,
+ [k]: (moduleStates[k] || ModuleState.INIT_STATE).applyShowMeasure(measure),
+ },
+ });
+ }
+
+ public removeHelper(index: number): ExploreState {
+ return this.change({ helpers: changeByIndex(this.helpers, index, () => undefined) });
+ }
+
+ public addHelper(helper: ExpressionMeta): ExploreState {
+ return this.change({ helpers: this.helpers.concat(helper) });
+ }
+
+ public getModuleStatesToShow(): (ModuleState | null)[] {
+ const moduleStates = this.moduleStates;
+ const numberToShow = ExploreState.LAYOUT_TO_NUM_TILES[this.getLayout()];
+ const ret: (ModuleState | null)[] = [];
+ for (let i = 0; i < numberToShow; i++) {
+ ret.push(moduleStates[i] || null);
+ }
+ return ret;
+ }
+
+ public isInitState(): boolean {
+ return (
+ this.source === '' &&
+ this.where instanceof SqlLiteral &&
+ isEmpty(this.moduleStates) &&
+ !this.hideResources &&
+ !this.helpers.length &&
+ !this.hideHelpers
+ );
+ }
+}
+
+ExploreState.DEFAULT_STATE = new ExploreState({
+ source: '',
+ where: SqlLiteral.TRUE,
+ moduleStates: {},
+});
diff --git a/web-console/src/views/explore-view/models/expression-meta.ts b/web-console/src/views/explore-view/models/expression-meta.ts
index ac8e07aa8a32..03b20267a792 100644
--- a/web-console/src/views/explore-view/models/expression-meta.ts
+++ b/web-console/src/views/explore-view/models/expression-meta.ts
@@ -57,6 +57,18 @@ export class ExpressionMeta {
});
}
+ static evaluateSqlType(
+ expression: SqlExpression,
+ columns: readonly Column[] = [],
+ ): string | undefined {
+ if (expression instanceof SqlColumn) {
+ const columnName = expression.getName();
+ const myColumn = columns.find(({ name }) => name === columnName);
+ return myColumn?.sqlType;
+ }
+ return;
+ }
+
public readonly expression: SqlExpression;
public readonly as?: string;
@@ -94,9 +106,13 @@ export class ExpressionMeta {
return this.change({ expression });
}
- public renameInExpression(rename: Map): this {
+ public applyRename(rename: Map): this {
const renamedExpression = renameColumnsInExpression(this.expression, rename);
if (renamedExpression === this.expression) return this;
return this.changeExpression(renamedExpression);
}
+
+ public evaluateSqlType(columns?: readonly Column[]): string | undefined {
+ return ExpressionMeta.evaluateSqlType(this.expression, columns);
+ }
}
diff --git a/web-console/src/views/explore-view/models/index.ts b/web-console/src/views/explore-view/models/index.ts
index 8938818acc20..a49b3de4170f 100644
--- a/web-console/src/views/explore-view/models/index.ts
+++ b/web-console/src/views/explore-view/models/index.ts
@@ -16,9 +16,11 @@
* limitations under the License.
*/
+export * from './explore-state';
export * from './expression-meta';
export * from './highlight';
export * from './measure';
export * from './measure-pattern';
+export * from './module-state';
export * from './parameter';
export * from './query-source';
diff --git a/web-console/src/views/explore-view/models/measure.ts b/web-console/src/views/explore-view/models/measure.ts
index a3aadab89b67..d47d5f76985f 100644
--- a/web-console/src/views/explore-view/models/measure.ts
+++ b/web-console/src/views/explore-view/models/measure.ts
@@ -199,7 +199,15 @@ export class Measure extends ExpressionMeta {
(this as any).name = this.as || Measure.defaultNameFromExpression(this.expression);
}
- public equivalent(other: ExpressionMeta | undefined): boolean {
+ public equals(other: Measure | undefined): boolean {
+ return (
+ other instanceof Measure &&
+ this.name === other.name &&
+ this.expression.equals(other.expression)
+ );
+ }
+
+ public equivalent(other: Measure | undefined): boolean {
if (!other || this.name !== other.name) return false;
if (Measure.getAggregateMeasureName(this.expression) === other.name) {
diff --git a/web-console/src/views/explore-view/models/module-state.ts b/web-console/src/views/explore-view/models/module-state.ts
new file mode 100644
index 000000000000..93959adea6d5
--- /dev/null
+++ b/web-console/src/views/explore-view/models/module-state.ts
@@ -0,0 +1,173 @@
+/*
+ * 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, SqlExpression } from 'druid-query-toolkit';
+
+import { isEmpty } from '../../../utils';
+import { ModuleRepository } from '../module-repository/module-repository';
+import type { Rename } from '../utils';
+
+import { ExpressionMeta } from './expression-meta';
+import type { Measure } from './measure';
+import type { ParameterValues } from './parameter';
+import { inflateParameterValues, renameColumnsInParameterValues } from './parameter';
+import type { QuerySource } from './query-source';
+
+interface ModuleStateValue {
+ moduleId: string;
+ parameterValues: ParameterValues;
+ showControls?: boolean;
+}
+
+export class ModuleState {
+ static INIT_STATE: ModuleState;
+
+ static fromJS(js: any) {
+ const inflatedParameterValues = inflateParameterValues(
+ js.parameterValues,
+ ModuleRepository.getModule(js.moduleId)?.parameters || {},
+ );
+ return new ModuleState({
+ ...js,
+ parameterValues: inflatedParameterValues,
+ });
+ }
+
+ public readonly moduleId: string;
+ public readonly parameterValues: ParameterValues;
+ public readonly showControls: boolean;
+
+ constructor(value: ModuleStateValue) {
+ this.moduleId = value.moduleId;
+ this.parameterValues = value.parameterValues;
+ this.showControls = Boolean(value.showControls);
+ }
+
+ valueOf(): ModuleStateValue {
+ const value: ModuleStateValue = {
+ moduleId: this.moduleId,
+ parameterValues: this.parameterValues,
+ };
+ if (this.showControls) value.showControls = true;
+ return value;
+ }
+
+ public change(newValues: Partial): ModuleState {
+ return new ModuleState({
+ ...this.valueOf(),
+ ...newValues,
+ });
+ }
+
+ public changeParameterValues(parameterValues: ParameterValues): ModuleState {
+ return this.change({ parameterValues });
+ }
+
+ public applyRename(rename: Rename): ModuleState {
+ const module = ModuleRepository.getModule(this.moduleId);
+ if (!module) return this;
+
+ return this.change({
+ parameterValues: renameColumnsInParameterValues(
+ this.parameterValues,
+ module.parameters,
+ rename,
+ ),
+ });
+ }
+
+ public restrictToQuerySource(querySource: QuerySource, where: SqlExpression): ModuleState {
+ const { moduleId, parameterValues } = this;
+ const module = ModuleRepository.getModule(moduleId);
+ if (!module) return this;
+ const newParameterValues = querySource.restrictParameterValues(
+ parameterValues,
+ module.parameters,
+ where,
+ );
+ if (parameterValues === newParameterValues) return this;
+
+ return this.change({
+ parameterValues: newParameterValues,
+ });
+ }
+
+ public applyShowColumn(column: Column): ModuleState {
+ let newModuleId: string;
+ let newParameterValues: ParameterValues = {};
+ if (column.sqlType === 'TIMESTAMP') {
+ newModuleId = 'time-chart';
+ } else if (column.sqlType === 'BOOLEAN') {
+ newModuleId = 'pie-chart';
+ newParameterValues = {
+ splitColumn: ExpressionMeta.fromColumn(column),
+ };
+ } else {
+ newModuleId = 'grouping-table';
+ newParameterValues = {
+ splitColumns: [ExpressionMeta.fromColumn(column)],
+ };
+ }
+
+ return this.change({
+ moduleId: newModuleId,
+ parameterValues: {
+ ...(this.moduleId === newModuleId ? this.parameterValues : {}),
+ ...newParameterValues,
+ },
+ });
+ }
+
+ public applyShowMeasure(measure: Measure): ModuleState {
+ 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' && isEmpty(this.parameterValues);
+ }
+}
+
+ModuleState.INIT_STATE = new ModuleState({
+ moduleId: 'record-table',
+ parameterValues: {},
+});
diff --git a/web-console/src/views/explore-view/models/parameter.ts b/web-console/src/views/explore-view/models/parameter.ts
index a924f901a099..ed021da6f5b7 100644
--- a/web-console/src/views/explore-view/models/parameter.ts
+++ b/web-console/src/views/explore-view/models/parameter.ts
@@ -18,7 +18,9 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
-import { mapRecord, mapRecordIfChanged } from '../../../utils';
+import type { SqlExpression } from 'druid-query-toolkit';
+
+import { deleteKeys, mapRecord, mapRecordOrReturn } from '../../../utils';
import { ExpressionMeta } from './expression-meta';
import { Measure } from './measure';
@@ -26,13 +28,28 @@ import type { QuerySource } from './query-source';
export type OptionValue = string | number;
-export type ModuleFunctor = T | ((options: { parameterValues: ParameterValues }) => T);
+function isOptionValue(v: unknown): v is OptionValue {
+ return typeof v === 'string' || typeof v === 'number';
+}
+
+export type ModuleFunctor =
+ | T
+ | ((options: {
+ querySource: QuerySource | undefined;
+ where: SqlExpression;
+ parameterValues: ParameterValues;
+ }) => T);
-export function evaluateFunctor(fn: ModuleFunctor, parameterValues: ParameterValues): T {
- if (typeof fn === 'function') {
- return (fn as any)({ parameterValues });
+export function evaluateFunctor(
+ functor: ModuleFunctor | undefined,
+ parameterValues: ParameterValues,
+ querySource: QuerySource | undefined,
+ where: SqlExpression,
+): T | undefined {
+ if (typeof functor === 'function') {
+ return (functor as any)({ where, parameterValues, querySource });
} else {
- return fn;
+ return functor;
}
}
@@ -41,13 +58,15 @@ export interface ParameterTypes {
boolean: boolean;
number: number;
option: OptionValue;
- options: OptionValue[];
+ options: readonly OptionValue[];
expression: ExpressionMeta;
- expressions: ExpressionMeta[];
+ expressions: readonly ExpressionMeta[];
measure: Measure;
- measures: Measure[];
+ measures: readonly Measure[];
}
+type OptionLabels = { [key: string | number]: string } | ((x: string) => string);
+
interface TypedExtensions {
boolean: {};
string: {};
@@ -56,12 +75,12 @@ interface TypedExtensions {
max?: number;
};
option: {
- options: readonly OptionValue[];
- optionLabels?: { [key: string | number]: string };
+ options: ModuleFunctor;
+ optionLabels?: OptionLabels;
};
options: {
- options: readonly OptionValue[];
- optionLabels?: { [key: string | number]: string };
+ options: ModuleFunctor;
+ optionLabels?: OptionLabels;
allowDuplicates?: boolean;
nonEmpty?: boolean;
};
@@ -81,33 +100,14 @@ export type TypedParameterDefinition = TypedE
label?: ModuleFunctor;
type: Type;
transferGroup?: string;
- defaultValue?:
- | ParameterTypes[Type]
- | ((querySource: QuerySource) => ParameterTypes[Type] | undefined);
-
+ defaultValue?: ModuleFunctor;
sticky?: boolean;
required?: ModuleFunctor;
+ important?: boolean;
description?: ModuleFunctor;
placeholder?: string;
+ defined?: ModuleFunctor;
visible?: ModuleFunctor;
-
- /**
- * Validate the value of this parameter.
- *
- * @param value - Current parameter value or undefined if no value has been set.
- * @returns - An error message if the value is invalid, or undefined if the value is valid.
- */
- validate?: (value: ParameterTypes[Type] | undefined) => string | undefined;
-
- /**
- * Determines whether the parameter should exist in the visual modules parameters.
- *
- * If the provided function returns false, the parameter value will be deleted from
- * the module's parameters. If true, it will be whatever the relative control
- *
- * @default undefined
- */
- defined?: (options: { parametersValues: Record }) => boolean;
};
export type ParameterDefinition =
@@ -132,20 +132,23 @@ export function getModuleOptionLabel(
optionValue: OptionValue,
parameterDefinition: ParameterDefinition,
): string {
- const { optionLabels = {} } = parameterDefinition as any;
-
- return (
- optionLabels[optionValue] ??
- (typeof optionValue === 'string'
- ? optionValue
- : typeof optionValue !== 'undefined'
- ? String(optionValue)
- : 'Malformed option')
- );
+ const { optionLabels } = parameterDefinition as any;
+
+ if (typeof optionLabels === 'function') {
+ const l = optionLabels(optionValue);
+ if (typeof l !== 'undefined') return l;
+ }
+
+ if (optionLabels && typeof optionLabels === 'object') {
+ const l = optionLabels[optionValue];
+ if (typeof l !== 'undefined') return l;
+ }
+
+ return typeof optionValue !== 'undefined' ? String(optionValue) : 'Malformed option';
}
export type ParameterValues = Readonly>;
-export type Parameters = Record;
+export type Parameters = Readonly>;
// -----------------------------------------------------
@@ -165,26 +168,19 @@ function inflateParameterValue(value: unknown, parameter: ParameterDefinition):
return Boolean(value);
case 'number': {
- let v = Number(value);
- if (isNaN(v)) v = 0;
- if (typeof parameter.min === 'number') {
- v = Math.max(v, parameter.min);
- }
- if (typeof parameter.max === 'number') {
- v = Math.min(v, parameter.max);
- }
+ const v = Number(value);
+ if (isNaN(v)) return;
return v;
}
- case 'option':
- if (!parameter.options || !parameter.options.includes(value as OptionValue)) return;
- return value as OptionValue;
+ case 'option': {
+ if (!isOptionValue(value)) return;
+ return value;
+ }
- case 'options': {
+ case 'options':
if (!Array.isArray(value)) return [];
- const options = parameter.options || [];
- return value.filter(v => options.includes(v));
- }
+ return value.filter(isOptionValue);
case 'expression':
return ExpressionMeta.inflate(value);
@@ -205,6 +201,25 @@ function inflateParameterValue(value: unknown, parameter: ParameterDefinition):
// -----------------------------------------------------
+export function removeUndefinedParameterValues(
+ parameterValues: ParameterValues,
+ parameters: Parameters,
+ querySource: QuerySource | undefined,
+ where: SqlExpression,
+): ParameterValues {
+ const keysToRemove = Object.keys(parameterValues).filter(key => {
+ const parameter = parameters[key];
+ if (!parameter) return true;
+ return (
+ typeof parameter.defined !== 'undefined' &&
+ !evaluateFunctor(parameter.defined, parameterValues, querySource, where)
+ );
+ });
+ return keysToRemove.length ? deleteKeys(parameterValues, keysToRemove) : parameterValues;
+}
+
+// -----------------------------------------------------
+
function defaultForType(parameterType: keyof ParameterTypes): any {
switch (parameterType) {
case 'boolean':
@@ -221,21 +236,26 @@ function defaultForType(parameterType: keyof ParameterTypes): any {
export function effectiveParameterDefault(
parameter: ParameterDefinition,
+ parameterValues: ParameterValues,
querySource: QuerySource | undefined,
+ where: SqlExpression,
+ previousParameterValue: any,
): any {
- const { defaultValue } = parameter;
- switch (typeof defaultValue) {
- case 'function':
- return (
- (querySource ? defaultValue(querySource) : undefined) ?? defaultForType(parameter.type)
- );
-
- case 'undefined':
- return defaultForType(parameter.type);
+ if (
+ typeof parameter.defined !== 'undefined' &&
+ evaluateFunctor(parameter.defined, parameterValues, querySource, where) === false
+ ) {
+ return;
+ }
+ const newDefault =
+ evaluateFunctor(parameter.defaultValue, parameterValues, querySource, where) ??
+ defaultForType(parameter.type);
- default:
- return defaultValue;
+ if (previousParameterValue instanceof Measure && previousParameterValue.equals(newDefault)) {
+ return previousParameterValue;
}
+
+ return newDefault;
}
// -----------------------------------------------------
@@ -245,7 +265,7 @@ export function renameColumnsInParameterValues(
parameters: Parameters,
rename: Map,
): ParameterValues {
- return mapRecordIfChanged(parameterValues, (parameterValue, k) =>
+ return mapRecordOrReturn(parameterValues, (parameterValue, k) =>
renameColumnsInParameterValue(parameterValue, parameters[k], rename),
);
}
@@ -258,10 +278,10 @@ function renameColumnsInParameterValue(
if (typeof parameterValue !== 'undefined') {
switch (parameter.type) {
case 'expression':
- return (parameterValue as ExpressionMeta).renameInExpression(rename);
+ return (parameterValue as ExpressionMeta).applyRename(rename);
case 'measure':
- return (parameterValue as Measure).renameInExpression(rename);
+ return (parameterValue as Measure).applyRename(rename);
case 'expressions':
case 'measures':
diff --git a/web-console/src/views/explore-view/models/query-source.ts b/web-console/src/views/explore-view/models/query-source.ts
index 24c4021d94c4..fa3f047426e1 100644
--- a/web-console/src/views/explore-view/models/query-source.ts
+++ b/web-console/src/views/explore-view/models/query-source.ts
@@ -19,11 +19,12 @@
import type { Column } from 'druid-query-toolkit';
import { C, F, SqlColumn, SqlExpression, SqlQuery, SqlStar } from 'druid-query-toolkit';
-import { filterMap, mapRecordIfChanged } from '../../../utils';
+import { filterMap, filterOrReturn, mapRecordOrReturn } from '../../../utils';
import { ExpressionMeta } from './expression-meta';
import { Measure } from './measure';
-import type { ParameterDefinition, Parameters, ParameterValues } from './parameter';
+import type { OptionValue, ParameterDefinition, Parameters, ParameterValues } from './parameter';
+import { evaluateFunctor } from './parameter';
function expressionWithinColumns(ex: SqlExpression, columns: readonly Column[]): boolean {
const usedColumns = ex.getUsedColumnNames();
@@ -328,15 +329,44 @@ export class QuerySource {
public restrictParameterValues(
parameterValues: ParameterValues,
parameters: Parameters,
+ where: SqlExpression,
): ParameterValues {
- return mapRecordIfChanged(parameterValues, (parameterValue, k) =>
- this.restrictParameterValue(parameterValue, parameters[k]),
- );
+ return mapRecordOrReturn(parameterValues, (parameterValue, k) => {
+ const parameter = parameters[k];
+ if (!parameter) return;
+ return this.restrictParameterValue(parameterValue, parameter, where, parameterValues);
+ });
}
- private restrictParameterValue(parameterValue: any, parameter: ParameterDefinition): any {
+ private restrictParameterValue(
+ parameterValue: any,
+ parameter: ParameterDefinition,
+ where: SqlExpression,
+ parameterValues: ParameterValues,
+ ): any {
if (typeof parameterValue !== 'undefined') {
switch (parameter.type) {
+ case 'number': {
+ if (typeof parameter.min === 'number') {
+ parameterValue = Math.max(parameterValue, parameter.min);
+ }
+ if (typeof parameter.max === 'number') {
+ parameterValue = Math.min(parameterValue, parameter.max);
+ }
+ return parameterValue;
+ }
+
+ case 'option': {
+ const options = evaluateFunctor(parameter.options, parameterValues, this, where);
+ if (!options || !options.includes(parameterValue as OptionValue)) return;
+ return parameterValue as OptionValue;
+ }
+
+ case 'options': {
+ const options = evaluateFunctor(parameter.options, {}, this, where) || [];
+ return filterOrReturn(parameterValue, v => options.includes(v));
+ }
+
case 'expression':
if (!this.validateExpressionMeta(parameterValue)) return;
break;
@@ -345,19 +375,13 @@ export class QuerySource {
if (!this.validateMeasure(parameterValue)) return;
break;
- case 'expressions': {
- const valid = parameterValue.filter((v: ExpressionMeta) =>
+ case 'expressions':
+ return filterOrReturn(parameterValue, v =>
this.validateExpressionMeta(v),
);
- if (valid.length !== parameterValue.length) return valid;
- break;
- }
- case 'measures': {
- const valid = parameterValue.filter((v: Measure) => this.validateMeasure(v));
- if (valid.length !== parameterValue.length) return valid;
- break;
- }
+ case 'measures':
+ return filterOrReturn(parameterValue, v => this.validateMeasure(v));
default:
break;
diff --git a/web-console/src/views/explore-view/module-repository/module-repository.ts b/web-console/src/views/explore-view/module-repository/module-repository.ts
index 5f82dbc6dfe1..531bf4417130 100644
--- a/web-console/src/views/explore-view/module-repository/module-repository.ts
+++ b/web-console/src/views/explore-view/module-repository/module-repository.ts
@@ -20,14 +20,14 @@ import type { IconName } from '@blueprintjs/icons';
import type { CancelToken } from 'axios';
import type { QueryResult, SqlExpression, SqlQuery } from 'druid-query-toolkit';
-import type { Stage } from '../../../utils/stage';
+import type { Stage } from '../../../utils';
import type { ParameterDefinition, QuerySource } from '../models';
interface ModuleDefinition {
id: string;
icon: IconName;
title: string;
- parameters: Record;
+ parameters: Readonly>;
component: (props: ModuleComponentProps) => any;
}
diff --git a/web-console/src/views/explore-view/modules/bar-chart-module.tsx b/web-console/src/views/explore-view/modules/bar-chart-module/bar-chart-module.tsx
similarity index 60%
rename from web-console/src/views/explore-view/modules/bar-chart-module.tsx
rename to web-console/src/views/explore-view/modules/bar-chart-module/bar-chart-module.tsx
index 34af0404ee6c..704b869dafbd 100644
--- a/web-console/src/views/explore-view/modules/bar-chart-module.tsx
+++ b/web-console/src/views/explore-view/modules/bar-chart-module/bar-chart-module.tsx
@@ -16,26 +16,31 @@
* limitations under the License.
*/
+import { Button, Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
-import { L } from 'druid-query-toolkit';
+import { F, L } from 'druid-query-toolkit';
import type { ECharts } from 'echarts';
import * as echarts from 'echarts';
-import { useEffect, useMemo, useRef } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
-import { Loader } from '../../../components';
-import { useQueryManager } from '../../../hooks';
-import { formatEmpty } from '../../../utils';
-import { Issue } from '../components';
-import { highlightStore } from '../highlight-store/highlight-store';
-import type { ExpressionMeta } from '../models';
-import { ModuleRepository } from '../module-repository/module-repository';
-
-import './record-table-module.scss';
+import { Loader, PortalBubble, type PortalBubbleOpenOn } from '../../../../components';
+import { useQueryManager } from '../../../../hooks';
+import { formatEmpty } from '../../../../utils';
+import { Issue } from '../../components';
+import type { ExpressionMeta } from '../../models';
+import { ModuleRepository } from '../../module-repository/module-repository';
+import { updateFilterClause } from '../../utils';
const OVERALL_LABEL = 'Overall';
+interface BarChartHighlight extends PortalBubbleOpenOn {
+ dim: number;
+ met: number;
+}
+
interface BarChartParameterValues {
splitColumn: ExpressionMeta;
+ timeBucket: string;
measure: ExpressionMeta;
measureToSort: ExpressionMeta;
limit: number;
@@ -51,17 +56,37 @@ ModuleRepository.registerModule({
label: 'Bar column',
transferGroup: 'show',
required: true,
+ important: true,
},
+ timeBucket: {
+ type: 'option',
+ label: 'Time bucket',
+ options: ['PT1M', 'PT5M', 'PT1H', 'P1D', 'P1M'],
+ optionLabels: {
+ PT1M: '1 minute',
+ PT5M: '5 minutes',
+ PT1H: '1 hour',
+ P1D: '1 day',
+ P1M: '1 month',
+ },
+ defaultValue: 'PT1H',
+ important: true,
+ defined: ({ parameterValues, querySource }) =>
+ parameterValues.splitColumn?.evaluateSqlType(querySource?.columns) === 'TIMESTAMP',
+ },
+
measure: {
type: 'measure',
label: 'Measure to show',
transferGroup: 'show-agg',
- defaultValue: querySource => querySource.getFirstAggregateMeasure(),
+ defaultValue: ({ querySource }) => querySource?.getFirstAggregateMeasure(),
required: true,
+ important: true,
},
measureToSort: {
type: 'measure',
- label: 'Measure to sort (default to shown)',
+ label: 'Measure to sort',
+ description: 'Default to shown measure',
},
limit: {
type: 'number',
@@ -72,25 +97,34 @@ ModuleRepository.registerModule({
},
component: function BarChartModule(props) {
const { querySource, where, setWhere, parameterValues, stage, runSqlQuery } = props;
+ const containerRef = useRef();
const chartRef = useRef();
+ const [highlight, setHighlight] = useState();
- const { splitColumn, measure, measureToSort, limit } = parameterValues;
+ const { splitColumn, timeBucket, measure, measureToSort, limit } = parameterValues;
const dataQuery = useMemo(() => {
const splitExpression = splitColumn ? splitColumn.expression : L(OVERALL_LABEL);
return querySource
.getInitQuery(where)
- .addSelect(splitExpression.as('dim'), { addToGroupBy: 'end' })
+ .addSelect(
+ splitExpression.applyIf(timeBucket, ex => F.timeFloor(ex, timeBucket)).as('dim'),
+ {
+ addToGroupBy: 'end',
+ addToOrderBy: !measureToSort && timeBucket ? 'end' : undefined,
+ direction: 'ASC',
+ },
+ )
.addSelect(measure.expression.as('met'), {
- addToOrderBy: measureToSort ? undefined : 'end',
+ addToOrderBy: !measureToSort && !timeBucket ? 'end' : undefined,
direction: 'DESC',
})
.applyIf(measureToSort, q =>
q.addOrderBy(measureToSort.expression.toOrderByExpression('DESC')),
)
.changeLimitValue(limit);
- }, [querySource, where, splitColumn, measure, measureToSort, limit]);
+ }, [querySource, where, splitColumn, timeBucket, measure, measureToSort, limit]);
const [sourceDataState, queryManager] = useQueryManager({
query: dataQuery,
@@ -155,23 +189,36 @@ ModuleRepository.registerModule({
const [x, y] = myChart.convertToPixel({ seriesIndex: 0 }, [dim, met]);
- highlightStore.getState().setHighlight({
- label: formatEmpty(label),
- x,
+ setHighlight({
+ title: formatEmpty(label),
+ x: x,
y: y - 20,
- data: [dim, met],
- onDrop: () => {
- highlightStore.getState().dropHighlight();
- },
- onSave:
- label !== OVERALL_LABEL
- ? () => {
- if (splitColumn) {
- setWhere(where.toggleClauseInWhere(splitColumn.expression.equal(label)));
- }
- highlightStore.getState().dropHighlight();
- }
- : undefined,
+ dim,
+ met,
+ text: (
+
+ {label !== OVERALL_LABEL && (
+ {
+ if (splitColumn) {
+ setWhere(updateFilterClause(where, splitColumn.expression.equal(label)));
+ }
+ setHighlight(undefined);
+ }}
+ />
+ )}
+ {
+ setHighlight(undefined);
+ }}
+ />
+
+ ),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -184,12 +231,12 @@ ModuleRepository.registerModule({
// if there is a highlight, update its x position
// by calculating new pixel position from the highlight's data
- const highlight = highlightStore.getState().highlight;
if (highlight) {
- const [x, y] = myChart.convertToPixel({ seriesIndex: 0 }, highlight.data as number[]);
+ const [x, y] = myChart.convertToPixel({ seriesIndex: 0 }, [highlight.dim, highlight.met]);
- highlightStore.getState().updateHighlight({
- x,
+ setHighlight({
+ ...highlight,
+ x: x,
y: y - 20,
});
}
@@ -202,6 +249,7 @@ ModuleRepository.registerModule({
className="echart-container"
ref={container => {
if (chartRef.current || !container) return;
+ containerRef.current = container;
chartRef.current = setupChart(container);
}}
/>
@@ -209,6 +257,11 @@ ModuleRepository.registerModule({
{sourceDataState.loading && (
queryManager.cancelCurrent()} />
)}
+
);
},
diff --git a/web-console/src/views/explore-view/modules/grouping-table-module.scss b/web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.scss
similarity index 100%
rename from web-console/src/views/explore-view/modules/grouping-table-module.scss
rename to web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.scss
diff --git a/web-console/src/views/explore-view/modules/grouping-table-module.tsx b/web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx
similarity index 92%
rename from web-console/src/views/explore-view/modules/grouping-table-module.tsx
rename to web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx
index 81e7d1924799..5dbff04b7d07 100644
--- a/web-console/src/views/explore-view/modules/grouping-table-module.tsx
+++ b/web-console/src/views/explore-view/modules/grouping-table-module/grouping-table-module.tsx
@@ -22,12 +22,12 @@ import type { SqlExpression, SqlOrderByDirection, SqlQuery } from 'druid-query-t
import { C, F } from 'druid-query-toolkit';
import { useMemo } from 'react';
-import { Loader } from '../../../components';
-import { useQueryManager } from '../../../hooks';
-import { formatInteger } from '../../../utils';
-import { calculateInitPageSize, GenericOutputTable } from '../components';
-import type { ExpressionMeta, Measure } from '../models';
-import { ModuleRepository } from '../module-repository/module-repository';
+import { Loader } from '../../../../components';
+import { useQueryManager } from '../../../../hooks';
+import { formatInteger } from '../../../../utils';
+import { calculateInitPageSize, GenericOutputTable } from '../../components';
+import type { ExpressionMeta, Measure } from '../../models';
+import { ModuleRepository } from '../../module-repository/module-repository';
import type {
Compare,
CompareStrategy,
@@ -35,8 +35,8 @@ import type {
MultipleValueMode,
QueryAndHints,
RestrictTop,
-} from '../utils';
-import { DEFAULT_TOP_VALUES_K, makeTableQueryAndHints } from '../utils';
+} from '../../utils';
+import { DEFAULT_TOP_VALUES_K, makeTableQueryAndHints } from '../../utils';
import './grouping-table-module.scss';
@@ -78,6 +78,7 @@ ModuleRepository.registerModule({
label: 'Group by',
transferGroup: 'show',
defaultValue: [],
+ important: true,
},
timeBucket: {
@@ -92,8 +93,11 @@ ModuleRepository.registerModule({
P1M: '1 month',
},
defaultValue: 'PT1H',
- visible: ({ parameterValues }) =>
- (parameterValues.splitColumns || []).some((c: any) => c.name === '__time'),
+ important: true,
+ defined: ({ parameterValues, querySource }) =>
+ (parameterValues.splitColumns || []).some(
+ (c: ExpressionMeta) => c.evaluateSqlType(querySource?.columns) === 'TIMESTAMP',
+ ),
},
showColumns: {
@@ -129,8 +133,9 @@ ModuleRepository.registerModule({
measures: {
type: 'measures',
transferGroup: 'show-agg',
- defaultValue: querySource => querySource.getFirstAggregateMeasureArray(),
+ defaultValue: ({ querySource }) => querySource?.getFirstAggregateMeasureArray(),
nonEmpty: true,
+ important: true,
},
compares: {
diff --git a/web-console/src/views/explore-view/modules/index.ts b/web-console/src/views/explore-view/modules/index.ts
index 3d8a063b104e..74cc85924ffb 100644
--- a/web-console/src/views/explore-view/modules/index.ts
+++ b/web-console/src/views/explore-view/modules/index.ts
@@ -16,9 +16,9 @@
* limitations under the License.
*/
-import './grouping-table-module';
-import './record-table-module';
-import './time-chart-module';
-import './bar-chart-module';
-import './pie-chart-module';
-import './multi-axis-chart-module';
+import './grouping-table-module/grouping-table-module';
+import './record-table-module/record-table-module';
+import './time-chart-module/time-chart-module';
+import './bar-chart-module/bar-chart-module';
+import './pie-chart-module/pie-chart-module';
+import './multi-axis-chart-module/multi-axis-chart-module';
diff --git a/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx b/web-console/src/views/explore-view/modules/multi-axis-chart-module/multi-axis-chart-module.tsx
similarity index 70%
rename from web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx
rename to web-console/src/views/explore-view/modules/multi-axis-chart-module/multi-axis-chart-module.tsx
index 3c3f4c48e646..e4009ab02d25 100644
--- a/web-console/src/views/explore-view/modules/multi-axis-chart-module.tsx
+++ b/web-console/src/views/explore-view/modules/multi-axis-chart-module/multi-axis-chart-module.tsx
@@ -16,33 +16,38 @@
* limitations under the License.
*/
+import { Button, Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
+import { Duration, Timezone } from 'chronoshift';
import type { SqlQuery } from 'druid-query-toolkit';
import { C, F, L } from 'druid-query-toolkit';
import type { ECharts } from 'echarts';
import * as echarts from 'echarts';
-import { useEffect, useMemo, useRef } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
-import { Loader } from '../../../components';
-import { useQueryManager } from '../../../hooks';
+import { Loader, PortalBubble, type PortalBubbleOpenOn } from '../../../../components';
+import { useQueryManager } from '../../../../hooks';
import {
- Duration,
formatInteger,
+ formatIsoDateRange,
formatNumber,
+ getAutoGranularity,
prettyFormatIsoDateTick,
prettyFormatIsoDateWithMsIfNeeded,
-} from '../../../utils';
-import { Issue } from '../components';
-import { highlightStore } from '../highlight-store/highlight-store';
-import type { ExpressionMeta } from '../models';
-import { ModuleRepository } from '../module-repository/module-repository';
-import { DATE_FORMAT, getAutoGranularity } from '../utils';
+} from '../../../../utils';
+import { Issue } from '../../components';
+import type { ExpressionMeta } from '../../models';
+import { ModuleRepository } from '../../module-repository/module-repository';
+import { updateFilterClause } from '../../utils';
-import './record-table-module.scss';
+interface MultiAxisChartHighlight extends PortalBubbleOpenOn {
+ start: Date;
+ end: Date;
+}
interface MultiAxisChartParameterValues {
- timeGranularity: string;
measures: ExpressionMeta[];
+ granularity: string;
}
ModuleRepository.registerModule({
@@ -50,38 +55,33 @@ ModuleRepository.registerModule({
title: 'Multi-axis chart',
icon: IconNames.SERIES_ADD,
parameters: {
- timeGranularity: {
- type: 'option',
- options: ['auto', 'PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
- optionLabels: {
- auto: 'Auto',
- PT1M: 'Minute',
- PT5M: '5 minutes',
- PT30M: '30 minutes',
- PT1H: 'Hour',
- PT6H: '6 hours',
- P1D: 'Day',
- },
- defaultValue: 'auto',
- },
measures: {
type: 'measures',
label: 'Measures to show',
transferGroup: 'show',
- defaultValue: querySource => querySource.getFirstAggregateMeasureArray(),
+ defaultValue: ({ querySource }) => querySource?.getFirstAggregateMeasureArray(),
nonEmpty: true,
required: true,
+ important: true,
+ },
+ granularity: {
+ type: 'option',
+ options: ['auto', 'PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
+ optionLabels: g => (g === 'auto' ? 'Auto' : new Duration(g).getDescription(true)),
+ defaultValue: 'auto',
},
},
component: function MultiAxisChartModule(props) {
const { querySource, where, setWhere, parameterValues, stage, runSqlQuery } = props;
+ const containerRef = useRef();
const chartRef = useRef();
+ const [highlight, setHighlight] = useState();
const timeColumnName = querySource.columns.find(column => column.sqlType === 'TIMESTAMP')?.name;
const timeGranularity =
- parameterValues.timeGranularity === 'auto'
- ? getAutoGranularity(where, timeColumnName || '__time')
- : parameterValues.timeGranularity;
+ parameterValues.granularity === 'auto'
+ ? getAutoGranularity(where, timeColumnName || '__time', 200)
+ : parameterValues.granularity;
const { measures } = parameterValues;
@@ -218,6 +218,8 @@ ModuleRepository.registerModule({
},
);
+ myChart.off('brush');
+
myChart.on('brush', (params: any) => {
if (!params.areas.length) return;
@@ -225,73 +227,58 @@ ModuleRepository.registerModule({
// the positioning is done with the true coordinates until the user
// releases the mouse button (in the `brushend` event)
const duration = new Duration(timeGranularity);
- const start = duration.round(params.areas[0].coordRange[0], 'Etc/UTC');
- const end = duration.round(params.areas[0].coordRange[1], 'Etc/UTC');
+ const start = duration.round(params.areas[0].coordRange[0], Timezone.UTC);
+ const end = duration.round(params.areas[0].coordRange[1], Timezone.UTC);
const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[0]);
const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[1]);
- highlightStore.getState().setHighlight({
- label: DATE_FORMAT.formatRange(start, end),
- x: x0 + (x1 - x0) / 2,
- y: 40,
- data: { start, end },
- onDrop: () => {
- highlightStore.getState().dropHighlight();
- myChart.dispatchAction({
- type: 'brush',
- command: 'clear',
- areas: [],
- });
- },
- onSave: () => {
- if (!timeColumnName) return;
- setWhere(
- where.changeClauseInWhere(
- F(
- 'TIME_IN_INTERVAL',
- C(timeColumnName),
- `${start.toISOString()}/${end.toISOString()}`,
- ),
- ),
- );
- highlightStore.getState().dropHighlight();
- myChart.dispatchAction({
- type: 'brush',
- command: 'clear',
- areas: [],
- });
- },
- });
- });
-
- // once the user is done selecting a range, this will snap the start and end
- myChart.on('brushend', () => {
- const highlight = highlightStore.getState().highlight;
- if (!highlight) return;
-
- // this is already snapped
- const { start, end } = highlight.data;
-
- const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
- const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
-
- // positions the bubble on the snapped start and end
- highlightStore.getState().updateHighlight({
- x: x0 + (x1 - x0) / 2,
- });
-
- // gives the chart the snapped range to highlight
- // (will replace the area the user just selected)
- myChart.dispatchAction({
- type: 'brush',
- areas: [
- {
- brushType: 'lineX',
- coordRange: [start, end],
- xAxisIndex: 0,
- },
- ],
+ setHighlight({
+ title: formatIsoDateRange(start, end),
+ x: (x0 + x1) / 2,
+ y: 50,
+ start,
+ end,
+ text: (
+
+ {
+ if (!timeColumnName) return;
+ setWhere(
+ updateFilterClause(
+ where,
+ F(
+ 'TIME_IN_INTERVAL',
+ C(timeColumnName),
+ `${start.toISOString()}/${end.toISOString()}`,
+ ),
+ ),
+ );
+ setHighlight(undefined);
+ myChart.dispatchAction({
+ type: 'brush',
+ command: 'clear',
+ areas: [],
+ });
+ }}
+ />
+ {
+ setHighlight(undefined);
+ myChart.dispatchAction({
+ type: 'brush',
+ command: 'clear',
+ areas: [],
+ });
+ }}
+ />
+
+ ),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -304,15 +291,15 @@ ModuleRepository.registerModule({
// if there is a highlight, update its x position
// by calculating new pixel position from the highlight's data
- const highlight = highlightStore.getState().highlight;
if (highlight) {
- const { start, end } = highlight.data;
+ const { start, end } = highlight;
const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
- highlightStore.getState().updateHighlight({
- x: x0 + (x1 - x0) / 2,
+ setHighlight({
+ ...highlight,
+ x: (x0 + x1) / 2,
});
}
}, [stage]);
@@ -324,6 +311,7 @@ ModuleRepository.registerModule({
className="echart-container"
ref={container => {
if (chartRef.current || !container) return;
+ containerRef.current = container;
chartRef.current = setupChart(container);
}}
/>
@@ -331,6 +319,11 @@ ModuleRepository.registerModule({
{sourceDataState.loading && (
queryManager.cancelCurrent()} />
)}
+
);
},
diff --git a/web-console/src/views/explore-view/modules/pie-chart-module.tsx b/web-console/src/views/explore-view/modules/pie-chart-module/pie-chart-module.tsx
similarity index 74%
rename from web-console/src/views/explore-view/modules/pie-chart-module.tsx
rename to web-console/src/views/explore-view/modules/pie-chart-module/pie-chart-module.tsx
index d90dfbeae606..ec1e36dd01db 100644
--- a/web-console/src/views/explore-view/modules/pie-chart-module.tsx
+++ b/web-console/src/views/explore-view/modules/pie-chart-module/pie-chart-module.tsx
@@ -16,21 +16,20 @@
* limitations under the License.
*/
+import { Button, Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
-import { C, L } from 'druid-query-toolkit';
+import { C, F, L } from 'druid-query-toolkit';
import type { ECharts } from 'echarts';
import * as echarts from 'echarts';
-import { useEffect, useMemo, useRef } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
-import { Loader } from '../../../components';
-import { useQueryManager } from '../../../hooks';
-import { formatEmpty, formatNumber } from '../../../utils';
-import { Issue } from '../components';
-import { highlightStore } from '../highlight-store/highlight-store';
-import type { ExpressionMeta } from '../models';
-import { ModuleRepository } from '../module-repository/module-repository';
-
-import './record-table-module.scss';
+import { Loader, PortalBubble, type PortalBubbleOpenOn } from '../../../../components';
+import { useQueryManager } from '../../../../hooks';
+import { formatEmpty, formatNumber } from '../../../../utils';
+import { Issue } from '../../components';
+import type { ExpressionMeta } from '../../models';
+import { ModuleRepository } from '../../module-repository/module-repository';
+import { updateFilterClause } from '../../utils';
const OVERALL_LABEL = 'Overall';
@@ -54,6 +53,11 @@ function getCentroid(chart: echarts.ECharts, dataIndex: number) {
return { x, y };
}
+interface PieChartHighlight extends PortalBubbleOpenOn {
+ name: string;
+ dataIndex: number;
+}
+
interface PieChartParameterValues {
splitColumn: ExpressionMeta;
measure: ExpressionMeta;
@@ -71,12 +75,14 @@ ModuleRepository.registerModule({
label: 'Slice column',
transferGroup: 'show',
required: true,
+ important: true,
},
measure: {
type: 'measure',
transferGroup: 'show',
- defaultValue: querySource => querySource.getFirstAggregateMeasure(),
+ defaultValue: ({ querySource }) => querySource?.getFirstAggregateMeasure(),
required: true,
+ important: true,
},
limit: {
type: 'number',
@@ -92,7 +98,9 @@ ModuleRepository.registerModule({
},
component: function PieChartModule(props) {
const { querySource, where, setWhere, parameterValues, stage, runSqlQuery } = props;
+ const containerRef = useRef();
const chartRef = useRef();
+ const [highlight, setHighlight] = useState();
const { splitColumn, measure, limit, showOthers } = parameterValues;
@@ -102,7 +110,7 @@ ModuleRepository.registerModule({
return {
mainQuery: querySource
.getInitQuery(where)
- .addSelect(splitExpression.as('name'), { addToGroupBy: 'end' })
+ .addSelect(F.cast(splitExpression, 'VARCHAR').as('name'), { addToGroupBy: 'end' })
.addSelect(measure.expression.as('value'), {
addToOrderBy: 'end',
direction: 'DESC',
@@ -191,31 +199,47 @@ ModuleRepository.registerModule({
});
myChart.on('click', 'series', p => {
- if (highlightStore.getState().highlight?.data.name === p.name) {
- highlightStore.getState().dropHighlight();
+ if (highlight?.name === p.name) {
+ setHighlight(undefined);
return;
}
const centroid = getCentroid(myChart, p.dataIndex);
-
if (!centroid) return;
const { name, value, __isOthers } = p.data as any;
- highlightStore.getState().setHighlight({
- label: formatEmpty(name) + ': ' + formatNumber(value),
+ setHighlight({
+ title: formatEmpty(name),
x: centroid.x,
y: centroid.y - 20,
- data: { name, value, dataIndex: p.dataIndex },
- onDrop: () => {
- highlightStore.getState().dropHighlight();
- },
- onSave: __isOthers
- ? undefined
- : () => {
- setWhere(where.toggleClauseInWhere(C(splitColumn.name).equal(name)));
- highlightStore.getState().dropHighlight();
- },
+ name,
+ dataIndex: p.dataIndex,
+ text: (
+ <>
+ {formatNumber(value)}
+
+ {!__isOthers && (
+ {
+ setWhere(updateFilterClause(where, C(splitColumn.name).equal(name)));
+ setHighlight(undefined);
+ }}
+ />
+ )}
+ {
+ setHighlight(undefined);
+ }}
+ />
+
+ >
+ ),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -228,15 +252,15 @@ ModuleRepository.registerModule({
// if there is a highlight, update its x position
// by calculating new pixel position from the highlight's data
- const highlight = highlightStore.getState().highlight;
if (highlight) {
- const { dataIndex } = highlight.data;
+ const { dataIndex } = highlight;
const centroid = getCentroid(myChart, dataIndex);
if (!centroid) return;
- highlightStore.getState().updateHighlight({
+ setHighlight({
+ ...highlight,
x: centroid.x,
y: centroid.y - 20,
});
@@ -250,6 +274,7 @@ ModuleRepository.registerModule({
className="echart-container"
ref={container => {
if (chartRef.current || !container) return;
+ containerRef.current = container;
chartRef.current = setupChart(container);
}}
/>
@@ -257,6 +282,11 @@ ModuleRepository.registerModule({
{sourceDataState.loading && (
queryManager.cancelCurrent()} />
)}
+
);
},
diff --git a/web-console/src/views/explore-view/modules/record-table-module.scss b/web-console/src/views/explore-view/modules/record-table-module/record-table-module.scss
similarity index 100%
rename from web-console/src/views/explore-view/modules/record-table-module.scss
rename to web-console/src/views/explore-view/modules/record-table-module/record-table-module.scss
diff --git a/web-console/src/views/explore-view/modules/record-table-module.tsx b/web-console/src/views/explore-view/modules/record-table-module/record-table-module.tsx
similarity index 91%
rename from web-console/src/views/explore-view/modules/record-table-module.tsx
rename to web-console/src/views/explore-view/modules/record-table-module/record-table-module.tsx
index d7a812e4c167..275b2c97c2eb 100644
--- a/web-console/src/views/explore-view/modules/record-table-module.tsx
+++ b/web-console/src/views/explore-view/modules/record-table-module/record-table-module.tsx
@@ -20,12 +20,12 @@ import { IconNames } from '@blueprintjs/icons';
import { C, SqlQuery } from 'druid-query-toolkit';
import { useMemo } from 'react';
-import { Loader } from '../../../components';
-import { useQueryManager } from '../../../hooks';
-import type { ColumnHint } from '../../../utils';
-import { filterMap } from '../../../utils';
-import { calculateInitPageSize, GenericOutputTable } from '../components';
-import { ModuleRepository } from '../module-repository/module-repository';
+import { Loader } from '../../../../components';
+import { useQueryManager } from '../../../../hooks';
+import type { ColumnHint } from '../../../../utils';
+import { filterMap } from '../../../../utils';
+import { calculateInitPageSize, GenericOutputTable } from '../../components';
+import { ModuleRepository } from '../../module-repository/module-repository';
import './record-table-module.scss';
diff --git a/web-console/src/views/explore-view/modules/time-chart-module.tsx b/web-console/src/views/explore-view/modules/time-chart-module.tsx
deleted file mode 100644
index a07455b547a5..000000000000
--- a/web-console/src/views/explore-view/modules/time-chart-module.tsx
+++ /dev/null
@@ -1,451 +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 { IconNames } from '@blueprintjs/icons';
-import { C, F, L, SqlCase } from 'druid-query-toolkit';
-import type { ECharts } from 'echarts';
-import * as echarts from 'echarts';
-import { useEffect, useMemo, useRef } from 'react';
-
-import { Loader } from '../../../components';
-import { useQueryManager } from '../../../hooks';
-import {
- Duration,
- formatInteger,
- formatNumber,
- prettyFormatIsoDateTick,
- prettyFormatIsoDateWithMsIfNeeded,
-} from '../../../utils';
-import { Issue } from '../components';
-import { highlightStore } from '../highlight-store/highlight-store';
-import type { ExpressionMeta } from '../models';
-import { ModuleRepository } from '../module-repository/module-repository';
-import { DATE_FORMAT, getAutoGranularity } from '../utils';
-
-import './record-table-module.scss';
-
-const TIME_NAME = '__t__';
-const METRIC_NAME = '__met__';
-const STACK_NAME = '__stack__';
-const OTHERS_VALUE = 'Others';
-
-function transformData(data: any[], vs: string[]): Record[] {
- const zeroDatum = Object.fromEntries(vs.map(v => [v, 0]));
-
- let lastTime = -1;
- let lastDatum: Record | undefined;
- const ret = [];
- for (const d of data) {
- const t = d[TIME_NAME];
- if (t.valueOf() !== lastTime) {
- if (lastDatum) ret.push(lastDatum);
- lastTime = t.valueOf();
- lastDatum = { ...zeroDatum, [TIME_NAME]: t };
- }
- lastDatum![d[STACK_NAME]] = d[METRIC_NAME];
- }
- if (lastDatum) ret.push(lastDatum);
- return ret;
-}
-
-interface TimeChartParameterValues {
- timeGranularity: string;
- splitColumn?: ExpressionMeta;
- numberToStack: number;
- showOthers: boolean;
- measure: ExpressionMeta;
- snappyHighlight: boolean;
-}
-
-ModuleRepository.registerModule({
- id: 'time-chart',
- title: 'Time chart',
- icon: IconNames.TIMELINE_LINE_CHART,
- parameters: {
- timeGranularity: {
- type: 'option',
- options: ['auto', 'PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
- defaultValue: 'auto',
- optionLabels: {
- auto: 'Auto',
- PT1M: 'Minute',
- PT5M: '5 minutes',
- PT30M: '30 minutes',
- PT1H: 'Hour',
- PT6H: '6 hours',
- P1D: 'Day',
- },
- },
- splitColumn: {
- type: 'expression',
- label: 'Stack by',
- transferGroup: 'show',
- },
- numberToStack: {
- type: 'number',
- label: 'Max stacks',
- defaultValue: 7,
- min: 2,
- required: true,
- visible: ({ parameterValues }) => Boolean(parameterValues.splitColumn),
- },
- showOthers: {
- type: 'boolean',
- defaultValue: true,
- visible: ({ parameterValues }) => Boolean(parameterValues.splitColumn),
- },
- measure: {
- type: 'measure',
- label: 'Measure to show',
- transferGroup: 'show-agg',
- defaultValue: querySource => querySource.getFirstAggregateMeasure(),
- required: true,
- },
- snappyHighlight: {
- type: 'boolean',
- label: 'Snap highlight to granularity',
- defaultValue: true,
- sticky: true,
- },
- },
- component: function TimeChartModule(props) {
- const { querySource, where, setWhere, parameterValues, stage, runSqlQuery } = props;
- const chartRef = useRef();
-
- const timeColumnName = querySource.columns.find(column => column.sqlType === 'TIMESTAMP')?.name;
- const timeGranularity =
- parameterValues.timeGranularity === 'auto'
- ? getAutoGranularity(where, timeColumnName || '__time')
- : parameterValues.timeGranularity;
-
- const { splitColumn, numberToStack, showOthers, measure, snappyHighlight } = parameterValues;
-
- const dataQuery = useMemo(() => {
- return {
- initQuery: querySource.getInitQuery(where),
- measure,
- splitExpression: splitColumn?.expression,
- numberToStack,
- showOthers,
- };
- }, [querySource, where, measure, splitColumn, numberToStack, showOthers]);
-
- const [sourceDataState, queryManager] = useQueryManager({
- query: dataQuery,
- processQuery: async (
- { initQuery, measure, splitExpression, numberToStack, showOthers },
- cancelToken,
- ) => {
- if (!timeColumnName) {
- throw new Error(`Must have a column of type TIMESTAMP for the time chart to work`);
- }
-
- const vs = splitExpression
- ? (
- await runSqlQuery(
- initQuery
- .addSelect(splitExpression.as('v'), { addToGroupBy: 'end' })
- .changeOrderByExpression(measure.expression.toOrderByExpression('DESC'))
- .changeLimitValue(numberToStack),
- cancelToken,
- )
- ).getColumnByIndex(0)!
- : undefined;
-
- cancelToken.throwIfRequested();
-
- const dataset = (
- await runSqlQuery(
- initQuery
- .applyIf(splitExpression && vs && !showOthers, q =>
- q.addWhere(splitExpression!.in(vs!)),
- )
- .addSelect(F.timeFloor(C(timeColumnName), L(timeGranularity)).as(TIME_NAME), {
- addToGroupBy: 'end',
- addToOrderBy: 'end',
- direction: 'ASC',
- })
- .applyIf(splitExpression, q => {
- if (!splitExpression || !vs) return q; // Should never get here, doing this to make peace between eslint and TS
- return q.addSelect(
- (showOthers
- ? SqlCase.ifThenElse(splitExpression.in(vs), splitExpression, L(OTHERS_VALUE))
- : splitExpression
- ).as(STACK_NAME),
- { addToGroupBy: 'end' },
- );
- })
- .addSelect(measure.expression.as(METRIC_NAME)),
- cancelToken,
- )
- ).toObjectArray();
-
- const effectiveVs = vs && showOthers ? vs.concat(OTHERS_VALUE) : vs;
- return {
- effectiveVs,
- sourceData: effectiveVs ? transformData(dataset, effectiveVs) : dataset,
- measure,
- };
- },
- });
-
- function setupChart(container: HTMLDivElement) {
- const myChart = echarts.init(container, 'dark');
-
- myChart.setOption({
- dataset: {
- dimensions: [],
- source: [],
- },
- tooltip: {
- trigger: 'axis',
- transitionDuration: 0,
- axisPointer: {
- type: 'cross',
- label: {
- backgroundColor: '#6a7985',
- formatter(d: any) {
- if (d.axisDimension === 'x') {
- return prettyFormatIsoDateWithMsIfNeeded(new Date(d.value).toISOString());
- } else {
- return Math.abs(d.value) < 1 ? formatNumber(d.value) : formatInteger(d.value);
- }
- },
- },
- },
- },
- legend: {
- data: [],
- },
- brush: {
- toolbox: ['lineX'],
- xAxisIndex: 0,
- },
- grid: {
- left: '3%',
- right: '4%',
- bottom: '3%',
- containLabel: true,
- },
- xAxis: [
- {
- type: 'time',
- boundaryGap: false,
- axisLabel: {
- formatter(value: any) {
- return prettyFormatIsoDateTick(new Date(value));
- },
- },
- },
- ],
- yAxis: [
- {
- type: 'value',
- },
- ],
- series: [],
- });
-
- // auto-enables the brush tool on load
- myChart.dispatchAction({
- type: 'takeGlobalCursor',
- key: 'brush',
- brushOption: {
- brushType: 'lineX',
- },
- });
-
- return myChart;
- }
-
- useEffect(() => {
- return () => {
- const myChart = chartRef.current;
- if (!myChart) return;
- myChart.dispose();
- };
- }, []);
-
- useEffect(() => {
- const myChart = chartRef.current;
- const data = sourceDataState.data;
- if (!myChart || !data) return;
- const { effectiveVs, sourceData, measure } = data;
-
- myChart.off('brush');
- myChart.off('brushend');
-
- myChart.on('brush', (params: any) => {
- if (!params.areas.length) return;
-
- // this is only used for the label and the data saved in the highlight
- // the positioning is done with the true coordinates until the user
- // releases the mouse button (in the `brushend` event)
- let start = params.areas[0].coordRange[0];
- let end = params.areas[0].coordRange[1];
- if (snappyHighlight) {
- const duration = new Duration(timeGranularity);
- start = duration.round(start, 'Etc/UTC');
- end = duration.round(end, 'Etc/UTC');
- }
-
- const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[0]);
- const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, params.areas[0].coordRange[1]);
-
- highlightStore.getState().setHighlight({
- label: DATE_FORMAT.formatRange(start, end),
- x: x0 + (x1 - x0) / 2,
- y: 40,
- data: { start, end },
- onDrop: () => {
- highlightStore.getState().dropHighlight();
- myChart.dispatchAction({
- type: 'brush',
- command: 'clear',
- areas: [],
- });
- },
- onSave: () => {
- if (!timeColumnName) return;
- setWhere(
- where.changeClauseInWhere(
- F(
- 'TIME_IN_INTERVAL',
- C(timeColumnName),
- `${start.toISOString()}/${end.toISOString()}`,
- ),
- ),
- );
- highlightStore.getState().dropHighlight();
- myChart.dispatchAction({
- type: 'brush',
- command: 'clear',
- areas: [],
- });
- },
- });
- });
-
- // once the user is done selecting a range, this will snap the start and end
- myChart.on('brushend', () => {
- const highlight = highlightStore.getState().highlight;
- if (!highlight) return;
-
- // this is already snapped
- const { start, end } = highlight.data;
-
- const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
- const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
-
- // positions the bubble on the snapped start and end
- highlightStore.getState().updateHighlight({
- x: x0 + (x1 - x0) / 2,
- });
-
- // gives the chart the snapped range to highlight
- // (will replace the area the user just selected)
- myChart.dispatchAction({
- type: 'brush',
- areas: [
- {
- brushType: 'lineX',
- coordRange: [start, end],
- xAxisIndex: 0,
- },
- ],
- });
- });
-
- const showSymbol = sourceData.length < 2;
- myChart.setOption(
- {
- dataset: {
- dimensions: [TIME_NAME].concat(effectiveVs || [METRIC_NAME]),
- source: sourceData,
- },
- animation: false,
- legend: effectiveVs
- ? {
- data: effectiveVs,
- }
- : undefined,
- series: (effectiveVs || [METRIC_NAME]).map(v => {
- return {
- id: v,
- name: effectiveVs ? v : measure.name,
- type: 'line',
- stack: 'Total',
- showSymbol,
- lineStyle: v === OTHERS_VALUE ? { color: '#ccc' } : {},
- areaStyle: v === OTHERS_VALUE ? { color: '#ccc' } : {},
- emphasis: {
- focus: 'series',
- },
- encode: {
- x: TIME_NAME,
- y: v,
- itemId: v,
- },
- };
- }),
- },
- {
- replaceMerge: ['legend', 'series'],
- },
- );
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sourceDataState.data, snappyHighlight]);
-
- useEffect(() => {
- const myChart = chartRef.current;
- if (!myChart) return;
- myChart.resize();
-
- // if there is a highlight, update its x position
- // by calculating new pixel position from the highlight's data
- const highlight = highlightStore.getState().highlight;
- if (highlight) {
- const { start, end } = highlight.data;
-
- const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
- const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
-
- highlightStore.getState().updateHighlight({
- x: x0 + (x1 - x0) / 2,
- });
- }
- }, [stage]);
-
- const errorMessage = sourceDataState.getErrorMessage();
- return (
-
-
{
- if (chartRef.current || !container) return;
- chartRef.current = setupChart(container);
- }}
- />
- {errorMessage && }
- {sourceDataState.loading && (
- queryManager.cancelCurrent()} />
- )}
-
- );
- },
-});
diff --git a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss
new file mode 100644
index 000000000000..a594385c35d5
--- /dev/null
+++ b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@import '../../../../variables';
+
+.continuous-chart-render {
+ position: relative;
+ overflow: hidden;
+ height: 100%;
+
+ svg.main-chart {
+ position: absolute;
+
+ text {
+ user-select: none;
+ }
+
+ .mark-bar {
+ fill: #497ee6;
+ }
+
+ .selection {
+ fill: white;
+ fill-opacity: 0.1;
+
+ &.finalized {
+ fill-opacity: 0.15;
+ }
+ }
+
+ .selected-bar {
+ fill: none;
+ stroke: #ffffff;
+ stroke-width: 1px;
+ opacity: 0.8;
+
+ &.finalized {
+ opacity: 1;
+ }
+ }
+
+ .shifter {
+ fill: white;
+ fill-opacity: 0.2;
+ filter: blur(1px);
+ }
+
+ .time-shift-indicator {
+ fill: white;
+ fill-opacity: 0.001;
+ cursor: grab;
+
+ &:hover {
+ fill-opacity: 0.1;
+ }
+
+ &.shifting {
+ fill-opacity: 0.2;
+ cursor: grabbing;
+ }
+ }
+
+ .h-gridline {
+ line {
+ stroke: $white;
+ stroke-dasharray: 5, 5;
+ opacity: 0.5;
+ }
+ }
+
+ .now-line {
+ stroke: $orange4;
+ stroke-dasharray: 2, 2;
+ opacity: 0.7;
+ }
+
+ .mark-line {
+ stroke-width: 1.5px;
+ stroke: #497ee6;
+ fill: none;
+ }
+
+ .mark-area {
+ fill: #497ee6;
+ opacity: 0.7;
+ }
+
+ .selected-point {
+ fill: #497ee6;
+ }
+ }
+
+ .zoom-out-button {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ }
+
+ .empty-placeholder {
+ @include pin-full;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ user-select: none;
+ pointer-events: none;
+ }
+}
+
+.continuous-chart-bubble {
+ .button-bar {
+ padding-top: 5px;
+ display: flex;
+ gap: 5px;
+ }
+}
diff --git a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx
new file mode 100644
index 000000000000..29232620048d
--- /dev/null
+++ b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx
@@ -0,0 +1,754 @@
+/*
+ * 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 { day, Duration, minute, second, Timezone } from 'chronoshift';
+import classNames from 'classnames';
+import { max, sort, sum } from 'd3-array';
+import { axisBottom, axisLeft, axisRight } from 'd3-axis';
+import { scaleLinear, scaleOrdinal, scaleUtc } from 'd3-scale';
+import { select } from 'd3-selection';
+import type { Area, Line } from 'd3-shape';
+import { area, curveLinear, curveMonotoneX, curveStep, line } from 'd3-shape';
+import type { MouseEvent as ReactMouseEvent } from 'react';
+import { useMemo, useRef, useState } from 'react';
+
+import type { PortalBubbleOpenOn } from '../../../../components';
+import { PortalBubble } from '../../../../components';
+import { useClock, useGlobalEventListener } from '../../../../hooks';
+import type { Margin, Stage } from '../../../../utils';
+import {
+ clamp,
+ filterMap,
+ formatIsoDateRange,
+ formatNumber,
+ formatStartDuration,
+ groupBy,
+ lookupBy,
+} from '../../../../utils';
+
+import './continuous-chart-render.scss';
+
+const Y_AXIS_WIDTH = 60;
+
+function getDefaultChartMargin(yAxis: undefined | 'left' | 'right') {
+ return {
+ top: 20,
+ right: 10 + (yAxis === 'right' ? Y_AXIS_WIDTH : 0),
+ bottom: 25,
+ left: 10 + (yAxis === 'left' ? Y_AXIS_WIDTH : 0),
+ };
+}
+
+const EXTEND_X_SCALE_DOMAIN_BY = 1;
+
+export const OTHER_VALUE = 'Other';
+const OTHER_COLOR = '#666666';
+const COLORS = ['#1b9e77', '#d95f02', '#7570b3', '#e7298a', '#66a61e', '#e6ab02', '#a6761d'];
+
+// ---------------------------------------
+
+export type Range = [number, number];
+
+export interface RangeDatum {
+ start: number;
+ end: number;
+ measure: number;
+ stack: string | undefined;
+}
+
+export interface StackedRangeDatum extends RangeDatum {
+ offset: number;
+}
+
+// ---------------------------------------
+
+const DAY_DURATION = new Duration('P1D');
+
+function getTodayRange(timezone: Timezone): Range {
+ const [start, end] = DAY_DURATION.range(new Date(), timezone);
+ return [start.valueOf(), end.valueOf()];
+}
+
+function offsetRange(dateRange: Range, offset: number, roundEnd?: (n: number) => number): Range {
+ const d = dateRange[1] - dateRange[0];
+ let newEnd = dateRange[1] + offset;
+ if (roundEnd) newEnd = roundEnd(newEnd);
+ return [newEnd - d, newEnd];
+}
+
+interface SelectionRange {
+ start: number;
+ end: number;
+ finalized?: boolean;
+ selectedDatum?: StackedRangeDatum;
+}
+
+export type ContinuousChartMarkType = 'bar' | 'area' | 'line';
+export type ContinuousChartCurveType = 'smooth' | 'linear' | 'step';
+
+function getCurveFactory(curveType: ContinuousChartCurveType | undefined) {
+ switch (curveType) {
+ case 'linear':
+ return curveLinear;
+
+ case 'step':
+ return curveStep;
+
+ case 'smooth':
+ default:
+ return curveMonotoneX;
+ }
+}
+
+export interface ContinuousChartRenderProps {
+ /**
+ * The data to be rendered it has to be ordered in reverse chronological order (latest first)
+ * If stacking is used then the stack bars should be ordered bottom to top.
+ */
+ data: RangeDatum[];
+ stacks: string[] | undefined;
+
+ /**
+ * The granularity that was used for bucketing.
+ */
+ granularity: Duration;
+ markType: ContinuousChartMarkType;
+
+ /**
+ * Defines how to render the curve in case 'area' or 'line' is selected as the mark type
+ */
+ curveType?: ContinuousChartCurveType;
+
+ /**
+ * The width x height to render
+ */
+ stage: Stage;
+ margin?: Margin;
+
+ yAxis?: 'left' | 'right';
+ showHorizontalGridlines?: 'auto' | 'always' | 'never';
+
+ /**
+ * The optional range of the x-axis to show, if not set it defaults to the extent of the data
+ */
+ domainRange: Range | undefined;
+ onChangeRange(range: Range): void;
+}
+
+export const ContinuousChartRender = function ContinuousChartRender(
+ props: ContinuousChartRenderProps,
+) {
+ const {
+ data,
+ stacks,
+ granularity,
+
+ markType,
+ curveType,
+
+ stage,
+ margin,
+ yAxis,
+ showHorizontalGridlines,
+ domainRange,
+ onChangeRange,
+ } = props;
+ const [mouseDownAt, setMouseDownAt] = useState<
+ { time: number; action: 'select' | 'shift' } | undefined
+ >();
+ const [selection, setSelection] = useState
();
+
+ function setSelectionIfNeeded(newSelection: SelectionRange) {
+ if (
+ selection &&
+ selection.start === newSelection.start &&
+ selection.end === newSelection.end &&
+ selection.finalized === newSelection.finalized &&
+ selection.selectedDatum === newSelection.selectedDatum
+ ) {
+ return;
+ }
+ setSelection(newSelection);
+ }
+
+ const [shiftOffset, setShiftOffset] = useState();
+
+ const now = useClock(minute.canonicalLength);
+ const svgRef = useRef(null);
+
+ const stackedData: StackedRangeDatum[] = useMemo(() => {
+ const effectiveStacks = stacks || ['undefined'];
+ const stackToIndex = lookupBy(
+ effectiveStacks,
+ s => s,
+ (_, i) => i,
+ );
+
+ // Sort the data into time descending column and stack order
+ const sortedData = sort(data, (a, b) => {
+ const diffStart = b.start - a.start;
+ if (diffStart) return diffStart;
+
+ return stackToIndex[String(a.stack)] - stackToIndex[String(b.stack)];
+ });
+
+ if (markType === 'line') {
+ // No need to stack
+ return sortedData.map(d => ({ ...d, offset: 0 }));
+ } else {
+ let lastStart: number | undefined;
+ let offset: number;
+ return sortedData.map(d => {
+ if (lastStart !== d.start) {
+ offset = 0;
+ lastStart = d.start;
+ }
+ const withOffset = { ...d, offset };
+ offset += d.measure;
+ return withOffset;
+ });
+ }
+ }, [data, stacks, markType]);
+
+ function findStackedDatum(time: number, measure: number): StackedRangeDatum | undefined {
+ const dataInRange = stackedData.filter(d => d.start <= time && time < d.end);
+ if (!dataInRange.length) return;
+ return (
+ dataInRange.find(r => r.offset <= measure && measure < r.measure + r.offset) ||
+ dataInRange[dataInRange.length - 1]
+ );
+ }
+
+ const stackColorizer = useMemo(() => {
+ const s = scaleOrdinal(COLORS);
+ return (v: string) => (v === OTHER_VALUE ? OTHER_COLOR : s(v));
+ }, []);
+
+ const chartMargin = { ...margin, ...getDefaultChartMargin(yAxis) };
+ const innerStage = stage.applyMargin(chartMargin);
+
+ const effectiveDomainRange =
+ domainRange ||
+ (stackedData.length
+ ? [stackedData[stackedData.length - 1].start, stackedData[0].end]
+ : getTodayRange(Timezone.UTC));
+
+ const baseTimeScale = scaleUtc()
+ .domain(effectiveDomainRange)
+ .range([EXTEND_X_SCALE_DOMAIN_BY, innerStage.width - EXTEND_X_SCALE_DOMAIN_BY]);
+ const timeScale = shiftOffset
+ ? baseTimeScale.copy().domain(offsetRange(effectiveDomainRange, shiftOffset))
+ : baseTimeScale;
+
+ const maxMeasure = max(stackedData, d => d.measure + d.offset);
+ const measureScale = scaleLinear()
+ .rangeRound([innerStage.height, 0])
+ .domain([0, (maxMeasure ?? 100) * 1.05]);
+
+ function handleMouseDown(e: ReactMouseEvent) {
+ const svg = svgRef.current;
+ if (!svg) return;
+ e.preventDefault();
+
+ const rect = svg.getBoundingClientRect();
+ const x = clamp(
+ e.clientX - rect.x - chartMargin.left,
+ EXTEND_X_SCALE_DOMAIN_BY,
+ innerStage.width - EXTEND_X_SCALE_DOMAIN_BY,
+ );
+ const y = e.clientY - rect.y - chartMargin.top;
+ const time = baseTimeScale.invert(x).valueOf();
+ const action = y > innerStage.height || e.shiftKey ? 'shift' : 'select';
+ setMouseDownAt({
+ time,
+ action,
+ });
+ if (action === 'select') {
+ const start = granularity.floor(new Date(time), Timezone.UTC);
+ setSelectionIfNeeded({
+ start: start.valueOf(),
+ end: granularity.shift(start, Timezone.UTC, 1).valueOf(),
+ });
+ } else {
+ setSelection(undefined);
+ }
+ }
+
+ useGlobalEventListener('mousemove', (e: MouseEvent) => {
+ const svg = svgRef.current;
+ if (!svg) return;
+ const rect = svg.getBoundingClientRect();
+ const x = e.clientX - rect.x - chartMargin.left;
+ const y = e.clientY - rect.y - chartMargin.top;
+
+ if (mouseDownAt) {
+ e.preventDefault();
+
+ if (mouseDownAt.action === 'shift' || e.shiftKey) {
+ const b = baseTimeScale.invert(x).valueOf();
+ setShiftOffset(mouseDownAt.time.valueOf() - b.valueOf());
+ } else {
+ const b = baseTimeScale
+ .invert(clamp(x, EXTEND_X_SCALE_DOMAIN_BY, innerStage.width - EXTEND_X_SCALE_DOMAIN_BY))
+ .valueOf();
+ if (mouseDownAt.time < b) {
+ setSelectionIfNeeded({
+ start: granularity.floor(new Date(mouseDownAt.time), Timezone.UTC).valueOf(),
+ end: granularity.ceil(new Date(b), Timezone.UTC).valueOf(),
+ });
+ } else {
+ setSelectionIfNeeded({
+ start: granularity.floor(new Date(b), Timezone.UTC).valueOf(),
+ end: granularity.ceil(new Date(mouseDownAt.time), Timezone.UTC).valueOf(),
+ });
+ }
+ }
+ } else if (!selection?.finalized) {
+ if (
+ 0 <= x &&
+ x <= innerStage.width &&
+ 0 <= y &&
+ y <= innerStage.height &&
+ svg.contains(e.target as any)
+ ) {
+ const time = baseTimeScale.invert(x).valueOf();
+ const measure = measureScale.invert(y);
+
+ const start = granularity.floor(new Date(time), Timezone.UTC);
+ const end = granularity.shift(start, Timezone.UTC, 1);
+
+ setSelectionIfNeeded({
+ start: start.valueOf(),
+ end: end.valueOf(),
+ selectedDatum: findStackedDatum(time, measure),
+ });
+ } else {
+ setSelection(undefined);
+ }
+ }
+ });
+
+ useGlobalEventListener('mouseup', (e: MouseEvent) => {
+ if (!mouseDownAt) return;
+ e.preventDefault();
+ setMouseDownAt(undefined);
+
+ if (!shiftOffset && !selection) return;
+
+ setShiftOffset(undefined);
+ if (mouseDownAt.action === 'shift' || e.shiftKey) {
+ if (shiftOffset) {
+ const domainRangeExtent = effectiveDomainRange[1] - effectiveDomainRange[0];
+ const snapGranularity =
+ domainRangeExtent > granularity.getCanonicalLength() * 5 &&
+ domainRangeExtent > second.canonicalLength
+ ? granularity
+ : new Duration('PT1S');
+ onChangeRange(
+ offsetRange(effectiveDomainRange, shiftOffset, n =>
+ snapGranularity.round(new Date(n), Timezone.UTC).valueOf(),
+ ),
+ );
+ }
+ } else {
+ if (selection) {
+ setSelection({
+ ...selection,
+ finalized: true,
+ });
+ }
+ }
+ });
+
+ useGlobalEventListener('keydown', (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setMouseDownAt(undefined);
+ setSelection(undefined);
+ }
+ });
+
+ const byStack = useMemo(() => {
+ if (markType === 'bar' || !stackedData.length) return [];
+
+ const effectiveStacks = stacks || ['undefined'];
+ const numStacks = effectiveStacks.length;
+ if (numStacks === 1) return [stackedData];
+
+ // Fill in 0s and make sure that the stacks are in the same order
+ const fullTimeIntervals = groupBy(
+ stackedData,
+ d => String(d.start),
+ dataForStart => {
+ const stackToDatum = lookupBy(dataForStart, d => d.stack!);
+ return effectiveStacks.map(
+ (stack, stackIndex) =>
+ stackToDatum[stack] || {
+ ...dataForStart[0],
+ stack,
+ measure: 0,
+ offset: Math.max(
+ 0,
+ ...filterMap(effectiveStacks.slice(0, stackIndex), s => stackToDatum[s]).map(
+ d => d.offset + d.measure,
+ ),
+ ),
+ },
+ );
+ },
+ );
+
+ // Add nulls to mark gaps in data
+ const seriesForStack: Record = {};
+ for (const stack of effectiveStacks) {
+ seriesForStack[stack] = [];
+ }
+
+ let lastDatum: StackedRangeDatum | undefined;
+ for (const fullTimeInterval of fullTimeIntervals) {
+ const datum = fullTimeInterval[0];
+
+ if (lastDatum && lastDatum.start !== datum.end) {
+ for (const stack of effectiveStacks) {
+ seriesForStack[stack].push(null);
+ }
+ }
+
+ for (let i = 0; i < numStacks; i++) {
+ seriesForStack[effectiveStacks[i]].push(fullTimeInterval[i]);
+ }
+ lastDatum = datum;
+ }
+
+ return Object.values(seriesForStack);
+ }, [markType, stackedData, stacks]);
+
+ if (innerStage.isInvalid()) return;
+
+ function startEndToXWidth({ start, end }: { start: number; end: number }) {
+ const xStart = timeScale(start);
+ const xEnd = timeScale(end);
+ if (xEnd < 0 || innerStage.width < xStart) return;
+
+ return {
+ x: xStart,
+ width: Math.max(xEnd - xStart - 1, 1),
+ };
+ }
+
+ function datumToYHeight({ measure, offset }: StackedRangeDatum) {
+ const y0 = measureScale(offset);
+ const y = measureScale(measure + offset);
+
+ return {
+ y: y,
+ height: y0 - y,
+ };
+ }
+
+ function datumToRect(d: StackedRangeDatum) {
+ const xWidth = startEndToXWidth(d);
+ if (!xWidth) return;
+ return {
+ ...xWidth,
+ ...datumToYHeight(d),
+ };
+ }
+
+ function datumToCxCy(d: StackedRangeDatum) {
+ const cx = timeScale((d.start + d.end) / 2);
+ if (cx < 0 || innerStage.width < cx) return;
+
+ return {
+ cx,
+ cy: measureScale(d.measure + d.offset),
+ };
+ }
+
+ const curve = getCurveFactory(curveType);
+
+ const areaFn = area()
+ .curve(curve)
+ .defined(Boolean)
+ .x(d => timeScale((d.start + d.end) / 2))
+ .y0(d => measureScale(d.offset))
+ .y1(d => measureScale(d.measure + d.offset)) as Area;
+
+ const lineFn = line()
+ .curve(curve)
+ .defined(Boolean)
+ .x(d => timeScale((d.start + d.end) / 2))
+ .y(d => measureScale(d.measure + d.offset)) as Line;
+
+ let hoveredOpenOn: PortalBubbleOpenOn | undefined;
+ if (selection) {
+ const { start, end, selectedDatum } = selection;
+
+ let title: string;
+ let info: string;
+ if (selectedDatum) {
+ title = formatStartDuration(new Date(selectedDatum.start), granularity);
+ info = formatNumber(selectedDatum.measure);
+ } else {
+ if (granularity.shift(new Date(start), Timezone.UTC).valueOf() === end) {
+ title = formatStartDuration(new Date(start), granularity);
+ } else {
+ title = formatIsoDateRange(new Date(start), new Date(end));
+ }
+
+ const selectedData = stackedData.filter(d => start <= d.start && d.start < end);
+ if (selectedData.length) {
+ info = formatNumber(sum(selectedData, b => b.measure));
+ } else {
+ info = 'No data';
+ }
+ }
+
+ hoveredOpenOn = {
+ x: chartMargin.left + timeScale((selection.start + selection.end) / 2),
+ y: chartMargin.top,
+ title,
+ text: (
+ <>
+ {selectedDatum?.stack && {selectedDatum?.stack}
}
+ {info}
+ {selection.finalized && (
+
+ {
+ if (!selection) return;
+ setSelection(undefined);
+ onChangeRange([selection.start, selection.end]);
+ }}
+ />
+
+ )}
+ >
+ ),
+ };
+ }
+
+ const gridlinesVisible =
+ showHorizontalGridlines === 'always' ||
+ (showHorizontalGridlines !== 'never' && innerStage.height > 75);
+
+ const d = effectiveDomainRange[1] - effectiveDomainRange[0];
+ const shiftStartBack = effectiveDomainRange[0] - d;
+ const shiftEndForward = effectiveDomainRange[1] + d;
+ const nowDayCeil = day.ceil(now, Timezone.UTC);
+ const zoomedOutRange: Range = [
+ shiftStartBack,
+ shiftEndForward < nowDayCeil.valueOf() ? shiftEndForward : nowDayCeil.valueOf(),
+ ];
+
+ const nowX = timeScale(now);
+ return (
+
+
+ {!data.length && (
+
+
There is no data in the selected range
+
+ )}
+
{
+ onChangeRange(zoomedOutRange);
+ }}
+ />
+ {svgRef.current && (
+ setSelection(undefined) : undefined}
+ mute
+ direction="up"
+ />
+ )}
+
+ );
+};
diff --git a/web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx b/web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx
new file mode 100644
index 000000000000..a2ba919f9fee
--- /dev/null
+++ b/web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx
@@ -0,0 +1,348 @@
+/*
+ * 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 { IconNames } from '@blueprintjs/icons';
+import { Duration, Timezone } from 'chronoshift';
+import type { SqlExpression } from 'druid-query-toolkit';
+import { C, F, fitFilterPatterns, L, SqlCase } from 'druid-query-toolkit';
+import { useMemo } from 'react';
+
+import { Loader } from '../../../../components';
+import { useQueryManager } from '../../../../hooks';
+import {
+ capitalizeFirst,
+ FINE_GRANULARITY_OPTIONS,
+ getAutoGranularity,
+ getTimeSpanInExpression,
+} from '../../../../utils';
+import { Issue } from '../../components';
+import type { ExpressionMeta } from '../../models';
+import { ModuleRepository } from '../../module-repository/module-repository';
+import { overqueryWhere, updateFilterClause } from '../../utils';
+
+import type {
+ ContinuousChartCurveType,
+ ContinuousChartMarkType,
+ Range,
+ RangeDatum,
+} from './continuous-chart-render';
+import { ContinuousChartRender, OTHER_VALUE } from './continuous-chart-render';
+
+const TIME_NAME = 't';
+const MEASURE_NAME = 'm';
+const STACK_NAME = 's';
+const MIN_SLICE_WIDTH = 8;
+
+function getRangeInExpression(
+ expression: SqlExpression,
+ timeColumnName: string,
+ maxTime?: Date,
+): Range | undefined {
+ const patterns = fitFilterPatterns(expression);
+ for (const pattern of patterns) {
+ if (pattern.type === 'timeInterval' && pattern.column === timeColumnName) {
+ return [pattern.start.valueOf(), pattern.end.valueOf()];
+ } else if (pattern.type === 'timeRelative' && pattern.column === timeColumnName) {
+ let anchor = pattern.anchor === 'timestamp' ? pattern.anchorTimestamp || new Date() : maxTime;
+ if (!anchor) return;
+
+ const timezone = pattern.timezone ? new Timezone(pattern.timezone) : Timezone.UTC;
+ if (pattern.alignType && pattern.alignDuration) {
+ const alignDuration = new Duration(pattern.alignDuration);
+ anchor =
+ pattern.alignType === 'floor'
+ ? alignDuration.floor(anchor, timezone)
+ : alignDuration.ceil(anchor, timezone);
+ }
+
+ if (pattern.shiftDuration && pattern.shiftStep) {
+ anchor = new Duration(pattern.shiftDuration).shift(anchor, timezone, pattern.shiftStep);
+ }
+
+ const rangeStep = pattern.rangeStep || 1;
+ const anchorWithRange = new Duration(pattern.rangeDuration).shift(
+ anchor,
+ timezone,
+ -rangeStep,
+ );
+
+ return [
+ (rangeStep >= 0 ? anchorWithRange : anchor).valueOf(),
+ (rangeStep < 0 ? anchorWithRange : anchor).valueOf(),
+ ];
+ }
+ }
+
+ return;
+}
+
+interface TimeChartParameterValues {
+ granularity: string;
+ splitColumn?: ExpressionMeta;
+ numberToStack: number;
+ showOthers: boolean;
+ measure: ExpressionMeta;
+ markType: ContinuousChartMarkType;
+ curveType: ContinuousChartCurveType;
+}
+
+ModuleRepository.registerModule({
+ id: 'time-chart',
+ title: 'Time chart',
+ icon: IconNames.TIMELINE_LINE_CHART,
+ parameters: {
+ granularity: {
+ type: 'option',
+ options: ({ querySource, where }) => {
+ let filterSpan: number | undefined;
+ if (querySource) {
+ const timeColumnName = querySource.columns.find(
+ column => column.sqlType === 'TIMESTAMP',
+ )?.name;
+ if (timeColumnName) {
+ filterSpan = getTimeSpanInExpression(where, timeColumnName);
+ }
+ }
+ return [
+ 'auto',
+ ...(typeof filterSpan === 'number'
+ ? FINE_GRANULARITY_OPTIONS.filter(g => {
+ const len = new Duration(g).getCanonicalLength();
+ return filterSpan < len * 1000 && len <= filterSpan;
+ })
+ : FINE_GRANULARITY_OPTIONS),
+ ];
+ },
+ defaultValue: 'auto',
+ important: true,
+ optionLabels: g => (g === 'auto' ? 'Auto' : new Duration(g).getDescription(true)),
+ },
+ splitColumn: {
+ type: 'expression',
+ label: 'Stack by',
+ transferGroup: 'show',
+ important: true,
+ },
+ numberToStack: {
+ type: 'number',
+ label: 'Max stacks',
+ defaultValue: 7,
+ min: 2,
+ required: true,
+ visible: ({ parameterValues }) => Boolean(parameterValues.splitColumn),
+ },
+ showOthers: {
+ type: 'boolean',
+ defaultValue: true,
+ visible: ({ parameterValues }) => Boolean(parameterValues.splitColumn),
+ },
+ measure: {
+ type: 'measure',
+ label: 'Measure to show',
+ transferGroup: 'show-agg',
+ important: true,
+ defaultValue: ({ querySource }) => querySource?.getFirstAggregateMeasure(),
+ required: true,
+ },
+ markType: {
+ type: 'option',
+ options: ['area', 'bar', 'line'],
+ defaultValue: 'area',
+ optionLabels: capitalizeFirst,
+ },
+ curveType: {
+ type: 'option',
+ options: ['smooth', 'linear', 'step'],
+ defaultValue: 'smooth',
+ optionLabels: capitalizeFirst,
+ defined: ({ parameterValues }) => parameterValues.markType !== 'bar',
+ },
+ },
+ component: function TimeChartModule(props) {
+ const { querySource, where, setWhere, parameterValues, stage, runSqlQuery } = props;
+
+ const timeColumnName = querySource.columns.find(column => column.sqlType === 'TIMESTAMP')?.name;
+ const timeGranularity =
+ parameterValues.granularity === 'auto'
+ ? getAutoGranularity(
+ where,
+ timeColumnName || '__time',
+ Math.floor(Math.max(stage.width - 80, 10) / MIN_SLICE_WIDTH),
+ )
+ : parameterValues.granularity;
+
+ const { splitColumn, numberToStack, showOthers, measure, markType } = parameterValues;
+
+ const dataQuery = useMemo(() => {
+ return {
+ querySource,
+ where,
+ timeGranularity,
+ measure,
+ splitExpression: splitColumn?.expression,
+ numberToStack,
+ showOthers,
+ oneExtra: markType !== 'bar',
+ };
+ }, [
+ querySource,
+ where,
+ timeGranularity,
+ measure,
+ splitColumn,
+ numberToStack,
+ showOthers,
+ markType,
+ ]);
+
+ const [sourceDataState, queryManager] = useQueryManager({
+ query: dataQuery,
+ processQuery: async (
+ {
+ querySource,
+ where,
+ timeGranularity,
+ measure,
+ splitExpression,
+ numberToStack,
+ showOthers,
+ oneExtra,
+ },
+ cancelToken,
+ ) => {
+ if (!timeColumnName) {
+ throw new Error(`Must have a column of type TIMESTAMP for the time chart to work`);
+ }
+
+ const granularity = new Duration(timeGranularity);
+
+ const vs = splitExpression
+ ? (
+ await runSqlQuery(
+ querySource
+ .getInitQuery(where)
+ .addSelect(splitExpression.cast('VARCHAR').as('v'), { addToGroupBy: 'end' })
+ .changeOrderByExpression(measure.expression.toOrderByExpression('DESC'))
+ .changeLimitValue(numberToStack),
+ cancelToken,
+ )
+ ).getColumnByIndex(0)!
+ : undefined;
+
+ cancelToken.throwIfRequested();
+
+ if (vs?.length === 0) {
+ // If vs is empty then there is no data at all and no need to do a larger query
+ return {
+ effectiveVs: [],
+ sourceData: [],
+ measure,
+ granularity,
+ };
+ }
+
+ const effectiveVs = vs && showOthers ? vs.concat(OTHER_VALUE) : vs;
+
+ const result = await runSqlQuery(
+ querySource
+ .getInitQuery(overqueryWhere(where, timeColumnName, granularity, oneExtra))
+ .applyIf(splitExpression && vs && !showOthers, q =>
+ q.addWhere(splitExpression!.cast('VARCHAR').in(vs!)),
+ )
+ .addSelect(F.timeFloor(C(timeColumnName), L(timeGranularity)).as(TIME_NAME), {
+ addToGroupBy: 'end',
+ addToOrderBy: 'end',
+ direction: 'DESC',
+ })
+ .applyIf(splitExpression, q => {
+ if (!splitExpression || !vs) return q; // Should never get here, doing this to make peace between eslint and TS
+ return q.addSelect(
+ (showOthers
+ ? SqlCase.ifThenElse(splitExpression.in(vs), splitExpression, L(OTHER_VALUE))
+ : splitExpression
+ )
+ .cast('VARCHAR')
+ .as(STACK_NAME),
+ { addToGroupBy: 'end' },
+ );
+ })
+ .addSelect(measure.expression.as(MEASURE_NAME))
+ .changeLimitValue(10000 * (effectiveVs ? Math.min(effectiveVs.length, 10) : 1)),
+ cancelToken,
+ );
+
+ const dataset = result.toObjectArray().map(
+ (b): RangeDatum => ({
+ start: b[TIME_NAME].valueOf(),
+ end: granularity.shift(b[TIME_NAME], Timezone.UTC, 1).valueOf(),
+ measure: b[MEASURE_NAME],
+ stack: b[STACK_NAME],
+ }),
+ );
+
+ return {
+ effectiveVs,
+ sourceData: dataset,
+ measure,
+ granularity,
+ maxTime: result.resultContext?.maxTime,
+ };
+ },
+ });
+
+ const sourceData = sourceDataState.getSomeData();
+ const domainRange = getRangeInExpression(
+ where,
+ timeColumnName || '__time',
+ sourceData?.maxTime,
+ );
+ const errorMessage = sourceDataState.getErrorMessage();
+ return (
+
+ {sourceData && (
+ {
+ setWhere(
+ updateFilterClause(
+ where,
+ F(
+ 'TIME_IN_INTERVAL',
+ C(timeColumnName || '__time'),
+ `${new Date(start).toISOString()}/${new Date(end).toISOString()}`,
+ ),
+ ),
+ );
+ }}
+ />
+ )}
+ {errorMessage && }
+ {sourceDataState.loading && (
+ queryManager.cancelCurrent()} />
+ )}
+
+ );
+ },
+});
diff --git a/web-console/src/views/explore-view/query-macros/max-data-time.ts b/web-console/src/views/explore-view/query-macros/max-data-time.ts
index d689d0a8386f..48a337c41ca0 100644
--- a/web-console/src/views/explore-view/query-macros/max-data-time.ts
+++ b/web-console/src/views/explore-view/query-macros/max-data-time.ts
@@ -21,18 +21,25 @@ import { L, SqlFunction } from 'druid-query-toolkit';
import { getMaxTimeForTable } from '../utils';
-export async function rewriteMaxDataTime(query: SqlQuery) {
- if (!query.containsFunction('MAX_DATA_TIME')) return query;
+export async function rewriteMaxDataTime(
+ query: SqlQuery,
+): Promise<{ query: SqlQuery; maxTime?: Date }> {
+ if (!query.containsFunction('MAX_DATA_TIME')) return { query };
const tableName = query.getFirstTableName();
- if (!tableName) return query;
+ if (!tableName) return { query };
const maxTime = await getMaxTimeForTable(tableName);
- if (!maxTime) return query;
+ if (!maxTime) return { query };
- return query.walk(ex =>
- ex instanceof SqlFunction && ex.getEffectiveFunctionName() === 'MAX_DATA_TIME'
- ? L(new Date(maxTime.valueOf() + 1)) // Add 1ms to the maxTime date to allow filters like `"__time" < {maxTime}" to capture the last event which might also be the only event
- : ex,
- ) as SqlQuery;
+ const adjustedMaxTime = new Date(maxTime.valueOf() + 1); // Add 1ms to the maxTime date to allow filters like `"__time" < {maxTime}" to capture the last event which might also be the only event
+
+ return {
+ query: query.walk(ex =>
+ ex instanceof SqlFunction && ex.getEffectiveFunctionName() === 'MAX_DATA_TIME'
+ ? L(adjustedMaxTime)
+ : ex,
+ ) as SqlQuery,
+ maxTime: adjustedMaxTime,
+ };
}
diff --git a/web-console/src/views/explore-view/utils/date-format.ts b/web-console/src/views/explore-view/utils/date-format.ts
deleted file mode 100644
index af92a6cac45e..000000000000
--- a/web-console/src/views/explore-view/utils/date-format.ts
+++ /dev/null
@@ -1,28 +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.
- */
-
-export const DATE_FORMAT = new Intl.DateTimeFormat('default', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: false,
- timeZone: 'UTC',
-});
diff --git a/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts b/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts
index faf643aef578..163032c61b82 100644
--- a/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts
+++ b/web-console/src/views/explore-view/utils/filter-pattern-helpers.ts
@@ -16,11 +16,20 @@
* limitations under the License.
*/
-import type { Column, FilterPattern } from 'druid-query-toolkit';
+import { Duration } from 'chronoshift';
+import { isDate } from 'date-fns';
+import type { Column, FilterPattern, SqlExpression } from 'druid-query-toolkit';
+import {
+ filterPatternsToExpression,
+ filterPatternToExpression,
+ fitFilterPattern,
+ fitFilterPatterns,
+ SqlComparison,
+ SqlMulti,
+ SqlQuery,
+} from 'druid-query-toolkit';
-import { Duration } from '../../../utils';
-
-import { DATE_FORMAT } from './date-format';
+import { formatIsoDateRange, prettyFormatIsoDateWithMsIfNeeded } from '../../../utils';
const TIME_RELATIVE_TYPES: Record = {
'maxDataTime/': 'latest',
@@ -68,7 +77,11 @@ export function formatPatternWithoutNegation(pattern: FilterPattern): string {
switch (pattern.type) {
case 'values':
return `${pattern.column}: ${pattern.values
- .map(v => (v === '' ? 'empty' : String(v)))
+ .map(v => {
+ if (v === '') return 'empty';
+ if (isDate(v)) return prettyFormatIsoDateWithMsIfNeeded(v as Date);
+ return String(v);
+ })
.join(', ')}`;
case 'contains':
@@ -78,7 +91,7 @@ export function formatPatternWithoutNegation(pattern: FilterPattern): string {
return `${pattern.column} ~ /${pattern.regexp}/`;
case 'timeInterval': {
- return DATE_FORMAT.formatRange(pattern.start, pattern.end);
+ return formatIsoDateRange(pattern.start, pattern.end);
}
case 'timeRelative': {
@@ -100,3 +113,67 @@ export function formatPatternWithoutNegation(pattern: FilterPattern): string {
return String(pattern.expression);
}
}
+
+export function patternToBoundsQuery(
+ source: SqlQuery,
+ filterPattern: FilterPattern,
+): SqlQuery | undefined {
+ if (filterPattern.type !== 'timeRelative') return;
+ const ex = filterPatternToExpression(filterPattern);
+ if (!(ex instanceof SqlMulti)) return;
+ if (ex.numArgs() !== 2) return;
+ const [startEx, endEx] = ex.getArgArray();
+ if (!(startEx instanceof SqlComparison)) return;
+ if (!(endEx instanceof SqlComparison)) return;
+ return SqlQuery.from(source)
+ .changeSelectExpressions([startEx.lhs.as('start'), (endEx.rhs as SqlExpression).as('end')])
+ .changeLimitValue(1); // Todo: make this better
+}
+
+export function addOrUpdatePattern(
+ patterns: readonly FilterPattern[],
+ oldPattern: FilterPattern | undefined,
+ newPattern: FilterPattern,
+): FilterPattern[] {
+ let added = false;
+ const newPatterns = patterns.map(pattern => {
+ if (pattern === oldPattern) {
+ added = true;
+ return newPattern;
+ } else {
+ return pattern;
+ }
+ });
+ if (!added) {
+ newPatterns.push(newPattern);
+ }
+ return newPatterns;
+}
+
+export function updateFilterPattern(
+ patterns: readonly FilterPattern[],
+ newPattern: FilterPattern,
+): FilterPattern[] {
+ let found = false;
+ const newPatterns = patterns.map(pattern => {
+ if (!('column' in pattern) || !('column' in newPattern)) return pattern;
+ if (pattern.column === newPattern.column) {
+ found = true;
+ return newPattern;
+ } else {
+ return pattern;
+ }
+ });
+
+ if (found) {
+ return newPatterns;
+ } else {
+ return [...newPatterns, newPattern];
+ }
+}
+
+export function updateFilterClause(filter: SqlExpression, clause: SqlExpression) {
+ return filterPatternsToExpression(
+ updateFilterPattern(fitFilterPatterns(filter), fitFilterPattern(clause)),
+ );
+}
diff --git a/web-console/src/views/explore-view/utils/index.ts b/web-console/src/views/explore-view/utils/index.ts
index 57c72e2c0e7e..d1664b8e92e1 100644
--- a/web-console/src/views/explore-view/utils/index.ts
+++ b/web-console/src/views/explore-view/utils/index.ts
@@ -16,10 +16,8 @@
* limitations under the License.
*/
-export * from './date-format';
export * from './filter-pattern-helpers';
export * from './general';
-export * from './get-auto-granularity';
export * from './known-aggregations';
export * from './max-time-for-table';
export * from './misc';
diff --git a/web-console/src/views/explore-view/utils/table-query.ts b/web-console/src/views/explore-view/utils/table-query.ts
index d46d68edb5e1..23584243a3bd 100644
--- a/web-console/src/views/explore-view/utils/table-query.ts
+++ b/web-console/src/views/explore-view/utils/table-query.ts
@@ -16,6 +16,7 @@
* limitations under the License.
*/
+import { Duration } from 'chronoshift';
import type { SqlAlias, SqlExpression, SqlOrderByExpression, SqlTable } from 'druid-query-toolkit';
import {
C,
@@ -31,7 +32,7 @@ import {
} from 'druid-query-toolkit';
import type { ColumnHint } from '../../../utils';
-import { Duration, forceSignInNumberFormatter, formatNumber, formatPercent } from '../../../utils';
+import { forceSignInNumberFormatter, formatNumber, formatPercent } from '../../../utils';
import type { ExpressionMeta } from '../models';
import { Measure } from '../models';
@@ -178,7 +179,7 @@ function getInnerJoinConditions(groupByExpressions: SqlAlias[]): SqlExpression[]
return groupByExpressions.map(groupByExpression =>
groupByExpression
.getUnderlyingExpression()
- .isNotDistinctFrom(T(TOP_VALUES_NAME).column(groupByExpression.getOutputName()!)),
+ .isNotDistinctFrom(T(TOP_VALUES_NAME).column(groupByExpression.getOutputName() || '')),
);
}
diff --git a/web-console/src/views/explore-view/utils/time-manipulation.spec.ts b/web-console/src/views/explore-view/utils/time-manipulation.spec.ts
index 70bdcf1e85ef..d37f97f4d2a5 100644
--- a/web-console/src/views/explore-view/utils/time-manipulation.spec.ts
+++ b/web-console/src/views/explore-view/utils/time-manipulation.spec.ts
@@ -16,9 +16,14 @@
* limitations under the License.
*/
+import { Duration } from 'chronoshift';
import { SqlExpression } from 'druid-query-toolkit';
-import { decomposeTimeInInterval, shiftBackAndExpandTimeInExpression } from './time-manipulation';
+import {
+ decomposeTimeInInterval,
+ overqueryWhere,
+ shiftBackAndExpandTimeInExpression,
+} from './time-manipulation';
describe('decomposeTimeInInterval', () => {
it('works with TIME_IN_INTERVAL (date)', () => {
@@ -223,3 +228,47 @@ describe('shiftBackAndExpandTimeInExpression', () => {
});
});
});
+
+describe('overqueryWhere', () => {
+ const min = new Duration('PT1M');
+
+ it('works when there is nothing to do', () => {
+ expect(String(overqueryWhere(SqlExpression.parse('TRUE'), '__time', min, false))).toEqual(
+ 'TRUE',
+ );
+ });
+
+ it('works with relative time (no extra)', () => {
+ expect(
+ String(
+ overqueryWhere(
+ SqlExpression.parse(
+ `(TIME_SHIFT(MAX_DATA_TIME(), 'PT1H', -1) <= "__time" AND "__time" < MAX_DATA_TIME()) AND country = 'USA'`,
+ ),
+ '__time',
+ min,
+ false,
+ ),
+ ),
+ ).toEqual(
+ `(TIME_FLOOR(TIME_SHIFT(MAX_DATA_TIME(), 'PT1H', -1), 'PT1M') <= "__time" AND "__time" < TIME_CEIL(MAX_DATA_TIME(), 'PT1M')) AND "country" = 'USA'`,
+ );
+ });
+
+ it('works with relative time (with extra)', () => {
+ expect(
+ String(
+ overqueryWhere(
+ SqlExpression.parse(
+ `(TIME_SHIFT(MAX_DATA_TIME(), 'PT1H', -1) <= "__time" AND "__time" < MAX_DATA_TIME()) AND country = 'USA'`,
+ ),
+ '__time',
+ min,
+ true,
+ ),
+ ),
+ ).toEqual(
+ `(TIME_SHIFT(TIME_FLOOR(TIME_SHIFT(MAX_DATA_TIME(), 'PT1H', -1), 'PT1M'), 'PT1M', -1) <= "__time" AND "__time" < TIME_SHIFT(TIME_CEIL(MAX_DATA_TIME(), 'PT1M'), 'PT1M', 1)) AND "country" = 'USA'`,
+ );
+ });
+});
diff --git a/web-console/src/views/explore-view/utils/time-manipulation.ts b/web-console/src/views/explore-view/utils/time-manipulation.ts
index 671ed3080f74..a4a7e3692726 100644
--- a/web-console/src/views/explore-view/utils/time-manipulation.ts
+++ b/web-console/src/views/explore-view/utils/time-manipulation.ts
@@ -16,14 +16,19 @@
* limitations under the License.
*/
+import type { Duration } from 'chronoshift';
+import { Timezone } from 'chronoshift';
import {
F,
+ filterPatternToExpression,
+ fitFilterPatterns,
SqlBetweenPart,
SqlColumn,
SqlComparison,
SqlExpression,
SqlFunction,
SqlLiteral,
+ SqlMulti,
} from 'druid-query-toolkit';
import { partition } from '../../../utils';
@@ -191,3 +196,56 @@ function shiftBackAndExpandEnd(
const ex = F.timeShift(end, shiftDuration, -1);
return expandDuration ? F.timeCeil(ex, expandDuration) : ex;
}
+
+export function overqueryWhere(
+ where: SqlExpression,
+ timeColumnName: string,
+ granularity: Duration,
+ oneExtra: boolean,
+) {
+ return SqlExpression.and(
+ ...fitFilterPatterns(where).map(pattern => {
+ if ('column' in pattern && pattern.column === timeColumnName) {
+ if (pattern.type === 'timeInterval') {
+ let start = granularity.floor(pattern.start, Timezone.UTC);
+ let end = granularity.ceil(pattern.end, Timezone.UTC);
+ if (oneExtra) {
+ start = granularity.shift(start, Timezone.UTC, -1);
+ end = granularity.shift(end, Timezone.UTC, 1);
+ }
+ return filterPatternToExpression({
+ ...pattern,
+ start,
+ end,
+ });
+ }
+
+ if (pattern.type === 'timeRelative') {
+ const ex = filterPatternToExpression(pattern);
+ // At this point we know that ex is something like (S <= __time AND __time < E)
+ // And we want to transform it to be (TIME_FLOOR(S, gran) <= __time AND __time < TIME_CEIL(E, gran))
+ if (ex instanceof SqlMulti && ex.op === 'AND') {
+ const condStart = ex.getArg(0);
+ const condEnd = ex.getArg(1);
+ if (
+ condStart instanceof SqlComparison &&
+ condEnd instanceof SqlComparison &&
+ condEnd.rhs instanceof SqlExpression
+ ) {
+ const p = granularity.toString();
+ return SqlExpression.and(
+ condStart.changeLhs(
+ F.timeFloor(condStart.lhs, p).applyIf(oneExtra, e => F.timeShift(e, p, -1)),
+ ),
+ condEnd.changeRhs(
+ F.timeCeil(condEnd.rhs, p).applyIf(oneExtra, e => F.timeShift(e, p, 1)),
+ ),
+ ).ensureParens();
+ }
+ }
+ }
+ }
+ return filterPatternToExpression(pattern);
+ }),
+ );
+}
diff --git a/web-console/src/views/load-data-view/load-data-view.tsx b/web-console/src/views/load-data-view/load-data-view.tsx
index 25943929a187..74c23027aaa2 100644
--- a/web-console/src/views/load-data-view/load-data-view.tsx
+++ b/web-console/src/views/load-data-view/load-data-view.tsx
@@ -1529,7 +1529,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ columnFilter })}
+ onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
{canHaveNestedData && (
@@ -1844,7 +1844,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ columnFilter })}
+ onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
this.setState({ columnFilter })}
+ onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
this.setState({ columnFilter })}
+ onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
@@ -2399,7 +2399,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ columnFilter })}
+ onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
diff --git a/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx b/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx
index d9b8e1089b9d..27b24a8350a0 100644
--- a/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx
+++ b/web-console/src/views/sql-data-loader-view/schema-step/schema-step.tsx
@@ -833,7 +833,7 @@ export const SchemaStep = function SchemaStep(props: SchemaStepProps) {
className="column-filter-control"
value={columnSearch}
placeholder="Search columns"
- onChange={setColumnSearch}
+ onValueChange={setColumnSearch}
/>
)}
diff --git a/web-console/src/views/workbench-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.tsx b/web-console/src/views/workbench-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.tsx
index 3723a5235bd5..32f0a49246ec 100644
--- a/web-console/src/views/workbench-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.tsx
+++ b/web-console/src/views/workbench-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.tsx
@@ -18,12 +18,13 @@
import { MenuDivider, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
+import { day, hour, month, Timezone, year } from 'chronoshift';
import type { SqlQuery } from 'druid-query-toolkit';
import { C, F, SqlExpression } from 'druid-query-toolkit';
import type { JSX } from 'react';
import React from 'react';
-import { day, hour, month, prettyPrintSql, TZ_UTC, year } from '../../../../../utils';
+import { prettyPrintSql } from '../../../../../utils';
const LATEST_HOUR: SqlExpression = SqlExpression.parse(
`? >= CURRENT_TIMESTAMP - INTERVAL '1' HOUR`,
@@ -77,10 +78,10 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
}
const now = new Date();
- const hourStart = hour.floor(now, TZ_UTC);
- const dayStart = day.floor(now, TZ_UTC);
- const monthStart = month.floor(now, TZ_UTC);
- const yearStart = year.floor(now, TZ_UTC);
+ const hourStart = hour.floor(now, Timezone.UTC);
+ const dayStart = day.floor(now, Timezone.UTC);
+ const monthStart = month.floor(now, Timezone.UTC);
+ const yearStart = year.floor(now, Timezone.UTC);
return (
{filterMenuItem(`Latest hour`, fillWithColumn(LATEST_HOUR, columnName))}
@@ -91,19 +92,19 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
{filterMenuItem(
`Current hour`,
- fillWithColumnStartEnd(columnName, hourStart, hour.shift(hourStart, TZ_UTC, 1)),
+ fillWithColumnStartEnd(columnName, hourStart, hour.shift(hourStart, Timezone.UTC, 1)),
)}
{filterMenuItem(
`Current day`,
- fillWithColumnStartEnd(columnName, dayStart, day.shift(dayStart, TZ_UTC, 1)),
+ fillWithColumnStartEnd(columnName, dayStart, day.shift(dayStart, Timezone.UTC, 1)),
)}
{filterMenuItem(
`Current month`,
- fillWithColumnStartEnd(columnName, monthStart, month.shift(monthStart, TZ_UTC, 1)),
+ fillWithColumnStartEnd(columnName, monthStart, month.shift(monthStart, Timezone.UTC, 1)),
)}
{filterMenuItem(
`Current year`,
- fillWithColumnStartEnd(columnName, yearStart, year.shift(yearStart, TZ_UTC, 1)),
+ fillWithColumnStartEnd(columnName, yearStart, year.shift(yearStart, Timezone.UTC, 1)),
)}
);
diff --git a/web-console/src/views/workbench-view/execution-details-pane/__snapshots__/execution-details-pane.spec.tsx.snap b/web-console/src/views/workbench-view/execution-details-pane/__snapshots__/execution-details-pane.spec.tsx.snap
index 996061eaeeec..308b030d19f1 100644
--- a/web-console/src/views/workbench-view/execution-details-pane/__snapshots__/execution-details-pane.spec.tsx.snap
+++ b/web-console/src/views/workbench-view/execution-details-pane/__snapshots__/execution-details-pane.spec.tsx.snap
@@ -46,7 +46,6 @@ exports[`ExecutionDetailsPane matches snapshot no init tab 1`] = `
,
execution?.destination?.type === 'durableStorage' && execution.destinationPages ? (