= {
name: 'inputSource.type',
@@ -1022,7 +353,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
info: (
Druid connects to raw data through{' '}
-
+
inputSources
. You can change your selected inputSource here.
@@ -1075,7 +406,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
required: true,
info: (
<>
-
+
inputSource.baseDir
Specifies the directory to search recursively for files to be ingested.
@@ -1099,7 +430,9 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
],
info: (
<>
-
+
inputSource.filter
@@ -1588,55 +921,6 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
throw new Error(`unknown input type ${ingestionComboType}`);
}
-function nonEmptyArray(a: any) {
- return Array.isArray(a) && Boolean(a.length);
-}
-
-function issueWithInputSource(inputSource: InputSource | undefined): string | undefined {
- if (!inputSource) return 'does not exist';
- if (!inputSource.type) return 'missing a type';
- switch (inputSource.type) {
- case 'local':
- if (!inputSource.baseDir) return `must have a 'baseDir'`;
- if (!inputSource.filter) return `must have a 'filter'`;
- break;
-
- case 'http':
- if (!nonEmptyArray(inputSource.uris)) {
- return 'must have at least one uri';
- }
- break;
-
- case 'druid':
- if (!inputSource.dataSource) return `must have a 'dataSource'`;
- if (!inputSource.interval) return `must have an 'interval'`;
- break;
-
- case 'inline':
- if (!inputSource.data) return `must have 'data'`;
- break;
-
- case 's3':
- case 'azure':
- case 'google':
- if (
- !nonEmptyArray(inputSource.uris) &&
- !nonEmptyArray(inputSource.prefixes) &&
- !nonEmptyArray(inputSource.objects)
- ) {
- return 'must have at least one uri or prefix or object';
- }
- break;
-
- case 'hdfs':
- if (!inputSource.paths) {
- return 'must have paths';
- }
- break;
- }
- return;
-}
-
export function issueWithIoConfig(
ioConfig: IoConfig | undefined,
ignoreInputFormat = false,
@@ -2096,30 +1380,29 @@ export function adjustTuningConfig(tuningConfig: TuningConfig) {
const tuningConfigType = deepGet(tuningConfig, 'type');
if (tuningConfigType !== 'index_parallel') return tuningConfig;
- const partitionsSpecType = deepGet(tuningConfig, 'partitionsSpec.type');
- if (tuningConfig.forceGuaranteedRollup) {
- if (partitionsSpecType !== 'hashed' && partitionsSpecType !== 'single_dim') {
- tuningConfig = deepSet(tuningConfig, 'partitionsSpec', { type: 'hashed' });
- }
- } else {
- if (partitionsSpecType !== 'dynamic') {
- tuningConfig = deepSet(tuningConfig, 'partitionsSpec', { type: 'dynamic' });
- }
+ const partitionsSpecType = deepGet(tuningConfig, 'partitionsSpec.type') || 'dynamic';
+ if (partitionsSpecType === 'dynamic') {
+ tuningConfig = deepDelete(tuningConfig, 'forceGuaranteedRollup');
+ } else if (oneOf(partitionsSpecType, 'hashed', 'single_dim')) {
+ tuningConfig = deepSet(tuningConfig, 'forceGuaranteedRollup', true);
}
+
return tuningConfig;
}
export function invalidTuningConfig(tuningConfig: TuningConfig, intervals: any): boolean {
- if (tuningConfig.type !== 'index_parallel' || !tuningConfig.forceGuaranteedRollup) return false;
+ if (tuningConfig.type !== 'index_parallel') return false;
- if (!intervals) return true;
switch (deepGet(tuningConfig, 'partitionsSpec.type')) {
case 'hashed':
+ if (!intervals) return true;
return (
Boolean(deepGet(tuningConfig, 'partitionsSpec.targetRowsPerSegment')) &&
Boolean(deepGet(tuningConfig, 'partitionsSpec.numShards'))
);
+
case 'single_dim':
+ if (!intervals) return true;
if (!deepGet(tuningConfig, 'partitionsSpec.partitionDimension')) return true;
const hasTargetRowsPerSegment = Boolean(
deepGet(tuningConfig, 'partitionsSpec.targetRowsPerSegment'),
@@ -2141,25 +1424,12 @@ export function getPartitionRelatedTuningSpecFormFields(
switch (specType) {
case 'index_parallel':
return [
- {
- name: 'forceGuaranteedRollup',
- type: 'boolean',
- defaultValue: false,
- info: (
-
- Forces guaranteeing the perfect rollup. The perfect rollup optimizes the total size of
- generated segments and querying time while indexing time will be increased. If this is
- set to true, the index task will read the entire input data twice: one for finding the
- optimal number of partitions per time chunk and one for generating segments.
-
- ),
- },
{
name: 'partitionsSpec.type',
label: 'Partitioning type',
type: 'string',
- suggestions: (t: TuningConfig) =>
- t.forceGuaranteedRollup ? ['hashed', 'single_dim'] : ['dynamic'],
+ required: true,
+ suggestions: ['dynamic', 'hashed', 'single_dim'],
info: (
For perfect rollup, you should use either hashed
(partitioning based on
@@ -2355,17 +1625,26 @@ const TUNING_CONFIG_FORM_FIELDS: Field[] = [
info: <>Used in determining when intermediate persists to disk should occur.>,
},
{
- name: 'maxNumMergeTasks',
+ name: 'totalNumMergeTasks',
type: 'number',
defaultValue: 10,
- defined: (t: TuningConfig) => Boolean(t.type === 'index_parallel' && t.forceGuaranteedRollup),
+ min: 1,
+ defined: (t: TuningConfig) =>
+ Boolean(
+ t.type === 'index_parallel' &&
+ oneOf(deepGet(t, 'tuningConfig.partitionsSpec.type'), 'hashed', 'single_dim'),
+ ),
info: <>Number of tasks to merge partial segments after shuffle.>,
},
{
name: 'maxNumSegmentsToMerge',
type: 'number',
defaultValue: 100,
- defined: (t: TuningConfig) => Boolean(t.type === 'index_parallel' && t.forceGuaranteedRollup),
+ defined: (t: TuningConfig) =>
+ Boolean(
+ t.type === 'index_parallel' &&
+ oneOf(deepGet(t, 'tuningConfig.partitionsSpec.type'), 'hashed', 'single_dim'),
+ ),
info: (
<>
Max limit for the number of segments a single task can merge at the same time after shuffle.
@@ -2376,7 +1655,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field[] = [
name: 'resetOffsetAutomatically',
type: 'boolean',
defaultValue: false,
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: (
<>
Whether to reset the consumer offset if the next offset that it is trying to fetch is less
@@ -2388,14 +1667,14 @@ const TUNING_CONFIG_FORM_FIELDS: Field[] = [
name: 'intermediatePersistPeriod',
type: 'duration',
defaultValue: 'PT10M',
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: <>The period that determines the rate at which intermediate persists occur.>,
},
{
name: 'intermediateHandoffPeriod',
type: 'duration',
defaultValue: 'P2147483647D',
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: (
<>
How often the tasks should hand off segments. Handoff will happen either if
@@ -2429,7 +1708,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field[] = [
name: 'handoffConditionTimeout',
type: 'number',
defaultValue: 0,
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: <>Milliseconds to wait for segment handoff. 0 means to wait forever.>,
},
{
@@ -2489,7 +1768,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field[] = [
name: 'workerThreads',
type: 'number',
placeholder: 'min(10, taskCount)',
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: (
<>The number of threads that will be used by the supervisor for asynchronous operations.>
),
@@ -2498,14 +1777,14 @@ const TUNING_CONFIG_FORM_FIELDS: Field[] = [
name: 'chatThreads',
type: 'number',
placeholder: 'min(10, taskCount * replicas)',
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: <>The number of threads that will be used for communicating with indexing tasks.>,
},
{
name: 'chatRetries',
type: 'number',
defaultValue: 8,
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: (
<>
The number of times HTTP requests to indexing tasks will be retried before considering tasks
@@ -2517,14 +1796,14 @@ const TUNING_CONFIG_FORM_FIELDS: Field[] = [
name: 'httpTimeout',
type: 'duration',
defaultValue: 'PT10S',
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: <>How long to wait for a HTTP response from an indexing task.>,
},
{
name: 'shutdownTimeout',
type: 'duration',
defaultValue: 'PT80S',
- defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
+ defined: (t: TuningConfig) => oneOf(t.type, 'kafka', 'kinesis'),
info: (
<>
How long to wait for the supervisor to attempt a graceful shutdown of tasks before exiting.
@@ -2676,7 +1955,7 @@ export function updateIngestionType(
}
if (!deepGet(spec, 'spec.dataSchema.timestampSpec')) {
- newSpec = deepSet(newSpec, 'spec.dataSchema.timestampSpec', getDummyTimestampSpec());
+ newSpec = deepSet(newSpec, 'spec.dataSchema.timestampSpec', PLACEHOLDER_TIMESTAMP_SPEC);
}
if (!deepGet(spec, 'spec.dataSchema.dimensionsSpec')) {
@@ -2744,111 +2023,91 @@ function inputFormatFromType(type: string, findColumnsFromHeader?: boolean): Inp
return inputFormat;
}
-export type DruidFilter = Record;
+// ------------------------
-export interface DimensionFiltersWithRest {
- dimensionFilters: DruidFilter[];
- restFilter?: DruidFilter;
+export function guessTypeFromSample(sample: any[]): string {
+ const definedValues = sample.filter(v => v != null);
+ if (
+ definedValues.length &&
+ definedValues.every(v => !isNaN(v) && oneOf(typeof v, 'number', 'string'))
+ ) {
+ if (definedValues.every(v => v % 1 === 0)) {
+ return 'long';
+ } else {
+ return 'double';
+ }
+ } else {
+ return 'string';
+ }
}
-export function splitFilter(filter: DruidFilter | null): DimensionFiltersWithRest {
- const inputAndFilters: DruidFilter[] = filter
- ? filter.type === 'and' && Array.isArray(filter.fields)
- ? filter.fields
- : [filter]
- : EMPTY_ARRAY;
- const dimensionFilters: DruidFilter[] = inputAndFilters.filter(
- f => typeof f.dimension === 'string',
+export function getColumnTypeFromHeaderAndRows(
+ headerAndRows: HeaderAndRows,
+ column: string,
+): string {
+ return guessTypeFromSample(
+ filterMap(headerAndRows.rows, (r: any) => (r.parsed ? r.parsed[column] : undefined)),
);
- const restFilters: DruidFilter[] = inputAndFilters.filter(f => typeof f.dimension !== 'string');
-
- return {
- dimensionFilters,
- restFilter: restFilters.length
- ? restFilters.length > 1
- ? { type: 'and', filters: restFilters }
- : restFilters[0]
- : undefined,
- };
}
-export function joinFilter(
- dimensionFiltersWithRest: DimensionFiltersWithRest,
-): DruidFilter | undefined {
- const { dimensionFilters, restFilter } = dimensionFiltersWithRest;
- let newFields = dimensionFilters || EMPTY_ARRAY;
- if (restFilter && restFilter.type) newFields = newFields.concat([restFilter]);
+function getTypeHintsFromSpec(spec: IngestionSpec): Record {
+ const typeHints: Record = {};
+ const currentDimensions = deepGet(spec, 'spec.dataSchema.dimensionsSpec.dimensions') || [];
+ for (const currentDimension of currentDimensions) {
+ typeHints[getDimensionSpecName(currentDimension)] = getDimensionSpecType(currentDimension);
+ }
- if (!newFields.length) return;
- if (newFields.length === 1) return newFields[0];
- return { type: 'and', fields: newFields };
+ const currentMetrics = deepGet(spec, 'spec.dataSchema.metricsSpec') || [];
+ for (const currentMetric of currentMetrics) {
+ const singleFieldName = getMetricSpecSingleFieldName(currentMetric);
+ const metricOutputType = getMetricSpecOutputType(currentMetric);
+ if (singleFieldName && metricOutputType) {
+ typeHints[singleFieldName] = metricOutputType;
+ }
+ }
+
+ return typeHints;
}
-const FILTER_FORM_FIELDS: Field[] = [
- {
- name: 'type',
- type: 'string',
- suggestions: ['selector', 'in', 'regex', 'like', 'not'],
- },
- {
- name: 'dimension',
- type: 'string',
- defined: (df: DruidFilter) => ['selector', 'in', 'regex', 'like'].includes(df.type),
- },
- {
- name: 'value',
- type: 'string',
- defined: (df: DruidFilter) => df.type === 'selector',
- },
- {
- name: 'values',
- type: 'string-array',
- defined: (df: DruidFilter) => df.type === 'in',
- },
- {
- name: 'pattern',
- type: 'string',
- defined: (df: DruidFilter) => ['regex', 'like'].includes(df.type),
- },
+export function updateSchemaWithSample(
+ spec: IngestionSpec,
+ headerAndRows: HeaderAndRows,
+ dimensionMode: DimensionMode,
+ rollup: boolean,
+): IngestionSpec {
+ const typeHints = getTypeHintsFromSpec(spec);
- {
- name: 'field.type',
- label: 'Sub-filter type',
- type: 'string',
- suggestions: ['selector', 'in', 'regex', 'like'],
- defined: (df: DruidFilter) => df.type === 'not',
- },
- {
- name: 'field.dimension',
- label: 'Sub-filter dimension',
- type: 'string',
- defined: (df: DruidFilter) => df.type === 'not',
- },
- {
- name: 'field.value',
- label: 'Sub-filter value',
- type: 'string',
- defined: (df: DruidFilter) => df.type === 'not' && deepGet(df, 'field.type') === 'selector',
- },
- {
- name: 'field.values',
- label: 'Sub-filter values',
- type: 'string-array',
- defined: (df: DruidFilter) => df.type === 'not' && deepGet(df, 'field.type') === 'in',
- },
- {
- name: 'field.pattern',
- label: 'Sub-filter pattern',
- type: 'string',
- defined: (df: DruidFilter) =>
- df.type === 'not' && ['regex', 'like'].includes(deepGet(df, 'field.type')),
- },
-];
+ let newSpec = spec;
-export function getFilterFormFields() {
- return FILTER_FORM_FIELDS;
+ if (dimensionMode === 'auto-detect') {
+ newSpec = deepSet(newSpec, 'spec.dataSchema.dimensionsSpec.dimensions', []);
+ } else {
+ newSpec = deepDelete(newSpec, 'spec.dataSchema.dimensionsSpec.dimensionExclusions');
+
+ const dimensions = getDimensionSpecs(headerAndRows, typeHints, rollup);
+ if (dimensions) {
+ newSpec = deepSet(newSpec, 'spec.dataSchema.dimensionsSpec.dimensions', dimensions);
+ }
+ }
+
+ if (rollup) {
+ newSpec = deepSet(newSpec, 'spec.dataSchema.granularitySpec.queryGranularity', 'HOUR');
+
+ const metrics = getMetricSpecs(headerAndRows, typeHints);
+ if (metrics) {
+ newSpec = deepSet(newSpec, 'spec.dataSchema.metricsSpec', metrics);
+ }
+ } else {
+ newSpec = deepSet(newSpec, 'spec.dataSchema.granularitySpec.queryGranularity', 'NONE');
+ newSpec = deepDelete(newSpec, 'spec.dataSchema.metricsSpec');
+ }
+
+ newSpec = deepSet(newSpec, 'spec.dataSchema.granularitySpec.rollup', rollup);
+ return newSpec;
}
+// ------------------------
+
export function upgradeSpec(spec: any): any {
if (deepGet(spec, 'spec.ioConfig.firehose')) {
switch (deepGet(spec, 'spec.ioConfig.firehose.type')) {
diff --git a/web-console/src/druid-models/input-format.tsx b/web-console/src/druid-models/input-format.tsx
new file mode 100644
index 00000000000..15cb68258d4
--- /dev/null
+++ b/web-console/src/druid-models/input-format.tsx
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+import { AutoForm, ExternalLink, Field } from '../components';
+import { getLink } from '../links';
+import { oneOf } from '../utils';
+
+import { FlattenSpec } from './flatten-spec';
+
+export interface InputFormat {
+ type: string;
+ findColumnsFromHeader?: boolean;
+ skipHeaderRows?: number;
+ columns?: string[];
+ listDelimiter?: string;
+ pattern?: string;
+ function?: string;
+ flattenSpec?: FlattenSpec;
+ keepNullColumns?: boolean;
+}
+
+export const INPUT_FORMAT_FIELDS: Field[] = [
+ {
+ name: 'type',
+ label: 'Input format',
+ type: 'string',
+ suggestions: ['json', 'csv', 'tsv', 'regex', 'parquet', 'orc', 'avro_ocf'],
+ required: true,
+ info: (
+ <>
+ The parser used to parse the data.
+
+ For more information see{' '}
+
+ the documentation
+
+ .
+
+ >
+ ),
+ },
+ {
+ name: 'pattern',
+ type: 'string',
+ required: true,
+ defined: (p: InputFormat) => p.type === 'regex',
+ },
+ {
+ name: 'function',
+ type: 'string',
+ required: true,
+ defined: (p: InputFormat) => p.type === 'javascript',
+ },
+ {
+ name: 'findColumnsFromHeader',
+ type: 'boolean',
+ required: true,
+ defined: (p: InputFormat) => oneOf(p.type, 'csv', 'tsv'),
+ },
+ {
+ name: 'skipHeaderRows',
+ type: 'number',
+ defaultValue: 0,
+ defined: (p: InputFormat) => oneOf(p.type, 'csv', 'tsv'),
+ min: 0,
+ info: (
+ <>
+ If both skipHeaderRows and hasHeaderRow options are set, skipHeaderRows is first applied.
+ For example, if you set skipHeaderRows to 2 and hasHeaderRow to true, Druid will skip the
+ first two lines and then extract column information from the third line.
+ >
+ ),
+ },
+ {
+ name: 'columns',
+ type: 'string-array',
+ required: true,
+ defined: (p: InputFormat) =>
+ (oneOf(p.type, 'csv', 'tsv') && !p.findColumnsFromHeader) || p.type === 'regex',
+ },
+ {
+ name: 'delimiter',
+ type: 'string',
+ defaultValue: '\t',
+ defined: (p: InputFormat) => p.type === 'tsv',
+ info: <>A custom delimiter for data values.>,
+ },
+ {
+ name: 'listDelimiter',
+ type: 'string',
+ defined: (p: InputFormat) => oneOf(p.type, 'csv', 'tsv', 'regex'),
+ info: <>A custom delimiter for multi-value dimensions.>,
+ },
+ {
+ name: 'binaryAsString',
+ type: 'boolean',
+ defaultValue: false,
+ defined: (p: InputFormat) => oneOf(p.type, 'parquet', 'orc', 'avro_ocf'),
+ info: (
+ <>
+ Specifies if the binary column which is not logically marked as a string should be treated
+ as a UTF-8 encoded string.
+ >
+ ),
+ },
+];
+
+export function issueWithInputFormat(inputFormat: InputFormat | undefined): string | undefined {
+ return AutoForm.issueWithModel(inputFormat, INPUT_FORMAT_FIELDS);
+}
+
+export function inputFormatCanFlatten(inputFormat: InputFormat): boolean {
+ return oneOf(inputFormat.type, 'json', 'parquet', 'orc', 'avro_ocf');
+}
diff --git a/web-console/src/druid-models/input-source.tsx b/web-console/src/druid-models/input-source.tsx
new file mode 100644
index 00000000000..8c4302e28b2
--- /dev/null
+++ b/web-console/src/druid-models/input-source.tsx
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function nonEmptyArray(a: any) {
+ return Array.isArray(a) && Boolean(a.length);
+}
+
+export interface InputSource {
+ type: string;
+ baseDir?: string;
+ filter?: any;
+ uris?: string[];
+ prefixes?: string[];
+ objects?: { bucket: string; path: string }[];
+ fetchTimeout?: number;
+
+ // druid
+ dataSource?: string;
+ interval?: string;
+ dimensions?: string[];
+ metrics?: string[];
+ maxInputSegmentBytesPerTask?: number;
+
+ // inline
+ data?: string;
+
+ // hdfs
+ paths?: string;
+}
+
+export function issueWithInputSource(inputSource: InputSource | undefined): string | undefined {
+ if (!inputSource) return 'does not exist';
+ if (!inputSource.type) return 'missing a type';
+ switch (inputSource.type) {
+ case 'local':
+ if (!inputSource.baseDir) return `must have a 'baseDir'`;
+ if (!inputSource.filter) return `must have a 'filter'`;
+ break;
+
+ case 'http':
+ if (!nonEmptyArray(inputSource.uris)) {
+ return 'must have at least one uri';
+ }
+ break;
+
+ case 'druid':
+ if (!inputSource.dataSource) return `must have a 'dataSource'`;
+ if (!inputSource.interval) return `must have an 'interval'`;
+ break;
+
+ case 'inline':
+ if (!inputSource.data) return `must have 'data'`;
+ break;
+
+ case 's3':
+ case 'azure':
+ case 'google':
+ if (
+ !nonEmptyArray(inputSource.uris) &&
+ !nonEmptyArray(inputSource.prefixes) &&
+ !nonEmptyArray(inputSource.objects)
+ ) {
+ return 'must have at least one uri or prefix or object';
+ }
+ break;
+
+ case 'hdfs':
+ if (!inputSource.paths) {
+ return 'must have paths';
+ }
+ break;
+ }
+ return;
+}
diff --git a/web-console/src/druid-models/lookup-spec.spec.ts b/web-console/src/druid-models/lookup-spec.spec.ts
new file mode 100644
index 00000000000..0978f7cf97c
--- /dev/null
+++ b/web-console/src/druid-models/lookup-spec.spec.ts
@@ -0,0 +1,453 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { isLookupInvalid } from './lookup-spec';
+
+describe('lookup-spec', () => {
+ describe('Type Map Should be disabled', () => {
+ it('Missing LookupName', () => {
+ expect(isLookupInvalid(undefined, 'v1', '__default', { type: '' })).toBe(true);
+ });
+
+ it('Empty version', () => {
+ expect(isLookupInvalid('lookup', '', '__default', { type: '' })).toBe(true);
+ });
+
+ it('Missing version', () => {
+ expect(isLookupInvalid('lookup', undefined, '__default', { type: '' })).toBe(true);
+ });
+
+ it('Empty tier', () => {
+ expect(isLookupInvalid('lookup', 'v1', '', { type: '' })).toBe(true);
+ });
+
+ it('Missing tier', () => {
+ expect(isLookupInvalid('lookup', 'v1', undefined, { type: '' })).toBe(true);
+ });
+
+ it('Missing spec', () => {
+ expect(isLookupInvalid('lookup', 'v1', '__default', {})).toBe(true);
+ });
+
+ it('Type undefined', () => {
+ expect(isLookupInvalid('lookup', 'v1', '__default', { type: undefined })).toBe(true);
+ });
+
+ it('Lookup of type map with no map', () => {
+ expect(isLookupInvalid('lookup', 'v1', '__default', { type: 'map' })).toBe(true);
+ });
+
+ it('Lookup of type cachedNamespace with no extractionNamespace', () => {
+ expect(isLookupInvalid('lookup', 'v1', '__default', { type: 'cachedNamespace' })).toBe(true);
+ });
+
+ it('Lookup of type cachedNamespace with extractionNamespace type uri, format csv, no namespaceParseSpec', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Lookup of type cachedNamespace with extractionNamespace type uri, format csv, no columns and no hasHeaderRow', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'csv',
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Lookup of type cachedNamespace with extractionNamespace type uri, format tsv, no columns', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'tsv',
+ skipHeaderRows: 0,
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Lookup of type cachedNamespace with extractionNamespace type customJson, format tsv, no keyFieldName', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'customJson',
+ valueFieldName: 'value',
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Lookup of type cachedNamespace with extractionNamespace type customJson, format customJson, no valueFieldName', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'customJson',
+ keyFieldName: 'key',
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+ });
+
+ describe('Type cachedNamespace should be disabled', () => {
+ it('No extractionNamespace', () => {
+ expect(isLookupInvalid('lookup', 'v1', '__default', { type: 'cachedNamespace' })).toBe(true);
+ });
+
+ describe('ExtractionNamespace type URI', () => {
+ it('Format csv, no namespaceParseSpec', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Format csv, no columns and skipHeaderRows', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'csv',
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Format tsv, no columns', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'tsv',
+ skipHeaderRows: 0,
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Format tsv, no keyFieldName', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'customJson',
+ valueFieldName: 'value',
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('Format customJson, no valueFieldName', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'customJson',
+ keyFieldName: 'key',
+ },
+ pollPeriod: 'PT5M',
+ },
+ }),
+ ).toBe(true);
+ });
+ });
+
+ describe('ExtractionNamespace type JDBC', () => {
+ it('No namespace', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'jdbc',
+ namespace: undefined,
+ connectorConfig: {
+ createTables: true,
+ connectURI: 'jdbc:mysql://localhost:3306/druid',
+ user: 'druid',
+ password: 'diurd',
+ },
+ table: 'some_lookup_table',
+ keyColumn: 'the_old_dim_value',
+ valueColumn: 'the_new_dim_value',
+ tsColumn: 'timestamp_column',
+ pollPeriod: 600000,
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('No connectorConfig', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'jdbc',
+ namespace: 'some_lookup',
+ connectorConfig: undefined,
+ table: 'some_lookup_table',
+ keyColumn: 'the_old_dim_value',
+ valueColumn: 'the_new_dim_value',
+ tsColumn: 'timestamp_column',
+ pollPeriod: 600000,
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('No table', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'jdbc',
+ namespace: 'some_lookup',
+ connectorConfig: {
+ createTables: true,
+ connectURI: 'jdbc:mysql://localhost:3306/druid',
+ user: 'druid',
+ password: 'diurd',
+ },
+ table: undefined,
+ keyColumn: 'the_old_dim_value',
+ valueColumn: 'the_new_dim_value',
+ tsColumn: 'timestamp_column',
+ pollPeriod: 600000,
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('No keyColumn', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'jdbc',
+ namespace: 'some_lookup',
+ connectorConfig: {
+ createTables: true,
+ connectURI: 'jdbc:mysql://localhost:3306/druid',
+ user: 'druid',
+ password: 'diurd',
+ },
+ table: 'some_lookup_table',
+ keyColumn: undefined,
+ valueColumn: 'the_new_dim_value',
+ tsColumn: 'timestamp_column',
+ pollPeriod: 600000,
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('No keyColumn', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'jdbc',
+ namespace: 'some_lookup',
+ connectorConfig: {
+ createTables: true,
+ connectURI: 'jdbc:mysql://localhost:3306/druid',
+ user: 'druid',
+ password: 'diurd',
+ },
+ table: 'some_lookup_table',
+ keyColumn: 'the_old_dim_value',
+ valueColumn: undefined,
+ tsColumn: 'timestamp_column',
+ pollPeriod: 600000,
+ },
+ }),
+ ).toBe(true);
+ });
+ });
+ });
+
+ describe('Type Map Should be enabled', () => {
+ it('Has type and has Map', () => {
+ expect(isLookupInvalid('lookup', 'v1', '__default', { type: 'map', map: { a: 'b' } })).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('Type cachedNamespace Should be enabled', () => {
+ describe('ExtractionNamespace type URI', () => {
+ it('Format csv with columns', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'csv',
+ columns: ['key', 'value'],
+ },
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it('Format csv with hasHeaderRow', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'csv',
+ hasHeaderRow: true,
+ },
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it('Format tsv, only columns', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'tsv',
+ columns: ['key', 'value'],
+ },
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it('Format tsv, keyFieldName and valueFieldName', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'uri',
+ uriPrefix: 's3://bucket/some/key/prefix/',
+ fileRegex: 'renames-[0-9]*\\.gz',
+ namespaceParseSpec: {
+ format: 'customJson',
+ valueFieldName: 'value',
+ keyFieldName: 'value',
+ },
+ },
+ }),
+ ).toBe(false);
+ });
+ });
+
+ describe('ExtractionNamespace type JDBC', () => {
+ it('No namespace', () => {
+ expect(
+ isLookupInvalid('lookup', 'v1', '__default', {
+ type: 'cachedNamespace',
+ extractionNamespace: {
+ type: 'jdbc',
+ namespace: 'lookup',
+ connectorConfig: {
+ createTables: true,
+ connectURI: 'jdbc:mysql://localhost:3306/druid',
+ user: 'druid',
+ password: 'diurd',
+ },
+ table: 'some_lookup_table',
+ keyColumn: 'the_old_dim_value',
+ valueColumn: 'the_new_dim_value',
+ },
+ }),
+ ).toBe(false);
+ });
+ });
+ });
+});
diff --git a/web-console/src/druid-models/lookup-spec.tsx b/web-console/src/druid-models/lookup-spec.tsx
new file mode 100644
index 00000000000..c9e0e5a13bd
--- /dev/null
+++ b/web-console/src/druid-models/lookup-spec.tsx
@@ -0,0 +1,456 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Code } from '@blueprintjs/core';
+import React from 'react';
+
+import { AutoForm, Field } from '../components';
+import { deepGet, deepSet, oneOf } from '../utils';
+
+export interface ExtractionNamespaceSpec {
+ type?: string;
+ uri?: string;
+ uriPrefix?: string;
+ fileRegex?: string;
+ namespaceParseSpec?: NamespaceParseSpec;
+ namespace?: string;
+ connectorConfig?: {
+ createTables: boolean;
+ connectURI: string;
+ user: string;
+ password: string;
+ };
+ table?: string;
+ keyColumn?: string;
+ valueColumn?: string;
+ filter?: any;
+ tsColumn?: string;
+ pollPeriod?: number | string;
+}
+
+export interface NamespaceParseSpec {
+ format: string;
+ columns?: string[];
+ keyColumn?: string;
+ valueColumn?: string;
+ hasHeaderRow?: boolean;
+ skipHeaderRows?: number;
+ keyFieldName?: string;
+ valueFieldName?: string;
+ delimiter?: string;
+ listDelimiter?: string;
+}
+
+export interface LookupSpec {
+ type?: string;
+ map?: Record;
+ extractionNamespace?: ExtractionNamespaceSpec;
+ firstCacheTimeout?: number;
+ injective?: boolean;
+}
+
+export const LOOKUP_FIELDS: Field[] = [
+ {
+ name: 'type',
+ type: 'string',
+ suggestions: ['map', 'cachedNamespace'],
+ required: true,
+ adjustment: (model: LookupSpec) => {
+ if (model.type === 'map' && !model.map) {
+ return deepSet(model, 'map', {});
+ }
+ if (model.type === 'cachedNamespace' && !deepGet(model, 'extractionNamespace.type')) {
+ return deepSet(model, 'extractionNamespace', { type: 'uri' });
+ }
+ return model;
+ },
+ },
+
+ // map lookups are simple
+ {
+ name: 'map',
+ type: 'json',
+ height: '60vh',
+ defined: (model: LookupSpec) => model.type === 'map',
+ required: true,
+ issueWithValue: value => {
+ if (!value) return 'map must be defined';
+ if (typeof value !== 'object') return `map must be an object`;
+ for (const k in value) {
+ const typeValue = typeof value[k];
+ if (typeValue !== 'string' && typeValue !== 'number') {
+ return `map key '${k}' is of the wrong type '${typeValue}'`;
+ }
+ }
+ return;
+ },
+ },
+
+ // cachedNamespace lookups have more options
+ {
+ name: 'extractionNamespace.type',
+ type: 'string',
+ label: 'Globally cached lookup type',
+ placeholder: 'uri',
+ suggestions: ['uri', 'jdbc'],
+ defined: (model: LookupSpec) => model.type === 'cachedNamespace',
+ required: true,
+ },
+ {
+ name: 'extractionNamespace.uriPrefix',
+ type: 'string',
+ label: 'URI prefix',
+ placeholder: 's3://bucket/some/key/prefix/',
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ !deepGet(model, 'extractionNamespace.uri'),
+ required: (model: LookupSpec) =>
+ !deepGet(model, 'extractionNamespace.uriPrefix') &&
+ !deepGet(model, 'extractionNamespace.uri'),
+ info:
+ 'A URI which specifies a directory (or other searchable resource) in which to search for files',
+ },
+ {
+ name: 'extractionNamespace.uri',
+ type: 'string',
+ label: 'URI (deprecated)',
+ placeholder: 's3://bucket/some/key/prefix/lookups-01.gz',
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ !deepGet(model, 'extractionNamespace.uriPrefix'),
+ required: (model: LookupSpec) =>
+ !deepGet(model, 'extractionNamespace.uriPrefix') &&
+ !deepGet(model, 'extractionNamespace.uri'),
+ info: (
+ <>
+ URI for the file of interest, specified as a file, hdfs, or s3 path
+ The URI prefix option is strictly better than URI and should be used instead
+ >
+ ),
+ },
+ {
+ name: 'extractionNamespace.fileRegex',
+ type: 'string',
+ label: 'File regex',
+ defaultValue: '.*',
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ Boolean(deepGet(model, 'extractionNamespace.uriPrefix')),
+ info: 'Optional regex for matching the file name under uriPrefix.',
+ },
+
+ // namespaceParseSpec
+ {
+ name: 'extractionNamespace.namespaceParseSpec.format',
+ type: 'string',
+ label: 'Parse format',
+ suggestions: ['csv', 'tsv', 'simpleJson', 'customJson'],
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'uri',
+ required: true,
+ info: (
+ <>
+ The format of the data in the lookup files.
+
+ The simpleJson
lookupParseSpec does not take any parameters. It is simply a
+ line delimited JSON file where the field is the key, and the field's value is the value.
+
+ >
+ ),
+ },
+
+ // CSV + TSV
+ {
+ name: 'extractionNamespace.namespaceParseSpec.skipHeaderRows',
+ type: 'number',
+ label: 'Skip header rows',
+ defaultValue: 0,
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ oneOf(deepGet(model, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
+ info: `Number of header rows to be skipped. The default number of header rows to be skipped is 0.`,
+ },
+ {
+ name: 'extractionNamespace.namespaceParseSpec.hasHeaderRow',
+ type: 'boolean',
+ label: 'Has header row',
+ defaultValue: false,
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ oneOf(deepGet(model, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
+ info: `A flag to indicate that column information can be extracted from the input files' header row`,
+ },
+ {
+ name: 'extractionNamespace.namespaceParseSpec.columns',
+ type: 'string-array',
+ label: 'Columns',
+ placeholder: `["key", "value"]`,
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ oneOf(deepGet(model, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
+ required: (model: LookupSpec) =>
+ !deepGet(model, 'extractionNamespace.namespaceParseSpec.hasHeaderRow'),
+ info: 'The list of columns in the csv file',
+ },
+ {
+ name: 'extractionNamespace.namespaceParseSpec.keyColumn',
+ type: 'string',
+ label: 'Key column',
+ placeholder: '(optional - defaults to the first column)',
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ oneOf(deepGet(model, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
+ info: 'The name of the column containing the key',
+ },
+ {
+ name: 'extractionNamespace.namespaceParseSpec.valueColumn',
+ type: 'string',
+ label: 'Value column',
+ placeholder: '(optional - defaults to the second column)',
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ oneOf(deepGet(model, 'extractionNamespace.namespaceParseSpec.format'), 'csv', 'tsv'),
+ info: 'The name of the column containing the value',
+ },
+
+ // TSV only
+ {
+ name: 'extractionNamespace.namespaceParseSpec.delimiter',
+ type: 'string',
+ label: 'Delimiter',
+ placeholder: `(optional)`,
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ deepGet(model, 'extractionNamespace.namespaceParseSpec.format') === 'tsv',
+ },
+ {
+ name: 'extractionNamespace.namespaceParseSpec.listDelimiter',
+ type: 'string',
+ label: 'List delimiter',
+ placeholder: `(optional)`,
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ deepGet(model, 'extractionNamespace.namespaceParseSpec.format') === 'tsv',
+ },
+
+ // Custom JSON
+ {
+ name: 'extractionNamespace.namespaceParseSpec.keyFieldName',
+ type: 'string',
+ label: 'Key field name',
+ placeholder: `key`,
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ deepGet(model, 'extractionNamespace.namespaceParseSpec.format') === 'customJson',
+ required: true,
+ },
+ {
+ name: 'extractionNamespace.namespaceParseSpec.valueFieldName',
+ type: 'string',
+ label: 'Value field name',
+ placeholder: `value`,
+ defined: (model: LookupSpec) =>
+ deepGet(model, 'extractionNamespace.type') === 'uri' &&
+ deepGet(model, 'extractionNamespace.namespaceParseSpec.format') === 'customJson',
+ required: true,
+ },
+ {
+ name: 'extractionNamespace.pollPeriod',
+ type: 'string',
+ label: 'Poll period',
+ defaultValue: '0',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'uri',
+ info: `Period between polling for updates`,
+ },
+
+ // JDBC stuff
+ {
+ name: 'extractionNamespace.namespace',
+ type: 'string',
+ label: 'Namespace',
+ placeholder: 'some_lookup',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ required: true,
+ info: (
+ <>
+ The namespace value in the SQL query:
+
+ SELECT keyColumn, valueColumn, tsColumn? FROM namespace .table WHERE
+ filter
+
+ >
+ ),
+ },
+ {
+ name: 'extractionNamespace.connectorConfig.connectURI',
+ type: 'string',
+ label: 'Connect URI',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ required: true,
+ info: 'Defines the connectURI value on the The connector config to used',
+ },
+ {
+ name: 'extractionNamespace.connectorConfig.user',
+ type: 'string',
+ label: 'User',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ info: 'Defines the user to be used by the connector config',
+ },
+ {
+ name: 'extractionNamespace.connectorConfig.password',
+ type: 'string',
+ label: 'Password',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ info: 'Defines the password to be used by the connector config',
+ },
+ {
+ name: 'extractionNamespace.connectorConfig.createTables',
+ type: 'boolean',
+ label: 'Create tables',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ info: 'Should tables be created',
+ },
+ {
+ name: 'extractionNamespace.table',
+ type: 'string',
+ label: 'Table',
+ placeholder: 'some_lookup_table',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ required: true,
+ info: (
+ <>
+
+ The table which contains the key value pairs. This will become the table value in the SQL
+ query:
+
+
+ SELECT keyColumn, valueColumn, tsColumn? FROM namespace.table WHERE
+ filter
+
+ >
+ ),
+ },
+ {
+ name: 'extractionNamespace.keyColumn',
+ type: 'string',
+ label: 'Key column',
+ placeholder: 'my_key_value',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ required: true,
+ info: (
+ <>
+
+ The column in the table which contains the keys. This will become the keyColumn value in
+ the SQL query:
+
+
+ SELECT keyColumn , valueColumn, tsColumn? FROM namespace.table WHERE
+ filter
+
+ >
+ ),
+ },
+ {
+ name: 'extractionNamespace.valueColumn',
+ type: 'string',
+ label: 'Value column',
+ placeholder: 'my_column_value',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ required: true,
+ info: (
+ <>
+
+ The column in table which contains the values. This will become the valueColumn value in
+ the SQL query:
+
+
+ SELECT keyColumn, valueColumn , tsColumn? FROM namespace.table WHERE
+ filter
+
+ >
+ ),
+ },
+ {
+ name: 'extractionNamespace.filter',
+ type: 'string',
+ label: 'Filter',
+ placeholder: '(optional)',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ info: (
+ <>
+
+ The filter to be used when selecting lookups, this is used to create a where clause on
+ lookup population. This will become the expression filter in the SQL query:
+
+
+ SELECT keyColumn, valueColumn, tsColumn? FROM namespace.table WHERE{' '}
+ filter
+
+ >
+ ),
+ },
+ {
+ name: 'extractionNamespace.tsColumn',
+ type: 'string',
+ label: 'Timestamp column',
+ placeholder: '(optional)',
+ defined: (model: LookupSpec) => deepGet(model, 'extractionNamespace.type') === 'jdbc',
+ info: (
+ <>
+
+ The column in table which contains when the key was updated. This will become the Value in
+ the SQL query:
+
+
+ SELECT keyColumn, valueColumn, tsColumn ? FROM namespace.table WHERE
+ filter
+
+ >
+ ),
+ },
+
+ // Extra cachedNamespace things
+ {
+ name: 'firstCacheTimeout',
+ type: 'number',
+ label: 'First cache timeout',
+ defaultValue: 0,
+ defined: (model: LookupSpec) => model.type === 'cachedNamespace',
+ info: `How long to wait (in ms) for the first run of the cache to populate. 0 indicates to not wait`,
+ },
+ {
+ name: 'injective',
+ type: 'boolean',
+ defaultValue: false,
+ defined: (model: LookupSpec) => model.type === 'cachedNamespace',
+ info: `If the underlying map is injective (keys and values are unique) then optimizations can occur internally by setting this to true`,
+ },
+];
+
+export function isLookupInvalid(
+ lookupName: string | undefined,
+ lookupVersion: string | undefined,
+ lookupTier: string | undefined,
+ lookupSpec: LookupSpec | undefined,
+) {
+ return (
+ !lookupName ||
+ !lookupVersion ||
+ !lookupTier ||
+ Boolean(AutoForm.issueWithModel(lookupSpec, LOOKUP_FIELDS))
+ );
+}
diff --git a/web-console/src/druid-models/metric-spec.spec.ts b/web-console/src/druid-models/metric-spec.spec.ts
new file mode 100644
index 00000000000..25b3f1572e9
--- /dev/null
+++ b/web-console/src/druid-models/metric-spec.spec.ts
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { getMetricSpecs } from './metric-spec';
+
+describe('metric-spec', () => {
+ it('getMetricSecs', () => {
+ expect(getMetricSpecs({ header: ['header'], rows: [] }, {})).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "name": "count",
+ "type": "count",
+ },
+ ]
+ `);
+ });
+});
diff --git a/web-console/src/druid-models/metric-spec.tsx b/web-console/src/druid-models/metric-spec.tsx
new file mode 100644
index 00000000000..fd1282fe770
--- /dev/null
+++ b/web-console/src/druid-models/metric-spec.tsx
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Code } from '@blueprintjs/core';
+import React from 'react';
+
+import { ExternalLink, Field } from '../components';
+import { getLink } from '../links';
+import { filterMap, oneOf } from '../utils';
+import { HeaderAndRows } from '../utils/sampler';
+
+import { getColumnTypeFromHeaderAndRows } from './ingestion-spec';
+
+export interface MetricSpec {
+ type: string;
+ name?: string;
+ fieldName?: string;
+ maxStringBytes?: number;
+ filterNullValues?: boolean;
+ fieldNames?: string[];
+ fnAggregate?: string;
+ fnCombine?: string;
+ fnReset?: string;
+ fields?: string[];
+ byRow?: boolean;
+ round?: boolean;
+ isInputHyperUnique?: boolean;
+ filter?: any;
+ aggregator?: MetricSpec;
+}
+
+export const METRIC_SPEC_FIELDS: Field[] = [
+ {
+ name: 'name',
+ type: 'string',
+ info: <>The metric name as it will appear in Druid.>,
+ },
+ {
+ name: 'type',
+ type: 'string',
+ suggestions: [
+ 'count',
+ {
+ group: 'sum',
+ suggestions: ['longSum', 'doubleSum', 'floatSum'],
+ },
+ {
+ group: 'min',
+ suggestions: ['longMin', 'doubleMin', 'floatMin'],
+ },
+ {
+ group: 'max',
+ suggestions: ['longMax', 'doubleMax', 'floatMax'],
+ },
+ {
+ group: 'first',
+ suggestions: ['longFirst', 'doubleFirst', 'floatFirst'],
+ },
+ {
+ group: 'last',
+ suggestions: ['longLast', 'doubleLast', 'floatLast'],
+ },
+ 'thetaSketch',
+ {
+ group: 'HLLSketch',
+ suggestions: ['HLLSketchBuild', 'HLLSketchMerge'],
+ },
+ 'quantilesDoublesSketch',
+ 'momentSketch',
+ 'fixedBucketsHistogram',
+ 'hyperUnique',
+ 'filtered',
+ ],
+ info: <>The aggregation function to apply.>,
+ },
+ {
+ name: 'fieldName',
+ type: 'string',
+ defined: m => m.type !== 'filtered',
+ info: <>The column name for the aggregator to operate on.>,
+ },
+ {
+ name: 'maxStringBytes',
+ type: 'number',
+ defaultValue: 1024,
+ defined: m => {
+ return oneOf(m.type, 'stringFirst', 'stringLast');
+ },
+ },
+ {
+ name: 'filterNullValues',
+ type: 'boolean',
+ defaultValue: false,
+ defined: m => {
+ return oneOf(m.type, 'stringFirst', 'stringLast');
+ },
+ },
+ // filtered
+ {
+ name: 'filter',
+ type: 'json',
+ defined: m => m.type === 'filtered',
+ },
+ {
+ name: 'aggregator',
+ type: 'json',
+ defined: m => m.type === 'filtered',
+ },
+ // thetaSketch
+ {
+ name: 'size',
+ type: 'number',
+ defined: m => m.type === 'thetaSketch',
+ defaultValue: 16384,
+ info: (
+ <>
+
+ Must be a power of 2. Internally, size refers to the maximum number of entries sketch
+ object will retain. Higher size means higher accuracy but more space to store sketches.
+ Note that after you index with a particular size, druid will persist sketch in segments
+ and you will use size greater or equal to that at query time.
+
+
+ See the{' '}
+
+ DataSketches site
+ {' '}
+ for details.
+
+ In general, We recommend just sticking to default size.
+ >
+ ),
+ },
+ {
+ name: 'isInputThetaSketch',
+ type: 'boolean',
+ defined: m => m.type === 'thetaSketch',
+ defaultValue: false,
+ info: (
+ <>
+ This should only be used at indexing time if your input data contains theta sketch objects.
+ This would be the case if you use datasketches library outside of Druid, say with Pig/Hive,
+ to produce the data that you are ingesting into Druid
+ >
+ ),
+ },
+ // HLLSketchBuild & HLLSketchMerge
+ {
+ name: 'lgK',
+ type: 'number',
+ defined: m => oneOf(m.type, 'HLLSketchBuild', 'HLLSketchMerge'),
+ defaultValue: 12,
+ info: (
+ <>
+
+ log2 of K that is the number of buckets in the sketch, parameter that controls the size
+ and the accuracy.
+
+ Must be between 4 to 21 inclusively.
+ >
+ ),
+ },
+ {
+ name: 'tgtHllType',
+ type: 'string',
+ defined: m => oneOf(m.type, 'HLLSketchBuild', 'HLLSketchMerge'),
+ defaultValue: 'HLL_4',
+ suggestions: ['HLL_4', 'HLL_6', 'HLL_8'],
+ info: (
+ <>
+ The type of the target HLL sketch. Must be HLL_4
, HLL_6
, or{' '}
+ HLL_8
.
+ >
+ ),
+ },
+ // quantilesDoublesSketch
+ {
+ name: 'k',
+ type: 'number',
+ defined: m => m.type === 'quantilesDoublesSketch',
+ defaultValue: 128,
+ info: (
+ <>
+
+ Parameter that determines the accuracy and size of the sketch. Higher k means higher
+ accuracy but more space to store sketches.
+
+
+ Must be a power of 2 from 2 to 32768. See the{' '}
+
+ Quantiles Accuracy
+ {' '}
+ for details.
+
+ >
+ ),
+ },
+ // momentSketch
+ {
+ name: 'k',
+ type: 'number',
+ defined: m => m.type === 'momentSketch',
+ required: true,
+ info: (
+ <>
+ Parameter that determines the accuracy and size of the sketch. Higher k means higher
+ accuracy but more space to store sketches. Usable range is generally [3,15]
+ >
+ ),
+ },
+ {
+ name: 'compress',
+ type: 'boolean',
+ defined: m => m.type === 'momentSketch',
+ defaultValue: true,
+ info: (
+ <>
+ Flag for whether the aggregator compresses numeric values using arcsinh. Can improve
+ robustness to skewed and long-tailed distributions, but reduces accuracy slightly on more
+ uniform distributions.
+ >
+ ),
+ },
+ // fixedBucketsHistogram
+ {
+ name: 'lowerLimit',
+ type: 'number',
+ defined: m => m.type === 'fixedBucketsHistogram',
+ required: true,
+ info: <>Lower limit of the histogram.>,
+ },
+ {
+ name: 'upperLimit',
+ type: 'number',
+ defined: m => m.type === 'fixedBucketsHistogram',
+ required: true,
+ info: <>Upper limit of the histogram.>,
+ },
+ {
+ name: 'numBuckets',
+ type: 'number',
+ defined: m => m.type === 'fixedBucketsHistogram',
+ defaultValue: 10,
+ required: true,
+ info: (
+ <>
+ Number of buckets for the histogram. The range [lowerLimit, upperLimit]
will be
+ divided into numBuckets
intervals of equal size.
+ >
+ ),
+ },
+ {
+ name: 'outlierHandlingMode',
+ type: 'string',
+ defined: m => m.type === 'fixedBucketsHistogram',
+ required: true,
+ suggestions: ['ignore', 'overflow', 'clip'],
+ info: (
+ <>
+
+ Specifies how values outside of [lowerLimit, upperLimit]
will be handled.
+
+
+ Supported modes are ignore
, overflow
, and clip
. See
+
+ outlier handling modes
+ {' '}
+ for more details.
+
+ >
+ ),
+ },
+ // hyperUnique
+ {
+ name: 'isInputHyperUnique',
+ type: 'boolean',
+ defined: m => m.type === 'hyperUnique',
+ defaultValue: false,
+ info: (
+ <>
+ This can be set to true to index precomputed HLL (Base64 encoded output from druid-hll is
+ expected).
+ >
+ ),
+ },
+];
+
+export function getMetricSpecName(metricSpec: MetricSpec): string {
+ return (
+ metricSpec.name || (metricSpec.aggregator ? getMetricSpecName(metricSpec.aggregator) : '?')
+ );
+}
+
+export function getMetricSpecSingleFieldName(metricSpec: MetricSpec): string | undefined {
+ return (
+ metricSpec.fieldName ||
+ (metricSpec.aggregator ? getMetricSpecSingleFieldName(metricSpec.aggregator) : undefined)
+ );
+}
+
+export function getMetricSpecOutputType(metricSpec: MetricSpec): string | undefined {
+ if (metricSpec.aggregator) return getMetricSpecOutputType(metricSpec.aggregator);
+ const m = String(metricSpec.type).match(/^(long|float|double)/);
+ if (!m) return;
+ return m[1];
+}
+
+export function getMetricSpecs(
+ headerAndRows: HeaderAndRows,
+ typeHints: Record,
+): MetricSpec[] {
+ return [{ name: 'count', type: 'count' }].concat(
+ filterMap(headerAndRows.header, h => {
+ if (h === '__time') return;
+ const type = typeHints[h] || getColumnTypeFromHeaderAndRows(headerAndRows, h);
+ switch (type) {
+ case 'double':
+ return { name: `sum_${h}`, type: 'doubleSum', fieldName: h };
+ case 'float':
+ return { name: `sum_${h}`, type: 'floatSum', fieldName: h };
+ case 'long':
+ return { name: `sum_${h}`, type: 'longSum', fieldName: h };
+ default:
+ return;
+ }
+ }),
+ );
+}
diff --git a/web-console/src/utils/druid-time.spec.ts b/web-console/src/druid-models/time.spec.ts
similarity index 95%
rename from web-console/src/utils/druid-time.spec.ts
rename to web-console/src/druid-models/time.spec.ts
index 6ebb4bceeb0..5670640f707 100644
--- a/web-console/src/utils/druid-time.spec.ts
+++ b/web-console/src/druid-models/time.spec.ts
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { timeFormatMatches } from './druid-time';
+import { timeFormatMatches } from './time';
describe('timeFormatMatches', () => {
it('works for auto', () => {
diff --git a/web-console/src/utils/druid-time.ts b/web-console/src/druid-models/time.ts
similarity index 98%
rename from web-console/src/utils/druid-time.ts
rename to web-console/src/druid-models/time.ts
index 3dc6fe93554..c20d2cbbc8e 100644
--- a/web-console/src/utils/druid-time.ts
+++ b/web-console/src/druid-models/time.ts
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import { jodaFormatToRegExp } from './joda-to-regexp';
+import { jodaFormatToRegExp } from '../utils/joda-to-regexp';
export const NUMERIC_TIME_FORMATS: string[] = ['posix', 'millis', 'micro', 'nano'];
export const BASIC_TIME_FORMATS: string[] = ['auto', 'iso'].concat(NUMERIC_TIME_FORMATS);
diff --git a/web-console/src/druid-models/timestamp-spec.tsx b/web-console/src/druid-models/timestamp-spec.tsx
new file mode 100644
index 00000000000..8e17d20dbbc
--- /dev/null
+++ b/web-console/src/druid-models/timestamp-spec.tsx
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+import { ExternalLink, Field } from '../components';
+import { deepGet, EMPTY_ARRAY, EMPTY_OBJECT } from '../utils';
+
+import { IngestionSpec } from './ingestion-spec';
+import {
+ BASIC_TIME_FORMATS,
+ DATE_ONLY_TIME_FORMATS,
+ DATETIME_TIME_FORMATS,
+ OTHER_TIME_FORMATS,
+} from './time';
+import { Transform } from './transform-spec';
+
+const NO_SUCH_COLUMN = '!!!_no_such_column_!!!';
+
+export const PLACEHOLDER_TIMESTAMP_SPEC: TimestampSpec = {
+ column: NO_SUCH_COLUMN,
+ missingValue: '1970-01-01T00:00:00Z',
+};
+
+export const CONSTANT_TIMESTAMP_SPEC: TimestampSpec = {
+ column: NO_SUCH_COLUMN,
+ missingValue: '2010-01-01T00:00:00Z',
+};
+
+export type TimestampSchema = 'none' | 'column' | 'expression';
+
+export function getTimestampSchema(spec: IngestionSpec): TimestampSchema {
+ const transforms: Transform[] =
+ deepGet(spec, 'spec.dataSchema.transformSpec.transforms') || EMPTY_ARRAY;
+
+ const timeTransform = transforms.find(transform => transform.name === '__time');
+ if (timeTransform) return 'expression';
+
+ const timestampSpec = deepGet(spec, 'spec.dataSchema.timestampSpec') || EMPTY_OBJECT;
+ return timestampSpec.column === NO_SUCH_COLUMN ? 'none' : 'column';
+}
+
+export interface TimestampSpec {
+ column?: string;
+ format?: string;
+ missingValue?: string;
+}
+
+export function getTimestampSpecColumnFromSpec(spec: IngestionSpec): string {
+ // For the default https://github.com/apache/druid/blob/master/core/src/main/java/org/apache/druid/data/input/impl/TimestampSpec.java#L44
+ return deepGet(spec, 'spec.dataSchema.timestampSpec.column') || 'timestamp';
+}
+
+export function getTimestampSpecConstantFromSpec(spec: IngestionSpec): string | undefined {
+ return deepGet(spec, 'spec.dataSchema.timestampSpec.missingValue');
+}
+
+export function getTimestampSpecExpressionFromSpec(spec: IngestionSpec): string | undefined {
+ const transforms: Transform[] =
+ deepGet(spec, 'spec.dataSchema.transformSpec.transforms') || EMPTY_ARRAY;
+
+ const timeTransform = transforms.find(transform => transform.name === '__time');
+ if (!timeTransform) return;
+ return timeTransform.expression;
+}
+
+export function getTimestampDetailFromSpec(spec: IngestionSpec): string {
+ const timestampSchema = getTimestampSchema(spec);
+ switch (timestampSchema) {
+ case 'none':
+ return `Constant: ${getTimestampSpecConstantFromSpec(spec)}`;
+
+ case 'column':
+ return `Column: ${getTimestampSpecColumnFromSpec(spec)}`;
+
+ case 'expression':
+ return `Expression: ${getTimestampSpecExpressionFromSpec(spec)}`;
+ }
+
+ return '-';
+}
+
+export const TIMESTAMP_SPEC_FIELDS: Field[] = [
+ {
+ name: 'column',
+ type: 'string',
+ defaultValue: 'timestamp',
+ required: true,
+ },
+ {
+ name: 'format',
+ type: 'string',
+ defaultValue: 'auto',
+ suggestions: [
+ ...BASIC_TIME_FORMATS,
+ {
+ group: 'Date and time formats',
+ suggestions: DATETIME_TIME_FORMATS,
+ },
+ {
+ group: 'Date only formats',
+ suggestions: DATE_ONLY_TIME_FORMATS,
+ },
+ {
+ group: 'Other time formats',
+ suggestions: OTHER_TIME_FORMATS,
+ },
+ ],
+ info: (
+
+ Please specify your timestamp format by using the suggestions menu or typing in a{' '}
+
+ format string
+
+ .
+
+ ),
+ },
+ {
+ name: 'missingValue',
+ type: 'string',
+ placeholder: '(optional)',
+ info: This value will be used if the specified column can not be found.
,
+ },
+];
+
+export const CONSTANT_TIMESTAMP_SPEC_FIELDS: Field[] = [
+ {
+ name: 'missingValue',
+ label: 'Placeholder value',
+ type: 'string',
+ info: The placeholder value that will be used as the timestamp.
,
+ },
+];
+
+export function issueWithTimestampSpec(
+ timestampSpec: TimestampSpec | undefined,
+): string | undefined {
+ if (!timestampSpec) return 'no spec';
+ if (!timestampSpec.column && !timestampSpec.missingValue) return 'timestamp spec is blank';
+ return;
+}
diff --git a/web-console/src/druid-models/transform-spec.tsx b/web-console/src/druid-models/transform-spec.tsx
new file mode 100644
index 00000000000..81e9b334e3a
--- /dev/null
+++ b/web-console/src/druid-models/transform-spec.tsx
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Code } from '@blueprintjs/core';
+import React from 'react';
+
+import { ExternalLink, Field } from '../components';
+import { getLink } from '../links';
+
+export interface TransformSpec {
+ transforms?: Transform[];
+ filter?: Record;
+}
+
+export interface Transform {
+ type: string;
+ name: string;
+ expression: string;
+}
+
+export const TRANSFORM_FIELDS: Field[] = [
+ {
+ name: 'name',
+ type: 'string',
+ placeholder: 'output_name',
+ required: true,
+ },
+ {
+ name: 'type',
+ type: 'string',
+ suggestions: ['expression'],
+ required: true,
+ },
+ {
+ name: 'expression',
+ type: 'string',
+ placeholder: '"foo" + "bar"',
+ required: true,
+ info: (
+ <>
+ A valid Druid{' '}
+ expression .
+ >
+ ),
+ },
+];
+
+export function getTimestampExpressionFields(transforms: Transform[]): Field[] {
+ const timeTransformIndex = transforms.findIndex(transform => transform.name === '__time');
+ if (timeTransformIndex < 0) return [];
+
+ return [
+ {
+ name: `${timeTransformIndex}.expression`,
+ label: 'Expression',
+ type: 'string',
+ placeholder: `timestamp_parse(concat("date", ' ', "time"))`,
+ required: true,
+ suggestions: [
+ `timestamp_parse(concat("date", ' ', "time"))`,
+ `timestamp_parse(concat("date", ' ', "time"), 'M/d/yyyy H:mm:ss')`,
+ `timestamp_parse(concat("year", '-', "month", '-', "day"))`,
+ ],
+ info: (
+ <>
+ A valid Druid{' '}
+ expression {' '}
+ that should output a millis timestamp. You most likely want to use the{' '}
+ timestamp_parse
function at the outer level.
+ >
+ ),
+ },
+ ];
+}
+
+export function addTimestampTransform(transforms: Transform[]): Transform[] {
+ return [
+ {
+ name: '__time',
+ type: 'expression',
+ expression: '',
+ },
+ ].concat(transforms);
+}
+
+export function removeTimestampTransform(transforms: Transform[]): Transform[] | undefined {
+ const newTransforms = transforms.filter(transform => transform.name !== '__time');
+ return newTransforms.length ? newTransforms : undefined;
+}
diff --git a/web-console/src/entry.scss b/web-console/src/entry.scss
index 38a0d1914eb..426480ea24b 100644
--- a/web-console/src/entry.scss
+++ b/web-console/src/entry.scss
@@ -16,13 +16,13 @@
* limitations under the License.
*/
-@import '../node_modules/normalize.css/normalize';
+@import '~normalize.css/normalize';
@import '~fontsource-open-sans/index.css';
@import './blueprint-overrides';
@import '~@blueprintjs/core/src/blueprint';
@import '~@blueprintjs/datetime/src/blueprint-datetime';
+@import '~react-splitter-layout/lib/index';
@import '../lib/react-table';
-@import '../node_modules/react-splitter-layout/lib/index.css';
html,
body {
@@ -45,6 +45,10 @@ body {
outline: none !important;
}
}
+
+ .ace-solarized-dark {
+ background-color: rgba($dark-gray1, 0.5);
+ }
}
.app-container {
diff --git a/web-console/src/links.ts b/web-console/src/links.ts
index b4b3b238a18..488b238e12b 100644
--- a/web-console/src/links.ts
+++ b/web-console/src/links.ts
@@ -19,7 +19,7 @@
import hasOwnProp from 'has-own-prop';
// This is set to the latest available version and should be updated to the next version before release
-const DRUID_DOCS_VERSION = '0.19.0';
+const DRUID_DOCS_VERSION = '0.20.0';
function fillVersion(str: string): string {
return str.replace(/\{\{VERSION}}/g, DRUID_DOCS_VERSION);
diff --git a/web-console/src/utils/druid-query.spec.ts b/web-console/src/utils/druid-query.spec.ts
index 140f146b521..55fd336920d 100644
--- a/web-console/src/utils/druid-query.spec.ts
+++ b/web-console/src/utils/druid-query.spec.ts
@@ -18,7 +18,7 @@
import { sane } from 'druid-query-toolkit/build/test-utils';
-import { DruidError } from './druid-query';
+import { DruidError, getDruidErrorMessage, parseHtmlError, parseQueryPlan } from './druid-query';
describe('DruidQuery', () => {
describe('DruidError.parsePosition', () => {
@@ -128,4 +128,18 @@ describe('DruidQuery', () => {
expect(suggestion).toBeUndefined();
});
});
+
+ describe('misc', () => {
+ it('parseHtmlError', () => {
+ expect(parseHtmlError('
')).toMatchInlineSnapshot(`undefined`);
+ });
+
+ it('parseHtmlError', () => {
+ expect(getDruidErrorMessage({})).toMatchInlineSnapshot(`undefined`);
+ });
+
+ it('parseQueryPlan', () => {
+ expect(parseQueryPlan('start')).toMatchInlineSnapshot(`"start"`);
+ });
+ });
});
diff --git a/web-console/src/utils/druid-type.ts b/web-console/src/utils/druid-type.ts
deleted file mode 100644
index 821dddc9b55..00000000000
--- a/web-console/src/utils/druid-type.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { filterMap } from './general';
-import { DimensionMode, DimensionSpec, IngestionSpec, MetricSpec } from './ingestion-spec';
-import { deepDelete, deepSet } from './object-change';
-import { HeaderAndRows } from './sampler';
-
-export function guessTypeFromSample(sample: any[]): string {
- const definedValues = sample.filter(v => v != null);
- if (
- definedValues.length &&
- definedValues.every(v => !isNaN(v) && (typeof v === 'number' || typeof v === 'string'))
- ) {
- if (definedValues.every(v => v % 1 === 0)) {
- return 'long';
- } else {
- return 'double';
- }
- } else {
- return 'string';
- }
-}
-
-export function getColumnTypeFromHeaderAndRows(
- headerAndRows: HeaderAndRows,
- column: string,
-): string {
- return guessTypeFromSample(
- filterMap(headerAndRows.rows, (r: any) => (r.parsed ? r.parsed[column] : undefined)),
- );
-}
-
-export function getDimensionSpecs(
- headerAndRows: HeaderAndRows,
- hasRollup: boolean,
-): (string | DimensionSpec)[] {
- return filterMap(headerAndRows.header, h => {
- if (h === '__time') return;
- const guessedType = getColumnTypeFromHeaderAndRows(headerAndRows, h);
- if (guessedType === 'string') return h;
- if (hasRollup) return;
- return {
- type: guessedType,
- name: h,
- };
- });
-}
-
-export function getMetricSpecs(headerAndRows: HeaderAndRows): MetricSpec[] {
- return [{ name: 'count', type: 'count' }].concat(
- filterMap(headerAndRows.header, h => {
- if (h === '__time') return;
- const guessedType = getColumnTypeFromHeaderAndRows(headerAndRows, h);
- switch (guessedType) {
- case 'double':
- return { name: `sum_${h}`, type: 'doubleSum', fieldName: h };
- case 'long':
- return { name: `sum_${h}`, type: 'longSum', fieldName: h };
- default:
- return;
- }
- }),
- );
-}
-
-export function updateSchemaWithSample(
- spec: IngestionSpec,
- headerAndRows: HeaderAndRows,
- dimensionMode: DimensionMode,
- rollup: boolean,
-): IngestionSpec {
- let newSpec = spec;
-
- if (dimensionMode === 'auto-detect') {
- newSpec = deepSet(newSpec, 'spec.dataSchema.dimensionsSpec.dimensions', []);
- } else {
- newSpec = deepDelete(newSpec, 'spec.dataSchema.dimensionsSpec.dimensionExclusions');
-
- const dimensions = getDimensionSpecs(headerAndRows, rollup);
- if (dimensions) {
- newSpec = deepSet(newSpec, 'spec.dataSchema.dimensionsSpec.dimensions', dimensions);
- }
- }
-
- if (rollup) {
- newSpec = deepSet(newSpec, 'spec.dataSchema.granularitySpec.queryGranularity', 'HOUR');
-
- const metrics = getMetricSpecs(headerAndRows);
- if (metrics) {
- newSpec = deepSet(newSpec, 'spec.dataSchema.metricsSpec', metrics);
- }
- } else {
- newSpec = deepSet(newSpec, 'spec.dataSchema.granularitySpec.queryGranularity', 'NONE');
- newSpec = deepDelete(newSpec, 'spec.dataSchema.metricsSpec');
- }
-
- newSpec = deepSet(newSpec, 'spec.dataSchema.granularitySpec.rollup', rollup);
- return newSpec;
-}
diff --git a/web-console/src/utils/general.spec.ts b/web-console/src/utils/general.spec.ts
index a9501034819..e614b870943 100644
--- a/web-console/src/utils/general.spec.ts
+++ b/web-console/src/utils/general.spec.ts
@@ -55,20 +55,26 @@ describe('general', () => {
});
describe('sqlQueryCustomTableFilter', () => {
- it('works', () => {
+ it('works with contains', () => {
expect(
- sqlQueryCustomTableFilter({
- id: 'datasource',
- value: `hello`,
- }),
- ).toMatchInlineSnapshot(`"LOWER(\\"datasource\\") LIKE LOWER('%hello%')"`);
+ String(
+ sqlQueryCustomTableFilter({
+ id: 'datasource',
+ value: `Hello`,
+ }),
+ ),
+ ).toEqual(`LOWER("datasource") LIKE '%hello%'`);
+ });
+ it('works with exact', () => {
expect(
- sqlQueryCustomTableFilter({
- id: 'datasource',
- value: `"hello"`,
- }),
- ).toMatchInlineSnapshot(`"\\"datasource\\" = 'hello'"`);
+ String(
+ sqlQueryCustomTableFilter({
+ id: 'datasource',
+ value: `"hello"`,
+ }),
+ ),
+ ).toEqual(`"datasource" = 'hello'`);
});
});
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx
index 2fc5762e34c..ca2a111199e 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -19,6 +19,7 @@
import { Button, HTMLSelect, InputGroup, Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import copy from 'copy-to-clipboard';
+import { SqlExpression, SqlFunction, SqlLiteral, SqlRef } from 'druid-query-toolkit';
import FileSaver from 'file-saver';
import hasOwnProp from 'has-own-prop';
import numeral from 'numeral';
@@ -27,6 +28,10 @@ import { Filter, FilterRender } from 'react-table';
import { AppToaster } from '../singletons/toaster';
+// These constants are used to make sure that they are not constantly recreated thrashing the pure components
+export const EMPTY_OBJECT: any = {};
+export const EMPTY_ARRAY: any[] = [];
+
export function wait(ms: number): Promise {
return new Promise(resolve => {
setTimeout(resolve, ms);
@@ -117,14 +122,15 @@ export function booleanCustomTableFilter(filter: Filter, value: any): boolean {
return haystack.includes(needle);
}
-export function sqlQueryCustomTableFilter(filter: Filter): string {
- const columnName = JSON.stringify(filter.id);
+export function sqlQueryCustomTableFilter(filter: Filter): SqlExpression {
const needleAndMode: NeedleAndMode = getNeedleAndMode(filter.value);
const needle = needleAndMode.needle;
if (needleAndMode.mode === 'exact') {
- return `${columnName} = '${needle}'`;
+ return SqlRef.columnWithQuotes(filter.id).equal(SqlLiteral.create(needle));
} else {
- return `LOWER(${columnName}) LIKE LOWER('%${needle}%')`;
+ return SqlFunction.simple('LOWER', [SqlRef.columnWithQuotes(filter.id)]).like(
+ SqlLiteral.create(`%${needle.toLowerCase()}%`),
+ );
}
}
@@ -135,6 +141,10 @@ export function caseInsensitiveContains(testString: string, searchString: string
return testString.toLowerCase().includes(searchString.toLowerCase());
}
+export function oneOf(thing: T, ...options: T[]): boolean {
+ return options.includes(thing);
+}
+
// ----------------------------
export function countBy(
diff --git a/web-console/src/utils/index.tsx b/web-console/src/utils/index.tsx
index 2bcf661c7f9..d27831794d1 100644
--- a/web-console/src/utils/index.tsx
+++ b/web-console/src/utils/index.tsx
@@ -24,4 +24,5 @@ export * from './query-manager';
export * from './query-cursor';
export * from './local-storage-keys';
export * from './column-metadata';
-export * from './compaction';
+export * from './object-change';
+export * from './capabilities';
diff --git a/web-console/src/utils/object-change.ts b/web-console/src/utils/object-change.ts
index 83b7cceaa2b..7ff7d5e2fc8 100644
--- a/web-console/src/utils/object-change.ts
+++ b/web-console/src/utils/object-change.ts
@@ -83,6 +83,17 @@ export function deepSet>(value: T, path: string, x
return valueCopy;
}
+export function deepSetMulti>(
+ value: T,
+ changes: Record,
+): T {
+ let newValue = value;
+ for (const k in changes) {
+ newValue = deepSet(newValue, k, changes[k]);
+ }
+ return newValue;
+}
+
export function deepDelete>(value: T, path: string): T {
const valueCopy = shallowCopy(value);
const parts = parsePath(path);
diff --git a/web-console/src/utils/query-manager.tsx b/web-console/src/utils/query-manager.tsx
index 411d0541486..906c7b6bd27 100644
--- a/web-console/src/utils/query-manager.tsx
+++ b/web-console/src/utils/query-manager.tsx
@@ -165,5 +165,8 @@ export class QueryManager {
public terminate(): void {
this.terminated = true;
+ if (this.currentRunCancelFn) {
+ this.currentRunCancelFn();
+ }
}
}
diff --git a/web-console/src/utils/sampler.ts b/web-console/src/utils/sampler.ts
index c56f57294e5..48db5f6c19b 100644
--- a/web-console/src/utils/sampler.ts
+++ b/web-console/src/utils/sampler.ts
@@ -18,24 +18,31 @@
import axios from 'axios';
-import { getDruidErrorMessage, queryDruidRune } from './druid-query';
-import { alphanumericCompare, filterMap, sortWithPrefixSuffix } from './general';
import {
DimensionsSpec,
- getDummyTimestampSpec,
getSpecType,
+ getTimestampSchema,
IngestionSpec,
IngestionType,
InputFormat,
IoConfig,
- isColumnTimestampSpec,
isDruidSource,
MetricSpec,
+ PLACEHOLDER_TIMESTAMP_SPEC,
TimestampSpec,
Transform,
TransformSpec,
upgradeSpec,
-} from './ingestion-spec';
+} from '../druid-models';
+
+import { getDruidErrorMessage, queryDruidRune } from './druid-query';
+import {
+ alphanumericCompare,
+ EMPTY_ARRAY,
+ filterMap,
+ oneOf,
+ sortWithPrefixSuffix,
+} from './general';
import { deepGet, deepSet } from './object-change';
const SAMPLER_URL = `/druid/indexer/v1/sampler`;
@@ -231,7 +238,8 @@ function cleanupQueryGranularity(queryGranularity: any): any {
if (typeof queryGranularityType !== 'string') return queryGranularity;
queryGranularityType = queryGranularityType.toUpperCase();
- const knownGranularity = [
+ const knownGranularity = oneOf(
+ queryGranularityType,
'NONE',
'SECOND',
'MINUTE',
@@ -240,7 +248,7 @@ function cleanupQueryGranularity(queryGranularity: any): any {
'WEEK',
'MONTH',
'YEAR',
- ].includes(queryGranularityType);
+ );
return knownGranularity ? queryGranularityType : queryGranularity;
}
@@ -272,7 +280,7 @@ export async function sampleForConnect(
ioConfig,
dataSchema: {
dataSource: 'sample',
- timestampSpec: getDummyTimestampSpec(),
+ timestampSpec: PLACEHOLDER_TIMESTAMP_SPEC,
dimensionsSpec: {},
},
} as any,
@@ -326,7 +334,7 @@ export async function sampleForParser(
ioConfig,
dataSchema: {
dataSource: 'sample',
- timestampSpec: getDummyTimestampSpec(),
+ timestampSpec: PLACEHOLDER_TIMESTAMP_SPEC,
dimensionsSpec: {},
},
},
@@ -342,7 +350,7 @@ export async function sampleForTimestamp(
): Promise {
const samplerType = getSpecType(spec);
const timestampSpec: TimestampSpec = deepGet(spec, 'spec.dataSchema.timestampSpec');
- const columnTimestampSpec = isColumnTimestampSpec(timestampSpec);
+ const timestampSchema = getTimestampSchema(spec);
// First do a query with a static timestamp spec
const sampleSpecColumns: SampleSpec = {
@@ -352,7 +360,7 @@ export async function sampleForTimestamp(
dataSchema: {
dataSource: 'sample',
dimensionsSpec: {},
- timestampSpec: columnTimestampSpec ? getDummyTimestampSpec() : timestampSpec,
+ timestampSpec: timestampSchema === 'column' ? PLACEHOLDER_TIMESTAMP_SPEC : timestampSpec,
},
},
samplerConfig: BASE_SAMPLER_CONFIG,
@@ -364,7 +372,10 @@ export async function sampleForTimestamp(
);
// If we are not parsing a column then there is nothing left to do
- if (!columnTimestampSpec) return sampleColumns;
+ if (timestampSchema === 'none') return sampleColumns;
+
+ const transforms: Transform[] =
+ deepGet(spec, 'spec.dataSchema.transformSpec.transforms') || EMPTY_ARRAY;
// If we are trying to parts a column then get a bit fancy:
// Query the same sample again (same cache key)
@@ -376,6 +387,9 @@ export async function sampleForTimestamp(
dataSource: 'sample',
dimensionsSpec: {},
timestampSpec,
+ transformSpec: {
+ transforms: transforms.filter(transform => transform.name === '__time'),
+ },
},
},
samplerConfig: BASE_SAMPLER_CONFIG,
diff --git a/web-console/src/utils/utils.spec.ts b/web-console/src/utils/utils.spec.ts
index cc85278666c..b2ddb024577 100644
--- a/web-console/src/utils/utils.spec.ts
+++ b/web-console/src/utils/utils.spec.ts
@@ -16,18 +16,11 @@
* limitations under the License.
*/
-import { getDruidErrorMessage, parseHtmlError, parseQueryPlan } from './druid-query';
-import {
- getColumnTypeFromHeaderAndRows,
- getDimensionSpecs,
- getMetricSpecs,
- guessTypeFromSample,
- updateSchemaWithSample,
-} from './druid-type';
-import { IngestionSpec } from './ingestion-spec';
+import { IngestionSpec } from '../druid-models';
+
import { applyCache, headerFromSampleResponse } from './sampler';
-describe('test-utils', () => {
+describe('utils', () => {
const ingestionSpec: IngestionSpec = {
type: 'index_parallel',
spec: {
@@ -123,161 +116,4 @@ describe('test-utils', () => {
}
`);
});
-
- // it('spec-utils sampleForParser', async () => {
- // expect(await sampleForParser(ingestionSpec, 'start', 'abc123')).toMatchInlineSnapshot(
- // `Promise {}`,
- // );
- // });
- //
- // it('spec-utils SampleSpec', async () => {
- // expect(await sampleForConnect(ingestionSpec, 'start')).toMatchInlineSnapshot(`Promise {}`);
- // });
- //
- // it('spec-utils sampleForTimestamp', async () => {
- // expect(await sampleForTimestamp(ingestionSpec, 'start', cacheRows)).toMatchInlineSnapshot();
- // });
- //
- // it('spec-utils sampleForTransform', async () => {
- // expect(await sampleForTransform(ingestionSpec, 'start', cacheRows)).toMatchInlineSnapshot();
- // });
- //
- // it('spec-utils sampleForFilter', async () => {
- // expect(await sampleForFilter(ingestionSpec, 'start', cacheRows)).toMatchInlineSnapshot();
- // });
- //
- // it('spec-utils sampleForSchema', async () => {
- // expect(await sampleForSchema(ingestionSpec, 'start', cacheRows)).toMatchInlineSnapshot();
- // });
- //
- // it('spec-utils sampleForExampleManifests', async () => {
- // expect(await sampleForExampleManifests('some url')).toMatchInlineSnapshot();
- // });
-});
-
-describe('druid-type.ts', () => {
- const ingestionSpec: IngestionSpec = {
- type: 'index_parallel',
- spec: {
- ioConfig: {
- type: 'index_parallel',
- inputSource: {
- type: 'http',
- uris: ['https://static.imply.io/data/wikipedia.json.gz'],
- },
- inputFormat: {
- type: 'json',
- },
- },
- tuningConfig: {
- type: 'index_parallel',
- },
- dataSchema: {
- dataSource: 'wikipedia',
- granularitySpec: {
- type: 'uniform',
- segmentGranularity: 'DAY',
- queryGranularity: 'HOUR',
- },
- timestampSpec: {
- column: 'timestamp',
- format: 'iso',
- },
- dimensionsSpec: {},
- },
- },
- };
-
- it('spec-utils guessTypeFromSample', () => {
- expect(guessTypeFromSample([])).toMatchInlineSnapshot(`"string"`);
- });
-
- it('spec-utils getColumnTypeFromHeaderAndRows', () => {
- expect(
- getColumnTypeFromHeaderAndRows({ header: ['header'], rows: [] }, 'header'),
- ).toMatchInlineSnapshot(`"string"`);
- });
-
- it('spec-utils getDimensionSpecs', () => {
- expect(getDimensionSpecs({ header: ['header'], rows: [] }, true)).toMatchInlineSnapshot(`
- Array [
- "header",
- ]
- `);
- });
-
- it('spec-utils getMetricSecs', () => {
- expect(getMetricSpecs({ header: ['header'], rows: [] })).toMatchInlineSnapshot(`
- Array [
- Object {
- "name": "count",
- "type": "count",
- },
- ]
- `);
- });
-
- it('spec-utils updateSchemaWithSample', () => {
- expect(
- updateSchemaWithSample(ingestionSpec, { header: ['header'], rows: [] }, 'specific', true),
- ).toMatchInlineSnapshot(`
- Object {
- "spec": Object {
- "dataSchema": Object {
- "dataSource": "wikipedia",
- "dimensionsSpec": Object {
- "dimensions": Array [
- "header",
- ],
- },
- "granularitySpec": Object {
- "queryGranularity": "HOUR",
- "rollup": true,
- "segmentGranularity": "DAY",
- "type": "uniform",
- },
- "metricsSpec": Array [
- Object {
- "name": "count",
- "type": "count",
- },
- ],
- "timestampSpec": Object {
- "column": "timestamp",
- "format": "iso",
- },
- },
- "ioConfig": Object {
- "inputFormat": Object {
- "type": "json",
- },
- "inputSource": Object {
- "type": "http",
- "uris": Array [
- "https://static.imply.io/data/wikipedia.json.gz",
- ],
- },
- "type": "index_parallel",
- },
- "tuningConfig": Object {
- "type": "index_parallel",
- },
- },
- "type": "index_parallel",
- }
- `);
- });
-});
-describe('druid-query.ts', () => {
- it('spec-utils parseHtmlError', () => {
- expect(parseHtmlError('
')).toMatchInlineSnapshot(`undefined`);
- });
-
- it('spec-utils parseHtmlError', () => {
- expect(getDruidErrorMessage({})).toMatchInlineSnapshot(`undefined`);
- });
-
- it('spec-utils parseQueryPlan', () => {
- expect(parseQueryPlan('start')).toMatchInlineSnapshot(`"start"`);
- });
});
diff --git a/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap b/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
index 64b412dc15a..947e1d2bc32 100755
--- a/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
+++ b/web-console/src/views/datasource-view/__snapshots__/datasource-view.spec.tsx.snap
@@ -64,6 +64,7 @@ exports[`data source view matches snapshot 1`] = `
"Segment load/drop queues",
"Total data size",
"Segment size",
+ "Segment granularity",
"Total rows",
"Avg. row size",
"Replicated size",
@@ -193,6 +194,19 @@ exports[`data source view matches snapshot 1`] = `
"show": true,
"width": 220,
},
+ Object {
+ "Cell": [Function],
+ "Header":
+ Segment
+
+ granularity
+ ,
+ "accessor": [Function],
+ "filterable": false,
+ "id": "segment_granularity",
+ "show": true,
+ "width": 100,
+ },
Object {
"Cell": [Function],
"Header":
diff --git a/web-console/src/views/datasource-view/datasource-view.tsx b/web-console/src/views/datasource-view/datasource-view.tsx
index 9ad70d9d8cb..effd3d1301c 100644
--- a/web-console/src/views/datasource-view/datasource-view.tsx
+++ b/web-console/src/views/datasource-view/datasource-view.tsx
@@ -39,14 +39,20 @@ import {
} from '../../components';
import { AsyncActionDialog, CompactionDialog, RetentionDialog } from '../../dialogs';
import { DatasourceTableActionDialog } from '../../dialogs/datasource-table-action-dialog/datasource-table-action-dialog';
+import {
+ CompactionConfig,
+ CompactionStatus,
+ formatCompactionConfigAndStatus,
+ zeroCompactionStatus,
+} from '../../druid-models';
import { AppToaster } from '../../singletons/toaster';
import {
addFilter,
- CompactionConfig,
- CompactionStatus,
+ Capabilities,
+ CapabilitiesMode,
countBy,
+ deepGet,
formatBytes,
- formatCompactionConfigAndStatus,
formatInteger,
formatMillions,
formatPercent,
@@ -57,13 +63,10 @@ import {
queryDruidSql,
QueryManager,
QueryState,
- zeroCompactionStatus,
} from '../../utils';
import { BasicAction } from '../../utils/basic-action';
-import { Capabilities, CapabilitiesMode } from '../../utils/capabilities';
import { Rule, RuleUtil } from '../../utils/load-rule';
import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array';
-import { deepGet } from '../../utils/object-change';
import './datasource-view.scss';
@@ -74,6 +77,7 @@ const tableColumns: Record = {
'Segment load/drop queues',
'Total data size',
'Segment size',
+ 'Segment granularity',
'Total rows',
'Avg. row size',
'Replicated size',
@@ -100,6 +104,7 @@ const tableColumns: Record = {
'Segment load/drop queues',
'Total data size',
'Segment size',
+ 'Segment granularity',
'Total rows',
'Avg. row size',
'Replicated size',
@@ -149,6 +154,11 @@ interface DatasourceQueryResultRow {
readonly num_available_segments: number;
readonly num_segments_to_load: number;
readonly num_segments_to_drop: number;
+ readonly minute_aligned_segments: number;
+ readonly hour_aligned_segments: number;
+ readonly day_aligned_segments: number;
+ readonly month_aligned_segments: number;
+ readonly year_aligned_segments: number;
readonly total_data_size: number;
readonly replicated_size: number;
readonly min_segment_rows: number;
@@ -158,6 +168,17 @@ interface DatasourceQueryResultRow {
readonly avg_row_size: number;
}
+function segmentGranularityCountsToRank(row: DatasourceQueryResultRow): number {
+ return (
+ Number(Boolean(row.num_segments)) +
+ Number(Boolean(row.minute_aligned_segments)) +
+ Number(Boolean(row.hour_aligned_segments)) +
+ Number(Boolean(row.day_aligned_segments)) +
+ Number(Boolean(row.month_aligned_segments)) +
+ Number(Boolean(row.year_aligned_segments))
+ );
+}
+
interface Datasource extends DatasourceQueryResultRow {
readonly rules: Rule[];
readonly compactionConfig?: CompactionConfig;
@@ -227,6 +248,11 @@ export class DatasourcesView extends React.PureComponent<
COUNT(*) FILTER (WHERE is_available = 1 AND ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1)) AS num_available_segments,
COUNT(*) FILTER (WHERE is_published = 1 AND is_overshadowed = 0 AND is_available = 0) AS num_segments_to_load,
COUNT(*) FILTER (WHERE is_available = 1 AND NOT ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1)) AS num_segments_to_drop,
+ COUNT(*) FILTER (WHERE ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AND "start" LIKE '%:00.000Z' AND "end" LIKE '%:00.000Z') AS minute_aligned_segments,
+ COUNT(*) FILTER (WHERE ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AND "start" LIKE '%:00:00.000Z' AND "end" LIKE '%:00:00.000Z') AS hour_aligned_segments,
+ COUNT(*) FILTER (WHERE ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AND "start" LIKE '%T00:00:00.000Z' AND "end" LIKE '%T00:00:00.000Z') AS day_aligned_segments,
+ COUNT(*) FILTER (WHERE ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AND "start" LIKE '%-01T00:00:00.000Z' AND "end" LIKE '%-01T00:00:00.000Z') AS month_aligned_segments,
+ COUNT(*) FILTER (WHERE ((is_published = 1 AND is_overshadowed = 0) OR is_realtime = 1) AND "start" LIKE '%-01-01T00:00:00.000Z' AND "end" LIKE '%-01-01T00:00:00.000Z') AS year_aligned_segments,
SUM("size") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS total_data_size,
SUM("size" * "num_replicas") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS replicated_size,
MIN("num_rows") FILTER (WHERE is_published = 1 AND is_overshadowed = 0) AS min_segment_rows,
@@ -306,6 +332,11 @@ GROUP BY 1`;
num_segments: numSegments,
num_segments_to_load: segmentsToLoad,
num_segments_to_drop: 0,
+ minute_aligned_segments: -1,
+ hour_aligned_segments: -1,
+ day_aligned_segments: -1,
+ month_aligned_segments: -1,
+ year_aligned_segments: -1,
replicated_size: -1,
total_data_size: totalDataSize,
min_segment_rows: -1,
@@ -1031,6 +1062,37 @@ GROUP BY 1`;
>
),
},
+ {
+ Header: twoLines('Segment', 'granularity'),
+ show: capabilities.hasSql() && hiddenColumns.exists('Segment granularity'),
+ id: 'segment_granularity',
+ accessor: segmentGranularityCountsToRank,
+ filterable: false,
+ width: 100,
+ Cell: ({ original }) => {
+ const segmentGranularities: string[] = [];
+ if (!original.num_segments) return '-';
+ if (original.num_segments - original.minute_aligned_segments) {
+ segmentGranularities.push('Sub minute');
+ }
+ if (original.minute_aligned_segments - original.hour_aligned_segments) {
+ segmentGranularities.push('Minute');
+ }
+ if (original.hour_aligned_segments - original.day_aligned_segments) {
+ segmentGranularities.push('Hour');
+ }
+ if (original.day_aligned_segments - original.month_aligned_segments) {
+ segmentGranularities.push('Day');
+ }
+ if (original.month_aligned_segments - original.year_aligned_segments) {
+ segmentGranularities.push('Month');
+ }
+ if (original.year_aligned_segments) {
+ segmentGranularities.push('Year');
+ }
+ return segmentGranularities.join(', ');
+ },
+ },
{
Header: twoLines('Total', 'rows'),
show: capabilities.hasSql() && hiddenColumns.exists('Total rows'),
diff --git a/web-console/src/views/home-view/segments-card/segments-card.tsx b/web-console/src/views/home-view/segments-card/segments-card.tsx
index d84061e30b9..2245aae8d08 100644
--- a/web-console/src/views/home-view/segments-card/segments-card.tsx
+++ b/web-console/src/views/home-view/segments-card/segments-card.tsx
@@ -22,9 +22,7 @@ import { sum } from 'd3-array';
import React from 'react';
import { useQueryManager } from '../../../hooks';
-import { pluralIfNeeded, queryDruidSql } from '../../../utils';
-import { Capabilities } from '../../../utils/capabilities';
-import { deepGet } from '../../../utils/object-change';
+import { Capabilities, deepGet, pluralIfNeeded, queryDruidSql } from '../../../utils';
import { HomeViewCard } from '../home-view-card/home-view-card';
export interface SegmentCounts {
diff --git a/web-console/src/views/ingestion-view/ingestion-view.spec.tsx b/web-console/src/views/ingestion-view/ingestion-view.spec.tsx
index fa8c867e18f..ddbaa9d9103 100644
--- a/web-console/src/views/ingestion-view/ingestion-view.spec.tsx
+++ b/web-console/src/views/ingestion-view/ingestion-view.spec.tsx
@@ -32,7 +32,6 @@ describe('tasks view', () => {
datasourceId={'datasource'}
goToDatasource={() => {}}
goToQuery={() => {}}
- goToMiddleManager={() => {}}
goToLoadData={() => {}}
capabilities={Capabilities.FULL}
/>,
diff --git a/web-console/src/views/ingestion-view/ingestion-view.tsx b/web-console/src/views/ingestion-view/ingestion-view.tsx
index 380c1cf0a29..d4826d8105d 100644
--- a/web-console/src/views/ingestion-view/ingestion-view.tsx
+++ b/web-console/src/views/ingestion-view/ingestion-view.tsx
@@ -45,11 +45,13 @@ import {
addFilter,
addFilterRaw,
booleanCustomTableFilter,
+ deepGet,
formatDuration,
getDruidErrorMessage,
localStorageGet,
LocalStorageKeys,
localStorageSet,
+ oneOf,
queryDruidSql,
QueryManager,
QueryState,
@@ -57,7 +59,6 @@ import {
import { BasicAction } from '../../utils/basic-action';
import { Capabilities } from '../../utils/capabilities';
import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array';
-import { deepGet } from '../../utils/object-change';
import './ingestion-view.scss';
@@ -108,7 +109,6 @@ export interface IngestionViewProps {
openDialog: string | undefined;
goToDatasource: (datasource: string) => void;
goToQuery: (initSql: string) => void;
- goToMiddleManager: (middleManager: string) => void;
goToLoadData: (supervisorId?: string, taskId?: string) => void;
capabilities: Capabilities;
}
@@ -385,7 +385,7 @@ ORDER BY "rank" DESC, "created_time" DESC`;
const { goToDatasource, goToLoadData } = this.props;
const actions: BasicAction[] = [];
- if (type === 'kafka' || type === 'kinesis') {
+ if (oneOf(type, 'kafka', 'kinesis')) {
actions.push(
{
icon: IconNames.MULTI_SELECT,
@@ -659,14 +659,14 @@ ORDER BY "rank" DESC, "created_time" DESC`;
onAction: () => goToDatasource(datasource),
});
}
- if (type === 'index' || type === 'index_parallel') {
+ if (oneOf(type, 'index', 'index_parallel')) {
actions.push({
icon: IconNames.CLOUD_UPLOAD,
title: 'Open in data loader',
onAction: () => goToLoadData(undefined, id),
});
}
- if (status === 'RUNNING' || status === 'WAITING' || status === 'PENDING') {
+ if (oneOf(status, 'RUNNING', 'WAITING', 'PENDING')) {
actions.push({
icon: IconNames.CROSS,
title: 'Kill',
@@ -704,7 +704,6 @@ ORDER BY "rank" DESC, "created_time" DESC`;
}
renderTaskTable() {
- const { goToMiddleManager } = this.props;
const {
tasksState,
taskFilter,
@@ -812,21 +811,12 @@ ORDER BY "rank" DESC, "created_time" DESC`;
}),
Cell: row => {
if (row.aggregated) return '';
- const { status, location } = row.original;
- const locationHostname = location ? location.split(':')[0] : null;
+ const { status } = row.original;
const errorMsg = row.original.error_msg;
return (
●
{status}
- {location && (
- goToMiddleManager(locationHostname)}
- title={`Go to: ${locationHostname}`}
- >
- ➚
-
- )}
{errorMsg && (
this.setState({ alertErrorMsg: errorMsg })}
diff --git a/web-console/src/views/load-data-view/filter-table/filter-table.tsx b/web-console/src/views/load-data-view/filter-table/filter-table.tsx
index 89f380f44c7..db0bddc3eed 100644
--- a/web-console/src/views/load-data-view/filter-table/filter-table.tsx
+++ b/web-console/src/views/load-data-view/filter-table/filter-table.tsx
@@ -21,8 +21,8 @@ import React from 'react';
import ReactTable from 'react-table';
import { TableCell } from '../../../components';
+import { DruidFilter } from '../../../druid-models';
import { caseInsensitiveContains, filterMap } from '../../../utils';
-import { DruidFilter } from '../../../utils/ingestion-spec';
import { HeaderAndRows, SampleEntry } from '../../../utils/sampler';
import './filter-table.scss';
diff --git a/web-console/src/views/load-data-view/load-data-view.scss b/web-console/src/views/load-data-view/load-data-view.scss
index 905352ea096..bbc8627e8d1 100644
--- a/web-console/src/views/load-data-view/load-data-view.scss
+++ b/web-console/src/views/load-data-view/load-data-view.scss
@@ -19,6 +19,12 @@
@import '~@blueprintjs/core/src/common/colors';
@import '../../variables';
+$control-bar-width: 300px;
+
+$icon-width: 100px;
+$actual-icon-width: 520px;
+$actual-icon-height: 400px;
+
@mixin sunk-panel {
background: rgba($dark-gray1, 0.5);
border-radius: $pt-border-radius;
@@ -30,7 +36,7 @@
height: 100%;
display: grid;
grid-gap: $thin-padding 5px;
- grid-template-columns: 1fr 280px;
+ grid-template-columns: 1fr $control-bar-width;
grid-template-rows: 60px 1fr 28px;
grid-template-areas:
'navi navi'
@@ -133,7 +139,8 @@
}
img {
- width: 100px;
+ width: $icon-width;
+ height: $icon-width * ($actual-icon-height / $actual-icon-width);
display: inline-block;
}
}
@@ -144,7 +151,7 @@
&.tuning,
&.publish {
grid-gap: 20px 40px;
- grid-template-columns: 1fr 1fr 280px;
+ grid-template-columns: 1fr 1fr $control-bar-width;
grid-template-areas:
'navi navi navi'
'main othr ctrl'
diff --git a/web-console/src/views/load-data-view/load-data-view.tsx b/web-console/src/views/load-data-view/load-data-view.tsx
index 2d5074a752a..4db8286bf53 100644
--- a/web-console/src/views/load-data-view/load-data-view.tsx
+++ b/web-console/src/views/load-data-view/load-data-view.tsx
@@ -54,53 +54,46 @@ import {
} from '../../components';
import { FormGroupWithInfo } from '../../components/form-group-with-info/form-group-with-info';
import { AsyncActionDialog } from '../../dialogs';
-import { getLink } from '../../links';
-import { AppToaster } from '../../singletons/toaster';
-import { UrlBaser } from '../../singletons/url-baser';
import {
- filterMap,
- getDruidErrorMessage,
- localStorageGet,
- LocalStorageKeys,
- localStorageSet,
- parseJson,
- pluralIfNeeded,
- QueryState,
-} from '../../utils';
-import { NUMERIC_TIME_FORMATS, possibleDruidFormatForValues } from '../../utils/druid-time';
-import { updateSchemaWithSample } from '../../utils/druid-type';
+ addTimestampTransform,
+ CONSTANT_TIMESTAMP_SPEC,
+ CONSTANT_TIMESTAMP_SPEC_FIELDS,
+ DIMENSION_SPEC_FIELDS,
+ FILTER_FIELDS,
+ FLATTEN_FIELD_FIELDS,
+ getTimestampExpressionFields,
+ getTimestampSchema,
+ INPUT_FORMAT_FIELDS,
+ METRIC_SPEC_FIELDS,
+ removeTimestampTransform,
+ TIMESTAMP_SPEC_FIELDS,
+ TimestampSpec,
+ Transform,
+ TRANSFORM_FIELDS,
+ updateSchemaWithSample,
+} from '../../druid-models';
import {
- adjustIngestionSpec,
adjustTuningConfig,
cleanSpec,
+ computeFlattenPathsForData,
DimensionMode,
DimensionSpec,
DimensionsSpec,
DruidFilter,
- EMPTY_ARRAY,
- EMPTY_OBJECT,
fillDataSourceNameIfNeeded,
fillInputFormat,
FlattenField,
- getConstantTimestampSpec,
getDimensionMode,
- getDimensionSpecFormFields,
- getFilterFormFields,
- getFlattenFieldFormFields,
getIngestionComboType,
getIngestionDocLink,
getIngestionImage,
getIngestionTitle,
- getInputFormatFormFields,
getIoConfigFormFields,
getIoConfigTuningFormFields,
- getMetricSpecFormFields,
getPartitionRelatedTuningSpecFormFields,
getRequiredModule,
getRollup,
getSpecType,
- getTimestampSpecFormFields,
- getTransformFormFields,
getTuningSpecFormFields,
GranularitySpec,
IngestionComboTypeWithExtra,
@@ -110,7 +103,6 @@ import {
invalidIoConfig,
invalidTuningConfig,
IoConfig,
- isColumnTimestampSpec,
isDruidSource,
isEmptyIngestionSpec,
issueWithIoConfig,
@@ -119,14 +111,33 @@ import {
MAX_INLINE_DATA_LENGTH,
MetricSpec,
normalizeSpec,
+ NUMERIC_TIME_FORMATS,
+ possibleDruidFormatForValues,
splitFilter,
- TimestampSpec,
- Transform,
TuningConfig,
updateIngestionType,
upgradeSpec,
-} from '../../utils/ingestion-spec';
-import { deepDelete, deepGet, deepSet } from '../../utils/object-change';
+} from '../../druid-models';
+import { getLink } from '../../links';
+import { AppToaster } from '../../singletons/toaster';
+import { UrlBaser } from '../../singletons/url-baser';
+import {
+ deepDelete,
+ deepGet,
+ deepSet,
+ deepSetMulti,
+ EMPTY_ARRAY,
+ EMPTY_OBJECT,
+ filterMap,
+ getDruidErrorMessage,
+ localStorageGet,
+ LocalStorageKeys,
+ localStorageSet,
+ oneOf,
+ parseJson,
+ pluralIfNeeded,
+ QueryState,
+} from '../../utils';
import {
CacheRows,
ExampleManifest,
@@ -146,7 +157,6 @@ import {
SampleResponseWithExtraInfo,
SampleStrategy,
} from '../../utils/sampler';
-import { computeFlattenPathsForData } from '../../utils/spec-utils';
import { ExamplePicker } from './example-picker/example-picker';
import { FilterTable, filterTableSelectedColumnName } from './filter-table/filter-table';
@@ -187,7 +197,7 @@ function showBlankLine(line: SampleEntry): string {
}
function getTimestampSpec(headerAndRows: HeaderAndRows | null): TimestampSpec {
- if (!headerAndRows) return getConstantTimestampSpec();
+ if (!headerAndRows) return CONSTANT_TIMESTAMP_SPEC;
const timestampSpecs = filterMap(headerAndRows.header, sampleHeader => {
const possibleFormat = possibleDruidFormatForValues(
@@ -204,7 +214,7 @@ function getTimestampSpec(headerAndRows: HeaderAndRows | null): TimestampSpec {
timestampSpecs.find(ts => /time/i.test(ts.column)) || // Use a suggestion that has time in the name if possible
timestampSpecs.find(ts => !NUMERIC_TIME_FORMATS.includes(ts.format)) || // Use a suggestion that is not numeric
timestampSpecs[0] || // Fall back to the first one
- getConstantTimestampSpec() // Ok, empty it is...
+ CONSTANT_TIMESTAMP_SPEC // Ok, empty it is...
);
}
@@ -300,7 +310,7 @@ export interface LoadDataViewState {
// for timestamp
timestampQueryState: QueryState<{
headerAndRows: HeaderAndRows;
- timestampSpec: TimestampSpec;
+ spec: IngestionSpec;
}>;
// for transform
@@ -454,7 +464,6 @@ export class LoadDataView extends React.PureComponent {
newSpec = normalizeSpec(newSpec);
newSpec = upgradeSpec(newSpec);
- newSpec = adjustIngestionSpec(newSpec);
const deltaState: Partial = { spec: newSpec, specPreview: newSpec };
if (!deepGet(newSpec, 'spec.ioConfig.type')) {
deltaState.cacheRows = undefined;
@@ -470,7 +479,7 @@ export class LoadDataView extends React.PureComponent {
this.setState(state => {
localStorageSet(LocalStorageKeys.INGESTION_SPEC, JSON.stringify(state.specPreview));
- return { spec: state.specPreview };
+ return { spec: Object.assign({}, state.specPreview) };
});
};
@@ -577,14 +586,15 @@ export class LoadDataView extends React.PureComponent) {
const previewSpecSame = this.isPreviewSpecSame();
+ const queryStateHasError = Boolean(queryState && queryState.error);
return (
@@ -1047,7 +1057,7 @@ export class LoadDataView extends React.PureComponent ;
} else if (inputQueryState.error) {
- mainFill = {`Error: ${inputQueryState.error.message}`} ;
+ mainFill = {`Error: ${inputQueryState.getErrorMessage()}`} ;
} else if (inputQueryState.data) {
const inputData = inputQueryState.data.data;
mainFill = (
@@ -1168,7 +1178,7 @@ export class LoadDataView extends React.PureComponent
)}
- {(specType === 'kafka' || specType === 'kinesis') && (
+ {oneOf(specType, 'kafka', 'kinesis') && (
)}
- {this.renderApplyButtonBar()}
+ {this.renderApplyButtonBar(inputQueryState)}
{this.renderNextBar({
disabled: !inputQueryState.data,
@@ -1278,7 +1288,7 @@ export class LoadDataView extends React.PureComponent ;
} else if (parserQueryState.error) {
- mainFill = {`Error: ${parserQueryState.error.message}`} ;
+ mainFill = {`Error: ${parserQueryState.getErrorMessage()}`} ;
} else if (parserQueryState.data) {
mainFill = (
@@ -1380,13 +1390,13 @@ export class LoadDataView extends React.PureComponent
this.updateSpecPreview(deepSet(spec, 'spec.ioConfig.inputFormat', p))
}
/>
- {this.renderApplyButtonBar()}
+ {this.renderApplyButtonBar(parserQueryState)}
>
)}
{this.renderFlattenControls()}
@@ -1461,7 +1471,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ selectedFlattenField: f })}
/>
@@ -1529,7 +1539,6 @@ export class LoadDataView extends React.PureComponent ;
} else if (timestampQueryState.error) {
- mainFill = {`Error: ${timestampQueryState.error.message}`} ;
+ mainFill = {`Error: ${timestampQueryState.getErrorMessage()}`} ;
} else if (timestampQueryState.data) {
mainFill = (
@@ -1622,46 +1633,88 @@ export class LoadDataView extends React.PureComponent
Druid partitions data based on the primary time column of your data. This column is
- stored internally in Druid as __time
. Please specify the primary time
- column. If you do not have any time columns, you can choose "Constant value" to create
- a default one.
+ stored internally in Druid as __time
.
+
+ Configure how to define the time column for this data.
+
+ If your data does not have a time column, you can select "None" to use a placeholder
+ value. If the time information is spread across multiple columns you can combine them
+ into one by selecting "Expression" and defining a transform expression.
-
+
{
+ this.updateSpecPreview(
+ deepSetMulti(spec, {
+ 'spec.dataSchema.timestampSpec': CONSTANT_TIMESTAMP_SPEC,
+ 'spec.dataSchema.transformSpec.transforms': removeTimestampTransform(
+ transforms,
+ ),
+ }),
+ );
+ }}
+ />
+ {
const timestampSpec = {
column: 'timestamp',
format: 'auto',
};
this.updateSpecPreview(
- deepSet(spec, 'spec.dataSchema.timestampSpec', timestampSpec),
+ deepSetMulti(spec, {
+ 'spec.dataSchema.timestampSpec': timestampSpec,
+ 'spec.dataSchema.transformSpec.transforms': removeTimestampTransform(
+ transforms,
+ ),
+ }),
);
}}
/>
{
this.updateSpecPreview(
- deepSet(spec, 'spec.dataSchema.timestampSpec', getConstantTimestampSpec()),
+ deepSetMulti(spec, {
+ 'spec.dataSchema.timestampSpec': CONSTANT_TIMESTAMP_SPEC,
+ 'spec.dataSchema.transformSpec.transforms': addTimestampTransform(transforms),
+ }),
);
}}
/>
- {
- this.updateSpecPreview(deepSet(spec, 'spec.dataSchema.timestampSpec', timestampSpec));
- }}
- />
- {this.renderApplyButtonBar()}
+ {timestampSchema === 'expression' ? (
+ {
+ this.updateSpecPreview(
+ deepSet(spec, 'spec.dataSchema.transformSpec.transforms', transforms),
+ );
+ }}
+ />
+ ) : (
+ {
+ this.updateSpecPreview(
+ deepSet(spec, 'spec.dataSchema.timestampSpec', timestampSpec),
+ );
+ }}
+ />
+ )}
+ {this.renderApplyButtonBar(timestampQueryState)}
{this.renderNextBar({
disabled: !timestampQueryState.data,
@@ -1700,7 +1753,7 @@ export class LoadDataView extends React.PureComponent ;
} else if (transformQueryState.error) {
- mainFill = {`Error: ${transformQueryState.error.message}`} ;
+ mainFill = {`Error: ${transformQueryState.getErrorMessage()}`} ;
} else if (transformQueryState.data) {
mainFill = (
@@ -1834,7 +1887,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ selectedTransform })}
/>
@@ -1915,7 +1968,7 @@ export class LoadDataView extends React.PureComponent ;
} else if (filterQueryState.error) {
- mainFill = {`Error: ${filterQueryState.error.message}`} ;
+ mainFill = {`Error: ${filterQueryState.getErrorMessage()}`} ;
} else if (filterQueryState.data) {
mainFill = (
@@ -2048,10 +2101,10 @@ export class LoadDataView extends React.PureComponent
this.setState({ selectedFilter: f })}
- showCustom={f => !['selector', 'in', 'regex', 'like', 'not'].includes(f.type)}
+ showCustom={f => !oneOf(f.type, 'selector', 'in', 'regex', 'like', 'not')}
/>
- A comma separated list of intervals for the raw data being ingested. Ignored for
- real-time ingestion.
- >
- ),
+ info: <>A comma separated list of intervals for the raw data being ingested.>,
},
]}
model={spec}
@@ -2202,7 +2250,7 @@ export class LoadDataView extends React.PureComponent ;
} else if (schemaQueryState.error) {
- mainFill = {`Error: ${schemaQueryState.error.message}`} ;
+ mainFill = {`Error: ${schemaQueryState.getErrorMessage()}`} ;
} else if (schemaQueryState.data) {
mainFill = (
@@ -2362,7 +2410,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ newRollup: !rollup })}
- labelElement="Rollup"
+ label="Rollup"
/>
{this.renderNextBar({
disabled: !schemaQueryState.data,
+ onNextStep: () => {
+ let newSpec = spec;
+ if (rollup) {
+ newSpec = deepSet(newSpec, 'spec.tuningConfig.partitionsSpec', { type: 'hashed' });
+ newSpec = deepSet(newSpec, 'spec.tuningConfig.forceGuaranteedRollup', true);
+ } else {
+ newSpec = deepSet(newSpec, 'spec.tuningConfig.partitionsSpec', { type: 'dynamic' });
+ newSpec = deepDelete(newSpec, 'spec.tuningConfig.forceGuaranteedRollup');
+ }
+ this.updateSpec(newSpec);
+ },
})}
>
);
@@ -2544,7 +2603,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ selectedDimensionSpec })}
/>
@@ -2667,7 +2726,7 @@ export class LoadDataView extends React.PureComponent
this.setState({ selectedMetricSpec })}
/>
@@ -2742,6 +2801,7 @@ export class LoadDataView extends React.PureComponent
@@ -2774,25 +2834,25 @@ export class LoadDataView extends React.PureComponent this.updateSpec(deepSet(spec, 'spec.dataSchema.granularitySpec', g))}
/>
- Boolean(deepGet(spec, 'spec.tuningConfig.forceGuaranteedRollup')),
- info: (
- <>
- A comma separated list of intervals for the raw data being ingested. Ignored for
- real-time ingestion.
- >
- ),
- },
- ]}
- model={spec}
- onChange={s => this.updateSpec(s)}
- />
+ {!isStreaming && (
+
+ ['hashed', 'single_dim'].includes(
+ deepGet(spec, 'spec.tuningConfig.partitionsSpec.type'),
+ ),
+ info: <>A comma separated list of intervals for the raw data being ingested.>,
+ },
+ ]}
+ model={spec}
+ onChange={s => this.updateSpec(s)}
+ />
+ )}
Secondary partitioning
@@ -2904,7 +2964,8 @@ export class LoadDataView extends React.PureComponent
!deepGet(spec, 'spec.tuningConfig.forceGuaranteedRollup'),
+ defined: spec =>
+ deepGet(spec, 'spec.tuningConfig.partitionsSpec.type') === 'dynamic',
info: (
<>
Creates segments as additional shards of the latest version, effectively
diff --git a/web-console/src/views/load-data-view/parse-data-table/parse-data-table.tsx b/web-console/src/views/load-data-view/parse-data-table/parse-data-table.tsx
index 6a6975d52f5..e76f3ceacec 100644
--- a/web-console/src/views/load-data-view/parse-data-table/parse-data-table.tsx
+++ b/web-console/src/views/load-data-view/parse-data-table/parse-data-table.tsx
@@ -22,8 +22,8 @@ import ReactTable from 'react-table';
import { TableCell } from '../../../components';
import { TableCellUnparseable } from '../../../components/table-cell-unparseable/table-cell-unparseable';
+import { FlattenField } from '../../../druid-models';
import { caseInsensitiveContains, filterMap } from '../../../utils';
-import { FlattenField } from '../../../utils/ingestion-spec';
import { HeaderAndRows, SampleEntry } from '../../../utils/sampler';
import './parse-data-table.scss';
diff --git a/web-console/src/views/load-data-view/parse-time-table/parse-time-table.spec.tsx b/web-console/src/views/load-data-view/parse-time-table/parse-time-table.spec.tsx
index 13b82fc247d..f748c05a24b 100644
--- a/web-console/src/views/load-data-view/parse-time-table/parse-time-table.spec.tsx
+++ b/web-console/src/views/load-data-view/parse-time-table/parse-time-table.spec.tsx
@@ -19,7 +19,8 @@
import { render } from '@testing-library/react';
import React from 'react';
-import { getDummyTimestampSpec } from '../../../utils/ingestion-spec';
+import { IngestionSpec, PLACEHOLDER_TIMESTAMP_SPEC } from '../../../druid-models';
+import { deepSet } from '../../../utils';
import { ParseTimeTable } from './parse-time-table';
@@ -35,11 +36,17 @@ describe('parse time table', () => {
],
};
+ const spec = deepSet(
+ {} as IngestionSpec,
+ 'spec.dataSchema.timestampSpec',
+ PLACEHOLDER_TIMESTAMP_SPEC,
+ );
+
const parseTimeTable = (
{
- const timestamp = columnName === '__time';
- if (!timestamp && !caseInsensitiveContains(columnName, columnFilter)) return;
- const used = timestampSpec.column === columnName;
- const possibleFormat = timestamp
+ const isTimestamp = columnName === '__time';
+ if (!isTimestamp && !caseInsensitiveContains(columnName, columnFilter)) return;
+ const used = timestampSpecColumn === columnName;
+ const possibleFormat = isTimestamp
? null
: possibleDruidFormatForValues(
filterMap(headerAndRows.rows, d => (d.parsed ? d.parsed[columnName] : undefined)),
);
- if (possibleTimestampColumnsOnly && !timestamp && !possibleFormat) return;
+ if (possibleTimestampColumnsOnly && !isTimestamp && !possibleFormat) return;
const columnClassName = classNames({
- timestamp,
+ timestamp: isTimestamp,
used,
selected: selectedColumnName === columnName,
});
return {
Header: (
{
onTimestampColumnSelect({
@@ -105,11 +106,7 @@ export const ParseTimeTable = React.memo(function ParseTimeTable(props: ParseTim
>
{columnName}
- {timestamp
- ? timestampSpecFromColumn
- ? `from: '${timestampSpecColumn}'`
- : `mv: ${timestampSpec.missingValue}`
- : possibleFormat || ''}
+ {isTimestamp ? timestampDetail : possibleFormat || ''}
@@ -123,12 +120,12 @@ export const ParseTimeTable = React.memo(function ParseTimeTable(props: ParseTim
return ;
}
if (row.original.unparseable) {
- return ;
+ return ;
}
- return ;
+ return ;
},
- minWidth: timestamp ? 200 : 100,
- resizable: !timestamp,
+ minWidth: isTimestamp ? 200 : 100,
+ resizable: !isTimestamp,
};
},
)}
diff --git a/web-console/src/views/load-data-view/schema-table/schema-table.tsx b/web-console/src/views/load-data-view/schema-table/schema-table.tsx
index c334d8aaefe..ecb50e5206e 100644
--- a/web-console/src/views/load-data-view/schema-table/schema-table.tsx
+++ b/web-console/src/views/load-data-view/schema-table/schema-table.tsx
@@ -21,7 +21,6 @@ import React from 'react';
import ReactTable from 'react-table';
import { TableCell } from '../../../components';
-import { caseInsensitiveContains, filterMap, sortWithPrefixSuffix } from '../../../utils';
import {
DimensionSpec,
DimensionsSpec,
@@ -30,7 +29,8 @@ import {
getMetricSpecName,
inflateDimensionSpec,
MetricSpec,
-} from '../../../utils/ingestion-spec';
+} from '../../../druid-models';
+import { caseInsensitiveContains, filterMap, sortWithPrefixSuffix } from '../../../utils';
import { HeaderAndRows, SampleEntry } from '../../../utils/sampler';
import './schema-table.scss';
@@ -99,7 +99,7 @@ export const SchemaTable = React.memo(function SchemaTable(props: SchemaTablePro
className: columnClassName,
id: String(i),
accessor: (row: SampleEntry) => (row.parsed ? row.parsed[columnName] : null),
- Cell: row => ,
+ Cell: ({ value }) => ,
};
} else {
const timestamp = columnName === '__time';
diff --git a/web-console/src/views/load-data-view/transform-table/transform-table.tsx b/web-console/src/views/load-data-view/transform-table/transform-table.tsx
index 76dc1bf63ac..eb7c1bcf453 100644
--- a/web-console/src/views/load-data-view/transform-table/transform-table.tsx
+++ b/web-console/src/views/load-data-view/transform-table/transform-table.tsx
@@ -21,9 +21,9 @@ import React from 'react';
import ReactTable from 'react-table';
import { TableCell } from '../../../components';
+import { Transform } from '../../../druid-models';
import { caseInsensitiveContains, filterMap } from '../../../utils';
import { escapeColumnName } from '../../../utils/druid-expression';
-import { Transform } from '../../../utils/ingestion-spec';
import { HeaderAndRows, SampleEntry } from '../../../utils/sampler';
import './transform-table.scss';
diff --git a/web-console/src/views/lookups-view/lookups-view.tsx b/web-console/src/views/lookups-view/lookups-view.tsx
index 3666263dd62..1b2218c06ce 100644
--- a/web-console/src/views/lookups-view/lookups-view.tsx
+++ b/web-console/src/views/lookups-view/lookups-view.tsx
@@ -32,8 +32,8 @@ import {
ViewControlBar,
} from '../../components';
import { AsyncActionDialog, LookupEditDialog } from '../../dialogs/';
-import { LookupSpec } from '../../dialogs/lookup-edit-dialog/lookup-edit-dialog';
import { LookupTableActionDialog } from '../../dialogs/lookup-table-action-dialog/lookup-table-action-dialog';
+import { LookupSpec } from '../../druid-models';
import { AppToaster } from '../../singletons/toaster';
import {
getDruidErrorMessage,
@@ -167,7 +167,7 @@ export class LookupsView extends React.PureComponent {
+ const target: any = lookupEntriesAndTiers.lookupEntries.find(lookupEntry => {
return lookupEntry.tier === tier && lookupEntry.id === id;
});
if (id === '') {
@@ -179,7 +179,7 @@ export class LookupsView extends React.PureComponent
{parsedQuery &&
- (columnData.DATA_TYPE === 'BIGINT' ||
- columnData.DATA_TYPE === 'FLOAT') && (
+ oneOf(columnData.DATA_TYPE, 'BIGINT', 'FLOAT') && (
= {
'Start',
'End',
'Version',
+ 'Time span',
+ 'Partitioning',
'Partition',
'Size',
'Num rows',
@@ -87,6 +91,7 @@ const tableColumns: Record = {
'Start',
'End',
'Version',
+ 'Partitioning',
'Partition',
'Size',
'Num rows',
@@ -127,7 +132,9 @@ interface SegmentQueryResultRow {
end: string;
segment_id: string;
version: string;
- size: 0;
+ time_span: string;
+ partitioning: string;
+ size: number;
partition_num: number;
num_rows: number;
num_replicas: number;
@@ -153,6 +160,31 @@ export interface SegmentsViewState {
export class SegmentsView extends React.PureComponent {
static PAGE_SIZE = 25;
+ static WITH_QUERY = `WITH s AS (
+ SELECT
+ "segment_id", "datasource", "start", "end", "size", "version",
+ CASE
+ WHEN "start" LIKE '%-01-01T00:00:00.000Z' AND "end" LIKE '%-01-01T00:00:00.000Z' THEN 'Year'
+ WHEN "start" LIKE '%-01T00:00:00.000Z' AND "end" LIKE '%-01T00:00:00.000Z' THEN 'Month'
+ WHEN "start" LIKE '%T00:00:00.000Z' AND "end" LIKE '%T00:00:00.000Z' THEN 'Day'
+ WHEN "start" LIKE '%:00:00.000Z' AND "end" LIKE '%:00:00.000Z' THEN 'Hour'
+ WHEN "start" LIKE '%:00.000Z' AND "end" LIKE '%:00.000Z' THEN 'Minute'
+ ELSE 'Sub minute'
+ END AS "time_span",
+ 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":"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",
+ "partition_num", "num_replicas", "num_rows",
+ "is_published", "is_available", "is_realtime", "is_overshadowed"
+ FROM sys.segments
+)`;
+
private segmentsSqlQueryManager: QueryManager;
private segmentsNoSqlQueryManager: QueryManager;
@@ -178,12 +210,10 @@ export class SegmentsView extends React.PureComponent {
- const totalQuerySize = (query.page + 1) * query.pageSize;
-
const whereParts = filterMap(query.filtered, (f: Filter) => {
if (f.id.startsWith('is_')) {
if (f.value === 'all') return;
- return `${JSON.stringify(f.id)} = ${f.value === 'true' ? 1 : 0}`;
+ return SqlRef.columnWithQuotes(f.id).equal(f.value === 'true' ? 1 : 0);
} else {
return sqlQueryCustomTableFilter(f);
}
@@ -193,17 +223,18 @@ export class SegmentsView extends React.PureComponent {
this.setState({
@@ -270,23 +301,27 @@ export class SegmentsView extends React.PureComponent {
- return {
- segment_id: segment.identifier,
- datasource: segment.dataSource,
- start: segment.interval.split('/')[0],
- end: segment.interval.split('/')[1],
- version: segment.version,
- partition_num: segment.shardSpec.partitionNum ? 0 : segment.shardSpec.partitionNum,
- size: segment.size,
- num_rows: -1,
- num_replicas: -1,
- is_available: -1,
- is_published: -1,
- is_realtime: -1,
- is_overshadowed: -1,
- };
- });
+ return segments.map(
+ (segment: any): SegmentQueryResultRow => {
+ return {
+ segment_id: segment.identifier,
+ datasource: segment.dataSource,
+ start: segment.interval.split('/')[0],
+ end: segment.interval.split('/')[1],
+ version: segment.version,
+ time_span: '-',
+ partitioning: '-',
+ partition_num: deepGet(segment, 'shardSpec.partitionNum') || 0,
+ size: segment.size,
+ num_rows: -1,
+ num_replicas: -1,
+ is_available: -1,
+ is_published: -1,
+ is_realtime: -1,
+ is_overshadowed: -1,
+ };
+ },
+ );
}),
);
@@ -387,6 +422,23 @@ export class SegmentsView extends React.PureComponent formatInteger(d.num_rows)).concat('(unknown)');
+ const renderFilterableCell = (field: string) => {
+ return (row: { value: any }) => {
+ const value = row.value;
+ return (
+ {
+ this.setState({
+ segmentFilter: addFilter(segmentFilter, field, value),
+ });
+ }}
+ >
+ {value}
+
+ );
+ };
+ };
+
return (
{
- const value = row.value;
- return (
- {
- this.setState({ segmentFilter: addFilter(segmentFilter, 'datasource', value) });
- }}
- >
- {value}
-
- );
- },
+ Cell: renderFilterableCell('datasource'),
},
{
Header: 'Interval',
@@ -440,18 +481,7 @@ export class SegmentsView extends React.PureComponent {
- const value = row.value;
- return (
- {
- this.setState({ segmentFilter: addFilter(segmentFilter, 'interval', value) });
- }}
- >
- {value}
-
- );
- },
+ Cell: renderFilterableCell('interval'),
},
{
Header: 'Start',
@@ -459,18 +489,7 @@ export class SegmentsView extends React.PureComponent {
- const value = row.value;
- return (
- {
- this.setState({ segmentFilter: addFilter(segmentFilter, 'start', value) });
- }}
- >
- {value}
-
- );
- },
+ Cell: renderFilterableCell('start'),
},
{
Header: 'End',
@@ -478,18 +497,7 @@ export class SegmentsView extends React.PureComponent {
- const value = row.value;
- return (
- {
- this.setState({ segmentFilter: addFilter(segmentFilter, 'end', value) });
- }}
- >
- {value}
-
- );
- },
+ Cell: renderFilterableCell('end'),
},
{
Header: 'Version',
@@ -498,6 +506,22 @@ export class SegmentsView extends React.PureComponent {
it('action services view', () => {
const servicesView = shallow(
- {}}
- goToTask={() => {}}
- capabilities={Capabilities.FULL}
- />,
+ {}} goToTask={() => {}} capabilities={Capabilities.FULL} />,
);
expect(servicesView).toMatchSnapshot();
});
diff --git a/web-console/src/views/services-view/services-view.tsx b/web-console/src/views/services-view/services-view.tsx
index a5069a93dd3..1f0280dd443 100644
--- a/web-console/src/views/services-view/services-view.tsx
+++ b/web-console/src/views/services-view/services-view.tsx
@@ -37,18 +37,20 @@ import {
import { AsyncActionDialog } from '../../dialogs';
import {
addFilter,
+ Capabilities,
+ CapabilitiesMode,
+ deepGet,
formatBytes,
formatBytesCompact,
LocalStorageKeys,
lookupBy,
+ oneOf,
queryDruidSql,
QueryManager,
QueryState,
} from '../../utils';
import { BasicAction } from '../../utils/basic-action';
-import { Capabilities, CapabilitiesMode } from '../../utils/capabilities';
import { LocalStorageBackedArray } from '../../utils/local-storage-backed-array';
-import { deepGet } from '../../utils/object-change';
import './services-view.scss';
@@ -92,7 +94,6 @@ function formatQueues(
}
export interface ServicesViewProps {
- middleManager: string | undefined;
goToQuery: (initSql: string) => void;
goToTask: (taskId: string) => void;
capabilities: Capabilities;
@@ -326,8 +327,7 @@ ORDER BY "rank" DESC, "service" DESC`;
show: hiddenColumns.exists('Type'),
accessor: 'service_type',
width: 150,
- Cell: row => {
- const value = row.value;
+ Cell: ({ value }) => {
return (
{
@@ -348,8 +348,7 @@ ORDER BY "rank" DESC, "service" DESC`;
accessor: row => {
return row.tier ? row.tier : row.worker ? row.worker.category : null;
},
- Cell: row => {
- const value = row.value;
+ Cell: ({ value }) => {
return (
{
@@ -428,7 +427,7 @@ ORDER BY "rank" DESC, "service" DESC`;
width: 100,
filterable: false,
accessor: row => {
- if (row.service_type === 'middle_manager' || row.service_type === 'indexer') {
+ if (oneOf(row.service_type, 'middle_manager', 'indexer')) {
return row.worker ? (row.currCapacityUsed || 0) / row.worker.capacity : null;
} else {
return row.max_size ? row.curr_size / row.max_size : null;
@@ -488,7 +487,7 @@ ORDER BY "rank" DESC, "service" DESC`;
width: 400,
filterable: false,
accessor: row => {
- if (row.service_type === 'middle_manager' || row.service_type === 'indexer') {
+ if (oneOf(row.service_type, 'middle_manager', 'indexer')) {
if (deepGet(row, 'worker.version') === '') return 'Disabled';
const details: string[] = [];
@@ -551,10 +550,10 @@ ORDER BY "rank" DESC, "service" DESC`;
width: ACTION_COLUMN_WIDTH,
accessor: row => row.worker,
filterable: false,
- Cell: row => {
- if (!row.value) return null;
- const disabled = row.value.version === '';
- const workerActions = this.getWorkerActions(row.value.host, disabled);
+ Cell: ({ value }) => {
+ if (!value) return null;
+ const disabled = value.version === '';
+ const workerActions = this.getWorkerActions(value.host, disabled);
return ;
},
},
diff --git a/web-console/webpack.config.js b/web-console/webpack.config.js
index 533792a6892..2bf795e0ce2 100644
--- a/web-console/webpack.config.js
+++ b/web-console/webpack.config.js
@@ -61,7 +61,7 @@ module.exports = env => {
},
target: 'web',
resolve: {
- extensions: ['.tsx', '.ts', '.html', '.js', '.json', '.scss', '.css'],
+ extensions: ['.tsx', '.ts', '.js', '.scss', '.css'],
},
devServer: {
publicPath: '/public',