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