Web console: Misc fixes and improvements (#12361)

* Misc fixes

* pad column numbers

* make shard_type filterable
This commit is contained in:
Vadim Ogievetsky 2022-04-12 22:20:28 -07:00 committed by GitHub
parent 2c79d28bb7
commit a139cd22aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 477 additions and 184 deletions

View File

@ -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
---

View File

@ -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"
}

View File

@ -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",

View File

@ -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"

View File

@ -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"

View File

@ -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']} />;

View File

@ -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)) {

View File

@ -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}>

View File

@ -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'],
});
});
});
});

View File

@ -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,

View File

@ -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];
}

View File

@ -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');
});
});
});

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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>
),
}),
),
}),
),
}),

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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,
},

View File

@ -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 {

View File

@ -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 })}
/>
)}
</>
);
}