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

[druid] branch master updated: Web console: show segment sizes in rows not bytes (#10496)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new e8c5893  Web console: show segment sizes in rows not bytes (#10496)
e8c5893 is described below

commit e8c5893c34db2482ed80cd0a01c6d44c411515aa
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Tue Oct 13 13:19:39 2020 -0700

    Web console: show segment sizes in rows not bytes (#10496)
    
    * added query error suggestions
    
    * simplify the SQLs
    
    * change segment size display to rows
    
    * suggestion tests
    
    * update snapshot
    
    * make error detection more robust
    
    * remove errant console log
    
    * fix imports
    
    * put suggestion on top
    
    * better error rendering
    
    * format as millions
    
    * add .druid.pid to gitignore
    
    * rename segment_size to segment_rows, fix visability, fix divide by zero
    
    * update snapshots
---
 web-console/.gitignore                             |   1 +
 web-console/README.md                              |   6 --
 web-console/package.json                           |   2 +-
 web-console/src/utils/druid-query.spec.ts          |  78 +++++++++++++-
 web-console/src/utils/druid-query.ts               |  79 ++++++++++++++
 web-console/src/utils/general.spec.ts              |  10 ++
 web-console/src/utils/general.tsx                  |   6 ++
 .../__snapshots__/datasource-view.spec.tsx.snap    |   8 +-
 .../src/views/datasource-view/datasource-view.tsx  | 115 ++++++++++-----------
 .../views/query-view/query-error/query-error.scss  |   4 +-
 .../views/query-view/query-error/query-error.tsx   |  52 ++++++++--
 web-console/src/views/query-view/query-view.tsx    |   2 +
 12 files changed, 286 insertions(+), 77 deletions(-)

diff --git a/web-console/.gitignore b/web-console/.gitignore
index 8e74bc2..2ca1b9d 100644
--- a/web-console/.gitignore
+++ b/web-console/.gitignore
@@ -16,3 +16,4 @@ lib/sql-docs.js
 tscommand-*.tmp.txt
 
 licenses.json
+.druid.pid
diff --git a/web-console/README.md b/web-console/README.md
index 570e7f3..1283589 100644
--- a/web-console/README.md
+++ b/web-console/README.md
@@ -49,12 +49,6 @@ As part of this repo:
 - `script/` - Some helper bash scripts for running this console
 - `src/` - This directory (together with `lib`) constitutes all the source code for this console
 
-Generated/copied dynamically
-
-- `index.html` - Entry file for the coordinator console
-- `pages/` - The files for the older coordinator console
-- `coordinator-console/` - Files for the coordinator console
-
 ## List of non SQL data reading APIs used
 
 ```
diff --git a/web-console/package.json b/web-console/package.json
index ea3bd20..f8266e5 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -6,7 +6,7 @@
   "license": "Apache-2.0",
   "repository": {
     "type": "git",
-    "url": "https://github.com/apache/druid/"
+    "url": "https://github.com/apache/druid"
   },
   "jest": {
     "preset": "ts-jest",
diff --git a/web-console/src/utils/druid-query.spec.ts b/web-console/src/utils/druid-query.spec.ts
index 41105a6..140f146 100644
--- a/web-console/src/utils/druid-query.spec.ts
+++ b/web-console/src/utils/druid-query.spec.ts
@@ -16,10 +16,12 @@
  * limitations under the License.
  */
 
+import { sane } from 'druid-query-toolkit/build/test-utils';
+
 import { DruidError } from './druid-query';
 
 describe('DruidQuery', () => {
-  describe('DruidError', () => {
+  describe('DruidError.parsePosition', () => {
     it('works for single error 1', () => {
       const message = `Encountered "COUNT" at line 2, column 12. Was expecting one of: <EOF> "AS" ... "EXCEPT" ... "FETCH" ... "FROM" ... "INTERSECT" ... "LIMIT" ...`;
 
@@ -52,4 +54,78 @@ describe('DruidQuery', () => {
       });
     });
   });
+
+  describe('DruidError.getSuggestion', () => {
+    it('works for ==', () => {
+      const sql = sane`
+        SELECT *
+        FROM wikipedia -- test ==
+        WHERE channel == '#ar.wikipedia'
+      `;
+      const suggestion = DruidError.getSuggestion(`Encountered "= =" at line 3, column 15.`);
+      expect(suggestion!.label).toEqual(`Replace == with =`);
+      expect(suggestion!.fn(sql)).toEqual(sane`
+        SELECT *
+        FROM wikipedia -- test ==
+        WHERE channel = '#ar.wikipedia'
+      `);
+    });
+
+    it('works for == 2', () => {
+      const sql = sane`
+        SELECT
+          channel, COUNT(*) AS "Count"
+        FROM wikipedia
+        WHERE channel == 'de'
+        GROUP BY 1
+        ORDER BY 2 DESC
+      `;
+      const suggestion = DruidError.getSuggestion(
+        `Encountered "= =" at line 4, column 15. Was expecting one of: <EOF> "EXCEPT" ... "FETCH" ... "GROUP" ...`,
+      );
+      expect(suggestion!.label).toEqual(`Replace == with =`);
+      expect(suggestion!.fn(sql)).toEqual(sane`
+        SELECT
+          channel, COUNT(*) AS "Count"
+        FROM wikipedia
+        WHERE channel = 'de'
+        GROUP BY 1
+        ORDER BY 2 DESC
+      `);
+    });
+
+    it('works for incorrectly quoted literal', () => {
+      const sql = sane`
+        SELECT *
+        FROM wikipedia -- test "#ar.wikipedia"
+        WHERE channel = "#ar.wikipedia"
+      `;
+      const suggestion = DruidError.getSuggestion(
+        `org.apache.calcite.runtime.CalciteContextException: From line 3, column 17 to line 3, column 31: Column '#ar.wikipedia' not found in any table`,
+      );
+      expect(suggestion!.label).toEqual(`Replace "#ar.wikipedia" with '#ar.wikipedia'`);
+      expect(suggestion!.fn(sql)).toEqual(sane`
+        SELECT *
+        FROM wikipedia -- test "#ar.wikipedia"
+        WHERE channel = '#ar.wikipedia'
+      `);
+    });
+
+    it('removes comma (,) before FROM', () => {
+      const suggestion = DruidError.getSuggestion(
+        `Encountered "FROM" at line 1, column 14. Was expecting one of: "ABS" ...`,
+      );
+      expect(suggestion!.label).toEqual(`Remove , before FROM`);
+      expect(suggestion!.fn(`SELECT page, FROM wikipedia WHERE channel = '#ar.wikipedia'`)).toEqual(
+        `SELECT page FROM wikipedia WHERE channel = '#ar.wikipedia'`,
+      );
+    });
+
+    it('does nothing there there is nothing to do', () => {
+      const suggestion = DruidError.getSuggestion(
+        `Encountered "channel" at line 1, column 35. Was expecting one of: <EOF> "EXCEPT" ...`,
+      );
+      expect(suggestion).toBeUndefined();
+    });
+  });
 });
diff --git a/web-console/src/utils/druid-query.ts b/web-console/src/utils/druid-query.ts
index 8013f72..d0865c1 100644
--- a/web-console/src/utils/druid-query.ts
+++ b/web-console/src/utils/druid-query.ts
@@ -31,6 +31,11 @@ export interface DruidErrorResponse {
   host?: string;
 }
 
+export interface QuerySuggestion {
+  label: string;
+  fn: (query: string) => string | undefined;
+}
+
 export function parseHtmlError(htmlStr: string): string | undefined {
   const startIndex = htmlStr.indexOf('</h3><pre>');
   const endIndex = htmlStr.indexOf('\n\tat');
@@ -92,12 +97,77 @@ export class DruidError extends Error {
     return;
   }
 
+  static positionToIndex(str: string, line: number, column: number): number {
+    const lines = str.split('\n').slice(0, line);
+    const lastLineIndex = lines.length - 1;
+    lines[lastLineIndex] = lines[lastLineIndex].slice(0, column - 1);
+    return lines.join('\n').length;
+  }
+
+  static getSuggestion(errorMessage: string): QuerySuggestion | undefined {
+    // == is used instead of =
+    // ex: Encountered "= =" at line 3, column 15. Was expecting one of
+    const matchEquals = errorMessage.match(/Encountered "= =" at line (\d+), column (\d+)./);
+    if (matchEquals) {
+      const line = Number(matchEquals[1]);
+      const column = Number(matchEquals[2]);
+      return {
+        label: `Replace == with =`,
+        fn: str => {
+          const index = DruidError.positionToIndex(str, line, column);
+          if (!str.slice(index).startsWith('==')) return;
+          return `${str.slice(0, index)}=${str.slice(index + 2)}`;
+        },
+      };
+    }
+
+    // Incorrect quoting on table
+    // ex: org.apache.calcite.runtime.CalciteContextException: From line 3, column 17 to line 3, column 31: Column '#ar.wikipedia' not found in any table
+    const matchQuotes = errorMessage.match(
+      /org.apache.calcite.runtime.CalciteContextException: From line (\d+), column (\d+) to line \d+, column \d+: Column '([^']+)' not found in any table/,
+    );
+    if (matchQuotes) {
+      const line = Number(matchQuotes[1]);
+      const column = Number(matchQuotes[2]);
+      const literalString = matchQuotes[3];
+      return {
+        label: `Replace "${literalString}" with '${literalString}'`,
+        fn: str => {
+          const index = DruidError.positionToIndex(str, line, column);
+          if (!str.slice(index).startsWith(`"${literalString}"`)) return;
+          return `${str.slice(0, index)}'${literalString}'${str.slice(
+            index + literalString.length + 2,
+          )}`;
+        },
+      };
+    }
+
+    // , before FROM
+    const matchComma = errorMessage.match(/Encountered "(FROM)" at/i);
+    if (matchComma) {
+      const fromKeyword = matchComma[1];
+      return {
+        label: `Remove , before ${fromKeyword}`,
+        fn: str => {
+          const newQuery = str.replace(/,(\s+FROM)/gim, '$1');
+          if (newQuery === str) return;
+          return newQuery;
+        },
+      };
+    }
+
+    return;
+  }
+
   public canceled?: boolean;
   public error?: string;
   public errorMessage?: string;
+  public errorMessageWithoutExpectation?: string;
+  public expectation?: string;
   public position?: RowColumn;
   public errorClass?: string;
   public host?: string;
+  public suggestion?: QuerySuggestion;
 
   constructor(e: any) {
     super(axios.isCancel(e) ? CANCELED_MESSAGE : getDruidErrorMessage(e));
@@ -126,6 +196,15 @@ export class DruidError extends Error {
 
       if (this.errorMessage) {
         this.position = DruidError.parsePosition(this.errorMessage);
+        this.suggestion = DruidError.getSuggestion(this.errorMessage);
+
+        const expectationIndex = this.errorMessage.indexOf('Was expecting one of');
+        if (expectationIndex >= 0) {
+          this.errorMessageWithoutExpectation = this.errorMessage.slice(0, expectationIndex).trim();
+          this.expectation = this.errorMessage.slice(expectationIndex).trim();
+        } else {
+          this.errorMessageWithoutExpectation = this.errorMessage;
+        }
       }
     }
   }
diff --git a/web-console/src/utils/general.spec.ts b/web-console/src/utils/general.spec.ts
index 9b2398b..a950103 100644
--- a/web-console/src/utils/general.spec.ts
+++ b/web-console/src/utils/general.spec.ts
@@ -22,6 +22,7 @@ import {
   formatBytesCompact,
   formatInteger,
   formatMegabytes,
+  formatMillions,
   formatPercent,
   sortWithPrefixSuffix,
   sqlQueryCustomTableFilter,
@@ -118,4 +119,13 @@ describe('general', () => {
       expect(formatPercent(2 / 3)).toEqual('66.67%');
     });
   });
+
+  describe('formatMillions', () => {
+    it('works', () => {
+      expect(formatMillions(1e6)).toEqual('1.000 M');
+      expect(formatMillions(1e6 + 1)).toEqual('1.000 M');
+      expect(formatMillions(1234567)).toEqual('1.235 M');
+      expect(formatMillions(345.2)).toEqual('345');
+    });
+  });
 });
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 7afe385..2fc5762 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -235,6 +235,12 @@ export function formatPercent(n: number): string {
   return (n * 100).toFixed(2) + '%';
 }
 
+export function formatMillions(n: number): string {
+  const s = (n / 1e6).toFixed(3);
+  if (s === '0.000') return String(Math.round(n));
+  return s + ' M';
+}
+
 function pad2(str: string | number): string {
   return ('00' + str).substr(-2);
 }
diff --git a/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap b/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
index 2364a8a..64b412d 100755
--- a/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
+++ b/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
@@ -184,14 +184,14 @@ exports[`data source view matches snapshot 1`] = `
         Object {
           "Cell": [Function],
           "Header": <React.Fragment>
-            Segment size (MB)
+            Segment size (rows)
             <br />
-            min / avg / max
+            minimum / average / maximum
           </React.Fragment>,
-          "accessor": "avg_segment_size",
+          "accessor": "avg_segment_rows",
           "filterable": false,
           "show": true,
-          "width": 150,
+          "width": 220,
         },
         Object {
           "Cell": [Function],
diff --git a/web-console/src/views/datasource-view/datasource-view.tsx b/web-console/src/views/datasource-view/datasource-view.tsx
index c8aa949..e68e674 100644
--- a/web-console/src/views/datasource-view/datasource-view.tsx
+++ b/web-console/src/views/datasource-view/datasource-view.tsx
@@ -48,7 +48,7 @@ import {
   formatBytes,
   formatCompactionConfigAndStatus,
   formatInteger,
-  formatMegabytes,
+  formatMillions,
   formatPercent,
   getDruidErrorMessage,
   LocalStorageKeys,
@@ -88,7 +88,6 @@ const tableColumns: Record<CapabilitiesMode, string[]> = {
     'Availability',
     'Segment load/drop queues',
     'Total data size',
-    'Segment size',
     'Compaction',
     '% Compacted',
     'Left to be compacted',
@@ -120,7 +119,7 @@ function formatLoadDrop(segmentsToLoad: number, segmentsToDrop: number): string
 }
 
 const formatTotalDataSize = formatBytes;
-const formatSegmentSize = formatMegabytes;
+const formatSegmentRows = formatMillions;
 const formatTotalRows = formatInteger;
 const formatAvgRowSize = formatInteger;
 const formatReplicatedSize = formatBytes;
@@ -144,42 +143,41 @@ function progress(done: number, awaiting: number): number {
 
 const PERCENT_BRACES = [formatPercent(1)];
 
-interface Datasource {
-  datasource: string;
-  rules: Rule[];
-  compactionConfig?: CompactionConfig;
-  compactionStatus?: CompactionStatus;
-  [key: string]: any;
+interface DatasourceQueryResultRow {
+  readonly datasource: string;
+  readonly num_segments: number;
+  readonly num_available_segments: number;
+  readonly num_segments_to_load: number;
+  readonly num_segments_to_drop: number;
+  readonly total_data_size: number;
+  readonly replicated_size: number;
+  readonly min_segment_rows: number;
+  readonly avg_segment_rows: number;
+  readonly max_segment_rows: number;
+  readonly total_rows: number;
+  readonly avg_row_size: number;
 }
 
-interface DatasourcesAndDefaultRules {
-  datasources: Datasource[];
-  defaultRules: Rule[];
+interface Datasource extends DatasourceQueryResultRow {
+  readonly rules: Rule[];
+  readonly compactionConfig?: CompactionConfig;
+  readonly compactionStatus?: CompactionStatus;
+  readonly unused?: boolean;
 }
 
-interface DatasourceQueryResultRow {
-  datasource: string;
-  num_segments: number;
-  num_available_segments: number;
-  num_segments_to_load: number;
-  num_segments_to_drop: number;
-  total_data_size: number;
-  replicated_size: number;
-  min_segment_size: number;
-  avg_segment_size: number;
-  max_segment_size: number;
-  total_rows: number;
-  avg_row_size: number;
+interface DatasourcesAndDefaultRules {
+  readonly datasources: Datasource[];
+  readonly defaultRules: Rule[];
 }
 
 interface RetentionDialogOpenOn {
-  datasource: string;
-  rules: Rule[];
+  readonly datasource: string;
+  readonly rules: Rule[];
 }
 
 interface CompactionDialogOpenOn {
-  datasource: string;
-  compactionConfig: CompactionConfig;
+  readonly datasource: string;
+  readonly compactionConfig: CompactionConfig;
 }
 
 export interface DatasourcesViewProps {
@@ -229,19 +227,20 @@ export class DatasourcesView extends React.PureComponent<
   COUNT(*) FILTER (WHERE is_available = 1 AND ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1)) AS num_available_segments,
   COUNT(*) FILTER (WHERE is_published = 1 AND is_overshadowed = 0 AND is_available = 0) AS num_segments_to_load,
   COUNT(*) FILTER (WHERE is_available = 1 AND NOT ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1)) AS num_segments_to_drop,
-  SUM("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS total_data_size,
-  SUM("size" * "num_replicas") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS replicated_size,
-  MIN("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS min_segment_size,
-  (
-    SUM("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) /
-    COUNT(*) FILTER (WHERE (is_published = 1 AND is_overshadowed = 0))
-  ) AS avg_segment_size,
-  MAX("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) AS max_segment_size,
+  SUM("size") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS total_data_size,
+  SUM("size" * "num_replicas") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS replicated_size,
+  MIN("num_rows") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS min_segment_rows,
+  AVG("num_rows") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS avg_segment_rows,
+  MAX("num_rows") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS max_segment_rows,
   SUM("num_rows") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AS total_rows,
-  (
-    SUM("size") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0)) /
-    SUM("num_rows") FILTER (WHERE (is_published = 1 AND is_overshadowed = 0))
-  ) AS avg_row_size
+  CASE
+    WHEN SUM("num_rows") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) <> 0
+    THEN (
+      SUM("size") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) /
+      SUM("num_rows") FILTER (WHERE is_published = 1 AND is_overshadowed = 0)
+    )
+    ELSE 0
+  END AS avg_row_size
 FROM sys.segments
 GROUP BY 1`;
 
@@ -309,9 +308,9 @@ GROUP BY 1`;
                 num_segments_to_drop: 0,
                 replicated_size: -1,
                 total_data_size: totalDataSize,
-                min_segment_size: -1,
-                avg_segment_size: totalDataSize / numSegments,
-                max_segment_size: -1,
+                min_segment_rows: -1,
+                avg_segment_rows: -1,
+                max_segment_rows: -1,
                 total_rows: -1,
                 avg_row_size: -1,
               };
@@ -361,7 +360,7 @@ GROUP BY 1`;
         const allDatasources = (datasources as any).concat(
           unused.map(d => ({ datasource: d, unused: true })),
         );
-        allDatasources.forEach((ds: Datasource) => {
+        allDatasources.forEach((ds: any) => {
           ds.rules = rules[ds.datasource] || [];
           ds.compactionConfig = compactionConfigs[ds.datasource];
           ds.compactionStatus = compactionStatuses[ds.datasource];
@@ -869,11 +868,11 @@ GROUP BY 1`;
 
     const totalDataSizeValues = datasources.map(d => formatTotalDataSize(d.total_data_size));
 
-    const minSegmentSizeValues = datasources.map(d => formatSegmentSize(d.min_segment_size));
+    const minSegmentRowsValues = datasources.map(d => formatSegmentRows(d.min_segment_rows));
 
-    const avgSegmentSizeValues = datasources.map(d => formatSegmentSize(d.avg_segment_size));
+    const avgSegmentRowsValues = datasources.map(d => formatSegmentRows(d.avg_segment_rows));
 
-    const maxSegmentSizeValues = datasources.map(d => formatSegmentSize(d.max_segment_size));
+    const maxSegmentRowsValues = datasources.map(d => formatSegmentRows(d.max_segment_rows));
 
     const totalRowsValues = datasources.map(d => formatTotalRows(d.total_rows));
 
@@ -1011,23 +1010,23 @@ GROUP BY 1`;
               ),
             },
             {
-              Header: twoLines('Segment size (MB)', 'min / avg / max'),
-              show: hiddenColumns.exists('Segment size'),
-              accessor: 'avg_segment_size',
+              Header: twoLines('Segment size (rows)', 'minimum / average / maximum'),
+              show: capabilities.hasSql() && hiddenColumns.exists('Segment size'),
+              accessor: 'avg_segment_rows',
               filterable: false,
-              width: 150,
+              width: 220,
               Cell: ({ value, original }) => (
                 <>
                   <BracedText
-                    text={formatSegmentSize(original.min_segment_size)}
-                    braces={minSegmentSizeValues}
+                    text={formatSegmentRows(original.min_segment_rows)}
+                    braces={minSegmentRowsValues}
                   />{' '}
                   &nbsp;{' '}
-                  <BracedText text={formatSegmentSize(value)} braces={avgSegmentSizeValues} />{' '}
+                  <BracedText text={formatSegmentRows(value)} braces={avgSegmentRowsValues} />{' '}
                   &nbsp;{' '}
                   <BracedText
-                    text={formatSegmentSize(original.max_segment_size)}
-                    braces={maxSegmentSizeValues}
+                    text={formatSegmentRows(original.max_segment_rows)}
+                    braces={maxSegmentRowsValues}
                   />
                 </>
               ),
@@ -1044,7 +1043,7 @@ GROUP BY 1`;
             },
             {
               Header: twoLines('Avg. row size', '(bytes)'),
-              show: hiddenColumns.exists('Avg. row size'),
+              show: capabilities.hasSql() && hiddenColumns.exists('Avg. row size'),
               accessor: 'avg_row_size',
               filterable: false,
               width: 100,
diff --git a/web-console/src/views/query-view/query-error/query-error.scss b/web-console/src/views/query-view/query-error/query-error.scss
index a4511de..e954dac 100644
--- a/web-console/src/views/query-view/query-error/query-error.scss
+++ b/web-console/src/views/query-view/query-error/query-error.scss
@@ -20,7 +20,9 @@
   background: #232d35;
   padding: 20px 22px;
 
-  .cursor-link {
+  .cursor-link,
+  .more-or-less,
+  .suggestion {
     color: #2aabd2;
     text-decoration: underline;
     cursor: pointer;
diff --git a/web-console/src/views/query-view/query-error/query-error.tsx b/web-console/src/views/query-view/query-error/query-error.tsx
index a2939c4..2e0c829 100644
--- a/web-console/src/views/query-view/query-error/query-error.tsx
+++ b/web-console/src/views/query-view/query-error/query-error.tsx
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 
-import React from 'react';
+import React, { useState } from 'react';
 
 import { HighlightText } from '../../../components';
 import { DruidError, RowColumn } from '../../../utils';
@@ -26,24 +26,48 @@ import './query-error.scss';
 export interface QueryErrorProps {
   error: DruidError;
   moveCursorTo: (rowColumn: RowColumn) => void;
+  queryString?: string;
+  onQueryStringChange?: (newQueryString: string, run?: boolean) => void;
 }
 
 export const QueryError = React.memo(function QueryError(props: QueryErrorProps) {
-  const { error, moveCursorTo } = props;
+  const { error, moveCursorTo, queryString, onQueryStringChange } = props;
+  const [showMode, setShowMore] = useState(false);
 
   if (!error.errorMessage) {
     return <div className="query-error">{error.message}</div>;
   }
 
-  const { position } = error;
+  const { position, suggestion } = error;
+  let suggestionElement: JSX.Element | undefined;
+  if (suggestion && queryString && onQueryStringChange) {
+    const newQuery = suggestion.fn(queryString);
+    if (newQuery) {
+      suggestionElement = (
+        <p>
+          Suggestion:{' '}
+          <span
+            className="suggestion"
+            onClick={() => {
+              onQueryStringChange(newQuery, true);
+            }}
+          >
+            {suggestion.label}
+          </span>
+        </p>
+      );
+    }
+  }
+
   return (
     <div className="query-error">
+      {suggestionElement}
       {error.error && <p>{`Error: ${error.error}`}</p>}
-      {error.errorMessage && (
+      {error.errorMessageWithoutExpectation && (
         <p>
           {position ? (
             <HighlightText
-              text={error.errorMessage}
+              text={error.errorMessageWithoutExpectation}
               find={position.match}
               replace={
                 <span
@@ -57,8 +81,24 @@ export const QueryError = React.memo(function QueryError(props: QueryErrorProps)
               }
             />
           ) : (
-            error.errorMessage
+            error.errorMessageWithoutExpectation
           )}
+          {error.expectation && !showMode && (
+            <>
+              {' '}
+              <span className="more-or-less" onClick={() => setShowMore(true)}>
+                More...
+              </span>
+            </>
+          )}
+        </p>
+      )}
+      {error.expectation && showMode && (
+        <p>
+          {error.expectation}{' '}
+          <span className="more-or-less" onClick={() => setShowMore(false)}>
+            Less...
+          </span>
         </p>
       )}
       {error.errorClass && <p>{error.errorClass}</p>}
diff --git a/web-console/src/views/query-view/query-view.tsx b/web-console/src/views/query-view/query-view.tsx
index 3effd4e..7979073 100644
--- a/web-console/src/views/query-view/query-view.tsx
+++ b/web-console/src/views/query-view/query-view.tsx
@@ -514,6 +514,8 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
               moveCursorTo={position => {
                 this.moveToPosition(position);
               }}
+              queryString={queryString}
+              onQueryStringChange={this.handleQueryStringChange}
             />
           )}
           {queryResultState.loading && (


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