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

[druid] branch master updated: Web console: Misc fixes and improvements (#12361)

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 a139cd22aa Web console: Misc fixes and improvements  (#12361)
a139cd22aa is described below

commit a139cd22aa81d7b6e0de98a01002b0f8f00f28c6
Author: Vadim Ogievetsky <va...@ogievetsky.com>
AuthorDate: Tue Apr 12 22:20:28 2022 -0700

    Web console: Misc fixes and improvements  (#12361)
    
    * Misc fixes
    
    * pad column numbers
    
    * make shard_type filterable
---
 licenses.yaml                                      |   2 +-
 web-console/package-lock.json                      |   6 +-
 web-console/package.json                           |   2 +-
 web-console/script/cp-to                           |   1 +
 .../__snapshots__/table-cell.spec.tsx.snap         |  18 +++
 .../src/components/table-cell/table-cell.spec.tsx  |  14 ++
 .../src/components/table-cell/table-cell.tsx       |   3 +-
 .../show-value-dialog/show-value-dialog.tsx        |   7 +-
 .../src/druid-models/ingestion-spec.spec.ts        |  62 +++++++--
 web-console/src/druid-models/ingestion-spec.tsx    | 111 ++++++++++++----
 web-console/src/druid-models/metric-spec.tsx       |   2 +-
 web-console/src/utils/general.spec.ts              |  42 ++++++
 web-console/src/utils/general.tsx                  |  36 +++++
 web-console/src/utils/object-change.ts             |  16 ++-
 web-console/src/variables.scss                     |   9 ++
 .../views/query-view/column-tree/column-tree.tsx   | 147 ++++++++++-----------
 .../query-view/explain-dialog/explain-dialog.tsx   |   2 +-
 web-console/src/views/query-view/query-utils.ts    |  19 ++-
 .../__snapshots__/segments-view.spec.tsx.snap      |  21 ++-
 .../src/views/segments-view/segments-view.scss     |  17 +++
 .../src/views/segments-view/segments-view.tsx      | 126 ++++++++++++------
 21 files changed, 478 insertions(+), 185 deletions(-)

diff --git a/licenses.yaml b/licenses.yaml
index 4d3d64615c..f4235850c8 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5656,7 +5656,7 @@ license_category: binary
 module: web-console
 license_name: Apache License version 2.0
 copyright: Imply Data
-version: 0.14.6
+version: 0.14.10
 
 ---
 
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 3f5aab0319..64b73c39c6 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -8498,9 +8498,9 @@
       }
     },
     "druid-query-toolkit": {
-      "version": "0.14.6",
-      "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.14.6.tgz",
-      "integrity": "sha512-Dv/oXD80+2SEV8J8m8Ib6giIU5fWcHK0hr/l04NbZMCpZhX/9NLDWW9HEQltRp9EyD3UEHbkoMChcbyRPAgc8w==",
+      "version": "0.14.10",
+      "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.14.10.tgz",
+      "integrity": "sha512-Y720YxnT3EmqtE/x1QkrkEiomn5TdVArxI3+gdLRH8FYMRedpSPe2nkQVNYma9b7Lww/rzk4Q+a8mNWQ1YH9oQ==",
       "requires": {
         "tslib": "^2.2.0"
       }
diff --git a/web-console/package.json b/web-console/package.json
index 457f3d7863..f9cdf7f8cf 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -79,7 +79,7 @@
     "d3-axis": "^1.0.12",
     "d3-scale": "^3.2.0",
     "d3-selection": "^1.4.0",
-    "druid-query-toolkit": "^0.14.6",
+    "druid-query-toolkit": "^0.14.10",
     "file-saver": "^2.0.2",
     "follow-redirects": "^1.14.7",
     "fontsource-open-sans": "^3.0.9",
diff --git a/web-console/script/cp-to b/web-console/script/cp-to
index bbf31529e9..c6331a77f8 100755
--- a/web-console/script/cp-to
+++ b/web-console/script/cp-to
@@ -23,3 +23,4 @@ cp *.png "$1"
 cp console-config.js "$1"
 cp -r public "$1"
 cp -r assets "$1"
+echo "Finished copying web-console files"
diff --git a/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap b/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
index caf2d56db5..cd0a4e368b 100644
--- a/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
+++ b/web-console/src/components/table-cell/__snapshots__/table-cell.spec.tsx.snap
@@ -1,5 +1,23 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`TableCell matches snapshot Date (invalid) 1`] = `
+<span
+  class="table-cell timestamp"
+  title="NaN"
+>
+  Unusable date
+</span>
+`;
+
+exports[`TableCell matches snapshot Date 1`] = `
+<span
+  class="table-cell timestamp"
+  title="1645664523000"
+>
+  2022-02-24T01:02:03.000Z
+</span>
+`;
+
 exports[`TableCell matches snapshot array long 1`] = `
 <span
   class="table-cell truncated"
diff --git a/web-console/src/components/table-cell/table-cell.spec.tsx b/web-console/src/components/table-cell/table-cell.spec.tsx
index 0535d0626c..bd9681bd94 100644
--- a/web-console/src/components/table-cell/table-cell.spec.tsx
+++ b/web-console/src/components/table-cell/table-cell.spec.tsx
@@ -36,6 +36,20 @@ describe('TableCell', () => {
     expect(container.firstChild).toMatchSnapshot();
   });
 
+  it('matches snapshot Date', () => {
+    const tableCell = <TableCell value={new Date('2022-02-24T01:02:03Z')} />;
+
+    const { container } = render(tableCell);
+    expect(container.firstChild).toMatchSnapshot();
+  });
+
+  it('matches snapshot Date (invalid)', () => {
+    const tableCell = <TableCell value={new Date('blah blah')} />;
+
+    const { container } = render(tableCell);
+    expect(container.firstChild).toMatchSnapshot();
+  });
+
   it('matches snapshot array short', () => {
     const tableCell = <TableCell value={['a', 'b', 'c']} />;
 
diff --git a/web-console/src/components/table-cell/table-cell.tsx b/web-console/src/components/table-cell/table-cell.tsx
index 5fd4bf6371..7d20b717fc 100644
--- a/web-console/src/components/table-cell/table-cell.tsx
+++ b/web-console/src/components/table-cell/table-cell.tsx
@@ -91,9 +91,10 @@ export const TableCell = React.memo(function TableCell(props: TableCellProps) {
 
   if (value !== '' && value != null) {
     if (value instanceof Date) {
+      const dateValue = value.valueOf();
       return (
         <span className="table-cell timestamp" title={String(value.valueOf())}>
-          {value.toISOString()}
+          {isNaN(dateValue) ? 'Unusable date' : value.toISOString()}
         </span>
       );
     } else if (Array.isArray(value)) {
diff --git a/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx b/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx
index f5291e172b..8e1b029086 100644
--- a/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx
+++ b/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx
@@ -27,13 +27,14 @@ import { AppToaster } from '../../singletons';
 import './show-value-dialog.scss';
 
 export interface ShowValueDialogProps {
-  onClose: () => void;
+  title?: string;
   str: string;
   size?: 'normal' | 'large';
+  onClose: () => void;
 }
 
 export const ShowValueDialog = React.memo(function ShowValueDialog(props: ShowValueDialogProps) {
-  const { onClose, str, size } = props;
+  const { title, onClose, str, size } = props;
 
   function handleCopy() {
     copy(str, { format: 'text/plain' });
@@ -48,7 +49,7 @@ export const ShowValueDialog = React.memo(function ShowValueDialog(props: ShowVa
       className={classNames('show-value-dialog', size || 'normal')}
       isOpen
       onClose={onClose}
-      title="Full value"
+      title={title || 'Full value'}
     >
       <TextArea value={str} spellCheck={false} />
       <div className={Classes.DIALOG_FOOTER_ACTIONS}>
diff --git a/web-console/src/druid-models/ingestion-spec.spec.ts b/web-console/src/druid-models/ingestion-spec.spec.ts
index 36d3f02871..52efaed3a8 100644
--- a/web-console/src/druid-models/ingestion-spec.spec.ts
+++ b/web-console/src/druid-models/ingestion-spec.spec.ts
@@ -549,28 +549,72 @@ describe('ingestion-spec', () => {
       expect(guessInputFormat(['{"a":1}']).type).toEqual('json');
     });
 
-    it('works for TSV', () => {
-      expect(guessInputFormat(['A\tB\tX\tY']).type).toEqual('tsv');
+    it('works for CSV (with header)', () => {
+      expect(guessInputFormat(['A,B,"X,1",Y'])).toEqual({
+        type: 'csv',
+        findColumnsFromHeader: true,
+      });
     });
 
-    it('works for CSV', () => {
-      expect(guessInputFormat(['A,B,X,Y']).type).toEqual('csv');
+    it('works for CSV (no header)', () => {
+      expect(guessInputFormat(['"A,1","B,2",1,2'])).toEqual({
+        type: 'csv',
+        findColumnsFromHeader: false,
+        columns: ['column1', 'column2', 'column3', 'column4'],
+      });
+    });
+
+    it('works for TSV (with header)', () => {
+      expect(guessInputFormat(['A\tB\tX\tY'])).toEqual({
+        type: 'tsv',
+        findColumnsFromHeader: true,
+      });
+    });
+
+    it('works for TSV (no header)', () => {
+      expect(guessInputFormat(['A\tB\t1\t2\t3\t4\t5\t6\t7\t8\t9'])).toEqual({
+        type: 'tsv',
+        findColumnsFromHeader: false,
+        columns: [
+          'column01',
+          'column02',
+          'column03',
+          'column04',
+          'column05',
+          'column06',
+          'column07',
+          'column08',
+          'column09',
+          'column10',
+          'column11',
+        ],
+      });
     });
 
     it('works for TSV with ;', () => {
       const inputFormat = guessInputFormat(['A;B;X;Y']);
-      expect(inputFormat.type).toEqual('tsv');
-      expect(inputFormat.delimiter).toEqual(';');
+      expect(inputFormat).toEqual({
+        type: 'tsv',
+        delimiter: ';',
+        findColumnsFromHeader: true,
+      });
     });
 
     it('works for TSV with |', () => {
       const inputFormat = guessInputFormat(['A|B|X|Y']);
-      expect(inputFormat.type).toEqual('tsv');
-      expect(inputFormat.delimiter).toEqual('|');
+      expect(inputFormat).toEqual({
+        type: 'tsv',
+        delimiter: '|',
+        findColumnsFromHeader: true,
+      });
     });
 
     it('works for regex', () => {
-      expect(guessInputFormat(['A/B/X/Y']).type).toEqual('regex');
+      expect(guessInputFormat(['A/B/X/Y'])).toEqual({
+        type: 'regex',
+        pattern: '([\\s\\S]*)',
+        columns: ['line'],
+      });
     });
   });
 });
diff --git a/web-console/src/druid-models/ingestion-spec.tsx b/web-console/src/druid-models/ingestion-spec.tsx
index 2de22e5aaf..e58aeef320 100644
--- a/web-console/src/druid-models/ingestion-spec.tsx
+++ b/web-console/src/druid-models/ingestion-spec.tsx
@@ -17,6 +17,7 @@
  */
 
 import { Code } from '@blueprintjs/core';
+import { range } from 'd3-array';
 import React from 'react';
 
 import { AutoForm, ExternalLink, Field } from '../components';
@@ -32,6 +33,7 @@ import {
   EMPTY_OBJECT,
   filterMap,
   oneOf,
+  parseCsvLine,
   typeIs,
 } from '../utils';
 import { SampleHeaderAndRows } from '../utils/sampler';
@@ -2162,6 +2164,10 @@ export function fillInputFormatIfNeeded(
   return deepSet(spec, 'spec.ioConfig.inputFormat', guessInputFormat(sampleData));
 }
 
+function noNumbers(xs: string[]): boolean {
+  return xs.every(x => isNaN(Number(x)));
+}
+
 export function guessInputFormat(sampleData: string[]): InputFormat {
   let sampleDatum = sampleData[0];
   if (sampleDatum) {
@@ -2171,62 +2177,102 @@ export function guessInputFormat(sampleData: string[]): InputFormat {
 
     // Parquet 4 byte magic header: https://github.com/apache/parquet-format#file-format
     if (sampleDatum.startsWith('PAR1')) {
-      return inputFormatFromType('parquet');
+      return inputFormatFromType({ type: 'parquet' });
     }
     // ORC 3 byte magic header: https://orc.apache.org/specification/ORCv1/
     if (sampleDatum.startsWith('ORC')) {
-      return inputFormatFromType('orc');
+      return inputFormatFromType({ type: 'orc' });
     }
     // Avro OCF 4 byte magic header: https://avro.apache.org/docs/current/spec.html#Object+Container+Files
-    if (sampleDatum.startsWith('Obj') && sampleDatum.charCodeAt(3) === 1) {
-      return inputFormatFromType('avro_ocf');
+    if (sampleDatum.startsWith('Obj\x01')) {
+      return inputFormatFromType({ type: 'avro_ocf' });
     }
 
     // After checking for magic byte sequences perform heuristics to deduce string formats
 
     // If the string starts and ends with curly braces assume JSON
     if (sampleDatum.startsWith('{') && sampleDatum.endsWith('}')) {
-      return inputFormatFromType('json');
+      return inputFormatFromType({ type: 'json' });
     }
+
     // Contains more than 3 tabs assume TSV
-    if (sampleDatum.split('\t').length > 3) {
-      return inputFormatFromType('tsv', !/\t\d+\t/.test(sampleDatum));
+    const lineAsTsv = sampleDatum.split('\t');
+    if (lineAsTsv.length > 3) {
+      return inputFormatFromType({
+        type: 'tsv',
+        findColumnsFromHeader: noNumbers(lineAsTsv),
+        numColumns: lineAsTsv.length,
+      });
     }
-    // Contains more than 3 commas assume CSV
-    if (sampleDatum.split(',').length > 3) {
-      return inputFormatFromType('csv', !/,\d+,/.test(sampleDatum));
+
+    // Contains more than fields if parsed as CSV line
+    const lineAsCsv = parseCsvLine(sampleDatum);
+    if (lineAsCsv.length > 3) {
+      return inputFormatFromType({
+        type: 'csv',
+        findColumnsFromHeader: noNumbers(lineAsCsv),
+        numColumns: lineAsCsv.length,
+      });
     }
+
     // Contains more than 3 semicolons assume semicolon separated
-    if (sampleDatum.split(';').length > 3) {
-      return inputFormatFromType('tsv', !/;\d+;/.test(sampleDatum), ';');
+    const lineAsTsvSemicolon = sampleDatum.split(';');
+    if (lineAsTsvSemicolon.length > 3) {
+      return inputFormatFromType({
+        type: 'tsv',
+        delimiter: ';',
+        findColumnsFromHeader: noNumbers(lineAsTsvSemicolon),
+        numColumns: lineAsTsvSemicolon.length,
+      });
     }
+
     // Contains more than 3 pipes assume pipe separated
-    if (sampleDatum.split('|').length > 3) {
-      return inputFormatFromType('tsv', !/\|\d+\|/.test(sampleDatum), '|');
+    const lineAsTsvPipe = sampleDatum.split('|');
+    if (lineAsTsvPipe.length > 3) {
+      return inputFormatFromType({
+        type: 'tsv',
+        delimiter: '|',
+        findColumnsFromHeader: noNumbers(lineAsTsvPipe),
+        numColumns: lineAsTsvPipe.length,
+      });
     }
   }
 
-  return inputFormatFromType('regex');
+  return inputFormatFromType({ type: 'regex' });
 }
 
-function inputFormatFromType(
-  type: string,
-  findColumnsFromHeader?: boolean,
-  delimiter?: string,
-): InputFormat {
+interface InputFormatFromTypeOptions {
+  type: string;
+  delimiter?: string;
+  findColumnsFromHeader?: boolean;
+  numColumns?: number;
+}
+
+function inputFormatFromType(options: InputFormatFromTypeOptions): InputFormat {
+  const { type, delimiter, findColumnsFromHeader, numColumns } = options;
+
   let inputFormat: InputFormat = { type };
 
   if (type === 'regex') {
-    inputFormat = deepSet(inputFormat, 'pattern', '(.*)');
-    inputFormat = deepSet(inputFormat, 'columns', ['column1']);
-  }
-
-  if (typeof findColumnsFromHeader === 'boolean') {
-    inputFormat = deepSet(inputFormat, 'findColumnsFromHeader', findColumnsFromHeader);
-  }
+    inputFormat = deepSet(inputFormat, 'pattern', '([\\s\\S]*)');
+    inputFormat = deepSet(inputFormat, 'columns', ['line']);
+  } else {
+    if (typeof findColumnsFromHeader === 'boolean') {
+      inputFormat = deepSet(inputFormat, 'findColumnsFromHeader', findColumnsFromHeader);
+
+      if (!findColumnsFromHeader && numColumns) {
+        const padLength = String(numColumns).length;
+        inputFormat = deepSet(
+          inputFormat,
+          'columns',
+          range(0, numColumns).map(c => `column${String(c + 1).padStart(padLength, '0')}`),
+        );
+      }
+    }
 
-  if (delimiter) {
-    inputFormat = deepSet(inputFormat, 'delimiter', delimiter);
+    if (delimiter) {
+      inputFormat = deepSet(inputFormat, 'delimiter', delimiter);
+    }
   }
 
   return inputFormat;
@@ -2234,6 +2280,13 @@ function inputFormatFromType(
 
 // ------------------------
 
+export function guessIsArrayFromHeaderAndRows(
+  headerAndRows: SampleHeaderAndRows,
+  column: string,
+): boolean {
+  return headerAndRows.rows.some(r => Array.isArray(r.input?.[column]));
+}
+
 export function guessColumnTypeFromInput(
   sampleValues: any[],
   guessNumericStringsAsNumbers: boolean,
diff --git a/web-console/src/druid-models/metric-spec.tsx b/web-console/src/druid-models/metric-spec.tsx
index 54c7fc7f39..3aa9c7b19c 100644
--- a/web-console/src/druid-models/metric-spec.tsx
+++ b/web-console/src/druid-models/metric-spec.tsx
@@ -339,7 +339,7 @@ export function getMetricSpecSingleFieldName(metricSpec: MetricSpec): string | u
 
 export function getMetricSpecOutputType(metricSpec: MetricSpec): string | undefined {
   if (metricSpec.aggregator) return getMetricSpecOutputType(metricSpec.aggregator);
-  const m = /^(long|float|double)/.exec(String(metricSpec.type));
+  const m = /^(long|float|double|string)/.exec(String(metricSpec.type));
   if (!m) return;
   return m[1];
 }
diff --git a/web-console/src/utils/general.spec.ts b/web-console/src/utils/general.spec.ts
index 6d7596153f..fcb6e8b338 100644
--- a/web-console/src/utils/general.spec.ts
+++ b/web-console/src/utils/general.spec.ts
@@ -24,8 +24,11 @@ import {
   formatMegabytes,
   formatMillions,
   formatPercent,
+  hashJoaat,
   moveElement,
   moveToIndex,
+  objectHash,
+  parseCsvLine,
   sqlQueryCustomTableFilter,
   swapElements,
 } from './general';
@@ -154,4 +157,43 @@ describe('general', () => {
       expect(formatMillions(345.2)).toEqual('345');
     });
   });
+
+  describe('parseCsvLine', () => {
+    it('works in general', () => {
+      expect(parseCsvLine(`Hello,,"",world,123,Hi "you","Quote, ""escapes"", work"\r\n`)).toEqual([
+        `Hello`,
+        ``,
+        ``,
+        `world`,
+        `123`,
+        `Hi "you"`,
+        `Quote, "escapes", work`,
+      ]);
+    });
+
+    it('works in empty case', () => {
+      expect(parseCsvLine(``)).toEqual([``]);
+    });
+
+    it('works in trivial case', () => {
+      expect(parseCsvLine(`Hello`)).toEqual([`Hello`]);
+    });
+
+    it('only parses first line', () => {
+      expect(parseCsvLine(`Hi,there\na,b\nx,y\n`)).toEqual([`Hi`, `there`]);
+    });
+  });
+
+  describe('hashJoaat', () => {
+    it('works', () => {
+      expect(hashJoaat('a')).toEqual(0xca2e9442);
+      expect(hashJoaat('The quick brown fox jumps over the lazy dog')).toEqual(0x7647f758);
+    });
+  });
+
+  describe('objectHash', () => {
+    it('works', () => {
+      expect(objectHash({ hello: 'world1' })).toEqual('cc14ad13');
+    });
+  });
 });
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 22d0c53e6c..fb651e1829 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -476,3 +476,39 @@ export function twoLines(line1: string, line2: string) {
     </>
   );
 }
+
+export function parseCsvLine(line: string): string[] {
+  line = ',' + line.replace(/\r?\n?$/, ''); // remove trailing new lines
+  const parts: string[] = [];
+  let m: RegExpExecArray | null;
+  while ((m = /^,(?:"([^"]*(?:""[^"]*)*)"|([^,\r\n]*))/m.exec(line))) {
+    parts.push(typeof m[1] === 'string' ? m[1].replace(/""/g, '"') : m[2]);
+    line = line.substr(m[0].length);
+  }
+  return parts;
+}
+
+// From: https://en.wikipedia.org/wiki/Jenkins_hash_function
+export function hashJoaat(str: string): number {
+  let hash = 0;
+  const n = str.length;
+  for (let i = 0; i < n; i++) {
+    hash += str.charCodeAt(i);
+    // eslint-disable-next-line no-bitwise
+    hash += hash << 10;
+    // eslint-disable-next-line no-bitwise
+    hash ^= hash >> 6;
+  }
+  // eslint-disable-next-line no-bitwise
+  hash += hash << 3;
+  // eslint-disable-next-line no-bitwise
+  hash ^= hash >> 11;
+  // eslint-disable-next-line no-bitwise
+  hash += hash << 15;
+  // eslint-disable-next-line no-bitwise
+  return (hash & 4294967295) >>> 0;
+}
+
+export function objectHash(obj: any): string {
+  return hashJoaat(JSONBig.stringify(obj)).toString(16).padStart(8);
+}
diff --git a/web-console/src/utils/object-change.ts b/web-console/src/utils/object-change.ts
index 40efacfa5c..1e06fc01fb 100644
--- a/web-console/src/utils/object-change.ts
+++ b/web-console/src/utils/object-change.ts
@@ -159,12 +159,20 @@ export function deepExtend<T extends Record<string, any>>(target: T, diff: Recor
   return newValue;
 }
 
-export function allowKeys(obj: Record<string, any>, whitelist: string[]): Record<string, any> {
+export function allowKeys(obj: Record<string, any>, keys: string[]): Record<string, any> {
   const newObj: Record<string, any> = {};
-  for (const w of whitelist) {
-    if (Object.prototype.hasOwnProperty.call(obj, w)) {
-      newObj[w] = obj[w];
+  for (const key of keys) {
+    if (Object.prototype.hasOwnProperty.call(obj, key)) {
+      newObj[key] = obj[key];
     }
   }
   return newObj;
 }
+
+export function deleteKeys(obj: Record<string, any>, keys: string[]): Record<string, any> {
+  const newObj: Record<string, any> = { ...obj };
+  for (const key of keys) {
+    delete newObj[key];
+  }
+  return newObj;
+}
diff --git a/web-console/src/variables.scss b/web-console/src/variables.scss
index e4105e3a11..d14c0b79a3 100644
--- a/web-console/src/variables.scss
+++ b/web-console/src/variables.scss
@@ -31,6 +31,15 @@ $druid-brand: #2ceefb;
 $druid-brand2: #00b6bf;
 $druid-brand-background: #1c1c26;
 
+@mixin card-background {
+  background: $white;
+  border-radius: $pt-border-radius;
+
+  .bp3-dark & {
+    background: $dark-gray3;
+  }
+}
+
 @mixin card-like {
   background: $white;
   border-radius: $pt-border-radius;
diff --git a/web-console/src/views/query-view/column-tree/column-tree.tsx b/web-console/src/views/query-view/column-tree/column-tree.tsx
index 9865066eb1..a97a72e031 100644
--- a/web-console/src/views/query-view/column-tree/column-tree.tsx
+++ b/web-console/src/views/query-view/column-tree/column-tree.tsx
@@ -41,10 +41,6 @@ import './column-tree.scss';
 
 const COUNT_STAR = SqlFunction.COUNT_STAR.as('Count');
 
-function caseInsensitiveCompare(a: any, b: any): number {
-  return String(a).toLowerCase().localeCompare(String(b).toLowerCase());
-}
-
 function getCountExpression(columnNames: string[]): SqlExpression {
   for (const columnName of columnNames) {
     if (columnName === 'count' || columnName === '__count') {
@@ -234,7 +230,6 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
                                     .changeSelectExpressions(
                                       metadata
                                         .map(child => child.COLUMN_NAME)
-                                        .sort(caseInsensitiveCompare)
                                         .map(columnName => SqlRef.column(columnName)),
                                     )
                                     .changeWhereExpression(getWhere()),
@@ -376,57 +371,38 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
                   {tableName}
                 </Popover2>
               ),
-              childNodes: metadata
-                .map(
-                  (columnData): TreeNodeInfo => ({
-                    id: columnData.COLUMN_NAME,
-                    icon: dataTypeToIcon(columnData.DATA_TYPE),
-                    label: (
-                      <Popover2
-                        position={Position.RIGHT}
-                        autoFocus={false}
-                        content={
-                          <Deferred
-                            content={() => {
-                              const parsedQuery = props.getParsedQuery();
-                              return (
-                                <Menu>
-                                  <MenuItem
-                                    icon={IconNames.FULLSCREEN}
-                                    text={`Show: ${columnData.COLUMN_NAME}`}
-                                    onClick={() => {
-                                      handleColumnShow({
-                                        columnSchema: schemaName,
-                                        columnTable: tableName,
-                                        columnName: columnData.COLUMN_NAME,
-                                        columnType: columnData.DATA_TYPE,
-                                        parsedQuery,
-                                        defaultWhere,
-                                        onQueryChange: onQueryChange,
-                                      });
-                                    }}
-                                  />
-                                  {parsedQuery &&
-                                    oneOf(columnData.DATA_TYPE, 'BIGINT', 'FLOAT', 'DOUBLE') && (
-                                      <NumberMenuItems
-                                        table={tableName}
-                                        schema={schemaName}
-                                        columnName={columnData.COLUMN_NAME}
-                                        parsedQuery={parsedQuery}
-                                        onQueryChange={onQueryChange}
-                                      />
-                                    )}
-                                  {parsedQuery && columnData.DATA_TYPE === 'VARCHAR' && (
-                                    <StringMenuItems
-                                      table={tableName}
-                                      schema={schemaName}
-                                      columnName={columnData.COLUMN_NAME}
-                                      parsedQuery={parsedQuery}
-                                      onQueryChange={onQueryChange}
-                                    />
-                                  )}
-                                  {parsedQuery && columnData.DATA_TYPE === 'TIMESTAMP' && (
-                                    <TimeMenuItems
+              childNodes: metadata.map(
+                (columnData): TreeNodeInfo => ({
+                  id: columnData.COLUMN_NAME,
+                  icon: dataTypeToIcon(columnData.DATA_TYPE),
+                  label: (
+                    <Popover2
+                      position={Position.RIGHT}
+                      autoFocus={false}
+                      content={
+                        <Deferred
+                          content={() => {
+                            const parsedQuery = props.getParsedQuery();
+                            return (
+                              <Menu>
+                                <MenuItem
+                                  icon={IconNames.FULLSCREEN}
+                                  text={`Show: ${columnData.COLUMN_NAME}`}
+                                  onClick={() => {
+                                    handleColumnShow({
+                                      columnSchema: schemaName,
+                                      columnTable: tableName,
+                                      columnName: columnData.COLUMN_NAME,
+                                      columnType: columnData.DATA_TYPE,
+                                      parsedQuery,
+                                      defaultWhere,
+                                      onQueryChange: onQueryChange,
+                                    });
+                                  }}
+                                />
+                                {parsedQuery &&
+                                  oneOf(columnData.DATA_TYPE, 'BIGINT', 'FLOAT', 'DOUBLE') && (
+                                    <NumberMenuItems
                                       table={tableName}
                                       schema={schemaName}
                                       columnName={columnData.COLUMN_NAME}
@@ -434,28 +410,45 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
                                       onQueryChange={onQueryChange}
                                     />
                                   )}
-                                  <MenuItem
-                                    icon={IconNames.CLIPBOARD}
-                                    text={`Copy: ${columnData.COLUMN_NAME}`}
-                                    onClick={() => {
-                                      copyAndAlert(
-                                        columnData.COLUMN_NAME,
-                                        `${columnData.COLUMN_NAME} query copied to clipboard`,
-                                      );
-                                    }}
+                                {parsedQuery && columnData.DATA_TYPE === 'VARCHAR' && (
+                                  <StringMenuItems
+                                    table={tableName}
+                                    schema={schemaName}
+                                    columnName={columnData.COLUMN_NAME}
+                                    parsedQuery={parsedQuery}
+                                    onQueryChange={onQueryChange}
+                                  />
+                                )}
+                                {parsedQuery && columnData.DATA_TYPE === 'TIMESTAMP' && (
+                                  <TimeMenuItems
+                                    table={tableName}
+                                    schema={schemaName}
+                                    columnName={columnData.COLUMN_NAME}
+                                    parsedQuery={parsedQuery}
+                                    onQueryChange={onQueryChange}
                                   />
-                                </Menu>
-                              );
-                            }}
-                          />
-                        }
-                      >
-                        {columnData.COLUMN_NAME}
-                      </Popover2>
-                    ),
-                  }),
-                )
-                .sort((a, b) => caseInsensitiveCompare(a.id, b.id)),
+                                )}
+                                <MenuItem
+                                  icon={IconNames.CLIPBOARD}
+                                  text={`Copy: ${columnData.COLUMN_NAME}`}
+                                  onClick={() => {
+                                    copyAndAlert(
+                                      columnData.COLUMN_NAME,
+                                      `${columnData.COLUMN_NAME} query copied to clipboard`,
+                                    );
+                                  }}
+                                />
+                              </Menu>
+                            );
+                          }}
+                        />
+                      }
+                    >
+                      {columnData.COLUMN_NAME}
+                    </Popover2>
+                  ),
+                }),
+              ),
             }),
           ),
         }),
diff --git a/web-console/src/views/query-view/explain-dialog/explain-dialog.tsx b/web-console/src/views/query-view/explain-dialog/explain-dialog.tsx
index d1b4301c64..dbd64bdd8f 100644
--- a/web-console/src/views/query-view/explain-dialog/explain-dialog.tsx
+++ b/web-console/src/views/query-view/explain-dialog/explain-dialog.tsx
@@ -52,7 +52,7 @@ function isExplainQuery(query: string): boolean {
 function wrapInExplainIfNeeded(query: string): string {
   query = trimSemicolon(query);
   if (isExplainQuery(query)) return query;
-  return `EXPLAIN PLAN FOR (${query}\n)`;
+  return `EXPLAIN PLAN FOR ${query}`;
 }
 
 export interface ExplainDialogProps {
diff --git a/web-console/src/views/query-view/query-utils.ts b/web-console/src/views/query-view/query-utils.ts
index b4d2f2b294..770a68c7f5 100644
--- a/web-console/src/views/query-view/query-utils.ts
+++ b/web-console/src/views/query-view/query-utils.ts
@@ -21,22 +21,35 @@ import { IconNames } from '@blueprintjs/icons';
 
 export function dataTypeToIcon(dataType: string): IconName {
   const typeUpper = dataType.toUpperCase();
-  if (typeUpper.startsWith('COMPLEX')) {
-    return IconNames.ASTERISK;
-  }
 
   switch (typeUpper) {
     case 'TIMESTAMP':
       return IconNames.TIME;
+
     case 'VARCHAR':
     case 'STRING':
       return IconNames.FONT;
+
     case 'BIGINT':
     case 'LONG':
     case 'FLOAT':
     case 'DOUBLE':
       return IconNames.NUMERICAL;
+
+    case 'ARRAY<STRING>':
+      return IconNames.ARRAY_STRING;
+
+    case 'ARRAY<LONG>':
+    case 'ARRAY<FLOAT>':
+    case 'ARRAY<DOUBLE>':
+      return IconNames.ARRAY_NUMERIC;
+
+    case 'COMPLEX<JSON>':
+      return IconNames.DIAGRAM_TREE;
+
     default:
+      if (typeUpper.startsWith('ARRAY')) return IconNames.ARRAY;
+      if (typeUpper.startsWith('COMPLEX')) return IconNames.ASTERISK;
       return IconNames.HELP;
   }
 }
diff --git a/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap b/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap
index c51534d36e..424f2ed6d4 100755
--- a/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap
+++ b/web-console/src/views/segments-view/__snapshots__/segments-view.spec.tsx.snap
@@ -57,8 +57,8 @@ exports[`SegmentsView matches snapshot 1`] = `
             "End",
             "Version",
             "Time span",
-            "Partitioning",
-            "Shard detail",
+            "Shard type",
+            "Shard spec",
             "Partition",
             "Size",
             "Num rows",
@@ -76,8 +76,6 @@ exports[`SegmentsView matches snapshot 1`] = `
         tableColumnsHidden={
           Array [
             "Time span",
-            "Partitioning",
-            "Shard detail",
           ]
         }
       />
@@ -203,19 +201,20 @@ exports[`SegmentsView matches snapshot 1`] = `
           },
           Object {
             "Cell": [Function],
-            "Header": "Partitioning",
-            "accessor": "partitioning",
-            "filterable": true,
-            "show": false,
-            "sortable": true,
+            "Header": "Shard type",
+            "accessor": [Function],
+            "id": "shard_type",
+            "show": true,
+            "sortable": false,
             "width": 100,
           },
           Object {
             "Cell": [Function],
-            "Header": "Shard detail",
+            "Header": "Shard spec",
             "accessor": "shard_spec",
             "filterable": false,
-            "show": false,
+            "id": "shard_spec",
+            "show": true,
             "sortable": false,
             "width": 400,
           },
diff --git a/web-console/src/views/segments-view/segments-view.scss b/web-console/src/views/segments-view/segments-view.scss
index 2d47ca0cf5..5f9cb4fc6c 100644
--- a/web-console/src/views/segments-view/segments-view.scss
+++ b/web-console/src/views/segments-view/segments-view.scss
@@ -42,6 +42,23 @@
         margin-right: 5px;
       }
     }
+
+    .spec-detail {
+      position: relative;
+      cursor: pointer;
+
+      .full-shard-spec-icon {
+        position: absolute;
+        top: 0;
+        right: 0;
+        color: #f5f8fa;
+        display: none;
+      }
+
+      &:hover .full-shard-spec-icon {
+        display: block;
+      }
+    }
   }
 
   &.show-segment-timeline {
diff --git a/web-console/src/views/segments-view/segments-view.tsx b/web-console/src/views/segments-view/segments-view.tsx
index 43ddbe5066..21ed68f2e0 100644
--- a/web-console/src/views/segments-view/segments-view.tsx
+++ b/web-console/src/views/segments-view/segments-view.tsx
@@ -16,10 +16,11 @@
  * limitations under the License.
  */
 
-import { Button, ButtonGroup, Intent, Label, MenuItem, Switch } from '@blueprintjs/core';
+import { Button, ButtonGroup, Icon, Intent, Label, MenuItem, Switch } from '@blueprintjs/core';
 import { IconNames } from '@blueprintjs/icons';
 import classNames from 'classnames';
-import { SqlExpression, SqlLiteral, SqlRef } from 'druid-query-toolkit';
+import { SqlComparison, SqlExpression, SqlLiteral, SqlRef } from 'druid-query-toolkit';
+import * as JSONBig from 'json-bigint-native';
 import React from 'react';
 import ReactTable, { Filter } from 'react-table';
 
@@ -37,6 +38,7 @@ import {
 } from '../../components';
 import { AsyncActionDialog } from '../../dialogs';
 import { SegmentTableActionDialog } from '../../dialogs/segments-table-action-dialog/segment-table-action-dialog';
+import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
 import { Api } from '../../singletons';
 import {
   addFilter,
@@ -74,8 +76,8 @@ const tableColumns: Record<CapabilitiesMode, string[]> = {
     'End',
     'Version',
     'Time span',
-    'Partitioning',
-    'Shard detail',
+    'Shard type',
+    'Shard spec',
     'Partition',
     'Size',
     'Num rows',
@@ -103,8 +105,8 @@ const tableColumns: Record<CapabilitiesMode, string[]> = {
     'Start',
     'End',
     'Version',
-    'Partitioning',
-    'Shard detail',
+    'Shard type',
+    'Shard spec',
     'Partition',
     'Size',
     'Num rows',
@@ -154,7 +156,6 @@ interface SegmentQueryResultRow {
   segment_id: string;
   version: string;
   time_span: string;
-  partitioning: string;
   shard_spec: string;
   partition_num: number;
   size: number;
@@ -178,6 +179,7 @@ export interface SegmentsViewState {
   visibleColumns: LocalStorageBackedVisibility;
   groupByInterval: boolean;
   showSegmentTimeline: boolean;
+  showFullShardSpec?: string;
 }
 
 export class SegmentsView extends React.PureComponent<SegmentsViewProps, SegmentsViewState> {
@@ -198,18 +200,7 @@ export class SegmentsView extends React.PureComponent<SegmentsViewProps, Segment
   WHEN "start" LIKE '%:00.000Z' AND "end" LIKE '%:00.000Z' THEN 'Minute'
   ELSE 'Sub minute'
 END AS "time_span"`,
-      visibleColumns.shown('Partitioning') &&
-        `CASE
-  WHEN "shard_spec" LIKE '%"type":"numbered"%' THEN 'dynamic'
-  WHEN "shard_spec" LIKE '%"type":"hashed"%' THEN 'hashed'
-  WHEN "shard_spec" LIKE '%"type":"single"%' THEN 'single_dim'
-  WHEN "shard_spec" LIKE '%"type":"range"%' THEN 'range'
-  WHEN "shard_spec" LIKE '%"type":"none"%' THEN 'none'
-  WHEN "shard_spec" LIKE '%"type":"linear"%' THEN 'linear'
-  WHEN "shard_spec" LIKE '%"type":"numbered_overwrite"%' THEN 'numbered_overwrite'
-  ELSE '-'
-END AS "partitioning"`,
-      visibleColumns.shown('Shard detail') && `"shard_spec"`,
+      (visibleColumns.shown('Shard type') || visibleColumns.shown('Shard spec')) && `"shard_spec"`,
       visibleColumns.shown('Partition') && `"partition_num"`,
       visibleColumns.shown('Size') && `"size"`,
       visibleColumns.shown('Num rows') && `"num_rows"`,
@@ -266,7 +257,7 @@ END AS "partitioning"`,
       segmentFilter,
       visibleColumns: new LocalStorageBackedVisibility(
         LocalStorageKeys.SEGMENT_TABLE_COLUMN_SELECTION,
-        ['Time span', 'Partitioning', 'Shard detail'],
+        ['Time span'],
       ),
       groupByInterval: false,
       showSegmentTimeline: false,
@@ -280,7 +271,16 @@ END AS "partitioning"`,
 
         if (capabilities.hasSql()) {
           const whereParts = filterMap(filtered, (f: Filter) => {
-            if (f.id.startsWith('is_')) {
+            if (f.id === 'shard_type') {
+              // Special handling for shard_type that needs to be search in the shard_spec
+              // Creates filters like `shard_spec LIKE '%"type":"numbered"%'`
+              const needleAndMode = getNeedleAndMode(f);
+              const closingQuote = needleAndMode.mode === 'exact' ? '"' : '';
+              return SqlComparison.like(
+                SqlRef.column('shard_spec'),
+                `%"type":"${needleAndMode.needle}${closingQuote}%`,
+              );
+            } else if (f.id.startsWith('is_')) {
               if (f.value === 'all') return;
               return SqlRef.columnWithQuotes(f.id).equal(f.value === 'true' ? 1 : 0);
             } else {
@@ -394,7 +394,6 @@ END AS "partitioning"`,
                 interval: segment.interval,
                 version: segment.version,
                 time_span: SegmentsView.computeTimeSpan(start, end),
-                partitioning: deepGet(segment, 'shardSpec.type') || '-',
                 shard_spec: deepGet(segment, 'shardSpec'),
                 partition_num: deepGet(segment, 'shardSpec.partitionNum') || 0,
                 size: segment.size,
@@ -590,17 +589,26 @@ END AS "partitioning"`,
             Cell: renderFilterableCell('time_span'),
           },
           {
-            Header: 'Partitioning',
-            show: visibleColumns.shown('Partitioning'),
-            accessor: 'partitioning',
+            Header: 'Shard type',
+            show: visibleColumns.shown('Shard type'),
+            id: 'shard_type',
             width: 100,
-            sortable: hasSql,
-            filterable: allowGeneralFilter,
-            Cell: renderFilterableCell('partitioning'),
+            sortable: false,
+            accessor: d => {
+              let v: any;
+              try {
+                v = JSONBig.parse(d.shard_spec);
+              } catch {}
+
+              if (typeof v?.type !== 'string') return '-';
+              return v?.type;
+            },
+            Cell: renderFilterableCell('shard_type'),
           },
           {
-            Header: 'Shard detail',
-            show: visibleColumns.shown('Shard detail'),
+            Header: 'Shard spec',
+            show: visibleColumns.shown('Shard spec'),
+            id: 'shard_spec',
             accessor: 'shard_spec',
             width: 400,
             sortable: false,
@@ -608,10 +616,19 @@ END AS "partitioning"`,
             Cell: ({ value }) => {
               let v: any;
               try {
-                v = JSON.parse(value);
-              } catch {
-                return '-';
-              }
+                v = JSONBig.parse(value);
+              } catch {}
+
+              const onShowFullShardSpec = () => {
+                this.setState({
+                  showFullShardSpec:
+                    v && typeof v === 'object' ? JSONBig.stringify(v, undefined, 2) : String(value),
+                });
+              };
+
+              const fullShardIcon = (
+                <Icon className="full-shard-spec-icon" icon={IconNames.EYE_OPEN} />
+              );
 
               switch (v?.type) {
                 case 'range': {
@@ -620,24 +637,26 @@ END AS "partitioning"`,
                     values.map((x, i) => formatRangeDimensionValue(dimensions[i], x)).join('; ');
 
                   return (
-                    <div className="range-detail">
+                    <div className="spec-detail range-detail" onClick={onShowFullShardSpec}>
                       <span className="range-label">Start:</span>
                       {Array.isArray(v.start) ? formatEdge(v.start) : '-∞'}
                       <br />
                       <span className="range-label">End:</span>
                       {Array.isArray(v.end) ? formatEdge(v.end) : '∞'}
+                      {fullShardIcon}
                     </div>
                   );
                 }
 
                 case 'single': {
                   return (
-                    <div className="range-detail">
+                    <div className="spec-detail range-detail" onClick={onShowFullShardSpec}>
                       <span className="range-label">Start:</span>
                       {v.start != null ? formatRangeDimensionValue(v.dimension, v.start) : '-∞'}
                       <br />
                       <span className="range-label">End:</span>
                       {v.end != null ? formatRangeDimensionValue(v.dimension, v.end) : '∞'}
+                      {fullShardIcon}
                     </div>
                   );
                 }
@@ -645,17 +664,34 @@ END AS "partitioning"`,
                 case 'hashed': {
                   const { partitionDimensions } = v;
                   if (!Array.isArray(partitionDimensions)) return value;
-                  return `Partition dimensions: ${
-                    partitionDimensions.length ? partitionDimensions.join('; ') : 'all'
-                  }`;
+                  return (
+                    <div className="spec-detail" onClick={onShowFullShardSpec}>
+                      {`hash(${
+                        partitionDimensions.length
+                          ? partitionDimensions.join(', ')
+                          : '<all dimensions>'
+                      })`}
+                      {fullShardIcon}
+                    </div>
+                  );
                 }
 
                 case 'numbered':
                 case 'none':
-                  return '-';
+                case 'tombstone':
+                  return (
+                    <div className="spec-detail" onClick={onShowFullShardSpec}>
+                      No detail{fullShardIcon}
+                    </div>
+                  );
 
                 default:
-                  return typeof value === 'string' ? value : '-';
+                  return (
+                    <div className="spec-detail" onClick={onShowFullShardSpec}>
+                      {String(value)}
+                      {fullShardIcon}
+                    </div>
+                  );
               }
             },
           },
@@ -843,6 +879,7 @@ END AS "partitioning"`,
       actions,
       visibleColumns,
       showSegmentTimeline,
+      showFullShardSpec,
     } = this.state;
     const { capabilities } = this.props;
     const { groupByInterval } = this.state;
@@ -913,6 +950,13 @@ END AS "partitioning"`,
             onClose={() => this.setState({ segmentTableActionDialogId: undefined })}
           />
         )}
+        {showFullShardSpec && (
+          <ShowValueDialog
+            title="Full shard spec"
+            str={showFullShardSpec}
+            onClose={() => this.setState({ showFullShardSpec: undefined })}
+          />
+        )}
       </>
     );
   }


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