mirror of https://github.com/apache/druid.git
Web console: Misc fixes and improvements (#12361)
* Misc fixes * pad column numbers * make shard_type filterable
This commit is contained in:
parent
2c79d28bb7
commit
a139cd22aa
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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']} />;
|
||||
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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']);
|
||||
}
|
||||
inputFormat = deepSet(inputFormat, 'pattern', '([\\s\\S]*)');
|
||||
inputFormat = deepSet(inputFormat, 'columns', ['line']);
|
||||
} else {
|
||||
if (typeof findColumnsFromHeader === 'boolean') {
|
||||
inputFormat = deepSet(inputFormat, 'findColumnsFromHeader', findColumnsFromHeader);
|
||||
|
||||
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,
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,48 +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
|
||||
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}
|
||||
|
@ -425,37 +410,45 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
|
|||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
)}
|
||||
{parsedQuery && columnData.DATA_TYPE === 'TIMESTAMP' && (
|
||||
<TimeMenuItems
|
||||
table={tableName}
|
||||
schema={schemaName}
|
||||
columnName={columnData.COLUMN_NAME}
|
||||
parsedQuery={parsedQuery}
|
||||
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}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{columnData.COLUMN_NAME}
|
||||
</Popover2>
|
||||
),
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => caseInsensitiveCompare(a.id, b.id)),
|
||||
)}
|
||||
{parsedQuery && columnData.DATA_TYPE === 'TIMESTAMP' && (
|
||||
<TimeMenuItems
|
||||
table={tableName}
|
||||
schema={schemaName}
|
||||
columnName={columnData.COLUMN_NAME}
|
||||
parsedQuery={parsedQuery}
|
||||
onQueryChange={onQueryChange}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue