Web console: Power up the data loader init step (#7947)

* Power up the data loader init step

* update snapshot

* normalize spec

* allow deselect

* added HDFS tile

* update border style

* text updates

* goodies

* new reset icon
This commit is contained in:
Vadim Ogievetsky 2019-06-26 15:50:48 -07:00 committed by Fangjin Yang
parent bc1413e4e3
commit d677c83ce4
18 changed files with 563 additions and 173 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
web-console/assets/http.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -229,9 +229,9 @@
} }
}, },
"@blueprintjs/core": { "@blueprintjs/core": {
"version": "3.15.1", "version": "3.16.2",
"resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.15.1.tgz", "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.16.2.tgz",
"integrity": "sha512-M8ltbqqlMZuZ6SEuqo/3Fr59ZcUfd8Er7ocbm7EACVfRW7dRhOCd/TKkf2kfICNtCDwznwXk0iAePLXZhUGtQg==", "integrity": "sha512-u+mSITWaNDwbdaPrbKx9XyxGsF4725SCAidWjd367ysX7AxCo4PK4SsFQVfXNylXpVWHQhJZekuo7+hdksc9lA==",
"requires": { "requires": {
"@blueprintjs/icons": "^3.8.0", "@blueprintjs/icons": "^3.8.0",
"@types/dom4": "^2.0.1", "@types/dom4": "^2.0.1",
@ -246,9 +246,9 @@
} }
}, },
"@blueprintjs/icons": { "@blueprintjs/icons": {
"version": "3.8.0", "version": "3.9.0",
"resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.8.0.tgz", "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.9.0.tgz",
"integrity": "sha512-yHaRQ3vfV9Gf3foZ4ONtxddz+u5ufkHqHj8Ia5VhPbFgG4el+cPdmsGGIIM72rgKS1KQa5Ay+ggjpByUlXvrKg==", "integrity": "sha512-kq1Bh6PtOF4PcuxcDme8NmnSlkfO0IV89FriZGo6zSA1+OOzSwzvoKqa6S7vJe8xCPPLO5r7lE9AjeOuGeH97g==",
"requires": { "requires": {
"classnames": "^2.2", "classnames": "^2.2",
"tslib": "^1.9.0" "tslib": "^1.9.0"

View File

@ -50,7 +50,8 @@
"stylelint": "stylelint 'src/**/*.scss'" "stylelint": "stylelint 'src/**/*.scss'"
}, },
"dependencies": { "dependencies": {
"@blueprintjs/core": "^3.15.1", "@blueprintjs/core": "^3.16.2",
"@blueprintjs/icons": "^3.9.0",
"@types/memoize-one": "^4.1.1", "@types/memoize-one": "^4.1.1",
"axios": "^0.19.0", "axios": "^0.19.0",
"brace": "^0.11.1", "brace": "^0.11.1",

View File

@ -25,3 +25,4 @@ cp -r coordinator-console "$1"
cp -r old-console "$1" cp -r old-console "$1"
cp -r pages "$1" cp -r pages "$1"
cp -r public "$1" cp -r public "$1"
cp -r assets "$1"

View File

@ -34,27 +34,31 @@ const readDoc = async () => {
if (functionMatch) { if (functionMatch) {
functionDocs.push({ functionDocs.push({
syntax: functionMatch[1], syntax: functionMatch[1],
description: functionMatch[2] description: functionMatch[2],
}) });
} }
const dataTypeMatch = line.match(/^\|([A-Z]+)\|([A-Z]+)\|(.*)\|(.*)\|$/); const dataTypeMatch = line.match(/^\|([A-Z]+)\|([A-Z]+)\|(.*)\|(.*)\|$/);
if (dataTypeMatch) { if (dataTypeMatch) {
dataTypeDocs.push({ dataTypeDocs.push({
syntax: dataTypeMatch[1], syntax: dataTypeMatch[1],
description: dataTypeMatch[4] || `Druid runtime type: ${dataTypeMatch[2]}` description: dataTypeMatch[4] || `Druid runtime type: ${dataTypeMatch[2]}`,
}) });
} }
} }
// Make sure there are at least 10 functions for sanity // Make sure there are at least 10 functions for sanity
if (functionDocs.length < 10) { if (functionDocs.length < 10) {
throw new Error(`Did not find enough function entries did the structure of '${readfile}' change? (found ${functionDocs.length})`); throw new Error(
`Did not find enough function entries did the structure of '${readfile}' change? (found ${functionDocs.length})`,
);
} }
// Make sure there are at least 5 data types for sanity // Make sure there are at least 5 data types for sanity
if (dataTypeDocs.length < 10) { if (dataTypeDocs.length < 10) {
throw new Error(`Did not find enough data type entries did the structure of '${readfile}' change? (found ${dataTypeDocs.length})`); throw new Error(
`Did not find enough data type entries did the structure of '${readfile}' change? (found ${dataTypeDocs.length})`,
);
} }
const content = `/* const content = `/*

View File

@ -37,6 +37,10 @@ export interface IngestionSpec {
tuningConfig?: TuningConfig; tuningConfig?: TuningConfig;
} }
export function isEmptyIngestionSpec(spec: IngestionSpec) {
return Object.keys(spec).length === 0;
}
export type IngestionType = 'kafka' | 'kinesis' | 'index_hadoop' | 'index' | 'index_parallel'; export type IngestionType = 'kafka' | 'kinesis' | 'index_hadoop' | 'index' | 'index_parallel';
// A combination of IngestionType and firehose // A combination of IngestionType and firehose
@ -48,6 +52,9 @@ export type IngestionComboType =
| 'index:static-s3' | 'index:static-s3'
| 'index:static-google-blobstore'; | 'index:static-google-blobstore';
// Some extra values that can be selected in the initial screen
export type IngestionComboTypeWithExtra = IngestionComboType | 'hadoop' | 'example' | 'other';
function ingestionTypeToIoAndTuningConfigType(ingestionType: IngestionType): string { function ingestionTypeToIoAndTuningConfigType(ingestionType: IngestionType): string {
switch (ingestionType) { switch (ingestionType) {
case 'kafka': case 'kafka':
@ -87,6 +94,65 @@ export function getIngestionComboType(spec: IngestionSpec): IngestionComboType |
return null; return null;
} }
export function getIngestionTitle(ingestionType: IngestionComboTypeWithExtra): string {
switch (ingestionType) {
case 'index:local':
return 'Local disk';
case 'index:http':
return 'HTTP(s)';
case 'index:static-s3':
return 'Amazon S3';
case 'index:static-google-blobstore':
return 'Google Cloud Storage';
case 'kafka':
return 'Apache Kafka';
case 'kinesis':
return 'Amazon Kinesis';
case 'hadoop':
return 'HDFS';
case 'example':
return 'Example data';
case 'other':
return 'Other';
default:
return 'Unknown ingestion';
}
}
export function getIngestionImage(ingestionType: IngestionComboTypeWithExtra): string {
const parts = ingestionType.split(':');
if (parts.length === 2) return parts[1];
return ingestionType;
}
export function getRequiredModule(ingestionType: IngestionComboTypeWithExtra): string | null {
switch (ingestionType) {
case 'index:static-s3':
return 'druid-s3-extensions';
case 'index:static-google-blobstore':
return 'druid-google-extensions';
case 'kafka':
return 'druid-kafka-indexing-service';
case 'kinesis':
return 'druid-kinesis-indexing-service';
default:
return null;
}
}
// -------------- // --------------
export interface DataSchema { export interface DataSchema {
@ -138,7 +204,7 @@ export function getRollup(spec: IngestionSpec): boolean {
return typeof specRollup === 'boolean' ? specRollup : true; return typeof specRollup === 'boolean' ? specRollup : true;
} }
export function getSpecType(spec: IngestionSpec): IngestionType | undefined { export function getSpecType(spec: Partial<IngestionSpec>): IngestionType | undefined {
return ( return (
deepGet(spec, 'type') || deepGet(spec, 'ioConfig.type') || deepGet(spec, 'tuningConfig.type') deepGet(spec, 'type') || deepGet(spec, 'ioConfig.type') || deepGet(spec, 'tuningConfig.type')
); );
@ -158,13 +224,21 @@ export function changeParallel(spec: IngestionSpec, parallel: boolean): Ingestio
* Make sure that the types are set in the root, ioConfig, and tuningConfig * Make sure that the types are set in the root, ioConfig, and tuningConfig
* @param spec * @param spec
*/ */
export function normalizeSpecType(spec: IngestionSpec) { export function normalizeSpec(spec: Partial<IngestionSpec>): IngestionSpec {
if (!spec || typeof spec !== 'object') {
// This does not match the type of IngestionSpec but this dialog is robust enough to deal with anything but spec must be an object
spec = {};
}
// Make sure that if we actually get a task payload we extract the spec
if (typeof (spec as any).spec === 'object') spec = (spec as any).spec;
const specType = getSpecType(spec); const specType = getSpecType(spec);
if (!specType) return spec; if (!specType) return spec as IngestionSpec;
if (!deepGet(spec, 'type')) spec = deepSet(spec, 'type', specType); if (!deepGet(spec, 'type')) spec = deepSet(spec, 'type', specType);
if (!deepGet(spec, 'ioConfig.type')) spec = deepSet(spec, 'ioConfig.type', specType); if (!deepGet(spec, 'ioConfig.type')) spec = deepSet(spec, 'ioConfig.type', specType);
if (!deepGet(spec, 'tuningConfig.type')) spec = deepSet(spec, 'tuningConfig.type', specType); if (!deepGet(spec, 'tuningConfig.type')) spec = deepSet(spec, 'tuningConfig.type', specType);
return spec; return spec as IngestionSpec;
} }
const PARSE_SPEC_FORM_FIELDS: Field<ParseSpec>[] = [ const PARSE_SPEC_FORM_FIELDS: Field<ParseSpec>[] = [
@ -851,7 +925,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
], ],
info: ( info: (
<> <>
The AWS Kinesis stream endpoint for a region. You can find a list of endpoints{' '} The Amazon Kinesis stream endpoint for a region. You can find a list of endpoints{' '}
<ExternalLink href="http://docs.aws.amazon.com/general/latest/gr/rande.html#ak_region"> <ExternalLink href="http://docs.aws.amazon.com/general/latest/gr/rande.html#ak_region">
here here
</ExternalLink> </ExternalLink>
@ -1662,40 +1736,40 @@ export interface Bitmap {
// -------------- // --------------
export function getBlankSpec(comboType: IngestionComboType): IngestionSpec { export function updateIngestionType(
spec: IngestionSpec,
comboType: IngestionComboType,
): IngestionSpec {
let [ingestionType, firehoseType] = comboType.split(':'); let [ingestionType, firehoseType] = comboType.split(':');
if (ingestionType === 'index') ingestionType = 'index_parallel'; if (ingestionType === 'index') ingestionType = 'index_parallel';
const ioAndTuningConfigType = ingestionTypeToIoAndTuningConfigType( const ioAndTuningConfigType = ingestionTypeToIoAndTuningConfigType(
ingestionType as IngestionType, ingestionType as IngestionType,
); );
const granularitySpec: GranularitySpec = { let newSpec = spec;
type: 'uniform', newSpec = deepSet(newSpec, 'type', ingestionType);
segmentGranularity: ingestionType === 'index_parallel' ? 'DAY' : 'HOUR', newSpec = deepSet(newSpec, 'ioConfig.type', ioAndTuningConfigType);
queryGranularity: 'HOUR', newSpec = deepSet(newSpec, 'tuningConfig.type', ioAndTuningConfigType);
};
const spec: IngestionSpec = {
type: ingestionType,
dataSchema: {
dataSource: 'new-data-source',
granularitySpec,
},
ioConfig: {
type: ioAndTuningConfigType,
},
tuningConfig: {
type: ioAndTuningConfigType,
},
} as any;
if (firehoseType) { if (firehoseType) {
spec.ioConfig.firehose = { newSpec = deepSet(newSpec, 'ioConfig.firehose', { type: firehoseType });
type: firehoseType,
};
} }
return spec; if (!deepGet(spec, 'dataSchema.dataSource')) {
newSpec = deepSet(newSpec, 'dataSchema.dataSource', 'new-data-source');
}
if (!deepGet(spec, 'dataSchema.granularitySpec')) {
const granularitySpec: GranularitySpec = {
type: 'uniform',
segmentGranularity: ingestionType === 'index_parallel' ? 'DAY' : 'HOUR',
queryGranularity: 'HOUR',
};
newSpec = deepSet(newSpec, 'dataSchema.granularitySpec', granularitySpec);
}
return newSpec;
} }
export function fillParser(spec: IngestionSpec, sampleData: string[]): IngestionSpec { export function fillParser(spec: IngestionSpec, sampleData: string[]): IngestionSpec {

View File

@ -2,41 +2,169 @@
exports[`load data view matches snapshot 1`] = ` exports[`load data view matches snapshot 1`] = `
<div <div
className="load-data-view app-view init" className="load-data-view app-view welcome"
> >
<div <div
className="intro" className="bp3-tabs step-nav"
> >
Please specify where your raw data is located <div
className="step-section"
key="Connect and parse raw data"
>
<div
className="step-nav-l1"
>
Connect and parse raw data
</div>
<Blueprint3.ButtonGroup
className="step-nav-l2"
>
<Blueprint3.Button
active={true}
className="welcome"
icon={false}
key="welcome"
onClick={[Function]}
text="Start"
/>
<Blueprint3.Button
active={false}
className="connect"
icon={false}
key="connect"
onClick={[Function]}
text="Connect"
/>
<Blueprint3.Button
active={false}
className="parser"
icon={false}
key="parser"
onClick={[Function]}
text="Parse data"
/>
<Blueprint3.Button
active={false}
className="timestamp"
icon={false}
key="timestamp"
onClick={[Function]}
text="Parse time"
/>
</Blueprint3.ButtonGroup>
</div>
<div
className="step-section"
key="Transform and configure schema"
>
<div
className="step-nav-l1"
>
Transform and configure schema
</div>
<Blueprint3.ButtonGroup
className="step-nav-l2"
>
<Blueprint3.Button
active={false}
className="transform"
icon={false}
key="transform"
onClick={[Function]}
text="Transform"
/>
<Blueprint3.Button
active={false}
className="filter"
icon={false}
key="filter"
onClick={[Function]}
text="Filter"
/>
<Blueprint3.Button
active={false}
className="schema"
icon={false}
key="schema"
onClick={[Function]}
text="Configure schema"
/>
</Blueprint3.ButtonGroup>
</div>
<div
className="step-section"
key="Tune parameters"
>
<div
className="step-nav-l1"
>
Tune parameters
</div>
<Blueprint3.ButtonGroup
className="step-nav-l2"
>
<Blueprint3.Button
active={false}
className="partition"
icon={false}
key="partition"
onClick={[Function]}
text="Partition"
/>
<Blueprint3.Button
active={false}
className="tuning"
icon={false}
key="tuning"
onClick={[Function]}
text="Tune"
/>
<Blueprint3.Button
active={false}
className="publish"
icon={false}
key="publish"
onClick={[Function]}
text="Publish"
/>
</Blueprint3.ButtonGroup>
</div>
<div
className="step-section"
key="Verify and submit"
>
<div
className="step-nav-l1"
>
Verify and submit
</div>
<Blueprint3.ButtonGroup
className="step-nav-l2"
>
<Blueprint3.Button
active={false}
className="spec"
icon="manually-entered-data"
key="spec"
onClick={[Function]}
text="Edit JSON spec"
/>
</Blueprint3.ButtonGroup>
</div>
</div> </div>
<div <div
className="cards" className="main"
/>
<div
className="control"
> >
<Blueprint3.Card <Blueprint3.Callout
elevation={0} className="intro"
interactive={true}
onClick={[Function]}
> >
Other (streaming) <p>
</Blueprint3.Card> Please specify where your raw data is located
<Blueprint3.Card </p>
elevation={0} </Blueprint3.Callout>
interactive={true}
onClick={[Function]}
>
Other (batch)
</Blueprint3.Card>
</div> </div>
<Blueprint3.Alert
canEscapeKeyCancel={false}
canOutsideClickCancel={false}
confirmButtonText="Close"
icon="warning-sign"
intent="warning"
isOpen={false}
onConfirm={[Function]}
>
<p />
</Blueprint3.Alert>
</div> </div>
`; `;

View File

@ -27,32 +27,45 @@
'main ctrl' 'main ctrl'
'main next'; 'main next';
&.init { &.welcome {
display: block; .main {
margin-left: -10px;
& > * {
margin-bottom: 15px;
}
.intro {
font-size: 20px;
}
.cards {
.bp3-card { .bp3-card {
position: relative;
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
width: 250px; width: 250px;
height: 140px; height: 140px;
margin-right: 15px; margin-left: 15px;
margin-bottom: 15px; margin-bottom: 15px;
font-size: 24px; font-size: 16px;
text-align: center; text-align: center;
padding-top: 47px;
& > * {
user-select: none;
pointer-events: none;
}
&.active::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
content: '';
border: 2px solid #48aff0;
border-radius: 2px;
}
&.disabled { &.disabled {
opacity: 0.4; opacity: 0.4;
} }
img {
width: 100px;
display: inline-block;
}
} }
} }
} }
@ -192,7 +205,7 @@
text-align: right; text-align: right;
padding: 0 5px; padding: 0 5px;
.prev { .left {
float: left; float: left;
} }
} }

View File

@ -25,6 +25,7 @@ import {
Card, Card,
Classes, Classes,
Code, Code,
Elevation,
FormGroup, FormGroup,
H5, H5,
HTMLSelect, HTMLSelect,
@ -50,6 +51,7 @@ import {
} from '../../components'; } from '../../components';
import { AsyncActionDialog } from '../../dialogs'; import { AsyncActionDialog } from '../../dialogs';
import { AppToaster } from '../../singletons/toaster'; import { AppToaster } from '../../singletons/toaster';
import { UrlBaser } from '../../singletons/url-baser';
import { import {
filterMap, filterMap,
getDruidErrorMessage, getDruidErrorMessage,
@ -72,18 +74,20 @@ import {
fillDataSourceName, fillDataSourceName,
fillParser, fillParser,
FlattenField, FlattenField,
getBlankSpec,
getDimensionMode, getDimensionMode,
getDimensionSpecFormFields, getDimensionSpecFormFields,
getEmptyTimestampSpec, getEmptyTimestampSpec,
getFilterFormFields, getFilterFormFields,
getFlattenFieldFormFields, getFlattenFieldFormFields,
getIngestionComboType, getIngestionComboType,
getIngestionImage,
getIngestionTitle,
getIoConfigFormFields, getIoConfigFormFields,
getIoConfigTuningFormFields, getIoConfigTuningFormFields,
getMetricSpecFormFields, getMetricSpecFormFields,
getParseSpecFormFields, getParseSpecFormFields,
getPartitionRelatedTuningSpecFormFields, getPartitionRelatedTuningSpecFormFields,
getRequiredModule,
getRollup, getRollup,
getSpecType, getSpecType,
getTimestampSpecFormFields, getTimestampSpecFormFields,
@ -91,16 +95,17 @@ import {
getTuningSpecFormFields, getTuningSpecFormFields,
GranularitySpec, GranularitySpec,
hasParallelAbility, hasParallelAbility,
IngestionComboType, IngestionComboTypeWithExtra,
IngestionSpec, IngestionSpec,
IoConfig, IoConfig,
isColumnTimestampSpec, isColumnTimestampSpec,
isEmptyIngestionSpec,
isParallel, isParallel,
issueWithIoConfig, issueWithIoConfig,
issueWithParser, issueWithParser,
joinFilter, joinFilter,
MetricSpec, MetricSpec,
normalizeSpecType, normalizeSpec,
Parser, Parser,
ParseSpec, ParseSpec,
parseSpecHasFlatten, parseSpecHasFlatten,
@ -108,6 +113,7 @@ import {
TimestampSpec, TimestampSpec,
Transform, Transform,
TuningConfig, TuningConfig,
updateIngestionType,
} from '../../utils/ingestion-spec'; } from '../../utils/ingestion-spec';
import { deepDelete, deepGet, deepSet } from '../../utils/object-change'; import { deepDelete, deepGet, deepSet } from '../../utils/object-change';
import { import {
@ -163,6 +169,7 @@ function getTimestampSpec(headerAndRows: HeaderAndRows | null): TimestampSpec {
} }
type Step = type Step =
| 'welcome'
| 'connect' | 'connect'
| 'parser' | 'parser'
| 'timestamp' | 'timestamp'
@ -172,9 +179,11 @@ type Step =
| 'partition' | 'partition'
| 'tuning' | 'tuning'
| 'publish' | 'publish'
| 'json-spec' | 'spec'
| 'loading'; | 'loading';
const STEPS: Step[] = [ const STEPS: Step[] = [
'welcome',
'connect', 'connect',
'parser', 'parser',
'timestamp', 'timestamp',
@ -184,18 +193,19 @@ const STEPS: Step[] = [
'partition', 'partition',
'tuning', 'tuning',
'publish', 'publish',
'json-spec', 'spec',
'loading', 'loading',
]; ];
const SECTIONS: { name: string; steps: Step[] }[] = [ const SECTIONS: { name: string; steps: Step[] }[] = [
{ name: 'Connect and parse raw data', steps: ['connect', 'parser', 'timestamp'] }, { name: 'Connect and parse raw data', steps: ['welcome', 'connect', 'parser', 'timestamp'] },
{ name: 'Transform and configure schema', steps: ['transform', 'filter', 'schema'] }, { name: 'Transform and configure schema', steps: ['transform', 'filter', 'schema'] },
{ name: 'Tune parameters', steps: ['partition', 'tuning', 'publish'] }, { name: 'Tune parameters', steps: ['partition', 'tuning', 'publish'] },
{ name: 'Verify and submit', steps: ['json-spec'] }, { name: 'Verify and submit', steps: ['spec'] },
]; ];
const VIEW_TITLE: Record<Step, string> = { const VIEW_TITLE: Record<Step, string> = {
welcome: 'Start',
connect: 'Connect', connect: 'Connect',
parser: 'Parse data', parser: 'Parse data',
timestamp: 'Parse time', timestamp: 'Parse time',
@ -205,7 +215,7 @@ const VIEW_TITLE: Record<Step, string> = {
partition: 'Partition', partition: 'Partition',
tuning: 'Tune', tuning: 'Tune',
publish: 'Publish', publish: 'Publish',
'json-spec': 'Edit JSON spec', spec: 'Edit JSON spec',
loading: 'Loading', loading: 'Loading',
}; };
@ -224,9 +234,11 @@ export interface LoadDataViewState {
newRollup: boolean | null; newRollup: boolean | null;
newDimensionMode: DimensionMode | null; newDimensionMode: DimensionMode | null;
// general // welcome
overlordModules: string[] | null; overlordModules: string[] | null;
overlordModuleNeededMessage: string | null; selectedComboType: IngestionComboTypeWithExtra | null;
// general
sampleStrategy: SampleStrategy; sampleStrategy: SampleStrategy;
columnFilter: string; columnFilter: string;
specialColumnsOnly: boolean; specialColumnsOnly: boolean;
@ -278,7 +290,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
let spec = parseJson(String(localStorageGet(LocalStorageKeys.INGESTION_SPEC))); let spec = parseJson(String(localStorageGet(LocalStorageKeys.INGESTION_SPEC)));
if (!spec || typeof spec !== 'object') spec = {}; if (!spec || typeof spec !== 'object') spec = {};
this.state = { this.state = {
step: 'connect', step: 'welcome',
spec, spec,
cacheKey: undefined, cacheKey: undefined,
@ -287,9 +299,11 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
newRollup: null, newRollup: null,
newDimensionMode: null, newDimensionMode: null,
// general // welcome
overlordModules: null, overlordModules: null,
overlordModuleNeededMessage: null, selectedComboType: null,
// general
sampleStrategy: 'start', sampleStrategy: 'start',
columnFilter: '', columnFilter: '',
specialColumnsOnly: false, specialColumnsOnly: false,
@ -329,6 +343,8 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
} }
componentDidMount(): void { componentDidMount(): void {
const { spec } = this.state;
this.getOverlordModules(); this.getOverlordModules();
if (this.props.initTaskId) { if (this.props.initTaskId) {
this.updateStep('loading'); this.updateStep('loading');
@ -336,6 +352,8 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
} else if (this.props.initSupervisorId) { } else if (this.props.initSupervisorId) {
this.updateStep('loading'); this.updateStep('loading');
this.getSupervisorJson(); this.getSupervisorJson();
} else if (isEmptyIngestionSpec(spec)) {
this.updateStep('welcome');
} else { } else {
this.updateStep('connect'); this.updateStep('connect');
} }
@ -380,28 +398,18 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
} }
private updateSpec = (newSpec: IngestionSpec) => { private updateSpec = (newSpec: IngestionSpec) => {
if (!newSpec || typeof newSpec !== 'object') { newSpec = normalizeSpec(newSpec);
// This does not match the type of IngestionSpec but this dialog is robust enough to deal with anything but spec must be an object
newSpec = {} as any;
}
this.setState({ spec: newSpec }); this.setState({ spec: newSpec });
localStorageSet(LocalStorageKeys.INGESTION_SPEC, JSON.stringify(newSpec)); localStorageSet(LocalStorageKeys.INGESTION_SPEC, JSON.stringify(newSpec));
}; };
render() { render() {
const { step, spec } = this.state; const { step } = this.state;
if (!Object.keys(spec).length && !this.props.initSupervisorId && !this.props.initTaskId) {
return (
<div className={classNames('load-data-view', 'app-view', 'init')}>
{this.renderInitStep()}
</div>
);
}
return ( return (
<div className={classNames('load-data-view', 'app-view', step)}> <div className={classNames('load-data-view', 'app-view', step)}>
{this.renderStepNav()} {this.renderStepNav()}
{step === 'welcome' && this.renderWelcomeStep()}
{step === 'connect' && this.renderConnectStep()} {step === 'connect' && this.renderConnectStep()}
{step === 'parser' && this.renderParserStep()} {step === 'parser' && this.renderParserStep()}
{step === 'timestamp' && this.renderTimestampStep()} {step === 'timestamp' && this.renderTimestampStep()}
@ -414,7 +422,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
{step === 'tuning' && this.renderTuningStep()} {step === 'tuning' && this.renderTuningStep()}
{step === 'publish' && this.renderPublishStep()} {step === 'publish' && this.renderPublishStep()}
{step === 'json-spec' && this.renderJsonSpecStep()} {step === 'spec' && this.renderSpecStep()}
{step === 'loading' && this.renderLoading()} {step === 'loading' && this.renderLoading()}
{this.renderResetConfirm()} {this.renderResetConfirm()}
@ -437,7 +445,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
key={s} key={s}
active={s === step} active={s === step}
onClick={() => this.updateStep(s)} onClick={() => this.updateStep(s)}
icon={s === 'json-spec' && IconNames.MANUALLY_ENTERED_DATA} icon={s === 'spec' && IconNames.MANUALLY_ENTERED_DATA}
text={VIEW_TITLE[s]} text={VIEW_TITLE[s]}
/> />
))} ))}
@ -484,79 +492,234 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
// ================================================================== // ==================================================================
initWith(comboType: IngestionComboType) { renderIngestionCard(comboType: IngestionComboTypeWithExtra) {
this.setState({ const { overlordModules, selectedComboType } = this.state;
spec: getBlankSpec(comboType),
});
setTimeout(() => {
this.updateStep('connect');
}, 10);
}
renderIngestionCard(title: string, comboType: IngestionComboType, requiredModule?: string) {
const { overlordModules } = this.state;
if (!overlordModules) return null; if (!overlordModules) return null;
const requiredModule = getRequiredModule(comboType);
const goodToGo = !requiredModule || overlordModules.includes(requiredModule); const goodToGo = !requiredModule || overlordModules.includes(requiredModule);
return ( return (
<Card <Card
className={classNames({ disabled: !goodToGo })} className={classNames({ disabled: !goodToGo, active: selectedComboType === comboType })}
interactive interactive
onClick={() => { onClick={() => {
if (goodToGo) { this.setState({ selectedComboType: selectedComboType !== comboType ? comboType : null });
this.initWith(comboType);
} else {
this.setState({
overlordModuleNeededMessage: `${title} ingestion requires the '${requiredModule}' to be loaded.`,
});
}
}} }}
> >
{title} <img src={UrlBaser.base(`/assets/${getIngestionImage(comboType)}.png`)} />
<p>{getIngestionTitle(comboType)}</p>
</Card> </Card>
); );
} }
renderInitStep() { renderWelcomeStep() {
const { goToTask } = this.props; const { spec } = this.state;
const { overlordModuleNeededMessage } = this.state;
return ( return (
<> <>
<div className="intro">Please specify where your raw data is located</div> <div className="main">
{this.renderIngestionCard('kafka')}
<div className="cards"> {this.renderIngestionCard('kinesis')}
{this.renderIngestionCard('Apache Kafka', 'kafka', 'druid-kafka-indexing-service')} {this.renderIngestionCard('index:static-s3')}
{this.renderIngestionCard('AWS Kinesis', 'kinesis', 'druid-kinesis-indexing-service')} {this.renderIngestionCard('index:static-google-blobstore')}
{this.renderIngestionCard('HTTP(s)', 'index:http')} {this.renderIngestionCard('hadoop')}
{this.renderIngestionCard('AWS S3', 'index:static-s3', 'druid-s3-extensions')} {this.renderIngestionCard('index:http')}
{this.renderIngestionCard( {this.renderIngestionCard('index:local')}
'Google Cloud Storage', {/* this.renderIngestionCard('example') */}
'index:static-google-blobstore', {this.renderIngestionCard('other')}
'druid-google-extensions', </div>
)} <div className="control">
{this.renderIngestionCard('Local disk', 'index:local')} <Callout className="intro">{this.renderWelcomeStepMessage()}</Callout>
<Card interactive onClick={() => goToTask(null, 'supervisor')}> {this.renderWelcomeStepControls()}
Other (streaming) {!isEmptyIngestionSpec(spec) && (
</Card> <Button icon={IconNames.RESET} text="Reset spec" onClick={this.handleResetConfirm} />
<Card interactive onClick={() => goToTask(null, 'task')}> )}
Other (batch)
</Card>
</div> </div>
<Alert
icon={IconNames.WARNING_SIGN}
intent={Intent.WARNING}
isOpen={Boolean(overlordModuleNeededMessage)}
confirmButtonText="Close"
onConfirm={() => this.setState({ overlordModuleNeededMessage: null })}
>
<p>{overlordModuleNeededMessage}</p>
</Alert>
</> </>
); );
} }
renderWelcomeStepMessage() {
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:http':
return (
<>
<p>Load data accessible through HTTP(s).</p>
<p>
Data must be in a text format and the HTTP(s) endpoint must be reachable by every
Druid process in the cluster.
</p>
</>
);
case 'index: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 a text format and must be accessible to all the Druid processes in
the cluster.
</p>
</>
);
case 'index:static-s3':
return <p>Load text based data from Amazon S3.</p>;
case 'index:static-google-blobstore':
return <p>Load text based data from the Google Blobstore.</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 'hadoop':
return (
<>
<p>
<em>Data loader support coming soon!</em>
</p>
<p>
You can not ingest data from HDFS via the data loader at this time, however you can
ingest it through a Druid task.
</p>
<p>
Please follow{' '}
<ExternalLink href="https://druid.apache.org/docs/latest/ingestion/hadoop.html">
the hadoop docs
</ExternalLink>{' '}
and submit a JSON spec to start the task.
</p>
</>
);
case 'example':
return <p>Pick one of these examples to get you started.</p>;
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="https://druid.apache.org/docs/latest/ingestion/index.html">
JSON task or supervisor spec
</ExternalLink>
.
</p>
);
default:
return <p>Unknown ingestion type.</p>;
}
}
renderWelcomeStepControls() {
const { goToTask } = this.props;
const { spec, selectedComboType } = this.state;
const issue = this.selectedIngestionTypeIssue();
if (issue) return null;
switch (selectedComboType) {
case 'index:http':
case 'index:local':
case 'index:static-s3':
case 'index:static-google-blobstore':
case 'kafka':
case 'kinesis':
return (
<FormGroup>
<Button
text="Connect data"
rightIcon={IconNames.ARROW_RIGHT}
onClick={() => {
this.setState({
spec: updateIngestionType(spec, selectedComboType as any),
});
setTimeout(() => {
this.updateStep('connect');
}, 10);
}}
intent={Intent.PRIMARY}
/>
</FormGroup>
);
case 'hadoop':
return (
<FormGroup>
<Button
text="Submit task"
rightIcon={IconNames.ARROW_RIGHT}
onClick={() => goToTask(null, 'task')}
intent={Intent.PRIMARY}
/>
</FormGroup>
);
case 'example':
return null;
case 'other':
return (
<>
<FormGroup>
<Button
text="Submit supervisor"
rightIcon={IconNames.ARROW_RIGHT}
onClick={() => goToTask(null, 'supervisor')}
intent={Intent.PRIMARY}
/>
</FormGroup>
<FormGroup>
<Button
text="Submit task"
rightIcon={IconNames.ARROW_RIGHT}
onClick={() => goToTask(null, 'task')}
intent={Intent.PRIMARY}
/>
</FormGroup>
</>
);
default:
return null;
}
}
selectedIngestionTypeIssue(): JSX.Element | null {
const { selectedComboType, overlordModules } = this.state;
if (!selectedComboType || !overlordModules) return null;
const requiredModule = getRequiredModule(selectedComboType);
if (!requiredModule || overlordModules.includes(requiredModule)) return null;
return (
<p>
{`${getIngestionTitle(selectedComboType)} ingestion requires the `}
<strong>{requiredModule}</strong>
{` extension to be loaded.`}
</p>
);
}
private handleResetConfirm = () => {
this.setState({ showResetConfirm: true });
};
renderResetConfirm() { renderResetConfirm() {
const { showResetConfirm } = this.state; const { showResetConfirm } = this.state;
if (!showResetConfirm) return null; if (!showResetConfirm) return null;
@ -713,8 +876,6 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
if (!inputQueryState.data) return; if (!inputQueryState.data) return;
this.updateSpec(fillDataSourceName(fillParser(spec, inputQueryState.data))); this.updateSpec(fillDataSourceName(fillParser(spec, inputQueryState.data)));
}, },
prevLabel: 'Restart',
onPrevStep: () => this.setState({ showResetConfirm: true }),
})} })}
</> </>
); );
@ -2398,8 +2559,8 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
try { try {
const resp = await axios.get(`/druid/indexer/v1/supervisor/${initSupervisorId}`); const resp = await axios.get(`/druid/indexer/v1/supervisor/${initSupervisorId}`);
this.updateSpec(normalizeSpecType(resp.data)); this.updateSpec(resp.data);
this.updateStep('json-spec'); this.updateStep('spec');
} catch (e) { } catch (e) {
AppToaster.show({ AppToaster.show({
message: `Failed to get supervisor spec: ${getDruidErrorMessage(e)}`, message: `Failed to get supervisor spec: ${getDruidErrorMessage(e)}`,
@ -2413,8 +2574,8 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
try { try {
const resp = await axios.get(`/druid/indexer/v1/task/${initTaskId}`); const resp = await axios.get(`/druid/indexer/v1/task/${initTaskId}`);
this.updateSpec(normalizeSpecType(resp.data.payload.spec)); this.updateSpec(resp.data.payload);
this.updateStep('json-spec'); this.updateStep('spec');
} catch (e) { } catch (e) {
AppToaster.show({ AppToaster.show({
message: `Failed to get task spec: ${getDruidErrorMessage(e)}`, message: `Failed to get task spec: ${getDruidErrorMessage(e)}`,
@ -2427,7 +2588,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
return <Loader loading />; return <Loader loading />;
} }
renderJsonSpecStep() { renderSpecStep() {
const { goToTask } = this.props; const { goToTask } = this.props;
const { spec } = this.state; const { spec } = this.state;
@ -2438,7 +2599,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
value={spec} value={spec}
onChange={s => { onChange={s => {
if (!s) return; if (!s) return;
this.updateSpec(normalizeSpecType(s)); this.updateSpec(s);
}} }}
height="100%" height="100%"
/> />
@ -2455,6 +2616,14 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
</Callout> </Callout>
</div> </div>
<div className="next-bar"> <div className="next-bar">
{!isEmptyIngestionSpec(spec) && (
<Button
className="left"
icon={IconNames.RESET}
text="Reset spec"
onClick={this.handleResetConfirm}
/>
)}
<Button <Button
text="Submit" text="Submit"
intent={Intent.PRIMARY} intent={Intent.PRIMARY}