Web console: data loader native batch shuffle UI (#8229)

* data loader shuffle UI

* better support for inline firehose

* feedback changes

* make numShards mandatory sometimes

* edit context dialog fixes

* make auto form less agro

* download full log

* improve auto column detection

* add sample for example
This commit is contained in:
Vadim Ogievetsky 2019-08-19 13:27:34 -07:00 committed by Clint Wylie
parent 566dc8c719
commit 56e440383f
36 changed files with 797 additions and 299 deletions

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { FormGroup, HTMLSelect, Icon, NumericInput, Popover } from '@blueprintjs/core';
import { FormGroup, HTMLSelect, Icon, Intent, NumericInput, Popover } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import React from 'react';
@ -33,11 +33,12 @@ export interface Field<T> {
info?: React.ReactNode;
type: 'number' | 'size-bytes' | 'string' | 'duration' | 'boolean' | 'string-array' | 'json';
defaultValue?: any;
isDefined?: (model: T) => boolean;
disabled?: boolean;
suggestions?: (string | SuggestionGroup)[];
placeholder?: string;
min?: number;
disabled?: boolean | ((model: T) => boolean);
defined?: boolean | ((model: T) => boolean);
required?: boolean | ((model: T) => boolean);
}
export interface AutoFormProps<T> {
@ -67,6 +68,24 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
return newLabel;
}
static evaluateFunctor<T>(
functor: undefined | boolean | ((model: T) => boolean),
model: T | undefined,
defaultValue = false,
): boolean {
if (!model || functor == null) return defaultValue;
switch (typeof functor) {
case 'boolean':
return functor;
case 'function':
return functor(model);
default:
throw new TypeError(`invalid functor`);
}
}
constructor(props: AutoFormProps<T>) {
super(props);
this.state = {
@ -77,10 +96,12 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
private fieldChange = (field: Field<T>, newValue: any) => {
const { model } = this.props;
if (!model) return;
const newModel =
typeof newValue === 'undefined'
? deepDelete(model, field.name)
: deepSet(model, field.name, newValue);
this.modelChange(newModel);
};
@ -88,13 +109,8 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
const { fields, onChange } = this.props;
for (const someField of fields) {
if (someField.isDefined && !someField.isDefined(newModel)) {
if (!AutoForm.evaluateFunctor(someField.defined, newModel, true)) {
newModel = deepDelete(newModel, someField.name);
} else if (
typeof someField.defaultValue !== 'undefined' &&
typeof deepGet(newModel, someField.name) === 'undefined'
) {
newModel = deepSet(newModel, someField.name, someField.defaultValue);
}
}
@ -103,9 +119,11 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
private renderNumberInput(field: Field<T>): JSX.Element {
const { model, large } = this.props;
const modelValue = deepGet(model as any, field.name) || field.defaultValue;
return (
<NumericInput
value={deepGet(model as any, field.name) || field.defaultValue}
value={modelValue}
onValueChange={(valueAsNumber: number, valueAsString: string) => {
if (valueAsString === '') {
this.fieldChange(field, undefined);
@ -117,8 +135,13 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
min={field.min || 0}
fill
large={large}
disabled={field.disabled}
disabled={AutoForm.evaluateFunctor(field.disabled, model)}
placeholder={field.placeholder}
intent={
AutoForm.evaluateFunctor(field.required, model) && modelValue == null
? Intent.PRIMARY
: undefined
}
/>
);
}
@ -136,7 +159,7 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
stepSize={1000}
majorStepSize={1000000}
large={large}
disabled={field.disabled}
disabled={AutoForm.evaluateFunctor(field.disabled, model)}
/>
);
}
@ -158,7 +181,7 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
placeholder={field.placeholder}
suggestions={field.suggestions}
large={large}
disabled={field.disabled}
disabled={AutoForm.evaluateFunctor(field.disabled, model)}
/>
);
}
@ -175,7 +198,7 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
this.fieldChange(field, v);
}}
large={large}
disabled={field.disabled}
disabled={AutoForm.evaluateFunctor(field.disabled, model)}
>
<option value="True">True</option>
<option value="False">False</option>
@ -220,7 +243,7 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
}}
placeholder={field.placeholder}
large={large}
disabled={field.disabled}
disabled={AutoForm.evaluateFunctor(field.disabled, model)}
/>
);
}
@ -250,8 +273,8 @@ export class AutoForm<T extends Record<string, any>> extends React.PureComponent
private renderField = (field: Field<T>) => {
const { model } = this.props;
if (!model) return null;
if (field.isDefined && !field.isDefined(model)) return null;
if (!model) return;
if (!AutoForm.evaluateFunctor(field.defined, model, true)) return;
const label = field.label || AutoForm.makeLabelName(field.name);
return (

View File

@ -17,9 +17,6 @@
*/
.center-message {
position: absolute;
width: 100%;
height: 100% !important;
overflow: hidden;
.center-message-inner {

View File

@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule editor matches snapshot 1`] = `null`;
exports[`show history matches snapshot 1`] = `null`;

View File

@ -21,7 +21,7 @@ import React from 'react';
import { ShowHistory } from './show-history';
describe('rule editor', () => {
describe('show history', () => {
it('matches snapshot', () => {
const showJson = <ShowHistory endpoint={'test'} downloadFilename={'test'} />;
const { container } = render(showJson);

View File

@ -22,16 +22,19 @@ exports[`show log describe show log 1`] = `
<div
class="bp3-button-group right-buttons"
>
<button
<a
class="bp3-button bp3-minimal"
type="button"
download="test"
href="test"
role="button"
tabindex="0"
>
<span
class="bp3-button-text"
>
Save
</span>
</button>
</a>
<button
class="bp3-button bp3-minimal"
type="button"

View File

@ -16,14 +16,14 @@
* limitations under the License.
*/
import { Button, ButtonGroup, Checkbox, Intent } from '@blueprintjs/core';
import { AnchorButton, Button, ButtonGroup, Checkbox, Intent } from '@blueprintjs/core';
import axios from 'axios';
import copy from 'copy-to-clipboard';
import React from 'react';
import { AppToaster } from '../../singletons/toaster';
import { UrlBaser } from '../../singletons/url-baser';
import { downloadFile, QueryManager } from '../../utils';
import { QueryManager } from '../../utils';
import './show-log.scss';
@ -123,11 +123,7 @@ export class ShowLog extends React.PureComponent<ShowLogProps, ShowLogState> {
)}
<ButtonGroup className="right-buttons">
{downloadFilename && (
<Button
text="Save"
minimal
onClick={() => downloadFile(logValue ? logValue : '', 'plain', downloadFilename)}
/>
<AnchorButton text="Save" minimal download={downloadFilename} href={endpoint} />
)}
<Button
text="Copy"

View File

@ -44,6 +44,7 @@ type Capabilities = 'working-with-sql' | 'working-without-sql' | 'broken';
export interface ConsoleApplicationProps {
hideLegacy: boolean;
exampleManifestsUrl?: string;
}
export interface ConsoleApplicationState {
@ -232,11 +233,14 @@ export class ConsoleApplication extends React.PureComponent<
};
private wrappedLoadDataView = () => {
const { exampleManifestsUrl } = this.props;
return this.wrapInViewContainer(
'load-data',
<LoadDataView
initSupervisorId={this.supervisorId}
initTaskId={this.taskId}
exampleManifestsUrl={exampleManifestsUrl}
goToTask={this.goToTaskWithTaskId}
/>,
'narrow-pad',

View File

@ -40,6 +40,8 @@ export class CompactionDialog extends React.PureComponent<
CompactionDialogProps,
CompactionDialogState
> {
static DEFAULT_TARGET_COMPACTION_SIZE_BYTES = 419430400;
constructor(props: CompactionDialogProps) {
super(props);
this.state = {
@ -54,7 +56,7 @@ export class CompactionDialog extends React.PureComponent<
inputSegmentSizeBytes: 419430400,
maxNumSegmentsToCompact: 150,
skipOffsetFromLatest: 'P1D',
targetCompactionSizeBytes: 419430400,
targetCompactionSizeBytes: CompactionDialog.DEFAULT_TARGET_COMPACTION_SIZE_BYTES,
taskContext: null,
taskPriority: 25,
tuningConfig: null,

View File

@ -62,9 +62,8 @@ exports[`clipboard dialog matches snapshot 1`] = `
<div
class="bp3-dialog-footer-actions"
>
<div
class="bp3-button-group edit-context-dialog-buttons"
class="edit-context-dialog-buttons"
>
<button
class="bp3-button"
@ -83,7 +82,7 @@ exports[`clipboard dialog matches snapshot 1`] = `
<span
class="bp3-button-text"
>
Submit
Save
</span>
</button>
</div>

View File

@ -24,7 +24,7 @@ import { EditContextDialog } from './edit-context-dialog';
describe('clipboard dialog', () => {
it('matches snapshot', () => {
const compactionDialog = (
<EditContextDialog queryContext={{}} onSubmit={() => null} onClose={() => null} />
<EditContextDialog queryContext={{}} onQueryContextChange={() => null} onClose={() => null} />
);
render(compactionDialog);
expect(document.body.lastChild).toMatchSnapshot();

View File

@ -15,22 +15,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, ButtonGroup, Callout, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core';
import { Button, Callout, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core';
import Hjson from 'hjson';
import React from 'react';
import { validJson } from '../../utils';
import { QueryContext } from '../../utils/query-context';
import './edit-context-dialog.scss';
export interface EditContextDialogProps {
queryContext: QueryContext;
onSubmit: (queryContext: {}) => void;
onQueryContextChange: (queryContext: QueryContext) => void;
onClose: () => void;
}
export interface EditContextDialogState {
queryContextString: string;
queryContext?: QueryContext;
error?: string;
}
@ -44,75 +45,61 @@ export class EditContextDialog extends React.PureComponent<
queryContextString: Object.keys(props.queryContext).length
? JSON.stringify(props.queryContext, undefined, 2)
: '{\n\n}',
error: '',
};
}
private onTextChange = (e: any) => {
let { error } = this.state;
const queryContextText = (e.target as HTMLInputElement).value;
error = undefined;
let queryContextObject;
private handleTextChange = (e: any) => {
const queryContextString = (e.target as HTMLInputElement).value;
let error: string | undefined;
let queryContext: QueryContext | undefined;
try {
queryContextObject = JSON.parse(queryContextText);
queryContext = Hjson.parse(queryContextString);
} catch (e) {
error = e.message;
}
if (!(typeof queryContextObject === 'object')) {
if (!error && (!queryContext || typeof queryContext !== 'object')) {
error = 'Input is not a valid object';
queryContext = undefined;
}
this.setState({
queryContextString: queryContextText,
queryContextString,
queryContext,
error,
});
};
private handleSave = () => {
const { onQueryContextChange } = this.props;
const { queryContext } = this.state;
if (!queryContext) return;
onQueryContextChange(queryContext);
};
render(): JSX.Element {
const { onClose, onSubmit } = this.props;
const { queryContextString } = this.state;
let { error } = this.state;
let queryContextObject: {} | undefined;
try {
queryContextObject = JSON.parse(queryContextString);
} catch (e) {
error = e.message;
}
if (!(typeof queryContextObject === 'object') && !error) {
error = 'Input is not a valid object';
}
const { onClose } = this.props;
const { queryContextString, error } = this.state;
return (
<Dialog
className="edit-context-dialog"
isOpen
onClose={() => onClose()}
title={'Edit query context'}
>
<TextArea value={queryContextString} onChange={this.onTextChange} autoFocus />
<Dialog className="edit-context-dialog" isOpen onClose={onClose} title={'Edit query context'}>
<TextArea value={queryContextString} onChange={this.handleTextChange} autoFocus />
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
{error && (
<Callout intent={Intent.DANGER} className="edit-context-dialog-error">
{error}
</Callout>
)}
<ButtonGroup className={'edit-context-dialog-buttons'}>
<div className={'edit-context-dialog-buttons'}>
<Button text={'Close'} onClick={onClose} />
<Button
text={'Close'}
onClick={() => {
onClose();
}}
/>
<Button
disabled={!validJson(queryContextString) || typeof queryContextObject !== 'object'}
text={'Submit'}
disabled={Boolean(error)}
text={'Save'}
intent={Intent.PRIMARY}
onClick={() => (queryContextObject ? onSubmit(queryContextObject) : null)}
onClick={this.handleSave}
/>
</ButtonGroup>
</div>
</div>
</Dialog>
);

View File

@ -56,23 +56,12 @@ exports[`query plan dialog matches snapshot 1`] = `
class="bp3-dialog-body"
>
<div
class="bp3-tabs bp3-vertical tab-area"
class="center-message bp3-input"
>
<div
class="bp3-tab-list"
role="tablist"
class="center-message-inner"
>
<div
class="bp3-tab-indicator-wrapper"
style="display: none;"
>
<div
class="bp3-tab-indicator"
/>
</div>
<div
class="bp3-flex-expander"
/>
The query history is empty.
</div>
</div>
</div>
@ -92,16 +81,6 @@ exports[`query plan dialog matches snapshot 1`] = `
Close
</span>
</button>
<button
class="bp3-button bp3-intent-primary"
type="button"
>
<span
class="bp3-button-text"
>
Open
</span>
</button>
</div>
</div>
</div>

View File

@ -30,4 +30,8 @@
height: 500px;
resize: none;
}
.center-message {
height: 400px;
}
}

View File

@ -19,6 +19,8 @@
import { Button, Classes, Dialog, Intent, Tab, Tabs, TextArea } from '@blueprintjs/core';
import React from 'react';
import { CenterMessage } from '../../components';
import './query-history-dialog.scss';
export interface QueryRecord {
@ -28,7 +30,7 @@ export interface QueryRecord {
export interface QueryHistoryDialogProps {
setQueryString: (queryString: string) => void;
onClose: () => void;
queryRecords: QueryRecord[];
queryRecords: readonly QueryRecord[];
}
export interface QueryHistoryDialogState {
@ -39,6 +41,32 @@ export class QueryHistoryDialog extends React.PureComponent<
QueryHistoryDialogProps,
QueryHistoryDialogState
> {
static getHistoryVersion(): string {
return new Date()
.toISOString()
.split('.')[0]
.replace('T', ' ');
}
static addQueryToHistory(
queryHistory: readonly QueryRecord[],
queryString: string,
): readonly QueryRecord[] {
// Do not add to history if already the same as the last element
if (queryHistory.length && queryHistory[0].queryString === queryString) {
return queryHistory;
}
return [
{
version: QueryHistoryDialog.getHistoryVersion(),
queryString,
},
]
.concat(queryHistory)
.slice(0, 10);
}
constructor(props: QueryHistoryDialogProps) {
super(props);
this.state = {
@ -46,10 +74,25 @@ export class QueryHistoryDialog extends React.PureComponent<
};
}
render(): JSX.Element {
const { onClose, queryRecords, setQueryString } = this.props;
private handleSelect = () => {
const { queryRecords, setQueryString } = this.props;
const { activeTab } = this.state;
setQueryString(queryRecords[activeTab].queryString);
};
private handleTabChange = (tab: number) => {
this.setState({ activeTab: tab });
};
renderContent(): JSX.Element {
const { queryRecords } = this.props;
const { activeTab } = this.state;
if (!queryRecords.length) {
return <CenterMessage>The query history is empty.</CenterMessage>;
}
const versions = queryRecords.map((record, index) => (
<Tab
id={index}
@ -60,29 +103,33 @@ export class QueryHistoryDialog extends React.PureComponent<
/>
));
return (
<Tabs
animate
renderActiveTabPanelOnly
vertical
className={'tab-area'}
selectedTabId={activeTab}
onChange={this.handleTabChange}
>
{versions}
<Tabs.Expander />
</Tabs>
);
}
render(): JSX.Element {
const { onClose, queryRecords } = this.props;
return (
<Dialog className="query-history-dialog" isOpen onClose={onClose} title="Query history">
<div className={Classes.DIALOG_BODY}>
<Tabs
animate
renderActiveTabPanelOnly
vertical
className={'tab-area'}
selectedTabId={activeTab}
onChange={(tab: number) => this.setState({ activeTab: tab })}
>
{versions}
<Tabs.Expander />
</Tabs>
</div>
<div className={Classes.DIALOG_BODY}>{this.renderContent()}</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Close" onClick={onClose} />
<Button
text="Open"
intent={Intent.PRIMARY}
onClick={() => setQueryString(queryRecords[activeTab].queryString)}
/>
{Boolean(queryRecords.length) && (
<Button text="Open" intent={Intent.PRIMARY} onClick={this.handleSelect} />
)}
</div>
</div>
</Dialog>

View File

@ -110,7 +110,7 @@ export class TaskTableActionDialog extends React.PureComponent<
<ShowLog
status={status}
endpoint={`/druid/indexer/v1/task/${taskId}/log`}
downloadFilename={`task-log-${taskId}.json`}
downloadFilename={`task-log-${taskId}.log`}
tailOffset={16000}
/>
)}

View File

@ -43,6 +43,7 @@ interface ConsoleConfig {
customHeaderName?: string;
customHeaderValue?: string;
customHeaders?: Record<string, string>;
exampleManifestsUrl?: string;
}
const consoleConfig: ConsoleConfig = (window as any).consoleConfig;
@ -64,6 +65,7 @@ if (consoleConfig.customHeaders) {
ReactDOM.render(
React.createElement(ConsoleApplication, {
hideLegacy: Boolean(consoleConfig.hideLegacy),
exampleManifestsUrl: consoleConfig.exampleManifestsUrl,
}) as any,
container,
);

View File

@ -0,0 +1,30 @@
/*
* 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 { timeFormatMatches } from './druid-time';
describe('timeFormatMatches', () => {
it('works for auto', () => {
expect(timeFormatMatches('auto', '2019-05-22 22:42:51+0000')).toBeTruthy();
});
it('works for iso', () => {
expect(timeFormatMatches('iso', '2019-05-22T22:42:51+0000')).toBeTruthy();
expect(timeFormatMatches('iso', '2019-05-22 22:42:51+0000')).toBeFalsy();
});
});

View File

@ -19,7 +19,7 @@
import { jodaFormatToRegExp } from './joda-to-regexp';
export const NUMERIC_TIME_FORMATS: string[] = ['posix', 'millis', 'micro', 'nano'];
export const BASIC_TIME_FORMATS: string[] = ['iso'].concat(NUMERIC_TIME_FORMATS);
export const BASIC_TIME_FORMATS: string[] = ['auto', 'iso'].concat(NUMERIC_TIME_FORMATS);
export const DATE_ONLY_TIME_FORMATS: string[] = [
'dd/MM/yyyy',
@ -52,13 +52,20 @@ const MIN_MICRO = MIN_MILLIS * 1000;
const MIN_NANO = MIN_MICRO * 1000;
const MAX_NANO = MIN_NANO * 1000;
// copied from http://goo.gl/0ejHHW with small tweak to make dddd not pass on its own
// tslint:disable-next-line:max-line-length
export const AUTO_MATCHER = /^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))( ((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)$/;
// tslint:disable-next-line:max-line-length
export const ISO_MATCHER = /^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))(T((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)$/;
// Note: AUTO and ISO are basically the same except ISO has a space as a separator instead of the T
export function timeFormatMatches(format: string, value: string | number): boolean {
const absValue = Math.abs(Number(value));
switch (format) {
case 'auto':
return AUTO_MATCHER.test(String(value));
case 'iso':
return ISO_MATCHER.test(String(value));

View File

@ -179,9 +179,9 @@ export function mapRecord<T, Q>(
}
export function groupBy<T, Q>(
array: T[],
array: readonly T[],
keyFn: (x: T, index: number) => string,
aggregateFn: (xs: T[], key: string) => Q,
aggregateFn: (xs: readonly T[], key: string) => Q,
): Q[] {
const buckets: Record<string, T[]> = {};
const n = array.length;
@ -194,7 +194,7 @@ export function groupBy<T, Q>(
return Object.keys(buckets).map(key => aggregateFn(buckets[key], key));
}
export function uniq(array: string[]): string[] {
export function uniq(array: readonly string[]): string[] {
const seen: Record<string, boolean> = {};
return array.filter(s => {
if (hasOwnProp(seen, s)) {

View File

@ -280,24 +280,24 @@ const PARSE_SPEC_FORM_FIELDS: Field<ParseSpec>[] = [
{
name: 'pattern',
type: 'string',
isDefined: (p: ParseSpec) => p.format === 'regex',
defined: (p: ParseSpec) => p.format === 'regex',
},
{
name: 'function',
type: 'string',
isDefined: (p: ParseSpec) => p.format === 'javascript',
defined: (p: ParseSpec) => p.format === 'javascript',
},
{
name: 'hasHeaderRow',
type: 'boolean',
defaultValue: true,
isDefined: (p: ParseSpec) => p.format === 'csv' || p.format === 'tsv',
defined: (p: ParseSpec) => p.format === 'csv' || p.format === 'tsv',
},
{
name: 'skipHeaderRows',
type: 'number',
defaultValue: 0,
isDefined: (p: ParseSpec) => p.format === 'csv' || p.format === 'tsv',
defined: (p: ParseSpec) => p.format === 'csv' || p.format === 'tsv',
min: 0,
info: (
<>
@ -310,14 +310,14 @@ const PARSE_SPEC_FORM_FIELDS: Field<ParseSpec>[] = [
{
name: 'columns',
type: 'string-array',
isDefined: (p: ParseSpec) =>
defined: (p: ParseSpec) =>
((p.format === 'csv' || p.format === 'tsv') && !p.hasHeaderRow) || p.format === 'regex',
},
{
name: 'listDelimiter',
type: 'string',
defaultValue: '|',
isDefined: (p: ParseSpec) => p.format === 'csv' || p.format === 'tsv',
defined: (p: ParseSpec) => p.format === 'csv' || p.format === 'tsv',
},
];
@ -385,7 +385,6 @@ const TIMESTAMP_SPEC_FORM_FIELDS: Field<TimestampSpec>[] = [
type: 'string',
defaultValue: 'auto',
suggestions: [
'auto',
...BASIC_TIME_FORMATS,
{
group: 'Date and time formats',
@ -400,7 +399,7 @@ const TIMESTAMP_SPEC_FORM_FIELDS: Field<TimestampSpec>[] = [
suggestions: OTHER_TIME_FORMATS,
},
],
isDefined: (timestampSpec: TimestampSpec) => isColumnTimestampSpec(timestampSpec),
defined: (timestampSpec: TimestampSpec) => isColumnTimestampSpec(timestampSpec),
info: (
<p>
Please specify your timestamp format by using the suggestions menu or typing in a{' '}
@ -470,7 +469,7 @@ const DIMENSION_SPEC_FORM_FIELDS: Field<DimensionSpec>[] = [
name: 'createBitmapIndex',
type: 'boolean',
defaultValue: true,
isDefined: (dimensionSpec: DimensionSpec) => dimensionSpec.type === 'string',
defined: (dimensionSpec: DimensionSpec) => dimensionSpec.type === 'string',
},
];
@ -518,7 +517,7 @@ const FLATTEN_FIELD_FORM_FIELDS: Field<FlattenField>[] = [
name: 'expr',
type: 'string',
placeholder: '$.thing',
isDefined: (flattenField: FlattenField) =>
defined: (flattenField: FlattenField) =>
flattenField.type === 'path' || flattenField.type === 'jq',
info: (
<>
@ -642,7 +641,7 @@ const METRIC_SPEC_FORM_FIELDS: Field<MetricSpec>[] = [
{
name: 'fieldName',
type: 'string',
isDefined: m => {
defined: m => {
return [
'longSum',
'doubleSum',
@ -670,7 +669,7 @@ const METRIC_SPEC_FORM_FIELDS: Field<MetricSpec>[] = [
name: 'maxStringBytes',
type: 'number',
defaultValue: 1024,
isDefined: m => {
defined: m => {
return ['stringFirst', 'stringLast'].includes(m.type);
},
},
@ -678,21 +677,21 @@ const METRIC_SPEC_FORM_FIELDS: Field<MetricSpec>[] = [
name: 'filterNullValues',
type: 'boolean',
defaultValue: false,
isDefined: m => {
defined: m => {
return ['stringFirst', 'stringLast'].includes(m.type);
},
},
{
name: 'filter',
type: 'json',
isDefined: m => {
defined: m => {
return m.type === 'filtered';
},
},
{
name: 'aggregator',
type: 'json',
isDefined: m => {
defined: m => {
return m.type === 'filtered';
},
},
@ -753,7 +752,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
name: 'firehose.type',
label: 'Firehose type',
type: 'string',
suggestions: ['local', 'http', 'static-s3', 'static-google-blobstore'],
suggestions: ['local', 'http', 'inline', 'static-s3', 'static-google-blobstore'],
info: (
<p>
Druid connects to raw data through{' '}
@ -895,12 +894,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
case 'index:inline':
return [
firehoseType,
{
name: 'firehose.data',
label: 'Data',
type: 'string',
info: <p>The data to ingest.</p>,
},
// do not add 'data' here as it has special handling in the load-data view
];
case 'index:static-s3':
@ -911,7 +905,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
label: 'S3 URIs',
type: 'string-array',
placeholder: 's3://your-bucket/some-file1.ext, s3://your-bucket/some-file2.ext',
isDefined: ioConfig => !deepGet(ioConfig, 'firehose.prefixes'),
defined: ioConfig => !deepGet(ioConfig, 'firehose.prefixes'),
info: (
<>
<p>
@ -927,7 +921,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
label: 'S3 prefixes',
type: 'string-array',
placeholder: 's3://your-bucket/some-path1, s3://your-bucket/some-path2',
isDefined: ioConfig => !deepGet(ioConfig, 'firehose.uris'),
defined: ioConfig => !deepGet(ioConfig, 'firehose.uris'),
info: (
<>
<p>A list of paths (with bucket) where your files are stored.</p>
@ -979,7 +973,7 @@ export function getIoConfigFormFields(ingestionComboType: IngestionComboType): F
{
name: 'topic',
type: 'string',
isDefined: (i: IoConfig) => i.type === 'kafka',
defined: (i: IoConfig) => i.type === 'kafka',
},
{
name: 'consumerProperties',
@ -1228,7 +1222,7 @@ export function getIoConfigTuningFormFields(
name: 'useEarliestOffset',
type: 'boolean',
defaultValue: false,
isDefined: (i: IoConfig) => i.type === 'kafka',
defined: (i: IoConfig) => i.type === 'kafka',
info: (
<>
<p>
@ -1244,7 +1238,7 @@ export function getIoConfigTuningFormFields(
name: 'skipOffsetGaps',
type: 'boolean',
defaultValue: false,
isDefined: (i: IoConfig) => i.type === 'kafka',
defined: (i: IoConfig) => i.type === 'kafka',
info: (
<>
<p>
@ -1260,7 +1254,7 @@ export function getIoConfigTuningFormFields(
name: 'pollTimeout',
type: 'number',
defaultValue: 100,
isDefined: (i: IoConfig) => i.type === 'kafka',
defined: (i: IoConfig) => i.type === 'kafka',
info: (
<>
<p>
@ -1274,7 +1268,7 @@ export function getIoConfigTuningFormFields(
name: 'useEarliestSequenceNumber',
type: 'boolean',
defaultValue: false,
isDefined: (i: IoConfig) => i.type === 'kinesis',
defined: (i: IoConfig) => i.type === 'kinesis',
info: (
<>
If a supervisor is managing a dataSource for the first time, it will obtain a set of
@ -1289,20 +1283,20 @@ export function getIoConfigTuningFormFields(
name: 'recordsPerFetch',
type: 'number',
defaultValue: 2000,
isDefined: (i: IoConfig) => i.type === 'kinesis',
defined: (i: IoConfig) => i.type === 'kinesis',
info: <>The number of records to request per GetRecords call to Kinesis.</>,
},
{
name: 'fetchDelayMillis',
type: 'number',
defaultValue: 1000,
isDefined: (i: IoConfig) => i.type === 'kinesis',
defined: (i: IoConfig) => i.type === 'kinesis',
info: <>Time in milliseconds to wait between subsequent GetRecords calls to Kinesis.</>,
},
{
name: 'deaggregate',
type: 'boolean',
isDefined: (i: IoConfig) => i.type === 'kinesis',
defined: (i: IoConfig) => i.type === 'kinesis',
info: <>Whether to use the de-aggregate function of the KCL.</>,
},
@ -1499,7 +1493,6 @@ export function guessDataSourceName(ioConfig: IoConfig): string | undefined {
export interface TuningConfig {
type: string;
targetPartitionSize?: number;
maxRowsInMemory?: number;
maxBytesInMemory?: number;
maxTotalRows?: number;
@ -1529,32 +1522,24 @@ export interface TuningConfig {
fetchThreads?: number;
}
export function invalidTuningConfig(tuningConfig: TuningConfig): boolean {
return Boolean(
tuningConfig.type === 'index_parallel' &&
tuningConfig.forceGuaranteedRollup &&
!tuningConfig.numShards,
);
}
export function getPartitionRelatedTuningSpecFormFields(
specType: IngestionType,
): Field<TuningConfig>[] {
switch (specType) {
case 'index':
case 'index_parallel':
const myIsParallel = specType === 'index_parallel';
return [
{
name: 'partitionDimensions',
type: 'string-array',
disabled: myIsParallel,
info: (
<>
<p>Does not currently work with parallel ingestion</p>
<p>
The dimensions to partition on. Leave blank to select all dimensions. Only used with
forceGuaranteedRollup = true, will be ignored otherwise.
</p>
</>
),
},
{
name: 'forceGuaranteedRollup',
type: 'boolean',
disabled: myIsParallel,
info: (
<>
<p>Does not currently work with parallel ingestion</p>
@ -1569,18 +1554,25 @@ export function getPartitionRelatedTuningSpecFormFields(
),
},
{
name: 'targetPartitionSize',
type: 'number',
name: 'partitionDimensions',
type: 'string-array',
defined: (t: TuningConfig) => Boolean(t.forceGuaranteedRollup),
info: (
<>
Target number of rows to include in a partition, should be a number that targets
segments of 500MB~1GB.
<p>Does not currently work with parallel ingestion</p>
<p>
The dimensions to partition on. Leave blank to select all dimensions. Only used with
forceGuaranteedRollup = true, will be ignored otherwise.
</p>
</>
),
},
{
name: 'numShards',
name: 'numShards', // This is mandatory if index_parallel and forceGuaranteedRollup
type: 'number',
defined: (t: TuningConfig) => Boolean(t.forceGuaranteedRollup),
required: (t: TuningConfig) =>
Boolean(t.type === 'index_parallel' && t.forceGuaranteedRollup),
info: (
<>
Directly specify the number of shards to create. If this is specified and 'intervals'
@ -1594,6 +1586,7 @@ export function getPartitionRelatedTuningSpecFormFields(
name: 'maxRowsPerSegment',
type: 'number',
defaultValue: 5000000,
defined: (t: TuningConfig) => t.numShards == null, // Can not be set if numShards is specified
info: <>Determines how many rows are in each segment.</>,
},
{
@ -1626,6 +1619,35 @@ export function getPartitionRelatedTuningSpecFormFields(
}
const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
{
name: 'maxNumConcurrentSubTasks',
type: 'number',
defaultValue: 1,
defined: (t: TuningConfig) => t.type === 'index_parallel',
info: (
<>
Maximum number of tasks which can be run at the same time. The supervisor task would spawn
worker tasks up to maxNumConcurrentSubTasks regardless of the available task slots. If this
value is set to 1, the supervisor task processes data ingestion on its own instead of
spawning worker tasks. If this value is set to too large, too many worker tasks can be
created which might block other ingestion.
</>
),
},
{
name: 'maxRetry',
type: 'number',
defaultValue: 3,
defined: (t: TuningConfig) => t.type === 'index_parallel',
info: <>Maximum number of retries on task failures.</>,
},
{
name: 'taskStatusCheckPeriodMs',
type: 'number',
defaultValue: 1000,
defined: (t: TuningConfig) => t.type === 'index_parallel',
info: <>Polling period in milliseconds to check running task statuses.</>,
},
{
name: 'maxRowsInMemory',
type: 'number',
@ -1638,6 +1660,24 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
placeholder: 'Default: 1/6 of max JVM memory',
info: <>Used in determining when intermediate persists to disk should occur.</>,
},
{
name: 'maxNumMergeTasks',
type: 'number',
defaultValue: 10,
defined: (t: TuningConfig) => Boolean(t.type === 'index_parallel' && t.forceGuaranteedRollup),
info: <>Number of tasks to merge partial segments after shuffle.</>,
},
{
name: 'maxNumSegmentsToMerge',
type: 'number',
defaultValue: 100,
defined: (t: TuningConfig) => Boolean(t.type === 'index_parallel' && t.forceGuaranteedRollup),
info: (
<>
Max limit for the number of segments a single task can merge at the same time after shuffle.
</>
),
},
{
name: 'indexSpec.bitmap.type',
label: 'Index bitmap type',
@ -1681,14 +1721,14 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'intermediatePersistPeriod',
type: 'duration',
defaultValue: 'PT10M',
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: <>The period that determines the rate at which intermediate persists occur.</>,
},
{
name: 'intermediateHandoffPeriod',
type: 'duration',
defaultValue: 'P2147483647D',
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: (
<>
How often the tasks should hand off segments. Handoff will happen either if
@ -1718,56 +1758,32 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
</>
),
},
{
name: 'maxNumConcurrentSubTasks',
type: 'number',
defaultValue: 1,
info: (
<>
Maximum number of tasks which can be run at the same time. The supervisor task would spawn
worker tasks up to maxNumConcurrentSubTasks regardless of the available task slots. If this
value is set to 1, the supervisor task processes data ingestion on its own instead of
spawning worker tasks. If this value is set to too large, too many worker tasks can be
created which might block other ingestion.
</>
),
},
{
name: 'maxRetry',
type: 'number',
defaultValue: 3,
info: <>Maximum number of retries on task failures.</>,
},
{
name: 'taskStatusCheckPeriodMs',
type: 'number',
defaultValue: 1000,
info: <>Polling period in milliseconds to check running task statuses.</>,
},
{
name: 'chatHandlerTimeout',
type: 'duration',
defaultValue: 'PT10S',
defined: (t: TuningConfig) => t.type === 'index_parallel',
info: <>Timeout for reporting the pushed segments in worker tasks.</>,
},
{
name: 'chatHandlerNumRetries',
type: 'number',
defaultValue: 5,
defined: (t: TuningConfig) => t.type === 'index_parallel',
info: <>Retries for reporting the pushed segments in worker tasks.</>,
},
{
name: 'handoffConditionTimeout',
type: 'number',
defaultValue: 0,
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: <>Milliseconds to wait for segment handoff. 0 means to wait forever.</>,
},
{
name: 'resetOffsetAutomatically',
type: 'boolean',
defaultValue: false,
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: (
<>
Whether to reset the consumer offset if the next offset that it is trying to fetch is less
@ -1779,7 +1795,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'workerThreads',
type: 'number',
placeholder: 'min(10, taskCount)',
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: (
<>The number of threads that will be used by the supervisor for asynchronous operations.</>
),
@ -1788,14 +1804,14 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'chatThreads',
type: 'number',
placeholder: 'min(10, taskCount * replicas)',
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: <>The number of threads that will be used for communicating with indexing tasks.</>,
},
{
name: 'chatRetries',
type: 'number',
defaultValue: 8,
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: (
<>
The number of times HTTP requests to indexing tasks will be retried before considering tasks
@ -1807,14 +1823,14 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'httpTimeout',
type: 'duration',
defaultValue: 'PT10S',
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: <>How long to wait for a HTTP response from an indexing task.</>,
},
{
name: 'shutdownTimeout',
type: 'duration',
defaultValue: 'PT80S',
isDefined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kafka' || t.type === 'kinesis',
info: (
<>
How long to wait for the supervisor to attempt a graceful shutdown of tasks before exiting.
@ -1825,7 +1841,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'offsetFetchPeriod',
type: 'duration',
defaultValue: 'PT30S',
isDefined: (t: TuningConfig) => t.type === 'kafka',
defined: (t: TuningConfig) => t.type === 'kafka',
info: (
<>
How often the supervisor queries Kafka and the indexing tasks to fetch current offsets and
@ -1837,7 +1853,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'recordBufferSize',
type: 'number',
defaultValue: 10000,
isDefined: (t: TuningConfig) => t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kinesis',
info: (
<>
Size of the buffer (number of events) used between the Kinesis fetch threads and the main
@ -1849,7 +1865,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'recordBufferOfferTimeout',
type: 'number',
defaultValue: 5000,
isDefined: (t: TuningConfig) => t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kinesis',
info: (
<>
Length of time in milliseconds to wait for space to become available in the buffer before
@ -1861,7 +1877,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'recordBufferFullWait',
type: 'number',
defaultValue: 5000,
isDefined: (t: TuningConfig) => t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kinesis',
info: (
<>
Length of time in milliseconds to wait for the buffer to drain before attempting to fetch
@ -1873,7 +1889,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'fetchSequenceNumberTimeout',
type: 'number',
defaultValue: 60000,
isDefined: (t: TuningConfig) => t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kinesis',
info: (
<>
Length of time in milliseconds to wait for Kinesis to return the earliest or latest sequence
@ -1887,7 +1903,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'fetchThreads',
type: 'number',
placeholder: 'max(1, {numProcessors} - 1)',
isDefined: (t: TuningConfig) => t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kinesis',
info: (
<>
Size of the pool of threads fetching data from Kinesis. There is no benefit in having more
@ -1899,7 +1915,7 @@ const TUNING_CONFIG_FORM_FIELDS: Field<TuningConfig>[] = [
name: 'maxRecordsPerPoll',
type: 'number',
defaultValue: 100,
isDefined: (t: TuningConfig) => t.type === 'kinesis',
defined: (t: TuningConfig) => t.type === 'kinesis',
info: (
<>
The maximum number of records/events to be fetched from buffer per poll. The actual maximum
@ -2002,13 +2018,18 @@ function guessParseSpec(sampleData: string[]): ParseSpec | undefined {
return parseSpecFromFormat('regex');
}
function parseSpecFromFormat(format: string, hasHeaderRow: boolean | null = null): ParseSpec {
function parseSpecFromFormat(format: string, hasHeaderRow?: boolean): ParseSpec {
const parseSpec: ParseSpec = {
format,
timestampSpec: {},
dimensionsSpec: {},
};
if (format === 'regex') {
parseSpec.pattern = '(.*)';
parseSpec.columns = ['column1'];
}
if (typeof hasHeaderRow === 'boolean') {
parseSpec.hasHeaderRow = hasHeaderRow;
}
@ -2069,12 +2090,12 @@ const FILTER_FORM_FIELDS: Field<DruidFilter>[] = [
{
name: 'value',
type: 'string',
isDefined: (druidFilter: DruidFilter) => druidFilter.type === 'selector',
defined: (druidFilter: DruidFilter) => druidFilter.type === 'selector',
},
{
name: 'values',
type: 'string-array',
isDefined: (druidFilter: DruidFilter) => druidFilter.type === 'in',
defined: (druidFilter: DruidFilter) => druidFilter.type === 'in',
},
];

View File

@ -19,7 +19,7 @@
import axios from 'axios';
import { getDruidErrorMessage } from './druid-query';
import { alphanumericCompare, sortWithPrefixSuffix } from './general';
import { alphanumericCompare, filterMap, sortWithPrefixSuffix } from './general';
import {
DimensionsSpec,
getEmptyTimestampSpec,
@ -72,6 +72,12 @@ export interface HeaderAndRows {
rows: SampleEntry[];
}
export interface ExampleManifest {
name: string;
description: string;
spec: any;
}
function dedupe(xs: string[]): string[] {
const seen: Record<string, boolean> = {};
return xs.filter(x => {
@ -521,3 +527,62 @@ export async function sampleForSchema(
return postToSampler(sampleSpec, 'schema');
}
export async function sampleForExampleManifests(
exampleManifestUrl: string,
): Promise<ExampleManifest[]> {
const sampleSpec: SampleSpec = {
type: 'index',
spec: {
type: 'index',
ioConfig: {
type: 'index',
firehose: { type: 'http', uris: [exampleManifestUrl] },
},
dataSchema: {
dataSource: 'sample',
parser: {
type: 'string',
parseSpec: {
format: 'tsv',
timestampSpec: {
column: 'timestamp',
missingValue: '2010-01-01T00:00:00Z',
},
dimensionsSpec: {},
hasHeaderRow: true,
},
},
},
},
samplerConfig: { numRows: 50, timeoutMs: 10000 },
};
const exampleData = await postToSampler(sampleSpec, 'example-manifest');
return filterMap(exampleData.data, datum => {
const parsed = datum.parsed;
if (!parsed) return;
let { name, description, spec } = parsed;
try {
spec = JSON.parse(spec);
} catch {
return;
}
if (
typeof name === 'string' &&
typeof description === 'string' &&
spec &&
typeof spec === 'object'
) {
return {
name: parsed.name,
description: parsed.description,
spec,
};
} else {
return;
}
});
}

View File

@ -859,7 +859,13 @@ GROUP BY 1`;
const { compaction } = row.original;
let text: string;
if (compaction) {
text = `Target: ${formatBytes(compaction.targetCompactionSizeBytes)}`;
if (compaction.targetCompactionSizeBytes == null) {
text = `Target: Default (${formatBytes(
CompactionDialog.DEFAULT_TARGET_COMPACTION_SIZE_BYTES,
)})`;
} else {
text = `Target: ${formatBytes(compaction.targetCompactionSizeBytes)}`;
}
} else {
text = 'None';
}

View File

@ -22,6 +22,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={true}
className="welcome"
disabled={false}
icon={false}
key="welcome"
onClick={[Function]}
@ -30,6 +31,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="connect"
disabled={true}
icon={false}
key="connect"
onClick={[Function]}
@ -38,6 +40,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="parser"
disabled={true}
icon={false}
key="parser"
onClick={[Function]}
@ -46,6 +49,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="timestamp"
disabled={true}
icon={false}
key="timestamp"
onClick={[Function]}
@ -68,6 +72,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="transform"
disabled={true}
icon={false}
key="transform"
onClick={[Function]}
@ -76,6 +81,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="filter"
disabled={true}
icon={false}
key="filter"
onClick={[Function]}
@ -84,6 +90,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="schema"
disabled={true}
icon={false}
key="schema"
onClick={[Function]}
@ -106,6 +113,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="partition"
disabled={true}
icon={false}
key="partition"
onClick={[Function]}
@ -114,6 +122,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="tuning"
disabled={true}
icon={false}
key="tuning"
onClick={[Function]}
@ -122,6 +131,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="publish"
disabled={true}
icon={false}
key="publish"
onClick={[Function]}
@ -144,6 +154,7 @@ exports[`load data view matches snapshot 1`] = `
<Blueprint3.Button
active={false}
className="spec"
disabled={false}
icon="manually-entered-data"
key="spec"
onClick={[Function]}

View File

@ -150,6 +150,7 @@
position: relative;
margin: 0 5px;
.inline-data,
.raw-lines {
position: absolute;
width: 100%;
@ -267,4 +268,10 @@
}
}
}
.center-message {
position: absolute;
width: 100%;
height: 100% !important;
}
}

View File

@ -97,6 +97,7 @@ import {
hasParallelAbility,
IngestionComboTypeWithExtra,
IngestionSpec,
invalidTuningConfig,
IoConfig,
isColumnTimestampSpec,
isEmptyIngestionSpec,
@ -117,11 +118,13 @@ import {
} from '../../utils/ingestion-spec';
import { deepDelete, deepGet, deepSet } from '../../utils/object-change';
import {
ExampleManifest,
getOverlordModules,
HeaderAndRows,
headerAndRowsFromSampleResponse,
SampleEntry,
sampleForConnect,
sampleForExampleManifests,
sampleForFilter,
sampleForParser,
sampleForSchema,
@ -231,6 +234,7 @@ const VIEW_TITLE: Record<Step, string> = {
export interface LoadDataViewProps {
initSupervisorId?: string;
initTaskId?: string;
exampleManifestsUrl?: string;
goToTask: (taskId: string | undefined, supervisor?: string) => void;
}
@ -248,6 +252,7 @@ export interface LoadDataViewState {
// welcome
overlordModules?: string[];
selectedComboType?: IngestionComboTypeWithExtra;
exampleManifests?: ExampleManifest[];
// general
sampleStrategy: SampleStrategy;
@ -385,6 +390,30 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
this.setState({ overlordModules });
}
isStepEnabled(step: Step): boolean {
const { spec } = this.state;
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || EMPTY_OBJECT;
const parser: Parser = deepGet(spec, 'dataSchema.parser') || EMPTY_OBJECT;
switch (step) {
case 'connect':
return Boolean(spec.type);
case 'parser':
case 'timestamp':
case 'transform':
case 'filter':
case 'schema':
case 'partition':
case 'tuning':
case 'publish':
return Boolean(spec.type && !issueWithIoConfig(ioConfig) && !issueWithParser(parser));
default:
return true;
}
}
private updateStep = (newStep: Step) => {
this.doQueryForStep(newStep);
this.setState({ step: newStep });
@ -392,16 +421,24 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
doQueryForStep(step: Step): any {
switch (step) {
case 'welcome':
return this.queryForWelcome();
case 'connect':
return this.queryForConnect(true);
case 'parser':
return this.queryForParser(true);
case 'timestamp':
return this.queryForTimestamp(true);
case 'transform':
return this.queryForTransform(true);
case 'filter':
return this.queryForFilter(true);
case 'schema':
return this.queryForSchema(true);
}
@ -485,6 +522,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
onClick={() => this.updateStep(s)}
icon={s === 'spec' && IconNames.MANUALLY_ENTERED_DATA}
text={VIEW_TITLE[s]}
disabled={!this.isStepEnabled(s)}
/>
))}
</ButtonGroup>
@ -530,11 +568,30 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
// ==================================================================
renderIngestionCard(comboType: IngestionComboTypeWithExtra) {
async queryForWelcome() {
const { exampleManifestsUrl } = this.props;
if (!exampleManifestsUrl) return;
let exampleManifests: ExampleManifest[] | undefined;
try {
exampleManifests = await sampleForExampleManifests(exampleManifestsUrl);
} catch (e) {
this.setState({
exampleManifests: undefined,
});
return;
}
this.setState({
exampleManifests,
});
}
renderIngestionCard(comboType: IngestionComboTypeWithExtra, disabled?: boolean) {
const { overlordModules, selectedComboType } = this.state;
if (!overlordModules) return null;
const requiredModule = getRequiredModule(comboType);
const goodToGo = !requiredModule || overlordModules.includes(requiredModule);
const goodToGo = !disabled && (!requiredModule || overlordModules.includes(requiredModule));
return (
<Card
@ -553,7 +610,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
}
renderWelcomeStep() {
const { spec } = this.state;
const { exampleManifestsUrl } = this.props;
const { spec, exampleManifests } = this.state;
const noExamples = Boolean(!exampleManifests || !exampleManifests.length);
return (
<>
@ -567,7 +626,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
{this.renderIngestionCard('index:http')}
{this.renderIngestionCard('index:local')}
{this.renderIngestionCard('index:inline')}
{/* this.renderIngestionCard('example') */}
{exampleManifestsUrl && this.renderIngestionCard('example', noExamples)}
{this.renderIngestionCard('other')}
</div>
<div className="control">
@ -590,7 +649,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
}
renderWelcomeStepMessage() {
const { selectedComboType } = this.state;
const { selectedComboType, exampleManifests } = this.state;
if (!selectedComboType) {
return <p>Please specify where your raw data is located</p>;
@ -676,7 +735,11 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
);
case 'example':
return <p>Pick one of these examples to get you started.</p>;
if (exampleManifests && exampleManifests.length) {
return <p>Pick one of these examples to get you started.</p>;
} else {
return <p>Could not load example.</p>;
}
case 'other':
return (
@ -696,7 +759,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
renderWelcomeStepControls() {
const { goToTask } = this.props;
const { spec, selectedComboType } = this.state;
const { spec, selectedComboType, exampleManifests } = this.state;
const issue = this.selectedIngestionTypeIssue();
if (issue) return null;
@ -715,13 +778,13 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Button
text="Connect data"
rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
onClick={() => {
this.updateSpec(updateIngestionType(spec, selectedComboType as any));
setTimeout(() => {
this.updateStep('connect');
}, 10);
}}
intent={Intent.PRIMARY}
/>
</FormGroup>
);
@ -732,14 +795,35 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Button
text="Submit task"
rightIcon={IconNames.ARROW_RIGHT}
onClick={() => goToTask(undefined, 'task')}
intent={Intent.PRIMARY}
onClick={() => goToTask(undefined, 'task')}
/>
</FormGroup>
);
case 'example':
return null;
return (
<>
{exampleManifests &&
exampleManifests.map((exampleManifest, i) => (
<FormGroup key={i}>
<Button
text={exampleManifest.name}
rightIcon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
title={exampleManifest.description}
fill
onClick={() => {
this.updateSpec(exampleManifest.spec);
setTimeout(() => {
this.updateStep('connect');
}, 10);
}}
/>
</FormGroup>
))}
</>
);
case 'other':
return (
@ -748,16 +832,16 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Button
text="Submit supervisor"
rightIcon={IconNames.ARROW_RIGHT}
onClick={() => goToTask(undefined, 'supervisor')}
intent={Intent.PRIMARY}
onClick={() => goToTask(undefined, 'supervisor')}
/>
</FormGroup>
<FormGroup>
<Button
text="Submit task"
rightIcon={IconNames.ARROW_RIGHT}
onClick={() => goToTask(undefined, 'task')}
intent={Intent.PRIMARY}
onClick={() => goToTask(undefined, 'task')}
/>
</FormGroup>
</>
@ -855,9 +939,22 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
const specType = getSpecType(spec);
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || EMPTY_OBJECT;
const isBlank = !ioConfig.type;
const inlineMode = deepGet(spec, 'ioConfig.firehose.type') === 'inline';
let mainFill: JSX.Element | string = '';
if (inputQueryState.isInit()) {
if (inlineMode) {
mainFill = (
<TextArea
className="inline-data"
placeholder="Paste your data here"
value={deepGet(spec, 'ioConfig.firehose.data')}
onChange={(e: any) => {
const stringValue = e.target.value.substr(0, 65536);
this.updateSpec(deepSet(spec, 'ioConfig.firehose.data', stringValue));
}}
/>
);
} else if (inputQueryState.isInit()) {
mainFill = (
<CenterMessage>
Please fill out the fields on the right sidebar to get started{' '}
@ -899,11 +996,20 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
</ExternalLink>{' '}
format that is optimized for analytic queries.
</p>
<p>
To get started, please specify where your raw data is stored and what data you want to
ingest.
</p>
<p>Click "Preview" to look at the sampled raw data.</p>
{inlineMode ? (
<>
<p>To get started, please paste some data in the box to the left.</p>
<p>Click "Register" to verify your data with Druid.</p>
</>
) : (
<>
<p>
To get started, please specify where your raw data is stored and what data you
want to ingest.
</p>
<p>Click "Preview" to look at the sampled raw data.</p>
</>
)}
</Callout>
{ingestionComboType ? (
<AutoForm
@ -938,7 +1044,11 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
</HTMLSelect>
</FormGroup>
)}
<Button text="Preview" disabled={isBlank} onClick={() => this.queryForConnect()} />
<Button
text={inlineMode ? 'Register' : 'Preview'}
disabled={isBlank}
onClick={() => this.queryForConnect()}
/>
</div>
{this.renderNextBar({
disabled: !inputQueryState.data,
@ -2433,7 +2543,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
name: 'segmentGranularity',
type: 'string',
suggestions: ['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'],
isDefined: (g: GranularitySpec) => g.type === 'uniform',
defined: (g: GranularitySpec) => g.type === 'uniform',
info: (
<>
The granularity to create time chunks at. Multiple segments can be created per
@ -2463,7 +2573,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
</Callout>
{this.renderParallelPickerIfNeeded()}
</div>
{this.renderNextBar({})}
{this.renderNextBar({
disabled: invalidTuningConfig(tuningConfig),
})}
</>
);
}
@ -2565,6 +2677,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
renderPublishStep() {
const { spec } = this.state;
const parallel = isParallel(spec);
return (
<>
@ -2602,6 +2715,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
name: 'tuningConfig.logParseExceptions',
label: 'Log parse exceptions',
type: 'boolean',
disabled: parallel,
defaultValue: false,
info: (
<>
@ -2614,6 +2728,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
name: 'tuningConfig.maxParseExceptions',
label: 'Max parse exceptions',
type: 'number',
disabled: parallel,
placeholder: '(unlimited)',
info: (
<>
@ -2626,6 +2741,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
name: 'tuningConfig.maxSavedParseExceptions',
label: 'Max saved parse exceptions',
type: 'number',
disabled: parallel,
defaultValue: 0,
info: (
<>
@ -2649,6 +2765,7 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
<Callout className="intro">
<p>Configure behavior of indexed data once it reaches Druid.</p>
</Callout>
{this.renderParallelPickerIfNeeded()}
</div>
{this.renderNextBar({})}
</>

View File

@ -1,3 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`number menu matches snapshot 1`] = `null`;
exports[`number menu matches snapshot 1`] = `
<li
class="bp3-submenu"
>
<span
class="bp3-popover-wrapper"
>
<span
class="bp3-popover-target"
>
<a
class="bp3-menu-item"
tabindex="0"
>
<span
class="bp3-icon bp3-icon-filter"
icon="filter"
>
<svg
data-icon="filter"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
filter
</desc>
<path
d="M13.99.99h-12a1.003 1.003 0 00-.71 1.71l4.71 4.71V14a1.003 1.003 0 001.71.71l2-2c.18-.18.29-.43.29-.71V7.41L14.7 2.7a1.003 1.003 0 00-.71-1.71z"
fill-rule="evenodd"
/>
</svg>
</span>
<div
class="bp3-text-overflow-ellipsis bp3-fill"
>
Filter
</div>
<span
class="bp3-icon bp3-icon-caret-right"
icon="caret-right"
>
<svg
data-icon="caret-right"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
caret-right
</desc>
<path
d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 006 4.5v7a.495.495 0 00.83.37l4-3.5c.1-.09.17-.22.17-.37z"
fill-rule="evenodd"
/>
</svg>
</span>
</a>
</span>
</span>
</li>
`;

View File

@ -17,11 +17,14 @@
*/
import { render } from '@testing-library/react';
import { sqlParserFactory } from 'druid-query-toolkit';
import React from 'react';
import { NumberMenuItems } from './number-menu-items';
describe('number menu', () => {
const parser = sqlParserFactory(['COUNT']);
it('matches snapshot', () => {
const numberMenu = (
<NumberMenuItems
@ -29,7 +32,8 @@ describe('number menu', () => {
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'text'}
columnName={'added'}
queryAst={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
/>
);

View File

@ -1,3 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`string menu matches snapshot 1`] = `null`;
exports[`string menu matches snapshot 1`] = `
<li
class="bp3-submenu"
>
<span
class="bp3-popover-wrapper"
>
<span
class="bp3-popover-target"
>
<a
class="bp3-menu-item"
tabindex="0"
>
<span
class="bp3-icon bp3-icon-filter"
icon="filter"
>
<svg
data-icon="filter"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
filter
</desc>
<path
d="M13.99.99h-12a1.003 1.003 0 00-.71 1.71l4.71 4.71V14a1.003 1.003 0 001.71.71l2-2c.18-.18.29-.43.29-.71V7.41L14.7 2.7a1.003 1.003 0 00-.71-1.71z"
fill-rule="evenodd"
/>
</svg>
</span>
<div
class="bp3-text-overflow-ellipsis bp3-fill"
>
Filter
</div>
<span
class="bp3-icon bp3-icon-caret-right"
icon="caret-right"
>
<svg
data-icon="caret-right"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
caret-right
</desc>
<path
d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 006 4.5v7a.495.495 0 00.83.37l4-3.5c.1-.09.17-.22.17-.37z"
fill-rule="evenodd"
/>
</svg>
</span>
</a>
</span>
</span>
</li>
`;

View File

@ -17,11 +17,14 @@
*/
import { render } from '@testing-library/react';
import { sqlParserFactory } from 'druid-query-toolkit';
import React from 'react';
import { StringMenuItems } from './string-menu-items';
describe('string menu', () => {
const parser = sqlParserFactory(['COUNT']);
it('matches snapshot', () => {
const stringMenu = (
<StringMenuItems
@ -29,7 +32,8 @@ describe('string menu', () => {
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'text'}
columnName={'channel'}
queryAst={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
/>
);

View File

@ -1,3 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`time menu matches snapshot 1`] = `null`;
exports[`time menu matches snapshot 1`] = `
<li
class="bp3-submenu"
>
<span
class="bp3-popover-wrapper"
>
<span
class="bp3-popover-target"
>
<a
class="bp3-menu-item"
tabindex="0"
>
<span
class="bp3-icon bp3-icon-filter"
icon="filter"
>
<svg
data-icon="filter"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
filter
</desc>
<path
d="M13.99.99h-12a1.003 1.003 0 00-.71 1.71l4.71 4.71V14a1.003 1.003 0 001.71.71l2-2c.18-.18.29-.43.29-.71V7.41L14.7 2.7a1.003 1.003 0 00-.71-1.71z"
fill-rule="evenodd"
/>
</svg>
</span>
<div
class="bp3-text-overflow-ellipsis bp3-fill"
>
Filter
</div>
<span
class="bp3-icon bp3-icon-caret-right"
icon="caret-right"
>
<svg
data-icon="caret-right"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
caret-right
</desc>
<path
d="M11 8c0-.15-.07-.28-.17-.37l-4-3.5A.495.495 0 006 4.5v7a.495.495 0 00.83.37l4-3.5c.1-.09.17-.22.17-.37z"
fill-rule="evenodd"
/>
</svg>
</span>
</a>
</span>
</span>
</li>
`;

View File

@ -17,11 +17,14 @@
*/
import { render } from '@testing-library/react';
import { sqlParserFactory } from 'druid-query-toolkit';
import React from 'react';
import { TimeMenuItems } from './time-menu-items';
describe('time menu', () => {
const parser = sqlParserFactory(['COUNT']);
it('matches snapshot', () => {
const timeMenu = (
<TimeMenuItems
@ -30,7 +33,8 @@ describe('time menu', () => {
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'text'}
columnName={'__time'}
queryAst={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
/>
);

View File

@ -114,7 +114,7 @@ ORDER BY "Count" DESC`,
export interface ColumnTreeProps {
columnMetadataLoading: boolean;
columnMetadata?: ColumnMetadata[];
columnMetadata?: readonly ColumnMetadata[];
onQueryStringChange: (queryString: string, run: boolean) => void;
defaultSchema?: string;
defaultTable?: string;
@ -141,7 +141,7 @@ export interface ColumnTreeProps {
}
export interface ColumnTreeState {
prevColumnMetadata?: ColumnMetadata[];
prevColumnMetadata?: readonly ColumnMetadata[];
prevGroupByStatus?: boolean;
columnTree?: ITreeNode[];
selectedTreeIndex: number;

View File

@ -36,7 +36,7 @@ export interface QueryInputProps {
queryString: string;
onQueryStringChange: (newQueryString: string) => void;
runeMode: boolean;
columnMetadata?: ColumnMetadata[];
columnMetadata?: readonly ColumnMetadata[];
currentSchema?: string;
currentTable?: string;
}
@ -46,7 +46,7 @@ export interface QueryInputState {
// Since this component will grown and shrink dynamically we will measure its height and then set it.
editorHeight: number;
completions: any[];
prevColumnMetadata?: ColumnMetadata[];
prevColumnMetadata?: readonly ColumnMetadata[];
prevCurrentTable?: string;
prevCurrentSchema?: string;
}

View File

@ -58,7 +58,9 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
data={queryResult ? queryResult.rows : []}
loading={loading}
noDataText={
!loading && queryResult && !queryResult.rows.length ? 'No queryResults' : error || ''
!loading && queryResult && !queryResult.rows.length
? 'Query returned no data'
: error || ''
}
sortable={false}
columns={(queryResult ? queryResult.header : []).map((h: any, i) => {

View File

@ -70,8 +70,8 @@ import { RunButton } from './run-button/run-button';
import './query-view.scss';
const parserRaw = sqlParserFactory(
SQL_FUNCTIONS.map((sql_function: SyntaxDescription) => {
return sql_function.syntax.substr(0, sql_function.syntax.indexOf('('));
SQL_FUNCTIONS.map((sqlFunction: SyntaxDescription) => {
return sqlFunction.syntax.substr(0, sqlFunction.syntax.indexOf('('));
}),
);
@ -107,7 +107,7 @@ export interface QueryViewState {
autoRun: boolean;
columnMetadataLoading: boolean;
columnMetadata?: ColumnMetadata[];
columnMetadata?: readonly ColumnMetadata[];
columnMetadataError?: string;
loading: boolean;
@ -124,7 +124,7 @@ export interface QueryViewState {
editContextDialogOpen: boolean;
historyDialogOpen: boolean;
queryHistory: QueryRecord[];
queryHistory: readonly QueryRecord[];
}
interface QueryResult {
@ -139,6 +139,10 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
return query.replace(/;+((?:\s*--[^\n]*)?\s*)$/, '$1');
}
static isEmptyQuery(query: string): boolean {
return query.trim() === '';
}
static isExplainQuery(query: string): boolean {
return /EXPLAIN\sPLAN\sFOR/i.test(query);
}
@ -456,7 +460,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
return (
<EditContextDialog
onSubmit={(queryContext: QueryContext) =>
onQueryContextChange={(queryContext: QueryContext) =>
this.setState({ queryContext, editContextDialogOpen: false })
}
onClose={() => this.setState({ editContextDialogOpen: false })}
@ -476,6 +480,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
autoRun,
wrapQuery,
} = this.state;
const emptyQuery = QueryView.isEmptyQuery(queryString);
let currentSchema;
let currentTable;
@ -523,8 +528,8 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
runeMode={runeMode}
queryContext={queryContext}
onQueryContextChange={this.handleQueryContextChange}
onRun={this.handleRun}
onExplain={this.handleExplain}
onRun={emptyQuery ? undefined : this.handleRun}
onExplain={emptyQuery ? undefined : this.handleExplain}
onHistory={() => this.setState({ historyDialogOpen: true })}
/>
{result && (
@ -651,25 +656,14 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
private handleRun = () => {
const { queryString, queryContext, wrapQuery, queryHistory } = this.state;
while (queryHistory.length > 9) {
queryHistory.pop();
}
queryHistory.unshift({
version: `${new Date().toISOString()}`,
queryString,
});
let queryHistoryString;
try {
queryHistoryString = JSON.stringify(queryHistory);
} catch {}
if (queryHistoryString) {
localStorageSet(LocalStorageKeys.QUERY_HISTORY, queryHistoryString);
}
if (QueryView.isJsonLike(queryString) && !QueryView.validRune(queryString)) return;
const newQueryHistory = QueryHistoryDialog.addQueryToHistory(queryHistory, queryString);
localStorageSet(LocalStorageKeys.QUERY_HISTORY, JSON.stringify(newQueryHistory));
localStorageSet(LocalStorageKeys.QUERY_KEY, queryString);
this.setState({ queryHistory: newQueryHistory });
this.sqlQueryManager.runQuery({ queryString, queryContext, wrapQuery });
};

View File

@ -51,8 +51,8 @@ export interface RunButtonProps {
setWrapQuery: (wrapQuery: boolean) => void;
queryContext: QueryContext;
onQueryContextChange: (newQueryContext: QueryContext) => void;
onRun: () => void;
onExplain: () => void;
onRun: (() => void) | undefined;
onExplain: (() => void) | undefined;
onEditContext: () => void;
onHistory: () => void;
}
@ -114,7 +114,7 @@ export class RunButton extends React.PureComponent<RunButtonProps> {
/>
{!runeMode && (
<>
<MenuItem icon={IconNames.CLEAN} text="Explain" onClick={onExplain} />
{onExplain && <MenuItem icon={IconNames.CLEAN} text="Explain" onClick={onExplain} />}
<MenuItem icon={IconNames.HISTORY} text="History" onClick={onHistory} />
<MenuCheckbox
checked={wrapQuery}
@ -160,17 +160,17 @@ export class RunButton extends React.PureComponent<RunButtonProps> {
render(): JSX.Element {
const { runeMode, onRun, wrapQuery } = this.props;
const runButtonText = runeMode ? 'Rune' : wrapQuery ? 'Run with limit' : 'Run as is';
return (
<ButtonGroup className="run-button">
<Tooltip content="Control + Enter" hoverOpenDelay={900}>
<Button
icon={IconNames.CARET_RIGHT}
onClick={this.handleRun}
text={runeMode ? 'Rune' : wrapQuery ? 'Run with limit' : 'Run as is'}
disabled={!onRun}
/>
</Tooltip>
{onRun ? (
<Tooltip content="Control + Enter" hoverOpenDelay={900}>
<Button icon={IconNames.CARET_RIGHT} onClick={this.handleRun} text={runButtonText} />
</Tooltip>
) : (
<Button icon={IconNames.CARET_RIGHT} text={runButtonText} disabled />
)}
<Popover position={Position.BOTTOM_LEFT} content={this.renderExtraMenu()}>
<Button icon={IconNames.MORE} />
</Popover>