blob: 520dfc3034a5aa3ca39fffaa9fbac2d2e7e467d2 [file] [log] [blame]
/*
* 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 type { IconName } from '@blueprintjs/core';
import {
AnchorButton,
Button,
ButtonGroup,
Callout,
Card,
Code,
FormGroup,
H5,
Icon,
InputGroup,
Intent,
Menu,
MenuItem,
Popover,
Radio,
RadioGroup,
Switch,
Tag,
TextArea,
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import * as JSONBig from 'json-bigint-native';
import memoize from 'memoize-one';
import type { JSX } from 'react';
import React from 'react';
import {
ArrayIngestModeSwitch,
AutoForm,
CenterMessage,
ClearableInput,
ExternalLink,
FormGroupWithInfo,
JsonInput,
Loader,
PopoverText,
} from '../../components';
import { AlertDialog, AsyncActionDialog, DiffDialog } from '../../dialogs';
import type {
ArrayIngestMode,
DimensionSpec,
DruidFilter,
FlattenField,
IngestionComboTypeWithExtra,
IngestionSpec,
InputFormat,
IoConfig,
MetricSpec,
SchemaMode,
TimestampSpec,
Transform,
TuningConfig,
} from '../../druid-models';
import {
addTimestampTransform,
adjustForceGuaranteedRollup,
adjustId,
BATCH_INPUT_FORMAT_FIELDS,
changeFlattenSpec,
chooseByBestTimestamp,
cleanSpec,
computeFlattenPathsForData,
CONSTANT_TIMESTAMP_SPEC,
CONSTANT_TIMESTAMP_SPEC_FIELDS,
DEFAULT_ARRAY_INGEST_MODE,
DEFAULT_FORCE_SEGMENT_SORT_BY_TIME,
DEFAULT_SCHEMA_MODE,
DIMENSION_SPEC_FIELDS,
fillDataSourceNameIfNeeded,
fillInputFormatIfNeeded,
FILTER_FIELDS,
FILTERS_FIELDS,
FLATTEN_FIELD_FIELDS,
getArrayIngestMode,
getDimensionSpecName,
getFlattenSpec,
getForceSegmentSortByTime,
getIngestionComboType,
getIngestionImage,
getIngestionTitle,
getIoConfigFormFields,
getIoConfigTuningFormFields,
getIssueWithSpec,
getMetricSpecName,
getPossibleSystemFieldsForSpec,
getRequiredModule,
getRollup,
getSchemaMode,
getSecondaryPartitionRelatedFormFields,
getSpecType,
getTimestampExpressionFields,
getTimestampSchema,
getTuningFormFields,
inputFormatCanProduceNestedData,
invalidIoConfig,
invalidPartitionConfig,
isDruidSource,
isEmptyIngestionSpec,
isFixedFormatSource,
isKafkaOrKinesis,
isStreamingSpec,
issueWithIoConfig,
issueWithSampleData,
joinFilter,
KAFKA_METADATA_INPUT_FORMAT_FIELDS,
KINESIS_METADATA_INPUT_FORMAT_FIELDS,
KNOWN_FILTER_TYPES,
MAX_INLINE_DATA_LENGTH,
METRIC_SPEC_FIELDS,
normalizeSpec,
possibleDruidFormatForValues,
PRIMARY_PARTITION_RELATED_FORM_FIELDS,
removeTimestampTransform,
showArrayIngestModeToggle,
splitFilter,
STREAMING_INPUT_FORMAT_FIELDS,
TIME_COLUMN,
TIMESTAMP_SPEC_FIELDS,
TRANSFORM_FIELDS,
updateIngestionType,
updateSchemaWithSample,
upgradeSpec,
} from '../../druid-models';
import { getSpecDatasourceName } from '../../helpers';
import { getLink } from '../../links';
import { Api, AppToaster, UrlBaser } from '../../singletons';
import {
alphanumericCompare,
compact,
deepDelete,
deepGet,
deepMove,
deepSet,
deepSetMulti,
deleteKeys,
EMPTY_ARRAY,
EMPTY_OBJECT,
filterMap,
getApiArray,
getDruidErrorMessage,
localStorageGetJson,
LocalStorageKeys,
localStorageSetJson,
moveElement,
moveToIndex,
pluralIfNeeded,
QueryState,
} from '../../utils';
import type {
CacheRows,
SampleEntry,
SampleResponse,
SampleResponseWithExtraInfo,
SampleStrategy,
} from '../../utils/sampler';
import {
getCacheRowsFromSampleResponse,
getHeaderNamesFromSampleResponse,
getProxyOverlordModules,
guessDimensionsFromSampleResponse,
sampleForConnect,
sampleForFilter,
sampleForParser,
sampleForSchema,
sampleForTimestamp,
sampleForTransform,
} from '../../utils/sampler';
import { ExamplePicker } from './example-picker/example-picker';
import { EXAMPLE_SPECS } from './example-specs';
import { FilterTable, filterTableSelectedColumnName } from './filter-table/filter-table';
import { FormEditor } from './form-editor/form-editor';
import {
AppendToExistingIssue,
ConnectMessage,
FilterMessage,
ParserMessage,
PartitionMessage,
PublishMessage,
SchemaMessage,
SpecMessage,
TimestampMessage,
TransformMessage,
TuningMessage,
} from './info-messages';
import { INGESTION_SPEC_COMPLETIONS } from './ingestion-spec-completions';
import { ParseDataTable } from './parse-data-table/parse-data-table';
import {
ParseTimeTable,
parseTimeTableSelectedColumnName,
} from './parse-time-table/parse-time-table';
import { ReorderMenu } from './reorder-menu/reorder-menu';
import { SchemaTable } from './schema-table/schema-table';
import {
TransformTable,
transformTableSelectedColumnName,
} from './transform-table/transform-table';
import './load-data-view.scss';
function showRawLine(line: SampleEntry): string {
if (!line.parsed) return 'No parse';
const raw = line.parsed.raw;
if (typeof raw !== 'string') return String(raw);
if (raw.includes('\n')) {
return `[Multi-line row, length: ${raw.length}]`;
}
if (raw.length > 1000) {
return raw.slice(0, 1000) + '...';
}
return raw;
}
function showDruidLine(line: SampleEntry): string {
if (!line.input) return 'Invalid druid row';
return `[Druid row: ${JSONBig.stringify(line.input)}]`;
}
function showKafkaLine(line: SampleEntry): string {
const { input } = line;
if (!input) return 'Invalid kafka row';
return compact([
`[ Kafka timestamp: ${input['kafka.timestamp']}`,
` Topic: ${input['kafka.topic']}`,
...filterMap(Object.entries(input), ([k, v]) => {
if (!k.startsWith('kafka.header.')) return;
return ` Header: ${k.slice(13)}=${v}`;
}),
input['kafka.key'] ? ` Key: ${input['kafka.key']}` : undefined,
` Payload: ${input.raw}`,
']',
]).join('\n');
}
function showKinesisLine(line: SampleEntry): string {
const { input } = line;
if (!input) return 'Invalid kinesis row';
return compact([
`[ Kinesis timestamp: ${input['kinesis.timestamp']}`,
input['kinesis.partitionKey'] ? ` Partition key: ${input['kinesis.partitionKey']}` : undefined,
` Payload: ${input.raw}`,
']',
]).join('\n');
}
function showBlankLine(line: SampleEntry): string {
return line.parsed ? `[Row: ${JSONBig.stringify(line.parsed)}]` : '[Binary data]';
}
function formatSampleEntries(
sampleEntries: SampleEntry[],
specialSource: undefined | 'fixedFormat' | 'druid' | 'kafka' | 'kinesis',
): string[] {
if (!sampleEntries.length) return ['No data returned from sampler'];
switch (specialSource) {
case 'fixedFormat':
return sampleEntries.map(l => JSONBig.stringify(l.parsed));
case 'druid':
return sampleEntries.map(showDruidLine);
case 'kafka':
return sampleEntries.map(showKafkaLine);
case 'kinesis':
return sampleEntries.map(showKinesisLine);
default:
return sampleEntries.every(l => !l.parsed)
? sampleEntries.map(showBlankLine)
: sampleEntries.map(showRawLine);
}
}
function getTimestampSpec(sampleResponse: SampleResponse | null): TimestampSpec {
if (!sampleResponse) return CONSTANT_TIMESTAMP_SPEC;
const timestampSpecs = filterMap(
getHeaderNamesFromSampleResponse(sampleResponse),
sampleHeader => {
const possibleFormat = possibleDruidFormatForValues(
filterMap(sampleResponse.data, d => (d.parsed ? d.parsed[sampleHeader] : undefined)),
);
if (!possibleFormat) return;
return {
column: sampleHeader,
format: possibleFormat,
};
},
);
return chooseByBestTimestamp(timestampSpecs) || CONSTANT_TIMESTAMP_SPEC;
}
function initializeSchemaWithSampleIfNeeded(
spec: Partial<IngestionSpec>,
sample: SampleResponse,
): Partial<IngestionSpec> {
if (deepGet(spec, 'spec.dataSchema.dimensionsSpec')) return spec;
return updateSchemaWithSample(
spec,
sample,
DEFAULT_FORCE_SEGMENT_SORT_BY_TIME,
DEFAULT_SCHEMA_MODE,
DEFAULT_ARRAY_INGEST_MODE,
getRollup(spec, false),
);
}
type Step =
| 'welcome'
| 'connect'
| 'parser'
| 'timestamp'
| 'transform'
| 'filter'
| 'schema'
| 'partition'
| 'tuning'
| 'publish'
| 'spec'
| 'loading';
const STEPS: Step[] = [
'welcome',
'connect',
'parser',
'timestamp',
'transform',
'filter',
'schema',
'partition',
'tuning',
'publish',
'spec',
'loading',
];
const SECTIONS: { name: string; steps: Step[] }[] = [
{ name: 'Connect and parse raw data', steps: ['welcome', 'connect', 'parser'] },
{
name: 'Transform data and configure schema',
steps: ['timestamp', 'transform', 'filter', 'schema'],
},
{ name: 'Tune parameters', steps: ['partition', 'tuning', 'publish'] },
{ name: 'Verify and submit', steps: ['spec'] },
];
const VIEW_TITLE: Record<Step, string> = {
welcome: 'Start',
connect: 'Connect',
parser: 'Parse data',
timestamp: 'Parse time',
transform: 'Transform',
filter: 'Filter',
schema: 'Configure schema',
partition: 'Partition',
tuning: 'Tune',
publish: 'Publish',
spec: 'Edit spec',
loading: 'Loading',
};
export type LoadDataViewMode = 'all' | 'streaming' | 'batch';
export interface LoadDataViewProps {
mode: LoadDataViewMode;
initSupervisorId?: string;
initTaskId?: string;
goToSupervisor: (supervisorId: string) => void;
openSupervisorSubmit: () => void;
goToTasks: (taskGroupId: string) => void;
openTaskSubmit: () => void;
}
interface SelectedIndex<T> {
value: Partial<T>;
index: number;
}
export interface LoadDataViewState {
step: Step;
spec: Partial<IngestionSpec>;
nextSpec?: Partial<IngestionSpec>;
cacheRows?: CacheRows;
// dialogs / modals
continueToSpec: boolean;
showResetConfirm: boolean;
newRollup?: boolean;
newForceSegmentSortByTime?: boolean;
newSchemaMode?: SchemaMode;
newArrayIngestMode?: ArrayIngestMode;
// welcome
overlordModules?: string[];
selectedComboType?: IngestionComboTypeWithExtra;
// general
sampleStrategy: SampleStrategy;
columnFilter: string;
specialColumnsOnly: boolean;
unsavedChange: boolean;
// for ioConfig
inputQueryState: QueryState<SampleResponseWithExtraInfo>;
// for parser
parserQueryState: QueryState<SampleResponse>;
// for flatten
selectedFlattenField?: SelectedIndex<FlattenField>;
// for timestamp
timestampQueryState: QueryState<{
sampleResponse: SampleResponse;
spec: Partial<IngestionSpec>;
}>;
// for transform
transformQueryState: QueryState<SampleResponse>;
selectedTransform?: SelectedIndex<Transform>;
// for filter
filterQueryState: QueryState<SampleResponse>;
selectedFilter?: SelectedIndex<DruidFilter>;
// for schema
schemaQueryState: QueryState<{
sampleResponse: SampleResponse;
dimensions: (string | DimensionSpec)[] | undefined;
metricsSpec: MetricSpec[] | undefined;
definedDimensions: boolean;
}>;
selectedAutoDimension?: string;
selectedDimensionSpec?: SelectedIndex<DimensionSpec>;
selectedMetricSpec?: SelectedIndex<MetricSpec>;
// for final step
existingDatasources?: string[];
submitting: boolean;
currentSupervisorSpec?: Partial<IngestionSpec>;
showDiffWithCurrent: boolean;
}
export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDataViewState> {
static MODE_TO_KEY: Record<LoadDataViewMode, LocalStorageKeys> = {
all: LocalStorageKeys.INGESTION_SPEC,
streaming: LocalStorageKeys.STREAMING_INGESTION_SPEC,
batch: LocalStorageKeys.BATCH_INGESTION_SPEC,
};
private readonly localStorageKey: LocalStorageKeys;
constructor(props: LoadDataViewProps) {
super(props);
this.localStorageKey = LoadDataView.MODE_TO_KEY[props.mode];
let spec = localStorageGetJson(this.localStorageKey);
if (!spec || typeof spec !== 'object') spec = {};
this.state = {
step: 'loading',
spec,
// dialogs / modals
showResetConfirm: false,
continueToSpec: false,
// general
sampleStrategy: 'start',
columnFilter: '',
specialColumnsOnly: false,
unsavedChange: false,
// for inputSource
inputQueryState: QueryState.INIT,
// for parser
parserQueryState: QueryState.INIT,
// for timestamp
timestampQueryState: QueryState.INIT,
// for transform
transformQueryState: QueryState.INIT,
// for filter
filterQueryState: QueryState.INIT,
// for dimensions
schemaQueryState: QueryState.INIT,
// for final step
submitting: false,
showDiffWithCurrent: false,
};
}
componentDidMount(): void {
const { initTaskId, initSupervisorId } = this.props;
const { spec } = this.state;
void this.getOverlordModules();
if (initTaskId) {
this.updateStep('loading');
void this.getTaskJson();
} else if (initSupervisorId) {
this.updateStep('loading');
void this.getSupervisorJson();
} else if (isEmptyIngestionSpec(spec)) {
this.updateStep('welcome');
} else {
this.updateStep('connect');
}
if (isEmptyIngestionSpec(spec)) {
this.setState({ continueToSpec: true });
}
}
async getOverlordModules() {
let overlordModules: string[];
try {
overlordModules = await getProxyOverlordModules();
} catch (e) {
AppToaster.show({
message: `Failed to get the list of loaded modules from the overlord: ${e.message}`,
intent: Intent.DANGER,
});
this.setState({ overlordModules: undefined });
return;
}
this.setState({ overlordModules });
}
isStepEnabled(step: Step): boolean {
const { spec, cacheRows } = this.state;
const ioConfig: IoConfig = deepGet(spec, 'spec.ioConfig') || EMPTY_OBJECT;
switch (step) {
case 'connect':
return Boolean(spec.type);
case 'parser':
return Boolean(!isFixedFormatSource(spec) && spec.type && !issueWithIoConfig(ioConfig));
case 'timestamp':
return Boolean(
!isDruidSource(spec) && cacheRows && deepGet(spec, 'spec.dataSchema.timestampSpec'),
);
case 'transform':
case 'filter':
return Boolean(cacheRows && deepGet(spec, 'spec.dataSchema.timestampSpec'));
case 'schema':
case 'partition':
case 'tuning':
case 'publish':
return Boolean(
cacheRows &&
deepGet(spec, 'spec.dataSchema.timestampSpec') &&
deepGet(spec, 'spec.dataSchema.dimensionsSpec'),
);
default:
return true;
}
}
private readonly handleDirty = () => {
this.setState({ unsavedChange: true });
};
private readonly updateStep = (newStep: Step) => {
const { unsavedChange, nextSpec } = this.state;
if (unsavedChange || nextSpec) {
AppToaster.show({
message: `You have an unsaved change in this step.`,
intent: Intent.WARNING,
action: {
icon: IconNames.TRASH,
text: 'Discard change',
onClick: this.resetSelected,
},
});
return;
}
this.resetSelected();
this.setState({ step: newStep });
};
private readonly updateSpec = (newSpec: Partial<IngestionSpec>) => {
newSpec = normalizeSpec(newSpec);
try {
newSpec = upgradeSpec(newSpec);
} catch (e) {
const streaming = isStreamingSpec(newSpec);
newSpec = {};
AppToaster.show({
icon: IconNames.ERROR,
intent: Intent.DANGER,
timeout: 30000,
message: (
<>
<p>
This spec can not be used in the data loader because it can not be auto-converted to
the latest spec format:
</p>
<p>{e.message}</p>
<p>You can still submit it directly form the Ingestion view.</p>
</>
),
action: {
text: `Go to ${streaming ? 'Supervisors' : 'Tasks'} view`,
onClick: streaming ? this.props.openSupervisorSubmit : this.props.openTaskSubmit,
},
});
}
const deltaState: Partial<LoadDataViewState> = { spec: newSpec };
if (!deepGet(newSpec, 'spec.ioConfig.type')) {
deltaState.cacheRows = undefined;
}
this.setState(deltaState as LoadDataViewState);
localStorageSetJson(this.localStorageKey, newSpec);
};
private readonly updateSpecPreview = (newSpecPreview: Partial<IngestionSpec>) => {
this.setState({ nextSpec: newSpecPreview });
};
private readonly applyPreviewSpec = () => {
this.setState(({ spec, nextSpec }) => {
if (nextSpec) {
localStorageSetJson(this.localStorageKey, nextSpec);
}
return { spec: nextSpec ? nextSpec : { ...spec }, nextSpec: undefined }; // If applying again, make a shallow copy to force a refresh
});
};
private getEffectiveSpec() {
const { spec, nextSpec } = this.state;
return nextSpec || spec;
}
private readonly resetSelected = () => {
this.setState({
nextSpec: undefined,
selectedFlattenField: undefined,
selectedTransform: undefined,
selectedFilter: undefined,
selectedAutoDimension: undefined,
selectedDimensionSpec: undefined,
selectedMetricSpec: undefined,
unsavedChange: false,
});
};
componentDidUpdate(_prevProps: LoadDataViewProps, prevState: LoadDataViewState) {
const { spec, step } = this.state;
const { spec: prevSpec, step: prevStep } = prevState;
if (spec !== prevSpec || step !== prevStep) {
this.doQueryForStep(step !== prevStep);
}
}
doQueryForStep(initRun: boolean): any {
const { step } = this.state;
switch (step) {
case 'connect':
return this.queryForConnect(initRun);
case 'parser':
return this.queryForParser(initRun);
case 'timestamp':
return this.queryForTimestamp(initRun);
case 'transform':
return this.queryForTransform(initRun);
case 'filter':
return this.queryForFilter(initRun);
case 'schema':
return this.queryForSchema(initRun);
case 'loading':
case 'welcome':
case 'partition':
case 'publish':
case 'tuning':
return;
case 'spec':
return this.queryForSpec();
}
}
renderActionCard(icon: IconName, title: string, caption: string, onClick: () => void) {
return (
<Card className="spec-card" interactive onClick={onClick} elevation={1}>
<Icon className="spec-card-icon" icon={icon} size={30} />
<div className="spec-card-header">
{title}
<div className="spec-card-caption">{caption}</div>
</div>
</Card>
);
}
render() {
const { mode } = this.props;
const { step, continueToSpec } = this.state;
const type = mode === 'all' ? '' : `${mode} `;
if (!continueToSpec) {
return (
<div className={classNames('load-data-continue-view load-data-view')}>
{this.renderActionCard(
IconNames.ASTERISK,
`Start a new ${type}spec`,
`Begin a new ${type}ingestion flow.`,
this.handleResetSpec,
)}
{this.renderActionCard(
IconNames.REPEAT,
`Continue from previous ${type}spec`,
`Go back to the most recent ${type}ingestion flow you were working on.`,
this.handleContinueSpec,
)}
</div>
);
}
return (
<div className={classNames('load-data-view', 'app-view', step)}>
{this.renderStepNav()}
{step === 'loading' && <Loader />}
{step === 'welcome' && this.renderWelcomeStep()}
{step === 'connect' && this.renderConnectStep()}
{step === 'parser' && this.renderParserStep()}
{step === 'timestamp' && this.renderTimestampStep()}
{step === 'transform' && this.renderTransformStep()}
{step === 'filter' && this.renderFilterStep()}
{step === 'schema' && this.renderSchemaStep()}
{step === 'partition' && this.renderPartitionStep()}
{step === 'tuning' && this.renderTuningStep()}
{step === 'publish' && this.renderPublishStep()}
{step === 'spec' && this.renderSpecStep()}
{this.renderResetConfirm()}
</div>
);
}
renderApplyButtonBar(queryState: QueryState<unknown>, issue: string | undefined) {
const { nextSpec } = this.state;
const queryStateHasError = Boolean(queryState && queryState.error);
return (
<FormGroup className="control-buttons">
{nextSpec && <Button text="Cancel" onClick={this.resetSelected} />}
<Button
text="Apply"
disabled={(!nextSpec && !queryStateHasError) || Boolean(issue)}
intent={Intent.PRIMARY}
onClick={this.applyPreviewSpec}
/>
</FormGroup>
);
}
renderStepNav() {
const { step } = this.state;
return (
<div className="step-nav">
<div className="step-nav-inner">
{SECTIONS.map(section => (
<div className="step-section" key={section.name}>
<div className="step-nav-l1">{section.name}</div>
<ButtonGroup className="step-nav-l2">
{section.steps.map(s => (
<Button
className={s}
key={s}
active={s === step}
onClick={() => this.updateStep(s)}
icon={s === 'spec' && IconNames.MANUALLY_ENTERED_DATA}
text={VIEW_TITLE[s]}
disabled={!this.isStepEnabled(s)}
/>
))}
</ButtonGroup>
</div>
))}
</div>
</div>
);
}
renderNextBar(options: { nextStep?: Step; disabled?: boolean; onNextStep?: () => boolean }) {
const { disabled, onNextStep } = options;
const { step, nextSpec, unsavedChange } = this.state;
const nextStep = options.nextStep || STEPS[STEPS.indexOf(step) + 1] || STEPS[0];
return (
<div className="next-bar">
<Button
text={`Next: ${VIEW_TITLE[nextStep]}`}
rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
disabled={Boolean(disabled || nextSpec || unsavedChange)}
onClick={() => {
if (onNextStep && !onNextStep()) return;
this.updateStep(nextStep);
}}
/>
</div>
);
}
// ==================================================================
renderIngestionCard(
comboType: IngestionComboTypeWithExtra,
disabled?: boolean,
): JSX.Element | undefined {
const { overlordModules, selectedComboType, spec } = this.state;
const requiredModule = getRequiredModule(comboType);
const goodToGo =
!disabled &&
(!requiredModule || !overlordModules || overlordModules.includes(requiredModule));
return (
<Card
className={classNames('ingestion-card', {
disabled: !goodToGo,
active: selectedComboType === comboType,
})}
interactive
elevation={1}
onClick={e => {
if (e.altKey && e.shiftKey) {
this.updateSpec(updateIngestionType(spec, comboType as any));
this.updateStep('connect');
} else {
this.setState({
selectedComboType: selectedComboType !== comboType ? comboType : undefined,
});
}
}}
>
<img
src={UrlBaser.base(`/assets/${getIngestionImage(comboType)}.png`)}
alt={`Ingestion tile for ${comboType}`}
/>
<p>{getIngestionTitle(comboType)}</p>
</Card>
);
}
renderWelcomeStep() {
const { mode } = this.props;
const { spec } = this.state;
const welcomeMessage = this.renderWelcomeStepMessage();
return (
<>
<div className="main">
<div className="ingestion-cards">
{mode !== 'batch' && (
<>
{this.renderIngestionCard('kafka')}
{this.renderIngestionCard('kinesis')}
{this.renderIngestionCard('azure-event-hubs')}
</>
)}
{mode !== 'streaming' && (
<>
{this.renderIngestionCard('index_parallel:s3')}
{this.renderIngestionCard('index_parallel:azureStorage')}
{this.renderIngestionCard('index_parallel:google')}
{this.renderIngestionCard('index_parallel:delta')}
{this.renderIngestionCard('index_parallel:hdfs')}
{this.renderIngestionCard('index_parallel:druid')}
{this.renderIngestionCard('index_parallel:http')}
{this.renderIngestionCard('index_parallel:local')}
{this.renderIngestionCard('index_parallel:inline')}
{this.renderIngestionCard('example')}
</>
)}
{this.renderIngestionCard('other')}
</div>
</div>
<div className="control">
{welcomeMessage && (
<FormGroup>
<Callout>{welcomeMessage}</Callout>
</FormGroup>
)}
{this.renderWelcomeStepControls()}
{!isEmptyIngestionSpec(spec) && (
<Button icon={IconNames.RESET} text="Reset spec" onClick={this.handleResetConfirm} />
)}
</div>
</>
);
}
renderWelcomeStepMessage(): JSX.Element | undefined {
const { selectedComboType } = this.state;
if (!selectedComboType) {
return <p>Please specify where your raw data is located.</p>;
}
const issue = this.selectedIngestionTypeIssue();
if (issue) return issue;
switch (selectedComboType) {
case 'index_parallel:http':
return (
<>
<p>Load data accessible through HTTP(s).</p>
<p>
Data must be in text, orc, or parquet format and the HTTP(s) endpoint must be
reachable by every Druid process in the cluster.
</p>
</>
);
case 'index_parallel:local':
return (
<>
<p>
<em>Recommended only in single server deployments.</em>
</p>
<p>Load data directly from a local file.</p>
<p>
Files must be in text, orc, or parquet format and must be accessible to all the Druid
processes in the cluster.
</p>
</>
);
case 'index_parallel:delta':
return (
<>
<p>Load data from Delta Lake.</p>
<p>Data must be stored in the Delta Lake format.</p>
</>
);
case 'index_parallel:druid':
return (
<>
<p>Reindex data from existing Druid segments.</p>
<p>
Reindexing data allows you to filter rows, add, transform, and delete columns, as well
as change the partitioning of the data.
</p>
</>
);
case 'index_parallel:inline':
return <p>Ingest a small amount of data directly from the clipboard.</p>;
case 'index_parallel:s3':
return <p>Load text based, orc, or parquet data from Amazon S3.</p>;
case 'index_parallel:azureStorage':
return <p>Load text based, orc, or parquet data from Azure.</p>;
case 'index_parallel:google':
return <p>Load text based, orc, or parquet data from the Google Blobstore.</p>;
case 'index_parallel:hdfs':
return <p>Load text based, orc, or parquet data from HDFS.</p>;
case 'kafka':
return <p>Load streaming data in real-time from Apache Kafka.</p>;
case 'kinesis':
return <p>Load streaming data in real-time from Amazon Kinesis.</p>;
case 'azure-event-hubs':
return (
<>
<p>Azure Event Hubs provides an Apache Kafka compatible API for consuming data.</p>
<p>
Data from an Event Hub can be streamed into Druid by enabling the Kafka API on the
Namespace.
</p>
<p>
Please see the{' '}
<ExternalLink href="https://docs.microsoft.com/en-us/azure/event-hubs/event-hubs-for-kafka-ecosystem-overview">
Event Hub documentation
</ExternalLink>{' '}
for more information.
</p>
</>
);
case 'example':
return; // Yield to example picker controls
case 'other':
return (
<p>
If you do not see your source of raw data here, you can try to ingest it by submitting a{' '}
<ExternalLink href={`${getLink('DOCS')}/ingestion/`}>
JSON task or supervisor spec
</ExternalLink>
.
</p>
);
default:
return <p>Unknown ingestion type.</p>;
}
}
renderWelcomeStepControls(): JSX.Element | undefined {
const { openSupervisorSubmit, openTaskSubmit } = this.props;
const { spec, selectedComboType } = this.state;
const issue = this.selectedIngestionTypeIssue();
if (issue) return;
switch (selectedComboType) {
case 'index_parallel:http':
case 'index_parallel:local':
case 'index_parallel:druid':
case 'index_parallel:inline':
case 'index_parallel:s3':
case 'index_parallel:azureStorage':
case 'index_parallel:google':
case 'index_parallel:delta':
case 'index_parallel:hdfs':
case 'kafka':
case 'kinesis':
return (
<FormGroup>
<Button
text="Connect data"
rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
onClick={() => {
this.updateSpec(updateIngestionType(spec, selectedComboType as any));
this.updateStep('connect');
}}
/>
</FormGroup>
);
case 'azure-event-hubs':
return (
<>
<FormGroup>
<Callout intent={Intent.WARNING}>
Please review and fill in the <Code>consumerProperties</Code> on the next step.
</Callout>
</FormGroup>
<FormGroup>
<Button
text="Connect via Kafka API"
rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
onClick={() => {
// Use the kafka ingestion type but preset some consumerProperties required for Event Hubs
let newSpec = updateIngestionType(spec, 'kafka');
newSpec = deepSet(
newSpec,
'spec.ioConfig.consumerProperties.{security.protocol}',
'SASL_SSL',
);
newSpec = deepSet(
newSpec,
'spec.ioConfig.consumerProperties.{sasl.mechanism}',
'PLAIN',
);
newSpec = deepSet(
newSpec,
'spec.ioConfig.consumerProperties.{sasl.jaas.config}',
`org.apache.kafka.common.security.plain.PlainLoginModule required username="$ConnectionString" password="Value of 'Connection string-primary key' in the Azure UI";`,
);
this.updateSpec(newSpec);
this.updateStep('connect');
}}
/>
</FormGroup>
</>
);
case 'example':
return (
<ExamplePicker
exampleSpecs={EXAMPLE_SPECS}
onSelectExample={exampleSpec => {
this.updateSpec(exampleSpec.spec);
this.updateStep('connect');
}}
/>
);
case 'other':
return (
<>
<FormGroup>
<Button
text="Submit supervisor"
rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
onClick={openSupervisorSubmit}
/>
</FormGroup>
<FormGroup>
<Button
text="Submit task"
rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
onClick={openTaskSubmit}
/>
</FormGroup>
</>
);
default:
return;
}
}
selectedIngestionTypeIssue(): JSX.Element | undefined {
const { selectedComboType, overlordModules } = this.state;
if (!selectedComboType || !overlordModules) return;
const requiredModule = getRequiredModule(selectedComboType);
if (!requiredModule || overlordModules.includes(requiredModule)) return;
return (
<>
<p>
{`${getIngestionTitle(selectedComboType)} ingestion requires the `}
<strong>{requiredModule}</strong>
{` extension to be loaded.`}
</p>
<p>
Please make sure that the
<Code>&quot;{requiredModule}&quot;</Code> extension is included in the{' '}
<Code>druid.extensions.loadList</Code>.
</p>
<p>
For more information please refer to the{' '}
<ExternalLink href={`${getLink('DOCS')}/operations/including-extensions`}>
documentation on loading extensions
</ExternalLink>
.
</p>
</>
);
}
private readonly handleResetConfirm = () => {
this.setState({ showResetConfirm: true });
};
private readonly handleResetSpec = () => {
this.setState({ showResetConfirm: false, continueToSpec: true });
this.updateSpec({});
this.updateStep('welcome');
};
private readonly handleContinueSpec = () => {
this.setState({ continueToSpec: true });
};
renderResetConfirm(): JSX.Element | undefined {
const { showResetConfirm } = this.state;
if (!showResetConfirm) return;
return (
<AlertDialog
cancelButtonText="Cancel"
confirmButtonText="Reset spec"
icon="trash"
intent={Intent.DANGER}
isOpen
onCancel={() => this.setState({ showResetConfirm: false })}
onConfirm={this.handleResetSpec}
>
<p>This will discard the current progress in the spec.</p>
</AlertDialog>
);
}
// ==================================================================
async queryForConnect(initRun = false) {
const { spec, sampleStrategy } = this.state;
const ioConfig: IoConfig = deepGet(spec, 'spec.ioConfig') || EMPTY_OBJECT;
let issue: string | undefined;
if (issueWithIoConfig(ioConfig, true)) {
issue = `IoConfig not ready, ${issueWithIoConfig(ioConfig)}`;
}
if (issue) {
this.setState({
inputQueryState: initRun ? QueryState.INIT : new QueryState({ error: new Error(issue) }),
});
return;
}
this.setState({
inputQueryState: new QueryState({ loading: true }),
});
let sampleResponse: SampleResponse;
try {
sampleResponse = await sampleForConnect(spec, sampleStrategy);
} catch (e) {
this.setState({
inputQueryState: new QueryState({ error: e }),
});
return;
}
const deltaState: Partial<LoadDataViewState> = {
inputQueryState: new QueryState({ data: sampleResponse }),
};
if (isFixedFormatSource(spec)) {
deltaState.cacheRows = getCacheRowsFromSampleResponse(sampleResponse);
}
this.setState(deltaState as LoadDataViewState);
}
renderConnectStep() {
const { inputQueryState, sampleStrategy } = this.state;
const spec = this.getEffectiveSpec();
const specType = getSpecType(spec);
const ioConfig: IoConfig = deepGet(spec, 'spec.ioConfig') || EMPTY_OBJECT;
const inlineMode = deepGet(spec, 'spec.ioConfig.inputSource.type') === 'inline';
const fixedFormatSource = isFixedFormatSource(spec);
const druidSource = isDruidSource(spec);
const specialSource = druidSource
? 'druid'
: fixedFormatSource
? 'fixedFormat'
: isKafkaOrKinesis(specType)
? specType
: undefined;
let mainFill: JSX.Element | string;
if (inlineMode) {
mainFill = (
<TextArea
className="inline-data"
placeholder="Paste your data here"
value={deepGet(spec, 'spec.ioConfig.inputSource.data')}
onChange={(e: any) => {
const stringValue = e.target.value.slice(0, MAX_INLINE_DATA_LENGTH);
this.updateSpecPreview(deepSet(spec, 'spec.ioConfig.inputSource.data', stringValue));
}}
/>
);
} else if (inputQueryState.isInit()) {
mainFill = (
<CenterMessage>
Please fill out the fields on the right sidebar to get started{' '}
<Icon icon={IconNames.ARROW_RIGHT} />
</CenterMessage>
);
} else {
const data = inputQueryState.getSomeData();
const inputData = data ? data.data : undefined;
mainFill = (
<>
{inputData && (
<TextArea
className="raw-lines"
readOnly
value={formatSampleEntries(inputData, specialSource).join('\n')}
/>
)}
{inputQueryState.isLoading() && <Loader />}
{inputQueryState.error && (
<CenterMessage>{inputQueryState.getErrorMessage()}</CenterMessage>
)}
</>
);
}
const ingestionComboType = getIngestionComboType(spec);
const ioConfigFields = ingestionComboType
? getIoConfigFormFields(ingestionComboType)
: undefined;
return (
<>
<div className="main">{mainFill}</div>
<div className="control">
<ConnectMessage inlineMode={inlineMode} spec={spec} />
{ioConfigFields ? (
<>
<AutoForm
fields={ioConfigFields}
model={ioConfig}
onChange={c => this.updateSpecPreview(deepSet(spec, 'spec.ioConfig', c))}
/>
{deepGet(spec, 'spec.ioConfig.inputSource.properties.secretAccessKey.password') && (
<FormGroup>
<Callout intent={Intent.WARNING}>
This key will be visible to anyone accessing this console and may appear in
server logs. For production scenarios, use of a more secure secret key type is
strongly recommended.
</Callout>
</FormGroup>
)}
</>
) : (
<FormGroup label="IO Config">
<JsonInput
value={ioConfig}
onChange={c => this.updateSpecPreview(deepSet(spec, 'spec.ioConfig', c))}
height="300px"
/>
</FormGroup>
)}
{deepGet(spec, 'spec.ioConfig.inputSource.type') === 'local' && (
<FormGroup>
<Callout intent={Intent.WARNING}>
This path must be available on the local filesystem of all Druid services.
</Callout>
</FormGroup>
)}
{isStreamingSpec(spec) && (
<FormGroup label="Where should the data be sampled from?">
<RadioGroup
selectedValue={sampleStrategy}
onChange={e => this.setState({ sampleStrategy: e.currentTarget.value as any })}
>
<Radio value="start">Start of stream</Radio>
<Radio value="end">End of stream</Radio>
</RadioGroup>
</FormGroup>
)}
{this.renderApplyButtonBar(
inputQueryState,
ioConfigFields ? AutoForm.issueWithModel(ioConfig, ioConfigFields) : undefined,
)}
</div>
{this.renderNextBar({
disabled: !inputQueryState.data,
nextStep: druidSource ? 'transform' : fixedFormatSource ? 'timestamp' : 'parser',
onNextStep: () => {
if (!inputQueryState.data) return false;
const inputData = inputQueryState.data;
if (druidSource) {
let newSpec = deepSet(spec, 'spec.dataSchema.timestampSpec', {
column: TIME_COLUMN,
format: 'millis',
});
if (typeof inputData.rollup === 'boolean') {
newSpec = deepSet(
newSpec,
'spec.dataSchema.granularitySpec.rollup',
inputData.rollup,
);
}
newSpec = deepSet(
newSpec,
'spec.dataSchema.granularitySpec.queryGranularity',
'none',
);
if (inputData.columns) {
const aggregators = inputData.aggregators || {};
newSpec = deepSet(
newSpec,
'spec.dataSchema.dimensionsSpec.dimensions',
filterMap(inputData.columns, column => {
if (column === TIME_COLUMN || aggregators[column]) return;
return {
name: column,
type: String(inputData.columnInfo?.[column]?.type || 'string').toLowerCase(),
};
}),
);
if (inputData.aggregators) {
newSpec = deepSet(
newSpec,
'spec.dataSchema.metricsSpec',
filterMap(inputData.columns, column => aggregators[column]),
);
}
}
this.updateSpec(fillDataSourceNameIfNeeded(newSpec));
}
if (fixedFormatSource) {
const newSpec = deepSet(
spec,
'spec.dataSchema.timestampSpec',
getTimestampSpec(inputQueryState.data),
);
this.updateSpec(fillDataSourceNameIfNeeded(newSpec));
} else {
const issue = issueWithSampleData(
filterMap(inputData.data, l => l.input?.raw),
isStreamingSpec(spec),
);
if (issue) {
AppToaster.show({
icon: IconNames.WARNING_SIGN,
intent: Intent.WARNING,
message: issue,
timeout: 30000,
});
return false;
}
this.updateSpec(fillDataSourceNameIfNeeded(fillInputFormatIfNeeded(spec, inputData)));
}
return true;
},
})}
</>
);
}
// ==================================================================
async queryForParser(initRun = false) {
const { spec, sampleStrategy } = this.state;
const ioConfig: IoConfig = deepGet(spec, 'spec.ioConfig') || EMPTY_OBJECT;
let issue: string | undefined;
if (issueWithIoConfig(ioConfig)) {
issue = `IoConfig not ready, ${issueWithIoConfig(ioConfig)}`;
}
if (issue) {
this.setState(({ parserQueryState }) => ({
parserQueryState: initRun
? QueryState.INIT
: new QueryState({ error: new Error(issue), lastData: parserQueryState.getSomeData() }),
}));
return;
}
this.setState(({ parserQueryState }) => ({
parserQueryState: new QueryState({ loading: true, lastData: parserQueryState.getSomeData() }),
}));
let sampleResponse: SampleResponse;
try {
sampleResponse = await sampleForParser(spec, sampleStrategy);
} catch (e) {
this.setState(({ parserQueryState }) => ({
parserQueryState: new QueryState({ error: e, lastData: parserQueryState.getSomeData() }),
}));
return;
}
this.setState(({ parserQueryState }) => ({
cacheRows: getCacheRowsFromSampleResponse(sampleResponse),
parserQueryState: new QueryState({
data: sampleResponse,
lastData: parserQueryState.getSomeData(),
}),
}));
}
renderParserStep() {
const { columnFilter, specialColumnsOnly, parserQueryState, selectedFlattenField } = this.state;
const spec = this.getEffectiveSpec();
const inputFormat: InputFormat = deepGet(spec, 'spec.ioConfig.inputFormat') || EMPTY_OBJECT;
const flattenFields: FlattenField[] = getFlattenSpec(spec)?.fields || EMPTY_ARRAY;
const canHaveNestedData = inputFormatCanProduceNestedData(inputFormat);
let mainFill: JSX.Element | string;
if (parserQueryState.isInit()) {
mainFill = (
<CenterMessage>
Please enter the parser details on the right <Icon icon={IconNames.ARROW_RIGHT} />
</CenterMessage>
);
} else {
const data = parserQueryState.getSomeData();
mainFill = (
<div className="table-with-control">
<div className="table-control">
<ClearableInput
value={columnFilter}
onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
{canHaveNestedData && (
<Switch
checked={specialColumnsOnly}
label="Flattened columns only"
onChange={() => this.setState({ specialColumnsOnly: !specialColumnsOnly })}
disabled={!flattenFields.length}
/>
)}
</div>
{data && (
<ParseDataTable
sampleResponse={data}
columnFilter={columnFilter}
canFlatten={canHaveNestedData}
flattenedColumnsOnly={specialColumnsOnly}
flattenFields={flattenFields}
onFlattenFieldSelect={this.onFlattenFieldSelect}
/>
)}
{parserQueryState.isLoading() && <Loader />}
{parserQueryState.error && (
<CenterMessage>{parserQueryState.getErrorMessage()}</CenterMessage>
)}
</div>
);
}
let suggestedFlattenFields: FlattenField[] | undefined;
if (canHaveNestedData && !flattenFields.length && parserQueryState.data) {
suggestedFlattenFields = computeFlattenPathsForData(
filterMap(parserQueryState.data.data, r => r.input),
'ignore-arrays',
);
}
const specType = getSpecType(spec);
const inputFormatFields = isStreamingSpec(spec)
? STREAMING_INPUT_FORMAT_FIELDS
: BATCH_INPUT_FORMAT_FIELDS;
const possibleSystemFields = getPossibleSystemFieldsForSpec(spec);
const normalInputAutoForm = (
<AutoForm
fields={inputFormatFields}
model={inputFormat}
onChange={p => this.updateSpecPreview(deepSet(spec, 'spec.ioConfig.inputFormat', p))}
/>
);
return (
<>
<div className="main">{mainFill}</div>
<div className="control">
<ParserMessage />
{!selectedFlattenField && (
<>
{!isKafkaOrKinesis(specType) ? (
normalInputAutoForm
) : (
<>
{!isKafkaOrKinesis(inputFormat?.type) ? (
normalInputAutoForm
) : (
<AutoForm
fields={inputFormatFields}
model={inputFormat?.valueFormat}
onChange={p =>
this.updateSpecPreview(
deepSet(spec, 'spec.ioConfig.inputFormat.valueFormat', p),
)
}
/>
)}
<FormGroup className="parse-metadata">
<Switch
label={
specType === 'kafka'
? 'Parse Kafka metadata (ts, headers, key)'
: 'Parse Kinesis metadata (ts, partition key)'
}
checked={isKafkaOrKinesis(inputFormat?.type)}
onChange={() => {
this.updateSpecPreview(
isKafkaOrKinesis(inputFormat?.type)
? deepMove(
spec,
'spec.ioConfig.inputFormat.valueFormat',
'spec.ioConfig.inputFormat',
)
: deepSet(spec, 'spec.ioConfig.inputFormat', {
type: specType,
valueFormat: inputFormat,
}),
);
}}
/>
</FormGroup>
{inputFormat?.type === 'kafka' && (
<AutoForm
fields={KAFKA_METADATA_INPUT_FORMAT_FIELDS}
model={inputFormat}
onChange={p =>
this.updateSpecPreview(deepSet(spec, 'spec.ioConfig.inputFormat', p))
}
/>
)}
{inputFormat?.type === 'kinesis' && (
<AutoForm
fields={KINESIS_METADATA_INPUT_FORMAT_FIELDS}
model={inputFormat}
onChange={p =>
this.updateSpecPreview(deepSet(spec, 'spec.ioConfig.inputFormat', p))
}
/>
)}
</>
)}
{possibleSystemFields.length > 0 && (
<AutoForm
fields={[
{
name: 'spec.ioConfig.inputSource.systemFields',
label: 'System fields',
type: 'string-array',
suggestions: possibleSystemFields,
info: 'JSON array of system fields to return as part of input rows.',
},
]}
model={spec}
onChange={this.updateSpecPreview}
/>
)}
{this.renderApplyButtonBar(
parserQueryState,
AutoForm.issueWithModel(inputFormat, inputFormatFields) ||
(inputFormat?.type === 'kafka'
? AutoForm.issueWithModel(inputFormat, KAFKA_METADATA_INPUT_FORMAT_FIELDS)
: undefined),
)}
</>
)}
{canHaveNestedData && this.renderFlattenControls()}
{suggestedFlattenFields && suggestedFlattenFields.length ? (
<FormGroup>
<Button
icon={IconNames.LIGHTBULB}
text={`Auto add ${pluralIfNeeded(suggestedFlattenFields.length, 'flatten spec')}`}
onClick={() => {
this.updateSpec(changeFlattenSpec(spec, { fields: suggestedFlattenFields }));
}}
/>
</FormGroup>
) : undefined}
</div>
{this.renderNextBar({
disabled: !parserQueryState.data,
onNextStep: () => {
if (!parserQueryState.data) return false;
const possibleTimestampSpec = isDruidSource(spec)
? {
column: TIME_COLUMN,
format: 'auto',
}
: getTimestampSpec(parserQueryState.data);
const newSpec = deepSet(spec, 'spec.dataSchema.timestampSpec', possibleTimestampSpec);
this.updateSpec(newSpec);
return true;
},
})}
</>
);
}
private readonly onFlattenFieldSelect = (field: FlattenField, index: number) => {
const { spec, unsavedChange } = this.state;
const inputFormat: InputFormat = deepGet(spec, 'spec.ioConfig.inputFormat') || EMPTY_OBJECT;
if (unsavedChange || !inputFormatCanProduceNestedData(inputFormat)) return;
this.setState({
selectedFlattenField: { value: field, index },
});
};
renderFlattenControls(): JSX.Element | undefined {
const { spec, nextSpec, selectedFlattenField } = this.state;
if (selectedFlattenField) {
return (
<FormEditor
key={selectedFlattenField.index}
fields={FLATTEN_FIELD_FIELDS}
initValue={selectedFlattenField.value}
onClose={this.resetSelected}
onDirty={this.handleDirty}
onApply={flattenField => {
const flattenSpec = getFlattenSpec(spec) || {};
this.updateSpec(
changeFlattenSpec(
spec,
deepSet(flattenSpec, `fields.${selectedFlattenField.index}`, flattenField),
),
);
}}
showDelete={selectedFlattenField.index !== -1}
onDelete={() => {
const flattenSpec = getFlattenSpec(spec) || {};
this.updateSpec(
changeFlattenSpec(
spec,
deepDelete(flattenSpec, `fields.${selectedFlattenField.index}`),
),
);
}}
/>
);
} else {
return (
<FormGroup>
<Button
text="Add column flattening"
disabled={Boolean(nextSpec)}
onClick={() => {
this.setState({
selectedFlattenField: { value: { type: 'path' }, index: -1 },
});
}}
/>
<AnchorButton
icon={IconNames.INFO_SIGN}
href={`${getLink('DOCS')}/ingestion/data-formats#flattenspec`}
target="_blank"
minimal
/>
</FormGroup>
);
}
}
// ==================================================================
async queryForTimestamp(initRun = false) {
const { spec, cacheRows } = this.state;
if (!cacheRows) {
this.setState(({ timestampQueryState }) => ({
timestampQueryState: initRun
? QueryState.INIT
: new QueryState({
error: new Error('must complete parse step'),
lastData: timestampQueryState.getSomeData(),
}),
}));
return;
}
this.setState(({ timestampQueryState }) => ({
timestampQueryState: new QueryState({
loading: true,
lastData: timestampQueryState.getSomeData(),
}),
}));
let sampleResponse: SampleResponse;
try {
sampleResponse = await sampleForTimestamp(spec, cacheRows);
} catch (e) {
this.setState(({ timestampQueryState }) => ({
timestampQueryState: new QueryState({
error: e,
lastData: timestampQueryState.getSomeData(),
}),
}));
return;
}
this.setState(({ timestampQueryState }) => ({
timestampQueryState: new QueryState({
data: {
sampleResponse,
spec,
},
lastData: timestampQueryState.getSomeData(),
}),
}));
}
renderTimestampStep() {
const { columnFilter, specialColumnsOnly, timestampQueryState } = this.state;
const spec = this.getEffectiveSpec();
const timestampSchema = getTimestampSchema(spec);
const timestampSpec: TimestampSpec =
deepGet(spec, 'spec.dataSchema.timestampSpec') || EMPTY_OBJECT;
const transforms: Transform[] =
deepGet(spec, 'spec.dataSchema.transformSpec.transforms') || EMPTY_ARRAY;
let mainFill: JSX.Element | string;
if (timestampQueryState.isInit()) {
mainFill = (
<CenterMessage>
Please enter the timestamp column details on the right{' '}
<Icon icon={IconNames.ARROW_RIGHT} />
</CenterMessage>
);
} else {
const data = timestampQueryState.getSomeData();
mainFill = (
<div className="table-with-control">
<div className="table-control">
<ClearableInput
value={columnFilter}
onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
<Switch
checked={specialColumnsOnly}
label="Suggested columns only"
onChange={() => this.setState({ specialColumnsOnly: !specialColumnsOnly })}
/>
</div>
{data && (
<ParseTimeTable
sampleBundle={data}
columnFilter={columnFilter}
possibleTimestampColumnsOnly={specialColumnsOnly}
selectedColumnName={parseTimeTableSelectedColumnName(
data.sampleResponse,
timestampSpec,
)}
onTimestampColumnSelect={this.onTimestampColumnSelect}
/>
)}
{timestampQueryState.isLoading() && <Loader />}
{timestampQueryState.error && (
<CenterMessage>{timestampQueryState.getErrorMessage()}</CenterMessage>
)}
</div>
);
}
return (
<>
<div className="main">{mainFill}</div>
<div className="control">
<TimestampMessage />
<FormGroup label="Parse timestamp from">
<ButtonGroup>
<Button
text="None"
active={timestampSchema === 'none'}
onClick={() => {
this.updateSpecPreview(
deepSetMulti(spec, {
'spec.dataSchema.timestampSpec': CONSTANT_TIMESTAMP_SPEC,
'spec.dataSchema.transformSpec.transforms':
removeTimestampTransform(transforms),
}),
);
}}
/>
<Button
text="Column"
active={timestampSchema === 'column'}
onClick={() => {
const timestampSpec = {
column: 'timestamp',
format: 'auto',
};
this.updateSpecPreview(
deepSetMulti(spec, {
'spec.dataSchema.timestampSpec': timestampSpec,
'spec.dataSchema.transformSpec.transforms':
removeTimestampTransform(transforms),
}),
);
}}
/>
<Button
text="Expression"
active={timestampSchema === 'expression'}
onClick={() => {
this.updateSpecPreview(
deepSetMulti(spec, {
'spec.dataSchema.timestampSpec': CONSTANT_TIMESTAMP_SPEC,
'spec.dataSchema.transformSpec.transforms': addTimestampTransform(transforms),
}),
);
}}
/>
</ButtonGroup>
</FormGroup>
{timestampSchema === 'expression' ? (
<AutoForm
fields={getTimestampExpressionFields(transforms)}
model={transforms}
onChange={transforms => {
this.updateSpecPreview(
deepSet(spec, 'spec.dataSchema.transformSpec.transforms', transforms),
);
}}
/>
) : (
<AutoForm
fields={
timestampSchema === 'none' ? CONSTANT_TIMESTAMP_SPEC_FIELDS : TIMESTAMP_SPEC_FIELDS
}
model={timestampSpec}
onChange={timestampSpec => {
this.updateSpecPreview(
deepSet(spec, 'spec.dataSchema.timestampSpec', timestampSpec),
);
}}
/>
)}
{this.renderApplyButtonBar(timestampQueryState, undefined)}
</div>
{this.renderNextBar({
disabled: !timestampQueryState.data,
})}
</>
);
}
private readonly onTimestampColumnSelect = (newTimestampSpec: TimestampSpec) => {
const spec = this.getEffectiveSpec();
this.updateSpecPreview(deepSet(spec, 'spec.dataSchema.timestampSpec', newTimestampSpec));
};
// ==================================================================
async queryForTransform(initRun = false) {
const { spec, cacheRows } = this.state;
if (!cacheRows) {
this.setState(({ transformQueryState }) => ({
transformQueryState: initRun
? QueryState.INIT
: new QueryState({
error: new Error('must complete parse step'),
lastData: transformQueryState.getSomeData(),
}),
}));
return;
}
this.setState(({ transformQueryState }) => ({
transformQueryState: new QueryState({
loading: true,
lastData: transformQueryState.getSomeData(),
}),
}));
let sampleResponse: SampleResponse;
try {
sampleResponse = await sampleForTransform(
spec,
cacheRows,
DEFAULT_FORCE_SEGMENT_SORT_BY_TIME,
);
} catch (e) {
this.setState(({ transformQueryState }) => ({
transformQueryState: new QueryState({
error: e,
lastData: transformQueryState.getSomeData(),
}),
}));
return;
}
this.setState(({ transformQueryState }) => ({
transformQueryState: new QueryState({
data: sampleResponse,
lastData: transformQueryState.getSomeData(),
}),
}));
}
renderTransformStep() {
const { spec, columnFilter, specialColumnsOnly, transformQueryState, selectedTransform } =
this.state;
const transforms: Transform[] =
deepGet(spec, 'spec.dataSchema.transformSpec.transforms') || EMPTY_ARRAY;
let mainFill: JSX.Element | string;
if (transformQueryState.isInit()) {
mainFill = <CenterMessage>Please fill in the previous steps</CenterMessage>;
} else {
const sampleResponse = transformQueryState.getSomeData();
mainFill = (
<div className="table-with-control">
<div className="table-control">
<ClearableInput
value={columnFilter}
onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
<Switch
checked={specialColumnsOnly}
label="Transformed columns only"
onChange={() => this.setState({ specialColumnsOnly: !specialColumnsOnly })}
disabled={!transforms.length}
/>
</div>
{sampleResponse && (
<TransformTable
sampleResponse={sampleResponse}
columnFilter={columnFilter}
transformedColumnsOnly={specialColumnsOnly}
transforms={transforms}
selectedColumnName={transformTableSelectedColumnName(
sampleResponse,
selectedTransform?.value,
)}
onTransformSelect={this.onTransformSelect}
/>
)}
{transformQueryState.isLoading() && <Loader />}
{transformQueryState.error && (
<CenterMessage>{transformQueryState.getErrorMessage()}</CenterMessage>
)}
</div>
);
}
return (
<>
<div className="main">{mainFill}</div>
<div className="control">
<TransformMessage />
{Boolean(transformQueryState.error && transforms.length) && (
<FormGroup>
<Button
icon={IconNames.EDIT}
text="Edit last added transform"
intent={Intent.PRIMARY}
onClick={() => {
this.setState({
selectedTransform: {
value: transforms[transforms.length - 1],
index: transforms.length - 1,
},
});
}}
/>
</FormGroup>
)}
{this.renderTransformControls()}
</div>
{this.renderNextBar({
disabled: !transformQueryState.data,
onNextStep: () => {
if (!transformQueryState.data) return false;
this.updateSpec(initializeSchemaWithSampleIfNeeded(spec, transformQueryState.data));
return true;
},
})}
</>
);
}
private readonly onTransformSelect = (transform: Partial<Transform>, index: number) => {
const { unsavedChange } = this.state;
if (unsavedChange) return;
this.setState({
selectedTransform: { value: transform, index },
});
};
renderTransformControls() {
const { spec, selectedTransform } = this.state;
if (selectedTransform) {
return (
<FormEditor
key={selectedTransform.index}
fields={TRANSFORM_FIELDS}
initValue={selectedTransform.value}
onClose={this.resetSelected}
onDirty={this.handleDirty}
onApply={transform =>
this.updateSpec(
deepSet(
spec,
`spec.dataSchema.transformSpec.transforms.${selectedTransform.index}`,
transform,
),
)
}
showDelete={selectedTransform.index !== -1}
onDelete={() =>
this.updateSpec(
deepDelete(
spec,
`spec.dataSchema.transformSpec.transforms.${selectedTransform.index}`,
),
)
}
/>
);
} else {
return (
<FormGroup>
<Button
text="Add column transform"
onClick={() => {
this.onTransformSelect({ type: 'expression' }, -1);
}}
/>
</FormGroup>
);
}
}
// ==================================================================
async queryForFilter(initRun = false) {
const { spec, cacheRows } = this.state;
if (!cacheRows) {
this.setState(({ filterQueryState }) => ({
filterQueryState: initRun
? QueryState.INIT
: new QueryState({
error: new Error('must complete parse step'),
lastData: filterQueryState.getSomeData(),
}),
}));
return;
}
this.setState(({ filterQueryState }) => ({
filterQueryState: new QueryState({ loading: true, lastData: filterQueryState.getSomeData() }),
}));
let sampleResponse: SampleResponse;
try {
sampleResponse = await sampleForFilter(spec, cacheRows);
} catch (e) {
this.setState(({ filterQueryState }) => ({
filterQueryState: new QueryState({ error: e, lastData: filterQueryState.getSomeData() }),
}));
return;
}
if (sampleResponse.data.length) {
this.setState(({ filterQueryState }) => ({
filterQueryState: new QueryState({
data: sampleResponse,
lastData: filterQueryState.getSomeData(),
}),
}));
return;
}
// The filters matched no data
let sampleResponseNoFilter: SampleResponse;
try {
const specNoFilter = deepSet(spec, 'spec.dataSchema.transformSpec.filter', null);
sampleResponseNoFilter = await sampleForFilter(specNoFilter, cacheRows);
} catch (e) {
this.setState(({ filterQueryState }) => ({
filterQueryState: new QueryState({ error: e, lastData: filterQueryState.getSomeData() }),
}));
return;
}
this.setState(({ filterQueryState }) => ({
// cacheRows: sampleResponseNoFilter.cacheKey,
filterQueryState: new QueryState({
data: sampleResponseNoFilter,
lastData: filterQueryState.getSomeData(),
}),
}));
}
private readonly getMemoizedDimensionFiltersFromSpec = memoize(spec => {
const { dimensionFilters } = splitFilter(deepGet(spec, 'spec.dataSchema.transformSpec.filter'));
return dimensionFilters;
});
renderFilterStep() {
const { columnFilter, filterQueryState, selectedFilter } = this.state;
const spec = this.getEffectiveSpec();
const dimensionFilters = this.getMemoizedDimensionFiltersFromSpec(spec);
let mainFill: JSX.Element | string;
if (filterQueryState.isInit()) {
mainFill = <CenterMessage>Please enter more details for the previous steps</CenterMessage>;
} else {
const filterQuery = filterQueryState.getSomeData();
mainFill = (
<div className="table-with-control">
<div className="table-control">
<ClearableInput
value={columnFilter}
onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
</div>
{filterQuery && (
<FilterTable
sampleResponse={filterQuery}
columnFilter={columnFilter}
dimensionFilters={dimensionFilters}
selectedFilterName={filterTableSelectedColumnName(filterQuery, selectedFilter?.value)}
onFilterSelect={this.onFilterSelect}
/>
)}
{filterQueryState.isLoading() && <Loader />}
{filterQueryState.error && (
<CenterMessage>{filterQueryState.getErrorMessage()}</CenterMessage>
)}
</div>
);
}
return (
<>
<div className="main">{mainFill}</div>
<div className="control">
<FilterMessage />
{!selectedFilter && (
<>
<AutoForm fields={FILTERS_FIELDS} model={spec} onChange={this.updateSpecPreview} />
{this.renderApplyButtonBar(filterQueryState, undefined)}
<FormGroup>
<Button
text="Add column filter"
onClick={() => {
this.setState({
selectedFilter: { value: { type: 'selector' }, index: -1 },
});
}}
/>
</FormGroup>
</>
)}
{this.renderColumnFilterControls()}
</div>
{this.renderNextBar({
onNextStep: () => {
if (!filterQueryState.data) return false;
this.updateSpec(initializeSchemaWithSampleIfNeeded(spec, filterQueryState.data));
return true;
},
})}
</>
);
}
private readonly onFilterSelect = (filter: DruidFilter, index: number) => {
this.setState({
selectedFilter: { value: filter, index },
});
};
renderColumnFilterControls() {
const { spec, selectedFilter } = this.state;
if (!selectedFilter) return;
return (
<FormEditor
key={selectedFilter.index}
fields={FILTER_FIELDS}
initValue={selectedFilter.value}
showCustom={f => !KNOWN_FILTER_TYPES.includes(f.type || '')}
onClose={this.resetSelected}
onDirty={this.handleDirty}
onApply={filter => {
const curFilter = splitFilter(deepGet(spec, 'spec.dataSchema.transformSpec.filter'));
const newFilter = joinFilter(
deepSet(curFilter, `dimensionFilters.${selectedFilter.index}`, filter),
);
this.updateSpec(deepSet(spec, 'spec.dataSchema.transformSpec.filter', newFilter));
}}
showDelete={selectedFilter.index !== -1}
onDelete={() => {
const curFilter = splitFilter(deepGet(spec, 'spec.dataSchema.transformSpec.filter'));
const newFilter = joinFilter(
deepDelete(curFilter, `dimensionFilters.${selectedFilter.index}`),
);
this.updateSpec(deepSet(spec, 'spec.dataSchema.transformSpec.filter', newFilter));
}}
/>
);
}
// ==================================================================
async queryForSchema(initRun = false) {
const { spec, cacheRows } = this.state;
const metricsSpec: MetricSpec[] | undefined = deepGet(spec, 'spec.dataSchema.metricsSpec');
const dimensions: (string | DimensionSpec)[] = deepGet(
spec,
'spec.dataSchema.dimensionsSpec.dimensions',
);
if (!cacheRows) {
this.setState(({ schemaQueryState }) => ({
schemaQueryState: initRun
? QueryState.INIT
: new QueryState({
error: new Error('must complete parse step'),
lastData: schemaQueryState.getSomeData(),
}),
}));
return;
}
this.setState(({ schemaQueryState }) => ({
schemaQueryState: new QueryState({ loading: true, lastData: schemaQueryState.getSomeData() }),
}));
let sampleResponse: SampleResponse;
try {
sampleResponse = await sampleForSchema(spec, cacheRows);
} catch (e) {
this.setState(({ schemaQueryState }) => ({
schemaQueryState: new QueryState({ error: e, lastData: schemaQueryState.getSomeData() }),
}));
return;
}
this.setState(({ schemaQueryState }) => ({
schemaQueryState: new QueryState({
data: {
sampleResponse,
dimensions: dimensions || guessDimensionsFromSampleResponse(sampleResponse),
metricsSpec,
definedDimensions: Boolean(dimensions),
},
lastData: schemaQueryState.getSomeData(),
}),
}));
}
renderSchemaStep() {
const {
columnFilter,
schemaQueryState,
selectedAutoDimension,
selectedDimensionSpec,
selectedMetricSpec,
} = this.state;
const spec = this.getEffectiveSpec();
const rollup = Boolean(deepGet(spec, 'spec.dataSchema.granularitySpec.rollup'));
const somethingSelected = Boolean(
selectedAutoDimension || selectedDimensionSpec || selectedMetricSpec,
);
const forceSegmentSortByTime = getForceSegmentSortByTime(spec);
const schemaMode = getSchemaMode(spec);
const arrayMode = getArrayIngestMode(spec);
let mainFill: JSX.Element | string;
if (schemaQueryState.isInit()) {
mainFill = <CenterMessage>Please enter more details for the previous steps</CenterMessage>;
} else {
const data = schemaQueryState.getSomeData();
mainFill = (
<div className="table-with-control">
<div className="table-control">
<ClearableInput
value={columnFilter}
onValueChange={columnFilter => this.setState({ columnFilter })}
placeholder="Search columns"
/>
</div>
{data && (
<SchemaTable
sampleBundle={data}
columnFilter={columnFilter}
selectedAutoDimension={selectedAutoDimension}
selectedDimensionSpecIndex={selectedDimensionSpec ? selectedDimensionSpec.index : -1}
selectedMetricSpecIndex={selectedMetricSpec ? selectedMetricSpec.index : -1}
onAutoDimensionSelect={this.onAutoDimensionSelect}
onDimensionSelect={this.onDimensionSelect}
onMetricSelect={this.onMetricSelect}
/>
)}
{schemaQueryState.isLoading() && <Loader />}
{schemaQueryState.error && (
<CenterMessage>{schemaQueryState.getErrorMessage()}</CenterMessage>
)}
</div>
);
}
const schemaToolsMenu = this.renderSchemaToolsMenu();
return (
<>
<div className="main">{mainFill}</div>
<div className="control">
<SchemaMessage schemaMode={schemaMode} />
{!somethingSelected && (
<>
<FormGroupWithInfo
inlineInfo
info={
<PopoverText>
<p>
When set to true (the default), segments created by the ingestion job are
sorted by <Code>{'{__time, dimensions[0], dimensions[1], ...}'}</Code>. When
set to false, segments created by the ingestion job are sorted by{' '}
<Code>{'{dimensions[0], dimensions[1], ...}'}</Code>. To include{' '}
<Code>__time</Code> in the sort order when this parameter is set to{' '}
<Code>false</Code>, you must include a dimension named <Code>__time</Code>{' '}
with type <Code>long</Code> explicitly in the `dimensions` list.
</p>
<p>
Setting this to `false` is an experimental feature; see
<ExternalLink href={`${getLink('DOCS')}/ingestion/partitioning/#sorting`}>
Sorting
</ExternalLink>{' '}
for details.
</p>
</PopoverText>
}
>
<Switch
checked={forceSegmentSortByTime}
onChange={() =>
this.setState({
newForceSegmentSortByTime: !forceSegmentSortByTime,
})
}
label="Force segment sort by time"
/>
</FormGroupWithInfo>
<FormGroupWithInfo
inlineInfo
info={
<PopoverText>
<p>
Select whether or not you want to set an explicit list of{' '}
<ExternalLink
href={`${getLink('DOCS')}/ingestion/ingestion-spec#dimensionsspec`}
>
dimensions
</ExternalLink>{' '}
and{' '}
<ExternalLink href={`${getLink('DOCS')}/querying/aggregations`}>
metrics
</ExternalLink>
. Explicitly setting dimensions and metrics can lead to better compression and
performance. If you disable this option, Druid will try to auto-detect fields
in your data and treat them as individual columns.
</p>
</PopoverText>
}
>
<Switch
checked={schemaMode === 'fixed'}
onChange={() =>
this.setState({
newSchemaMode: schemaMode === 'fixed' ? 'type-aware-discovery' : 'fixed',
})
}
label="Explicitly specify schema"
/>
</FormGroupWithInfo>
{showArrayIngestModeToggle(spec) && (
<ArrayIngestModeSwitch
arrayIngestMode={arrayMode}
changeArrayIngestMode={newArrayIngestMode => {
this.setState({
newArrayIngestMode: newArrayIngestMode,
});
}}
/>
)}
{schemaMode !== 'fixed' && (
<AutoForm
fields={[
{
name: 'spec.dataSchema.dimensionsSpec.dimensionExclusions',
label: 'Dimension exclusions',
type: 'string-array',
info: (
<>
Provide a comma separated list of columns (use the column name from the
raw data) you do not want Druid to ingest.
</>
),
},
]}
model={spec}
onChange={this.updateSpec}
/>
)}
<FormGroupWithInfo
inlineInfo
info={
<PopoverText>
<p>
If you enable{' '}
<ExternalLink href={`${getLink('DOCS')}/tutorials/tutorial-rollup`}>
roll-up
</ExternalLink>
, Druid will try to pre-aggregate data before indexing it to conserve storage.
The primary timestamp will be truncated to the specified query granularity,
and rows containing the same string field values will be aggregated together.
</p>
<p>
If you enable rollup, you must specify which columns are{' '}
<a href={`${getLink('DOCS')}/ingestion/ingestion-spec#dimensionsspec`}>
dimensions
</a>{' '}
(fields you want to group and filter on), and which are{' '}
<a href={`${getLink('DOCS')}/querying/aggregations`}>metrics</a> (fields you
want to aggregate on).
</p>
</PopoverText>
}
>
<Switch
checked={rollup}
onChange={() => this.setState({ newRollup: !rollup })}
label="Rollup"
/>
</FormGroupWithInfo>
<AutoForm
fields={[
{
name: 'spec.dataSchema.granularitySpec.queryGranularity',
label: 'Query granularity',
type: 'string',
suggestions: [
'none',
'second',
'minute',
'fifteen_minute',
'thirty_minute',
'hour',
'day',
'week',
'month',
'quarter',
'year',
'all',
],
info: (
<>
This granularity determines how timestamps will be truncated (not at all, to
the minute, hour, day, etc). After data is rolled up, this granularity
becomes the minimum granularity you can query data at.
</>
),
},
]}
model={spec}
onChange={this.updateSpecPreview}
onFinalize={this.applyPreviewSpec}
/>
<FormGroup>
<Button
text="Add dimension"
disabled={schemaMode !== 'fixed'}
onClick={() => {
this.setState({
selectedDimensionSpec: {
value: {
type: 'string',
},
index: -1,
},
});
}}
/>
</FormGroup>
<FormGroup>
<Button
text="Add metric"
onClick={() => {
this.setState({
selectedMetricSpec: {
value: {
type: 'doubleSum',
},
index: -1,
},
});
}}
/>
</FormGroup>
{schemaToolsMenu && (
<FormGroup>
<Popover content={schemaToolsMenu}>
<Button icon={IconNames.BUILD} text="Tools" />
</Popover>
</FormGroup>
)}
</>
)}
{this.renderAutoDimensionControls()}
{this.renderDimensionSpecControls()}
{this.renderMetricSpecControls()}
{this.renderChangeForceSegmentSortByTime()}
{this.renderChangeRollupAction()}
{this.renderChangeSchemaModeAction()}
{this.renderChangeArrayIngestModeAction()}
</div>
{this.renderNextBar({
disabled: !schemaQueryState.data,
})}
</>
);
}
private readonly renderSchemaToolsMenu = () => {
const { spec } = this.state;
const dimensions: DimensionSpec[] | undefined = deepGet(
spec,
`spec.dataSchema.dimensionsSpec.dimensions`,
);
const metrics: MetricSpec[] | undefined = deepGet(spec, `spec.dataSchema.metricsSpec`);
if (!dimensions && !metrics) return;
return (
<Menu>
{dimensions && (
<MenuItem
icon={IconNames.ARROWS_HORIZONTAL}
text="Order dimensions alphabetically"
onClick={() => {
if (!dimensions) return;
const newSpec = deepSet(
spec,
`spec.dataSchema.dimensionsSpec.dimensions`,
dimensions
.slice()
.sort((d1, d2) =>
alphanumericCompare(getDimensionSpecName(d1), getDimensionSpecName(d2)),
),
);
this.updateSpec(newSpec);
}}
/>
)}
{metrics && (
<MenuItem
icon={IconNames.ARROWS_HORIZONTAL}
text="Order metrics alphabetically"
onClick={() => {
if (!metrics) return;
const newSpec = deepSet(
spec,
`spec.dataSchema.metricsSpec`,
metrics
.slice()
.sort((m1, m2) =>
alphanumericCompare(getMetricSpecName(m1), getMetricSpecName(m2)),
),
);
this.updateSpec(newSpec);
}}
/>
)}
</Menu>
);
};
private readonly onAutoDimensionSelect = (selectedAutoDimension: string) => {
this.setState({
selectedAutoDimension,
selectedDimensionSpec: undefined,
selectedMetricSpec: undefined,
});
};
private readonly onDimensionSelect = (dimensionSpec: DimensionSpec, index: number) => {
this.setState({
selectedAutoDimension: undefined,
selectedDimensionSpec: { value: dimensionSpec, index },
selectedMetricSpec: undefined,
});
};
private readonly onMetricSelect = (metricSpec: MetricSpec, index: number) => {
this.setState({
selectedAutoDimension: undefined,
selectedDimensionSpec: undefined,
selectedMetricSpec: { value: metricSpec, index },
});
};
renderChangeForceSegmentSortByTime() {
const { newForceSegmentSortByTime, spec, cacheRows } = this.state;
if (typeof newForceSegmentSortByTime === 'undefined' || !cacheRows) return;
return (
<AsyncActionDialog
action={async () => {
const sampleResponse = await sampleForTransform(
spec,
cacheRows,
newForceSegmentSortByTime,
);
this.updateSpec(
updateSchemaWithSample(
spec,
sampleResponse,
newForceSegmentSortByTime,
getSchemaMode(spec),
getArrayIngestMode(spec),
getRollup(spec),
true,
),
);
}}
confirmButtonText={`Yes - ${
newForceSegmentSortByTime ? 'force time to be first' : "don't force time to be first"
}`}
successText={`forceSegmentSortByTime was set to ${newForceSegmentSortByTime}.`}
failText="Could change rollup"
intent={Intent.WARNING}
onClose={() => this.setState({ newForceSegmentSortByTime: undefined })}
>
<p>{`Are you sure you want to set forceSegmentSortByTime to ${newForceSegmentSortByTime}?`}</p>
<p>Making this change will reset any work you have done in this section.</p>
</AsyncActionDialog>
);
}
renderChangeRollupAction() {
const { newRollup, spec, cacheRows } = this.state;
if (typeof newRollup === 'undefined' || !cacheRows) return;
return (
<AsyncActionDialog
action={async () => {
const sampleResponse = await sampleForTransform(
spec,
cacheRows,
getForceSegmentSortByTime(spec),
);
this.updateSpec(
updateSchemaWithSample(
spec,
sampleResponse,
getForceSegmentSortByTime(spec),
getSchemaMode(spec),
getArrayIngestMode(spec),
newRollup,
true,
),
);
}}
confirmButtonText={`Yes - ${newRollup ? 'enable' : 'disable'} rollup`}
successText={`Rollup was ${newRollup ? 'enabled' : 'disabled'}. Schema has been updated.`}
failText="Could change rollup"
intent={Intent.WARNING}
onClose={() => this.setState({ newRollup: undefined })}
>
<p>{`Are you sure you want to ${newRollup ? 'enable' : 'disable'} rollup?`}</p>
<p>Making this change will reset any work you have done in this section.</p>
</AsyncActionDialog>
);
}
renderChangeSchemaModeAction() {
const { newSchemaMode, spec, cacheRows } = this.state;
if (!newSchemaMode || !cacheRows) return;
const autoDetect = newSchemaMode !== 'fixed';
return (
<AsyncActionDialog
action={async () => {
const sampleResponse = await sampleForTransform(
spec,
cacheRows,
getForceSegmentSortByTime(spec),
);
this.updateSpec(
updateSchemaWithSample(
spec,
sampleResponse,
getForceSegmentSortByTime(spec),
newSchemaMode,
getArrayIngestMode(spec),
getRollup(spec),
),
);
}}
confirmButtonText={`Yes - ${autoDetect ? 'auto detect' : 'explicitly define'} schema`}
successText={`Schema mode changed to ${autoDetect ? 'auto detect' : 'explicitly defined'}.`}
failText="Could not change schema mode"
intent={Intent.WARNING}
onClose={() => this.setState({ newSchemaMode: undefined })}
>
<p>
{autoDetect
? `Are you sure you want Druid to auto detect the data schema?`
: `Are you sure you want to explicitly specify a schema?`}
</p>
<p>Making this change will reset all schema configuration done so far.</p>
{autoDetect && (
<RadioGroup
label="Schemaless mode"
selectedValue={newSchemaMode}
onChange={() => {
this.setState({
newSchemaMode:
newSchemaMode === 'string-only-discovery'
? 'type-aware-discovery'
: 'string-only-discovery',
});
}}
>
<Radio value="type-aware-discovery">
Use the new type-aware schema discovery capability to discover columns according to
data type. Columns with multiple values will be ingested as ARRAY types. For more
information see the{' '}
<ExternalLink
href={`${getLink(
'DOCS',
)}/ingestion/schema-design#schema-auto-discovery-for-dimensions`}
>
documentation
</ExternalLink>
.
</Radio>
<Radio value="string-only-discovery">
Use classic string-only schema discovery to discover all new columns as strings.
Columns with multiple values will be ingested as multi-value-strings.
</Radio>
</RadioGroup>
)}
</AsyncActionDialog>
);
}
renderChangeArrayIngestModeAction() {
const { newArrayIngestMode, spec, cacheRows } = this.state;
if (!newArrayIngestMode || !cacheRows) return;
const multiValues = newArrayIngestMode === 'mvd';
return (
<AsyncActionDialog
action={async () => {
const sampleResponse = await sampleForTransform(
spec,
cacheRows,
getForceSegmentSortByTime(spec),
);
this.updateSpec(
updateSchemaWithSample(
spec,
sampleResponse,
getForceSegmentSortByTime(spec),
getSchemaMode(spec),
newArrayIngestMode,
getRollup(spec),
),
);
}}
confirmButtonText={`Yes - use ${multiValues ? 'MVDs' : 'ARRAYs'}`}
successText={`Array mode changed to ${multiValues ? 'multi-values' : 'arrays'}.`}
failText="Could not change array mode"
intent={Intent.WARNING}
onClose={() => this.setState({ newArrayIngestMode: undefined })}
>
<p>
{multiValues
? `Are you sure you want to use multi-value dimensions?`
: `Are you sure you want to use ARRAYs?`}
</p>
<p>Making this change will reset all schema configuration done so far.</p>
</AsyncActionDialog>
);
}
renderAutoDimensionControls() {
const { spec, selectedAutoDimension } = this.state;
if (!selectedAutoDimension) return;
return (
<div className="edit-controls">
<FormGroup label="Name">
<InputGroup value={selectedAutoDimension} onChange={() => {}} readOnly />
</FormGroup>
<FormGroup>
<Button
icon={IconNames.CROSS}
text="Exclude"
intent={Intent.DANGER}
onClick={() => {
this.updateSpec(
deepSet(
spec,
`spec.dataSchema.dimensionsSpec.dimensionExclusions.[append]`,
selectedAutoDimension,
),
);
this.resetSelected();
}}
/>
</FormGroup>
<FormGroup>
<Button text="Close" onClick={this.resetSelected} />
</FormGroup>
</div>
);
}
renderDimensionSpecControls() {
const { spec, selectedDimensionSpec } = this.state;
if (!selectedDimensionSpec) return;
const selectedTime = selectedDimensionSpec.value.name === TIME_COLUMN;
const schemaMode = getSchemaMode(spec);
const dimensions = deepGet(spec, `spec.dataSchema.dimensionsSpec.dimensions`) || EMPTY_ARRAY;
const moveTo = (newIndex: number) => {
const newDimension = moveElement(dimensions, selectedDimensionSpec.index, newIndex);
const newSpec = deepSet(spec, `spec.dataSchema.dimensionsSpec.dimensions`, newDimension);
this.updateSpec(newSpec);
this.resetSelected();
};
const reorderDimensionMenu = (
<ReorderMenu
things={dimensions}
selectedIndex={selectedDimensionSpec.index}
moveTo={moveTo}
/>
);
const convertToMetric = (type: string, prefix: string) => {
const specWithoutDimension =
schemaMode === 'fixed'
? deepDelete(
spec,
`spec.dataSchema.dimensionsSpec.dimensions.${selectedDimensionSpec.index}`,
)
: spec;
const specWithMetric = deepSet(specWithoutDimension, `spec.dataSchema.metricsSpec.[append]`, {
name: `${prefix}_${selectedDimensionSpec.value.name}`,
type,
fieldName: selectedDimensionSpec.value.name,
});
this.updateSpec(specWithMetric);
this.resetSelected();
};
const convertToMetricMenu = (
<Menu>
<MenuItem
text="Convert to longSum metric"
onClick={() => convertToMetric('longSum', 'sum')}
/>
<MenuItem
text="Convert to doubleSum metric"
onClick={() => convertToMetric('doubleSum', 'sum')}
/>
<MenuItem
text="Convert to thetaSketch metric"
onClick={() => convertToMetric('thetaSketch', 'theta')}
/>
<MenuItem
text="Convert to HLLSketchBuild metric"
onClick={() => convertToMetric('HLLSketchBuild', 'hll')}
/>
<MenuItem
text="Convert to quantilesDoublesSketch metric"
onClick={() => convertToMetric('quantilesDoublesSketch', 'quantiles_doubles')}
/>
<MenuItem
text="Convert to hyperUnique metric"
onClick={() => convertToMetric('hyperUnique', 'unique')}
/>
</Menu>
);
return (
<FormEditor
key={selectedDimensionSpec.index}
fields={DIMENSION_SPEC_FIELDS}
initValue={selectedDimensionSpec.value}
onClose={this.resetSelected}
onDirty={this.handleDirty}
onApply={dimensionSpec =>
this.updateSpec(
deepSet(
spec,
`spec.dataSchema.dimensionsSpec.dimensions.${selectedDimensionSpec.index}`,
dimensionSpec,
),
)
}
showDelete={selectedDimensionSpec.index !== -1}
disableDelete={dimensions.length <= 1 || selectedTime}
onDelete={() =>
this.updateSpec(
deepDelete(
spec,
`spec.dataSchema.dimensionsSpec.dimensions.${selectedDimensionSpec.index}`,
),
)
}
>
{selectedDimensionSpec.index !== -1 && (
<FormGroup>
<Popover content={reorderDimensionMenu}>
<Button
icon={IconNames.ARROWS_HORIZONTAL}
text="Reorder dimension"
rightIcon={IconNames.CARET_DOWN}
/>
</Popover>
</FormGroup>
)}
{selectedDimensionSpec.index !== -1 &&
deepGet(spec, 'spec.dataSchema.metricsSpec') &&
!selectedTime && (
<FormGroup>
<Popover content={convertToMetricMenu}>
<Button
icon={IconNames.EXCHANGE}
text="Convert to metric"
rightIcon={IconNames.CARET_DOWN}
disabled={dimensions.length <= 1}
/>
</Popover>
</FormGroup>
)}
</FormEditor>
);
}
renderMetricSpecControls() {
const { spec, selectedMetricSpec } = this.state;
if (!selectedMetricSpec) return;
const schemaMode = getSchemaMode(spec);
const selectedMetricSpecFieldName = selectedMetricSpec.value.fieldName;
const convertToDimension = (type: string) => {
if (!selectedMetricSpecFieldName) return;
const specWithoutMetric = deepDelete(
spec,
`spec.dataSchema.metricsSpec.${selectedMetricSpec.index}`,
);
const specWithDimension = deepSet(
specWithoutMetric,
`spec.dataSchema.dimensionsSpec.dimensions.[append]`,
{
type,
name: selectedMetricSpecFieldName,
},
);
this.updateSpec(specWithDimension);
this.resetSelected();
};
const convertToDimensionMenu = (
<Menu>
<MenuItem text="Convert to string dimension" onClick={() => convertToDimension('string')} />
<MenuItem text="Convert to long dimension" onClick={() => convertToDimension('long')} />
<MenuItem text="Convert to float dimension" onClick={() => convertToDimension('float')} />
<MenuItem text="Convert to double dimension" onClick={() => convertToDimension('double')} />
</Menu>
);
return (
<FormEditor
key={selectedMetricSpec.index}
fields={METRIC_SPEC_FIELDS}
initValue={selectedMetricSpec.value}
onClose={this.resetSelected}
onDirty={this.handleDirty}
onApply={metricSpec =>
this.updateSpec(
deepSet(spec, `spec.dataSchema.metricsSpec.${selectedMetricSpec.index}`, metricSpec),
)
}
showDelete={selectedMetricSpec.index !== -1}
onDelete={() =>
this.updateSpec(
deepDelete(spec, `spec.dataSchema.metricsSpec.${selectedMetricSpec.index}`),
)
}
>
{selectedMetricSpec.index !== -1 &&
schemaMode === 'fixed' &&
selectedMetricSpecFieldName && (
<FormGroup>
<Popover content={convertToDimensionMenu}>
<Button
icon={IconNames.EXCHANGE}
text="Convert to dimension"
rightIcon={IconNames.CARET_DOWN}
/>
</Popover>
</FormGroup>
)}
</FormEditor>
);
}
// ==================================================================
renderPartitionStep() {
const { spec } = this.state;
const tuningConfig: TuningConfig = deepGet(spec, 'spec.tuningConfig') || EMPTY_OBJECT;
const dimensions: (string | DimensionSpec)[] | undefined = deepGet(
spec,
'spec.dataSchema.dimensionsSpec.dimensions',
);
const dimensionNames = dimensions?.map(getDimensionSpecName);
let nonsensicalSingleDimPartitioningMessage: JSX.Element | undefined;
if (dimensions && Array.isArray(dimensionNames) && dimensionNames.length) {
const partitionDimensions = deepGet(tuningConfig, 'partitionsSpec.partitionDimensions');
if (
deepGet(tuningConfig, 'partitionsSpec.type') === 'range' &&
Array.isArray(partitionDimensions) &&
partitionDimensions.join(',') !==
dimensionNames.slice(0, partitionDimensions.length).join(',')
) {
const dimensionNamesPrefix = dimensionNames.slice(0, partitionDimensions.length);
nonsensicalSingleDimPartitioningMessage = (
<FormGroup>
<Callout intent={Intent.WARNING}>
<p>Your partitioning and sorting configuration is uncommon.</p>
<p>
For best performance the leading dimensions in your schema (
<Code>{dimensionNamesPrefix.join(', ')}</Code>), which is what the data will be
primarily sorted on, commonly matches the partitioning dimensions (
<Code>{partitionDimensions.join(', ')}</Code>).
</p>
<p>
<Button
intent={Intent.WARNING}
onClick={() =>
this.updateSpec(
deepSet(
spec,
'spec.dataSchema.dimensionsSpec.dimensions',
moveToIndex(dimensions, d =>
partitionDimensions.indexOf(getDimensionSpecName(d)),
),
),
)
}
>
{`Put `}
<strong>{partitionDimensions.join(', ')}</strong>
{` first in the dimensions list`}
</Button>
</p>
<p>
<Button
intent={Intent.WARNING}
onClick={() =>
this.updateSpec(
deepSet(
spec,
'spec.tuningConfig.partitionsSpec.partitionDimensions',
dimensionNamesPrefix,
),
)
}
>
{`Partition on `}
<strong>{dimensionNames.slice(0, partitionDimensions.length).join(', ')}</strong>
{` instead`}
</Button>
</p>
</Callout>
</FormGroup>
);
}
const partitionDimension = deepGet(tuningConfig, 'partitionsSpec.partitionDimension');
if (
deepGet(tuningConfig, 'partitionsSpec.type') === 'single_dim' &&
typeof partitionDimension === 'string' &&
partitionDimension !== dimensionNames[0]
) {
const firstDimensionName = dimensionNames[0];
nonsensicalSingleDimPartitioningMessage = (
<FormGroup>
<Callout intent={Intent.WARNING}>
<p>Your partitioning and sorting configuration is uncommon.</p>
<p>
For best performance the first dimension in your schema (
<Tag minimal>{firstDimensionName}</Tag>), which is what the data will be primarily
sorted on, commonly matches the partitioning dimension (
<Tag minimal>{partitionDimension}</Tag>).
</p>
<p>
<Button
intent={Intent.WARNING}
onClick={() =>
this.updateSpec(
deepSet(
spec,
'spec.dataSchema.dimensionsSpec.dimensions',
moveToIndex(dimensions, d =>
getDimensionSpecName(d) === partitionDimension ? 0 : -1,
),
),
)
}
>
{`Put `}
<strong>{partitionDimension}</strong>
{` first in the dimensions list`}
</Button>
</p>
<p>
<Button
intent={Intent.WARNING}
onClick={() =>
this.updateSpec(
deepSet(
spec,
'spec.tuningConfig.partitionsSpec.partitionDimension',
firstDimensionName,
),
)
}
>
{`Partition on `}
<strong>{firstDimensionName}</strong>
{` instead`}
</Button>
</p>
</Callout>
</FormGroup>
);
}
}
return (
<>
<div className="main">
<H5>Primary partitioning (by time)</H5>
<AutoForm
fields={PRIMARY_PARTITION_RELATED_FORM_FIELDS}
model={spec}
onChange={this.updateSpec}
/>
</div>
<div className="other">
<H5>Secondary partitioning</H5>
<AutoForm
fields={getSecondaryPartitionRelatedFormFields(spec, dimensionNames)}
model={spec}
globalAdjustment={adjustForceGuaranteedRollup}
onChange={this.updateSpec}
/>
</div>
<div className="control">
<PartitionMessage />
{nonsensicalSingleDimPartitioningMessage}
<AppendToExistingIssue spec={spec} onChangeSpec={this.updateSpec} />
</div>
{this.renderNextBar({
disabled: invalidPartitionConfig(spec),
})}
</>
);
}
// ==================================================================
renderTuningStep() {
const { spec } = this.state;
const ioConfig: IoConfig = deepGet(spec, 'spec.ioConfig') || EMPTY_OBJECT;
const ingestionComboType = getIngestionComboType(spec);
const inputTuningFields = ingestionComboType
? getIoConfigTuningFormFields(ingestionComboType)
: null;
return (
<>
<div className="main">
<H5>Input tuning</H5>
{inputTuningFields ? (
inputTuningFields.length ? (
<AutoForm
fields={inputTuningFields}
model={ioConfig}
onChange={c => this.updateSpec(deepSet(spec, 'spec.ioConfig', c))}
/>
) : (
<div>
{ioConfig.inputSource
? `No specific tuning configs for inputSource of type '${deepGet(
ioConfig,
'inputSource.type',
)}'.`
: `No specific tuning configs.`}
</div>
)
) : (
<JsonInput
value={ioConfig}
onChange={c => this.updateSpec(deepSet(spec, 'spec.ioConfig', c))}
height="300px"
/>
)}
</div>
<div className="other">
<H5>General tuning</H5>
<AutoForm fields={getTuningFormFields()} model={spec} onChange={this.updateSpec} />
</div>
<div className="control">
<TuningMessage />
</div>
{this.renderNextBar({
disabled: invalidIoConfig(ioConfig),
})}
</>
);
}
// ==================================================================
renderPublishStep() {
const { spec } = this.state;
const parallel = deepGet(spec, 'spec.tuningConfig.maxNumConcurrentSubTasks') > 1;
return (
<>
<div className="main">
<H5>Publish configuration</H5>
<AutoForm
fields={[
{
name: 'spec.dataSchema.dataSource',
label: 'Datasource name',
type: 'string',
valueAdjustment: d => (typeof d === 'string' ? adjustId(d) : d),
info: (
<>
<p>This is the name of the datasource (table) in Druid.</p>
<p>
The datasource name can not start with a dot <Code>.</Code>, include slashes{' '}
<Code>/</Code>, or have whitespace other than space.
</p>
</>
),
},
{
name: 'spec.ioConfig.appendToExisting',
label: 'Append to existing',
type: 'boolean',
defined: s => !isStreamingSpec(s),
defaultValue: false,
// appendToExisting can only be set on 'dynamic' portioning.
// We chose to show it always and instead have a specific message, separate from this form, to notify the user of the issue.
info: (
<>
Creates segments as additional shards of the latest version, effectively
appending to the segment set instead of replacing it.
</>
),
},
{
name: 'suspended',
type: 'boolean',
defined: isStreamingSpec,
defaultValue: false,
info: (
<>
<p>Create a supervisor in a suspended state.</p>
<p>
Creating a supervisor in a suspended state can be helpful if you are not yet
ready to begin ingesting data or if you prefer to configure the
supervisor&apos;s metadata before starting it.
</p>
<p>
You can configure the exact offsets that the supervisor will read from using
the <Code>Actions</Code> menu on the <Code>Supervisors</Code> tab.
</p>
</>
),
},
]}
model={spec}
onChange={this.updateSpec}
/>
<FormGroupWithInfo
inlineInfo
info={
<PopoverText>
<p>
If you want to append data to a datasource while compaction is running, you need
to enable concurrent append and replace for the datasource by updating the
compaction settings.
</p>
<p>
For more information refer to the{' '}
<ExternalLink href={`${getLink('DOCS')}/ingestion/concurrent-append-replace`}>
documentation
</ExternalLink>
.
</p>
</PopoverText>
}
>
<Switch
label="Use concurrent locks"
checked={Boolean(deepGet(spec, 'context.useConcurrentLocks'))}
onChange={() => {
this.updateSpec(
deepGet(spec, 'context.useConcurrentLocks')
? deepDelete(spec, 'context.useConcurrentLocks')
: deepSet(spec, 'context.useConcurrentLocks', true),
);
}}
/>
</FormGroupWithInfo>
</div>
<div className="other">
<H5>Parse error reporting</H5>
<AutoForm
fields={[
{
name: 'spec.tuningConfig.logParseExceptions',
label: 'Log parse exceptions',
type: 'boolean',
defaultValue: false,
disabled: parallel,
info: (
<>
If true, log an error message when a parsing exception occurs, containing
information about the row where the error occurred.
</>
),
},
{
name: 'spec.tuningConfig.maxParseExceptions',
label: 'Max parse exceptions',
type: 'number',
disabled: parallel,
placeholder: '(unlimited)',
info: (
<>
The maximum number of parse exceptions that can occur before the task halts
ingestion and fails.
</>
),
},
{
name: 'spec.tuningConfig.maxSavedParseExceptions',
label: 'Max saved parse exceptions',
type: 'number',
disabled: parallel,
defaultValue: 0,
info: (
<>
<p>
When a parse exception occurs, Druid can keep track of the most recent parse
exceptions.
</p>
<p>
This property limits how many exception instances will be saved. These saved
exceptions will be made available after the task finishes in the task view.
</p>
</>
),
},
]}
model={spec}
onChange={this.updateSpec}
/>
</div>
<div className="control">
<PublishMessage />
<AppendToExistingIssue spec={spec} onChangeSpec={this.updateSpec} />
</div>
{this.renderNextBar({})}
</>
);
}
// ==================================================================
private readonly getSupervisorJson = async (): Promise<void> => {
const { initSupervisorId } = this.props;
if (!initSupervisorId) return;
try {
const resp = await Api.instance.get(
`/druid/indexer/v1/supervisor/${Api.encodePath(initSupervisorId)}`,
);
this.updateSpec(cleanSpec(resp.data));
this.setState({ continueToSpec: true });
this.updateStep('spec');
} catch (e) {
AppToaster.show({
message: `Failed to get supervisor spec: ${getDruidErrorMessage(e)}`,
intent: Intent.DANGER,
});
}
};
private readonly getTaskJson = async (): Promise<void> => {
const { initTaskId } = this.props;
if (!initTaskId) return;
try {
const resp = await Api.instance.get(`/druid/indexer/v1/task/${Api.encodePath(initTaskId)}`);
this.updateSpec(cleanSpec(resp.data.payload));
this.setState({ continueToSpec: true });
this.updateStep('spec');
} catch (e) {
AppToaster.show({
message: `Failed to get task spec: ${getDruidErrorMessage(e)}`,
intent: Intent.DANGER,
});
}
};
async queryForSpec() {
const { spec } = this.state;
let existingDatasources: string[];
try {
existingDatasources = await getApiArray<string>('/druid/coordinator/v1/datasources');
} catch {
return;
}
let currentSupervisorSpec: Partial<IngestionSpec> | undefined;
const supervisorId = getSpecDatasourceName(spec);
if (isStreamingSpec(spec) && supervisorId) {
try {
currentSupervisorSpec = cleanSpec(
(await Api.instance.get(`/druid/indexer/v1/supervisor/${Api.encodePath(supervisorId)}`))
.data,
);
} catch {}
}
this.setState({
existingDatasources,
currentSupervisorSpec,
});
}
renderSpecStep() {
const { spec, existingDatasources, submitting, currentSupervisorSpec, showDiffWithCurrent } =
this.state;
const issueWithSpec = getIssueWithSpec(spec);
const datasource = deepGet(spec, 'spec.dataSchema.dataSource');
return (
<>
<div className="main">
<JsonInput
value={spec}
onChange={s => {
if (!s) return;
this.updateSpec(s);
}}
height="100%"
jsonCompletions={INGESTION_SPEC_COMPLETIONS}
/>
</div>
<div className="control">
<SpecMessage />
{issueWithSpec && (
<FormGroup>
<Callout
intent={Intent.WARNING}
>{`There is an issue with the spec: ${issueWithSpec}`}</Callout>
</FormGroup>
)}
{getSchemaMode(spec) === 'type-aware-discovery' &&
existingDatasources?.includes(datasource) && (
<FormGroup>
<Callout intent={Intent.WARNING}>
<p>
You have enabled type-aware schema discovery (
<Code>useSchemaDiscovery: true</Code>) to ingest data into the existing
datasource <Tag minimal>{datasource}</Tag>.
</p>
<p>
If you used string-based schema discovery when first ingesting data to{' '}
<Tag minimal>{datasource}</Tag>, using type-aware schema discovery now can cause
problems with the values multi-value string dimensions.
</p>
<p>
For more information see the{' '}
<ExternalLink
href={`${getLink(
'DOCS',
)}/ingestion/schema-design#schema-auto-discovery-for-dimensions`}
>
documentation
</ExternalLink>
.
</p>
</Callout>
</FormGroup>
)}
<AppendToExistingIssue spec={spec} onChangeSpec={this.updateSpec} />
{isStreamingSpec(spec) && currentSupervisorSpec && (
<Button
icon={IconNames.DELTA}
text="Diff with current supervisor spec"
onClick={() => {
this.setState({
showDiffWithCurrent: true,
});
}}
/>
)}
{isStreamingSpec(spec) && (
<Switch
className="suspended-switch"
checked={Boolean(spec.suspended)}
label="Submit in suspended state"
onChange={() =>
this.updateSpec(
spec.suspended ? deleteKeys(spec, ['suspended']) : { ...spec, suspended: true },
)
}
/>
)}
</div>
<div className="next-bar">
{isStreamingSpec(spec) ? (
<Button
text={
submitting
? 'Submitting...'
: `Submit supervisor (${spec.suspended ? 'suspended' : 'running'})`
}
rightIcon={IconNames.CLOUD_UPLOAD}
intent={Intent.PRIMARY}
disabled={submitting || Boolean(issueWithSpec)}
onClick={() => void this.handleSubmitSupervisor()}
/>
) : (
<Button
text={submitting ? 'Submitting...' : 'Submit task'}
rightIcon={IconNames.CLOUD_UPLOAD}
intent={Intent.PRIMARY}
disabled={submitting || Boolean(issueWithSpec)}
onClick={() => void this.handleSubmitTask()}
/>
)}
</div>
{showDiffWithCurrent && currentSupervisorSpec && (
<DiffDialog
title="Diff with current spec"
oldValue={currentSupervisorSpec}
newValue={spec}
onClose={() => {
this.setState({
showDiffWithCurrent: false,
});
}}
/>
)}
</>
);
}
private readonly handleSubmitSupervisor = async () => {
const { goToSupervisor } = this.props;
const { spec, submitting } = this.state;
if (submitting) return;
this.setState({ submitting: true });
try {
await Api.instance.post('/druid/indexer/v1/supervisor', spec);
} catch (e) {
AppToaster.show({
message: `Failed to submit supervisor: ${getDruidErrorMessage(e)}`,
intent: Intent.DANGER,
});
this.setState({ submitting: false });
return;
}
AppToaster.show({
message: 'Supervisor submitted successfully. Going to Supervisors view...',
intent: Intent.SUCCESS,
});
const supervisorId = getSpecDatasourceName(spec);
if (supervisorId) {
setTimeout(() => {
goToSupervisor(supervisorId);
}, 1000);
}
};
private readonly handleSubmitTask = async () => {
const { goToTasks } = this.props;
const { spec, submitting } = this.state;
if (submitting) return;
this.setState({ submitting: true });
let taskResp: any;
try {
taskResp = await Api.instance.post('/druid/indexer/v1/task', spec);
} catch (e) {
AppToaster.show({
message: `Failed to submit task: ${getDruidErrorMessage(e)}`,
intent: Intent.DANGER,
});
this.setState({ submitting: false });
return;
}
AppToaster.show({
message: 'Task submitted successfully. Going to Tasks view...',
intent: Intent.SUCCESS,
});
setTimeout(() => {
goToTasks(taskResp.data.task);
}, 1000);
};
}