Data loader (GUI component) (#7572)

* data loader init

* fix timecolumn text

* feedback changes

* fixing typos and improving error reporting

* added local firehose warning

* update warning copy

* refine copy

* better copy

* fix tests

* remove console log

* copy change

* add banner message
This commit is contained in:
Vadim Ogievetsky 2019-05-03 17:14:57 -07:00 committed by Clint Wylie
parent afbcb9c07f
commit baf54f373c
47 changed files with 6506 additions and 1011 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,16 @@
"type": "git",
"url": "https://github.com/apache/druid/"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "jsdom",
"moduleNameMapper": {
"\\.scss$": "identity-obj-proxy"
},
"testMatch": [
"**/?(*.)+(spec).ts?(x)"
]
},
"scripts": {
"watch": "./script/watch",
"compile": "./script/build",
@ -16,7 +26,7 @@
"test": "jest --silent 2>&1",
"tslint": "./node_modules/.bin/tslint -c tslint.json --project tsconfig.json --formatters-dir ./node_modules/awesome-code-style/formatter 'src/**/*.ts?(x)'",
"tslint-fix": "npm run tslint -- --fix",
"tslint-changed-only": "git diff --diff-filter=ACMR --name-only | grep -E \\.tsx\\?$ | xargs ./node_modules/.bin/tslint -c tslint.json --project tsconfig.json --formatters-dir ./node_modules/awesome-code-style/formatter",
"tslint-changed-only": "git diff --diff-filter=ACMR --cached --name-only | grep -E \\.tsx\\?$ | xargs ./node_modules/.bin/tslint -c tslint.json --project tsconfig.json --formatters-dir ./node_modules/awesome-code-style/formatter",
"tslint-fix-changed-only": "npm run tslint-changed-only -- --fix",
"generate-licenses-file": "license-checker --production --json --out licenses.json",
"check-licenses": "license-checker --production --onlyAllow 'Apache-1.1;Apache-2.0;BSD-2-Clause;BSD-3-Clause;MIT;CC0-1.0' --summary",
@ -49,7 +59,6 @@
"@types/hjson": "^2.4.1",
"@types/jest": "^24.0.11",
"@types/lodash.debounce": "^4.0.6",
"@types/mocha": "^5.2.6",
"@types/node": "^11.13.4",
"@types/numeral": "^0.0.25",
"@types/react-dom": "^16.8.4",
@ -62,7 +71,6 @@
"ignore-styles": "^5.0.1",
"jest": "^24.7.1",
"license-checker": "^25.0.1",
"mocha": "^6.1.3",
"node-sass": "^4.11.0",
"node-sass-chokidar": "^1.3.4",
"postcss-cli": "^6.1.2",
@ -81,6 +89,7 @@
"tslint-loader": "^3.5.4",
"typescript": "^3.4.3",
"webpack": "^4.29.6",
"webpack-bundle-analyzer": "^3.3.2",
"webpack-cli": "^3.3.0",
"webpack-dev-server": "^3.3.1"
}

135
web-console/script/mkcomp Executable file
View File

@ -0,0 +1,135 @@
#!/usr/bin/env node
/*
* 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.
*/
let fs = require('fs-extra');
if (!(process.argv.length === 3 || process.argv.length === 4)) {
console.log('Usage: mkcomp <what?> <component-name>');
process.exit();
}
let name;
let what;
if (process.argv.length === 4) {
what = process.argv[2];
name = process.argv[3];
if (!(what === 'component' || what === 'dialog' || what === 'singleton')) {
console.log(`Bad what, should be on of: component, dialog, singleton`);
process.exit();
}
} else {
what = 'component';
name = process.argv[2];
}
if (!/^([a-z-])+$/.test(name)) {
console.log('must be a hyphen case name');
process.exit();
}
let path = `./src/${what}s/`;
fs.ensureDirSync(path);
console.log('Making path:', path);
const camelName = name.replace(/(^|-)[a-z]/g, (s) => s.replace('-', '').toUpperCase());
const year = (new Date()).getFullYear();
function writeFile(path, data) {
try {
return fs.writeFileSync(path, data, {
flag: 'wx', // x = fail if file exists
encoding: 'utf8'
});
} catch (error) {
return console.log(`Skipping ${path}`);
}
}
// Make the TypeScript file
writeFile(path + name + '.tsx',
`/*
* 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 { Button, InputGroup } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import * as React from 'react';
import './${name}.scss';
export interface ${camelName}Props extends React.Props<any> {
}
export interface ${camelName}State {
}
export class ${camelName} extends React.Component<${camelName}Props, ${camelName}State> {
constructor(props: ${camelName}Props, context: any) {
super(props, context);
// this.state = {};
}
render() {
return <div className="${name}">
</div>;
}
}
`);
// Make the SASS file
writeFile(path + name + '.scss',
`/*
* 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.
*/
.${name} {
}
`);

View File

@ -0,0 +1,58 @@
/*
* 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 { InputGroup, ITagInputProps } from '@blueprintjs/core';
import * as React from 'react';
export interface ArrayInputProps extends ITagInputProps {
}
export class ArrayInput extends React.Component<ArrayInputProps, { stringValue: string }> {
constructor(props: ArrayInputProps) {
super(props);
this.state = {
stringValue: Array.isArray(props.values) ? props.values.join(', ') : ''
};
}
private handleChange = (e: any) => {
const { onChange } = this.props;
const stringValue = e.target.value;
const newValues = stringValue.split(',').map((v: string) => v.trim());
const newValuesFiltered = newValues.filter(Boolean);
this.setState({
stringValue: newValues.length === newValuesFiltered.length ? newValues.join(', ') : stringValue
});
if (onChange) onChange(stringValue === '' ? undefined : newValuesFiltered);
}
render() {
const { className, placeholder, large, disabled } = this.props;
const { stringValue } = this.state;
return <InputGroup
className={className}
value={stringValue}
onChange={this.handleChange}
placeholder={placeholder}
large={large}
disabled={disabled}
/>;
}
}

View File

@ -20,4 +20,22 @@
.ace_scroller {
background-color: #212c36;
}
// Popover in info label
label.bp3-label {
position: relative;
.bp3-text-muted {
position: absolute;
right: 0;
.bp3-popover-wrapper {
display: inline;
.bp3-popover-target {
display: inline;
}
}
}
}
}

View File

@ -16,35 +16,62 @@
* limitations under the License.
*/
import { InputGroup } from '@blueprintjs/core';
import { FormGroup, HTMLSelect, NumericInput, TagInput } from '@blueprintjs/core';
import {
Button,
FormGroup,
HTMLSelect,
Icon,
InputGroup,
Menu,
MenuItem,
NumericInput,
Popover,
Position
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import * as React from 'react';
import { deepDelete, deepGet, deepSet } from '../utils/object-change';
import { ArrayInput } from './array-input';
import { JSONInput } from './json-input';
import './auto-form.scss';
interface Field {
export interface SuggestionGroup {
group: string;
suggestions: string[];
}
export interface Field<T> {
name: string;
label?: string;
info?: React.ReactNode;
type: 'number' | 'size-bytes' | 'string' | 'boolean' | 'string-array' | 'json';
defaultValue?: any;
isDefined?: (model: T) => boolean;
disabled?: boolean;
suggestions?: (string | SuggestionGroup)[];
placeholder?: string;
min?: number;
}
export interface AutoFormProps<T> extends React.Props<any> {
fields: Field[];
fields: Field<T>[];
model: T | null;
onChange: (newValue: T) => void;
onChange: (newModel: T) => void;
showCustom?: (model: T) => boolean;
updateJSONValidity?: (jsonValidity: boolean) => void;
large?: boolean;
}
export interface AutoFormState<T> {
jsonInputsValidity: any;
}
export class AutoForm<T> extends React.Component<AutoFormProps<T>, AutoFormState<T>> {
export class AutoForm<T extends Record<string, any>> extends React.Component<AutoFormProps<T>, AutoFormState<T>> {
static makeLabelName(label: string): string {
let newLabel = label.split(/(?=[A-Z])/).map(s => s.toLowerCase()).join(' ');
let newLabel = label.split(/(?=[A-Z])/).join(' ').toLowerCase().replace(/\./g, ' ');
newLabel = newLabel[0].toUpperCase() + newLabel.slice(1);
return newLabel;
}
@ -56,57 +83,135 @@ export class AutoForm<T> extends React.Component<AutoFormProps<T>, AutoFormState
};
}
private renderNumberInput(field: Field): JSX.Element {
const { model, onChange } = this.props;
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);
}
private modelChange = (newModel: T) => {
const { fields, onChange } = this.props;
for (const someField of fields) {
if (someField.isDefined && !someField.isDefined(newModel)) {
newModel = deepDelete(newModel, someField.name);
} else if (typeof someField.defaultValue !== 'undefined' && typeof deepGet(newModel, someField.name) === 'undefined') {
newModel = deepSet(newModel, someField.name, someField.defaultValue);
}
}
onChange(newModel);
}
private renderNumberInput(field: Field<T>): JSX.Element {
const { model, large } = this.props;
return <NumericInput
value={(model as any)[field.name]}
onValueChange={(v: any) => {
if (isNaN(v)) return;
onChange(Object.assign({}, model, { [field.name]: v }));
value={deepGet(model as any, field.name) || field.defaultValue}
onValueChange={(valueAsNumber: number, valueAsString: string) => {
if (valueAsString === '') {
this.fieldChange(field, undefined);
return;
}
if (isNaN(valueAsNumber)) return;
this.fieldChange(field, valueAsNumber);
}}
min={field.min || 0}
fill
large={large}
disabled={field.disabled}
placeholder={field.placeholder}
/>;
}
private renderSizeBytesInput(field: Field): JSX.Element {
const { model, onChange } = this.props;
private renderSizeBytesInput(field: Field<T>): JSX.Element {
const { model, large } = this.props;
return <NumericInput
value={(model as any)[field.name]}
value={deepGet(model as any, field.name) || field.defaultValue}
onValueChange={(v: number) => {
if (isNaN(v)) return;
onChange(Object.assign({}, model, { [field.name]: v }));
this.fieldChange(field, v);
}}
min={0}
stepSize={1000}
majorStepSize={1000000}
large={large}
disabled={field.disabled}
/>;
}
private renderStringInput(field: Field): JSX.Element {
const { model, onChange } = this.props;
private renderStringInput(field: Field<T>): JSX.Element {
const { model, large } = this.props;
const suggestionsMenu = field.suggestions ?
<Menu>
{
field.suggestions.map(suggestion => {
if (typeof suggestion === 'string') {
return <MenuItem
key={suggestion}
text={suggestion}
onClick={() => this.fieldChange(field, suggestion)}
/>;
} else {
return <MenuItem
key={suggestion.group}
text={suggestion.group}
>
{
suggestion.suggestions.map(suggestion => (
<MenuItem
key={suggestion}
text={suggestion}
onClick={() => this.fieldChange(field, suggestion)}
/>
))
}
</MenuItem>;
}
})
}
</Menu> :
undefined;
return <InputGroup
value={(model as any)[field.name]}
onChange={(v: any) => {
onChange(Object.assign({}, model, { [field.name]: v }));
value={deepGet(model as any, field.name) || field.defaultValue || ''}
onChange={(e: any) => {
const v = e.target.value;
this.fieldChange(field, v === '' ? undefined : v);
}}
placeholder={field.placeholder}
rightElement={
suggestionsMenu &&
<Popover content={suggestionsMenu} position={Position.BOTTOM_RIGHT} autoFocus={false}>
<Button icon={IconNames.CARET_DOWN} minimal />
</Popover>
}
large={large}
disabled={field.disabled}
/>;
}
private renderBooleanInput(field: Field): JSX.Element {
const { model, onChange } = this.props;
private renderBooleanInput(field: Field<T>): JSX.Element {
const { model, large } = this.props;
let curValue = deepGet(model as any, field.name);
if (curValue == null) curValue = field.defaultValue;
return <HTMLSelect
value={(model as any)[field.name] === true ? 'True' : 'False'}
value={curValue === true ? 'True' : 'False'}
onChange={(e: any) => {
onChange(Object.assign({}, model, { [field.name]: e.currentTarget.value === 'True' }));
const v = e.currentTarget.value === 'True';
this.fieldChange(field, v);
}}
large={large}
disabled={field.disabled}
>
<option value="True">True</option>
<option value="False">False</option>
</HTMLSelect>;
}
private renderJSONInput(field: Field): JSX.Element {
const { model, onChange, updateJSONValidity } = this.props;
private renderJSONInput(field: Field<T>): JSX.Element {
const { model, updateJSONValidity } = this.props;
const { jsonInputsValidity } = this.state;
const updateInputValidity = (e: any) => {
@ -121,25 +226,28 @@ export class AutoForm<T> extends React.Component<AutoFormProps<T>, AutoFormState
};
return <JSONInput
value={(model as any)[field.name]}
onChange={(e: any) => onChange(Object.assign({}, model, { [field.name]: e}))}
value={deepGet(model as any, field.name)}
onChange={(v: any) => this.fieldChange(field, v)}
updateInputValidity={updateInputValidity}
/>;
}
private renderStringArrayInput(field: Field): JSX.Element {
const { model, onChange } = this.props;
return <TagInput
values={(model as any)[field.name] || []}
private renderStringArrayInput(field: Field<T>): JSX.Element {
const { model, large } = this.props;
return <ArrayInput
values={deepGet(model as any, field.name) || []}
onChange={(v: any) => {
onChange(Object.assign({}, model, { [field.name]: v }));
this.fieldChange(field, v);
}}
placeholder={field.placeholder}
addOnBlur
fill
large={large}
disabled={field.disabled}
/>;
}
renderFieldInput(field: Field) {
renderFieldInput(field: Field<T>) {
switch (field.type) {
case 'number': return this.renderNumberInput(field);
case 'size-bytes': return this.renderSizeBytesInput(field);
@ -151,17 +259,45 @@ export class AutoForm<T> extends React.Component<AutoFormProps<T>, AutoFormState
}
}
renderField(field: Field) {
private renderField = (field: Field<T>) => {
const { model } = this.props;
if (!model) return null;
if (field.isDefined && !field.isDefined(model)) return null;
const label = field.label || AutoForm.makeLabelName(field.name);
return <FormGroup label={label} key={field.name}>
return <FormGroup
key={field.name}
label={label}
labelInfo={
field.info &&
<Popover
content={<div className="label-info-text">{field.info}</div>}
position="left-bottom"
>
<Icon icon={IconNames.INFO_SIGN} iconSize={14}/>
</Popover>
}
>
{this.renderFieldInput(field)}
</FormGroup>;
}
renderCustom() {
const { model } = this.props;
return <FormGroup label="Custom" key="custom">
<JSONInput
value={model}
onChange={this.modelChange}
/>
</FormGroup>;
}
render() {
const { fields, model } = this.props;
const { fields, model, showCustom } = this.props;
return <div className="auto-form">
{model && fields.map(field => this.renderField(field))}
{model && fields.map(this.renderField)}
{model && showCustom && showCustom(model) && this.renderCustom()}
</div>;
}
}

View File

@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.center-message {
position: absolute;
width: 100%;
height: 100% !important;
overflow: hidden;
.center-message-inner {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 50%;
}
}

View File

@ -0,0 +1,34 @@
/*
* 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 * as React from 'react';
import './center-message.scss';
export interface CenterMessageProps extends React.Props<any> {
}
export class CenterMessage extends React.Component<CenterMessageProps, {}> {
render() {
return <div className="center-message bp3-input">
<div className="center-message-inner">
{this.props.children}
</div>
</div>;
}
}

View File

@ -0,0 +1,43 @@
/*
* 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 { Button, InputGroup } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import * as React from 'react';
export interface ClearableInputProps extends React.Props<any> {
className?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
}
export class ClearableInput extends React.Component<ClearableInputProps, {}> {
render() {
const { className, value, onChange, placeholder } = this.props;
return <InputGroup
className={classNames('clearable-input', className)}
value={value}
onChange={(e: any) => onChange(e.target.value)}
rightElement={value ? <Button icon={IconNames.CROSS} minimal onClick={() => onChange('')} /> : undefined}
placeholder={placeholder}
/>;
}
}

View File

@ -0,0 +1,36 @@
/*
* 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 * as React from 'react';
export interface ExternalLinkProps extends React.Props<any> {
href: string;
}
export class ExternalLink extends React.Component<ExternalLinkProps, {}> {
render() {
const { href, children } = this.props;
return <a
href={href}
target="_blank"
>
{children}
</a>;
}
}

View File

@ -33,12 +33,7 @@
}
}
.config-popover .bp3-popover-content,
.legacy-popover .bp3-popover-content {
width: 240px;
}
.help-popover .bp3-popover-content {
width: 180px;
* {
white-space: nowrap;
}
}

View File

@ -16,7 +16,19 @@
* limitations under the License.
*/
import { Alignment, AnchorButton, Button, Classes, Menu, MenuItem, Navbar, NavbarDivider, NavbarGroup, Popover, Position } from '@blueprintjs/core';
import {
Alignment,
AnchorButton,
Button, Intent,
Menu,
MenuDivider,
MenuItem,
Navbar,
NavbarDivider,
NavbarGroup,
Popover,
Position
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames';
import * as React from 'react';
@ -24,6 +36,7 @@ import * as React from 'react';
import { AboutDialog } from '../dialogs/about-dialog';
import { CoordinatorDynamicConfigDialog } from '../dialogs/coordinator-dynamic-config';
import { OverlordDynamicConfigDialog } from '../dialogs/overlord-dynamic-config';
import { getWikipediaSpec } from '../utils/example-ingestion-spec';
import {
DRUID_DOCS,
DRUID_GITHUB,
@ -31,14 +44,16 @@ import {
LEGACY_COORDINATOR_CONSOLE,
LEGACY_OVERLORD_CONSOLE
} from '../variables';
import { LoadDataViewSeed } from '../views/load-data-view';
import './header-bar.scss';
export type HeaderActiveTab = null | 'datasources' | 'segments' | 'tasks' | 'servers' | 'sql' | 'lookups';
export type HeaderActiveTab = null | 'load-data' | 'query' | 'datasources' | 'segments' | 'tasks' | 'servers' | 'lookups';
export interface HeaderBarProps extends React.Props<any> {
active: HeaderActiveTab;
hideLegacy: boolean;
goToLoadDataView: (loadDataViewSeed: LoadDataViewSeed) => void;
}
export interface HeaderBarState {
@ -110,6 +125,8 @@ export class HeaderBar extends React.Component<HeaderBarProps, HeaderBarState> {
const { active, hideLegacy } = this.props;
const { aboutDialogOpen, coordinatorDynamicConfigDialogOpen, overlordDynamicConfigDialogOpen } = this.state;
const loadDataPrimary = false;
const legacyMenu = <Menu>
<MenuItem icon={IconNames.GRAPH} text="Legacy coordinator console" href={LEGACY_COORDINATOR_CONSOLE} target="_blank" />
<MenuItem icon={IconNames.MAP} text="Legacy overlord console" href={LEGACY_OVERLORD_CONSOLE} target="_blank" />
@ -123,7 +140,7 @@ export class HeaderBar extends React.Component<HeaderBarProps, HeaderBarState> {
</Menu>;
const configMenu = <Menu>
<MenuItem icon={IconNames.COG} text="Coordinator dynamic config" onClick={() => this.setState({ coordinatorDynamicConfigDialogOpen: true })}/>
<MenuItem icon={IconNames.SETTINGS} text="Coordinator dynamic config" onClick={() => this.setState({ coordinatorDynamicConfigDialogOpen: true })}/>
<MenuItem icon={IconNames.WRENCH} text="Overlord dynamic config" onClick={() => this.setState({ overlordDynamicConfigDialogOpen: true })}/>
<MenuItem icon={IconNames.PROPERTIES} active={active === 'lookups'} text="Lookups" href="#lookups"/>
</Menu>;
@ -133,26 +150,39 @@ export class HeaderBar extends React.Component<HeaderBarProps, HeaderBarState> {
<a href="#">
{this.renderLogo()}
</a>
<NavbarDivider />
<NavbarDivider/>
<AnchorButton
icon={IconNames.CLOUD_UPLOAD}
text="Load data"
active={active === 'load-data'}
href="#load-data"
minimal={!loadDataPrimary}
intent={loadDataPrimary ? Intent.PRIMARY : Intent.NONE}
/>
<AnchorButton minimal active={active === 'query'} icon={IconNames.APPLICATION} text="Query" href="#query" />
<NavbarDivider/>
<AnchorButton minimal active={active === 'datasources'} icon={IconNames.MULTI_SELECT} text="Datasources" href="#datasources" />
<AnchorButton minimal active={active === 'segments'} icon={IconNames.STACKED_CHART} text="Segments" href="#segments" />
<AnchorButton minimal active={active === 'tasks'} icon={IconNames.GANTT_CHART} text="Tasks" href="#tasks" />
<NavbarDivider/>
<AnchorButton minimal active={active === 'servers'} icon={IconNames.DATABASE} text="Data servers" href="#servers" />
<NavbarDivider />
<AnchorButton minimal active={active === 'sql'} icon={IconNames.APPLICATION} text="SQL" href="#sql" />
<Popover className="config-popover" content={configMenu} position={Position.BOTTOM_LEFT}>
<Button className={Classes.MINIMAL} icon={IconNames.SETTINGS} text="Config"/>
</Popover>
</NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}>
{
!hideLegacy &&
<Popover className="legacy-popover" content={legacyMenu} position={Position.BOTTOM_RIGHT}>
<Button className={Classes.MINIMAL} icon={IconNames.SHARE} text="Legacy"/>
<Popover content={legacyMenu} position={Position.BOTTOM_RIGHT}>
<Button minimal icon={IconNames.SHARE} text="Legacy"/>
</Popover>
}
<Popover className="help-popover" content={helpMenu} position={Position.BOTTOM_RIGHT}>
<Button className={Classes.MINIMAL} icon={IconNames.HELP} text="Help" />
<Popover content={configMenu} position={Position.BOTTOM_RIGHT}>
<Button minimal icon={IconNames.COG}/>
</Popover>
<Popover content={helpMenu} position={Position.BOTTOM_RIGHT}>
<Button minimal icon={IconNames.HELP}/>
</Popover>
</NavbarGroup>
{

View File

@ -0,0 +1,31 @@
/*
* 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.
*/
.null-table-cell {
&.null {
font-style: italic;
}
&.unparseable {
color: #9E2B0E;
}
&.timestamp {
font-weight: bold;
}
}

View File

@ -0,0 +1,44 @@
/*
* 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 * as React from 'react';
import './null-table-cell.scss';
export interface NullTableCellProps extends React.Props<any> {
value?: any;
timestamp?: boolean;
unparseable?: boolean;
}
export class NullTableCell extends React.Component<NullTableCellProps, {}> {
render() {
const { value, timestamp, unparseable } = this.props;
if (unparseable) {
return <span className="null-table-cell unparseable">{`error`}</span>;
} else if (value !== '' && value != null) {
if (timestamp) {
return <span className="null-table-cell timestamp" title={value}>{new Date(value).toISOString()}</span>;
} else {
return value;
}
} else {
return <span className="null-table-cell null">null</span>;
}
}
}

View File

@ -40,6 +40,7 @@ import * as ReactDOMServer from 'react-dom/server';
import { SQLFunctionDoc } from '../../lib/sql-function-doc';
import { AppToaster } from '../singletons/toaster';
import { DRUID_DOCS_RUNE, DRUID_DOCS_SQL } from '../variables';
import { MenuCheckbox } from './menu-checkbox';
@ -224,6 +225,12 @@ export class SqlControl extends React.Component<SqlControlProps, SqlControlState
const { query, autoComplete, bypassCache, wrapQuery } = this.state;
return <Menu>
<MenuItem
icon={IconNames.HELP}
text="Docs"
href={isRune ? DRUID_DOCS_RUNE : DRUID_DOCS_SQL}
target="_blank"
/>
{
!isRune &&
<>
@ -232,16 +239,16 @@ export class SqlControl extends React.Component<SqlControlProps, SqlControlState
text="Explain"
onClick={() => onExplain(query)}
/>
<MenuCheckbox
checked={autoComplete}
label="Auto complete"
onChange={() => this.setState({autoComplete: !autoComplete})}
/>
<MenuCheckbox
checked={wrapQuery}
label="Wrap query with limit"
onChange={() => this.setState({wrapQuery: !wrapQuery})}
/>
<MenuCheckbox
checked={autoComplete}
label="Auto complete"
onChange={() => this.setState({autoComplete: !autoComplete})}
/>
</>
}
<MenuCheckbox

View File

@ -23,14 +23,16 @@ import * as classNames from 'classnames';
import * as React from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom';
import { ExternalLink } from './components/external-link';
import { HeaderActiveTab, HeaderBar } from './components/header-bar';
import {Loader} from './components/loader';
import { Loader } from './components/loader';
import { AppToaster } from './singletons/toaster';
import { UrlBaser } from './singletons/url-baser';
import {QueryManager} from './utils';
import {DRUID_DOCS_API, DRUID_DOCS_SQL, LEGACY_COORDINATOR_CONSOLE, LEGACY_OVERLORD_CONSOLE} from './variables';
import { QueryManager } from './utils';
import { DRUID_DOCS_API, DRUID_DOCS_SQL } from './variables';
import { DatasourcesView } from './views/datasource-view';
import { HomeView } from './views/home-view';
import { LoadDataView, LoadDataViewSeed } from './views/load-data-view';
import { LookupsView } from './views/lookups-view';
import { SegmentsView } from './views/segments-view';
import { ServersView } from './views/servers-view';
@ -80,8 +82,8 @@ export class ConsoleApplication extends React.Component<ConsoleApplicationProps,
if (capabilities === 'working-without-sql') {
message = <>
It appears that the SQL endpoint is disabled. The console will fall back
to <a href={DRUID_DOCS_API} target="_blank">native Druid APIs</a> and will be
limited in functionality. Look at <a href={DRUID_DOCS_SQL} target="_blank">the SQL docs</a> to
to <ExternalLink href={DRUID_DOCS_API}>native Druid APIs</ExternalLink> and will be
limited in functionality. Look at <ExternalLink href={DRUID_DOCS_SQL}>the SQL docs</ExternalLink> to
enable the SQL endpoint.
</>;
} else if (capabilities === 'broken') {
@ -98,6 +100,7 @@ export class ConsoleApplication extends React.Component<ConsoleApplicationProps,
});
}
private loadDataViewSeed: LoadDataViewSeed | null;
private taskId: string | null;
private datasource: string | null;
private onlyUnavailable: boolean | null;
@ -145,8 +148,9 @@ export class ConsoleApplication extends React.Component<ConsoleApplicationProps,
this.capabilitiesQueryManager.terminate();
}
private resetInitialsDelay() {
private resetInitialsWithDelay() {
setTimeout(() => {
this.loadDataViewSeed = null;
this.taskId = null;
this.datasource = null;
this.onlyUnavailable = null;
@ -155,41 +159,85 @@ export class ConsoleApplication extends React.Component<ConsoleApplicationProps,
}, 50);
}
private goToTask = (taskId: string) => {
private goToLoadDataView = (loadDataViewSeed?: LoadDataViewSeed) => {
if (loadDataViewSeed) this.loadDataViewSeed = loadDataViewSeed;
window.location.hash = 'load-data';
this.resetInitialsWithDelay();
}
private goToTask = (taskId: string | null) => {
this.taskId = taskId;
window.location.hash = 'tasks';
this.resetInitialsDelay();
this.resetInitialsWithDelay();
}
private goToSegments = (datasource: string, onlyUnavailable = false) => {
this.datasource = `"${datasource}"`;
this.onlyUnavailable = onlyUnavailable;
window.location.hash = 'segments';
this.resetInitialsDelay();
this.resetInitialsWithDelay();
}
private goToMiddleManager = (middleManager: string) => {
this.middleManager = middleManager;
window.location.hash = 'servers';
this.resetInitialsDelay();
this.resetInitialsWithDelay();
}
private goToSql = (initSql: string) => {
this.initSql = initSql;
window.location.hash = 'sql';
this.resetInitialsDelay();
window.location.hash = 'query';
this.resetInitialsWithDelay();
}
private wrapInViewContainer = (active: HeaderActiveTab, el: JSX.Element, scrollable = false) => {
const { hideLegacy } = this.props;
return <>
<HeaderBar active={active} hideLegacy={hideLegacy} goToLoadDataView={this.goToLoadDataView}/>
<div className={classNames('view-container', { scrollable })}>{el}</div>
</>;
}
private wrappedHomeView = () => {
const { noSqlMode } = this.state;
return this.wrapInViewContainer(null, <HomeView noSqlMode={noSqlMode}/>);
}
private wrappedLoadDataView = () => {
return this.wrapInViewContainer('load-data', <LoadDataView seed={this.loadDataViewSeed} goToTask={this.goToTask}/>);
}
private wrappedSqlView = () => {
return this.wrapInViewContainer('query', <SqlView initSql={this.initSql}/>);
}
private wrappedDatasourcesView = () => {
const { noSqlMode } = this.state;
return this.wrapInViewContainer('datasources', <DatasourcesView goToSql={this.goToSql} goToSegments={this.goToSegments} noSqlMode={noSqlMode}/>);
}
private wrappedSegmentsView = () => {
const { noSqlMode } = this.state;
return this.wrapInViewContainer('segments', <SegmentsView datasource={this.datasource} onlyUnavailable={this.onlyUnavailable} goToSql={this.goToSql} noSqlMode={noSqlMode}/>);
}
private wrappedTasksView = () => {
const { noSqlMode } = this.state;
return this.wrapInViewContainer('tasks', <TasksView taskId={this.taskId} goToSql={this.goToSql} goToMiddleManager={this.goToMiddleManager} goToLoadDataView={this.goToLoadDataView} noSqlMode={noSqlMode}/>, true);
}
private wrappedServersView = () => {
const { noSqlMode } = this.state;
return this.wrapInViewContainer('servers', <ServersView middleManager={this.middleManager} goToSql={this.goToSql} goToTask={this.goToTask} noSqlMode={noSqlMode}/>, true);
}
private wrappedLookupsView = () => {
return this.wrapInViewContainer('lookups', <LookupsView/>);
}
render() {
const { hideLegacy } = this.props;
const { noSqlMode, capabilitiesLoading } = this.state;
const wrapInViewContainer = (active: HeaderActiveTab, el: JSX.Element, scrollable = false) => {
return <>
<HeaderBar active={active} hideLegacy={hideLegacy}/>
<div className={classNames('view-container', { scrollable })}>{el}</div>
</>;
};
const { capabilitiesLoading } = this.state;
if (capabilitiesLoading) {
return <div className={'loading-capabilities'}>
@ -203,47 +251,17 @@ export class ConsoleApplication extends React.Component<ConsoleApplicationProps,
return <HashRouter hashType="noslash">
<div className="console-application">
<Switch>
<Route
path="/datasources"
component={() => {
return wrapInViewContainer('datasources', <DatasourcesView goToSql={this.goToSql} goToSegments={this.goToSegments} noSqlMode={noSqlMode}/>);
}}
/>
<Route
path="/segments"
component={() => {
return wrapInViewContainer('segments', <SegmentsView datasource={this.datasource} onlyUnavailable={this.onlyUnavailable} goToSql={this.goToSql} noSqlMode={noSqlMode}/>);
}}
/>
<Route
path="/tasks"
component={() => {
return wrapInViewContainer('tasks', <TasksView taskId={this.taskId} goToSql={this.goToSql} goToMiddleManager={this.goToMiddleManager} noSqlMode={noSqlMode}/>, true);
}}
/>
<Route
path="/servers"
component={() => {
return wrapInViewContainer('servers', <ServersView middleManager={this.middleManager} goToSql={this.goToSql} goToTask={this.goToTask} noSqlMode={noSqlMode}/>, true);
}}
/>
<Route
path="/sql"
component={() => {
return wrapInViewContainer('sql', <SqlView initSql={this.initSql}/>);
}}
/>
<Route
path="/lookups"
component={() => {
return wrapInViewContainer('lookups', <LookupsView />);
}}
/>
<Route
component={() => {
return wrapInViewContainer(null, <HomeView noSqlMode={noSqlMode}/>);
}}
/>
<Route path="/load-data" component={this.wrappedLoadDataView}/>
<Route path="/query" component={this.wrappedSqlView}/>
<Route path="/sql" component={this.wrappedSqlView}/>
<Route path="/datasources" component={this.wrappedDatasourcesView}/>
<Route path="/segments" component={this.wrappedSegmentsView}/>
<Route path="/tasks" component={this.wrappedTasksView}/>
<Route path="/servers" component={this.wrappedServersView}/>
<Route path="/lookups" component={this.wrappedLookupsView}/>
<Route component={this.wrappedHomeView}/>
</Switch>
</div>
</HashRouter>;

View File

@ -20,6 +20,7 @@ import { AnchorButton, Button, Classes, Dialog, Intent } from '@blueprintjs/core
import { IconNames } from '@blueprintjs/icons';
import * as React from 'react';
import { ExternalLink } from '../components/external-link';
import { DRUID_COMMUNITY, DRUID_DEVELOPER_GROUP, DRUID_USER_GROUP, DRUID_WEBSITE } from '../variables';
export interface AboutDialogProps extends React.Props<any> {
@ -53,14 +54,14 @@ export class AboutDialog extends React.Component<AboutDialogProps, AboutDialogSt
</strong>
</p>
<p>
For help and support with Druid, please refer to the <a
href={DRUID_COMMUNITY} target="_blank">community page</a> and the <a
href={DRUID_USER_GROUP} target="_blank">user groups</a>.
For help and support with Druid, please refer to the <ExternalLink
href={DRUID_COMMUNITY}>community page</ExternalLink> and the <ExternalLink
href={DRUID_USER_GROUP}>user groups</ExternalLink>.
</p>
<p>
Druid is made with by a community of passionate developers.
To contribute, join in the discussion on the <a
href={DRUID_DEVELOPER_GROUP} target="_blank">developer group</a>.
To contribute, join in the discussion on the <ExternalLink
href={DRUID_DEVELOPER_GROUP}>developer group</ExternalLink>.
</p>
</div>
<div className={Classes.DIALOG_FOOTER}>

View File

@ -66,16 +66,16 @@ export class AsyncActionDialog extends React.Component<AsyncAlertDialogProps, As
message: `${failText}: ${e.message}`,
intent: Intent.DANGER
});
onClose(false);
this.setState({ working: false });
onClose(false);
return;
}
AppToaster.show({
message: successText,
intent: Intent.SUCCESS
});
onClose(true);
this.setState({ working: false });
onClose(true);
}
render() {

View File

@ -22,6 +22,7 @@ import axios from 'axios';
import * as React from 'react';
import { AutoForm } from '../components/auto-form';
import { ExternalLink } from '../components/external-link';
import { AppToaster } from '../singletons/toaster';
import { getDruidErrorMessage, QueryManager } from '../utils';
@ -124,7 +125,7 @@ export class CoordinatorDynamicConfigDialog extends React.Component<CoordinatorD
>
<p>
Edit the coordinator dynamic configuration on the fly.
For more information please refer to the <a href="http://druid.io/docs/latest/configuration/index.html#dynamic-configuration" target="_blank">documentation</a>.
For more information please refer to the <ExternalLink href="http://druid.io/docs/latest/configuration/index.html#dynamic-configuration">documentation</ExternalLink>.
</p>
<AutoForm
fields={[

View File

@ -22,6 +22,7 @@ import axios from 'axios';
import * as React from 'react';
import { AutoForm } from '../components/auto-form';
import { ExternalLink } from '../components/external-link';
import { AppToaster } from '../singletons/toaster';
import { getDruidErrorMessage, QueryManager } from '../utils';
@ -127,7 +128,7 @@ export class OverlordDynamicConfigDialog extends React.Component<OverlordDynamic
>
<p>
Edit the overlord dynamic configuration on the fly.
For more information please refer to the <a href="http://druid.io/docs/latest/configuration/index.html#overlord-dynamic-configuration" target="_blank">documentation</a>.
For more information please refer to the <ExternalLink href="http://druid.io/docs/latest/configuration/index.html#overlord-dynamic-configuration">documentation</ExternalLink>.
</p>
<AutoForm
fields={[

View File

@ -17,7 +17,6 @@
*/
import * as React from 'react';
(React as any).PropTypes = require('prop-types'); // Trick blueprint 1.0.1 into accepting React 16 as React 15.
import { reorderArray } from './retention-dialog';

View File

@ -50,3 +50,24 @@ svg {
height: 100%;
width: 100%;
}
.label-info-text {
max-width: 400px;
padding: 15px;
p:last-child {
margin-bottom: 0;
}
}
.bp3-form-group {
.bp3-form-content {
position: relative;
& > .bp3-popover-wrapper {
position: absolute;
right: 0;
top: 7px;
}
}
}

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.
*/
const UNSAFE_CHAR = /[^a-z0-9 ,._\-;:(){}\[\]<>!@#$%^&*`~?]/ig;
function escape(str: string): string {
return str.replace(UNSAFE_CHAR, (s) => {
return '\\u' + ('000' + s.charCodeAt(0).toString(16)).substr(-4);
});
}
export function escapeColumnName(name: string): string {
return `"${escape(name)}"`;
}

View File

@ -19,14 +19,35 @@
import axios from 'axios';
import { AxiosResponse } from 'axios';
export function parseHtmlError(htmlStr: string): string | null {
const startIndex = htmlStr.indexOf('</h3><pre>');
const endIndex = htmlStr.indexOf('\n\tat');
if (startIndex === -1 || endIndex === -1) return null;
return htmlStr
.substring(startIndex + 10, endIndex)
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>');
}
export function getDruidErrorMessage(e: any) {
const data: any = ((e.response || {}).data || {});
return [
data.error,
data.errorMessage,
data.errorClass,
data.host ? `on host ${data.host}` : null
].filter(Boolean).join(' / ') || e.message;
switch (typeof data) {
case 'object':
return [
data.error,
data.errorMessage,
data.errorClass,
data.host ? `on host ${data.host}` : null
].filter(Boolean).join(' / ') || e.message;
case 'string':
const htmlResp = parseHtmlError(data);
return htmlResp ? `HTML Error: ${htmlResp}` : e.message;
default:
return e.message;
}
}
export async function queryDruidRune(runeQuery: Record<string, any>): Promise<any> {

View File

@ -0,0 +1,72 @@
/*
* 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.
*/
export type DruidTimestampFormat = 'iso' | 'millis' | 'posix' | 'auto' | 'd/M/yyyy' | 'dd-M-yyyy hh:mm:ss a' |
'MM/dd/YYYY' | 'M/d/YY' | 'MM/dd/YYYY hh:mm:ss a' | 'YYYY-MM-dd HH:mm:ss' | 'YYYY-MM-dd HH:mm:ss.S';
export const TIMESTAMP_FORMAT_VALUES: DruidTimestampFormat[] = [
'iso', 'millis', 'posix', 'MM/dd/YYYY hh:mm:ss a', 'MM/dd/YYYY', 'M/d/YY', 'd/M/yyyy',
'YYYY-MM-dd HH:mm:ss', 'YYYY-MM-dd HH:mm:ss.S'
];
const EXAMPLE_DATE_ISO = '2015-10-29T23:00:00.000Z';
const EXAMPLE_DATE_VALUE = Date.parse(EXAMPLE_DATE_ISO);
const MIN_MILLIS = 3.15576e11; // 3 years in millis, so Tue Jan 01 1980
const MAX_MILLIS = EXAMPLE_DATE_VALUE * 10;
const MIN_POSIX = MIN_MILLIS / 1000;
const MAX_POSIX = MAX_MILLIS / 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 ISO_MATCHER = new RegExp(/^([\+-]?\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)?)?)?)$/);
export const JODA_TO_REGEXP_LOOKUP: Record<string, RegExp> = {
'd/M/yyyy': /^[12]?\d\/1?\d\/\d\d\d\d$/,
'MM/dd/YYYY': /^\d\d\/\d\d\/\d\d\d\d$/,
'M/d/YY': /^1?\d\/[12]?\d\/\d\d$/,
'd-M-yyyy hh:mm:ss a': /^[12]?\d-1?\d-\d\d\d\d \d\d:\d\d:\d\d [ap]m$/i,
'MM/dd/YYYY hh:mm:ss a' : /^\d\d\/\d\d\/\d\d\d\d \d\d:\d\d:\d\d [ap]m$/i,
'YYYY-MM-dd HH:mm:ss' : /^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/,
'YYYY-MM-dd HH:mm:ss.S': /^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d\.\d\d\d$/
};
export function timeFormatMatches(format: DruidTimestampFormat, value: string | number): boolean {
if (format === 'iso') {
return ISO_MATCHER.test(String(value));
}
if (format === 'millis') {
const absValue = Math.abs(Number(value));
return MIN_MILLIS < absValue && absValue < MAX_MILLIS;
}
if (format === 'posix') {
const absValue = Math.abs(Number(value));
return MIN_POSIX < absValue && absValue < MAX_POSIX;
}
const formatRegexp = JODA_TO_REGEXP_LOOKUP[format];
if (!formatRegexp) throw new Error(`unknown Druid format ${format}`);
return formatRegexp.test(String(value));
}
export function possibleDruidFormatForValues(values: any[]): DruidTimestampFormat | null {
return TIMESTAMP_FORMAT_VALUES.filter(format => {
return values.every(value => timeFormatMatches(format, value));
})[0] || null;
}

View File

@ -0,0 +1,98 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { filterMap } from './general';
import { DimensionMode, DimensionSpec, IngestionSpec, MetricSpec } from './ingestion-spec';
import { deepDelete, deepSet } from './object-change';
import { HeaderAndRows } from './sampler';
export function guessTypeFromSample(sample: any[]): string {
const definedValues = sample.filter(v => v != null);
if (definedValues.length && definedValues.every(v => !isNaN(v) && (typeof v === 'number' || typeof v === 'string'))) {
if (definedValues.every(v => v % 1 === 0)) {
return 'long';
} else {
return 'float';
}
} else {
return 'string';
}
}
export function getColumnTypeFromHeaderAndRows(headerAndRows: HeaderAndRows, column: string): string {
return guessTypeFromSample(filterMap(headerAndRows.rows, (r: any) => r.parsed ? r.parsed[column] : null));
}
export function getDimensionSpecs(headerAndRows: HeaderAndRows, hasRollup: boolean): (string | DimensionSpec)[] {
return filterMap(headerAndRows.header, (h) => {
if (h === '__time') return null;
const guessedType = getColumnTypeFromHeaderAndRows(headerAndRows, h);
if (guessedType === 'string') return h;
if (hasRollup) return null;
return {
type: guessedType,
name: h
};
});
}
export function getMetricSecs(headerAndRows: HeaderAndRows): MetricSpec[] {
return [{ name: 'count', type: 'count' }].concat(filterMap(headerAndRows.header, (h) => {
if (h === '__time') return null;
const guessedType = getColumnTypeFromHeaderAndRows(headerAndRows, h);
switch (guessedType) {
case 'double': return { name: `sum_${h}`, type: 'doubleSum', fieldName: h };
case 'long': return { name: `sum_${h}`, type: 'longSum', fieldName: h };
default: return null;
}
}));
}
export function updateSchemaWithSample(spec: IngestionSpec, headerAndRows: HeaderAndRows, dimensionMode: DimensionMode, rollup: boolean): IngestionSpec {
let newSpec = spec;
if (dimensionMode === 'auto-detect') {
newSpec = deepSet(newSpec, 'dataSchema.parser.parseSpec.dimensionsSpec.dimensions', []);
} else {
newSpec = deepDelete(newSpec, 'dataSchema.parser.parseSpec.dimensionsSpec.dimensionExclusions');
const dimensions = getDimensionSpecs(headerAndRows, rollup);
if (dimensions) {
newSpec = deepSet(newSpec, 'dataSchema.parser.parseSpec.dimensionsSpec.dimensions', dimensions);
}
}
if (rollup) {
newSpec = deepSet(newSpec, 'dataSchema.granularitySpec.queryGranularity', 'HOUR');
const metrics = getMetricSecs(headerAndRows);
if (metrics) {
newSpec = deepSet(newSpec, 'dataSchema.metricsSpec', metrics);
}
} else {
newSpec = deepSet(newSpec, 'dataSchema.granularitySpec.queryGranularity', 'NONE');
newSpec = deepDelete(newSpec, 'dataSchema.metricsSpec');
}
newSpec = deepSet(newSpec, 'dataSchema.granularitySpec.rollup', rollup);
return newSpec;
}

View File

@ -0,0 +1,97 @@
/*
* 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 { IngestionSpec } from './ingestion-spec';
export function getWikipediaSpec(dataSourceSuffix: string): IngestionSpec {
return {
'type': 'index',
'dataSchema': {
'dataSource': 'wikipedia-' + dataSourceSuffix,
'parser': {
'type': 'string',
'parseSpec': {
'format': 'json',
'dimensionsSpec': {
'dimensions': [
'isRobot',
'channel',
'flags',
'isUnpatrolled',
'page',
'diffUrl',
{
'name': 'added',
'type': 'long'
},
'comment',
{
'name': 'commentLength',
'type': 'long'
},
'isNew',
'isMinor',
{
'name': 'delta',
'type': 'long'
},
'isAnonymous',
'user',
{
'name': 'deltaBucket',
'type': 'long'
},
{
'name': 'deleted',
'type': 'long'
},
'namespace'
]
},
'timestampSpec': {
'column': 'timestamp',
'format': 'iso'
}
}
},
'granularitySpec': {
'type': 'uniform',
'segmentGranularity': 'DAY',
'rollup': false,
'queryGranularity': 'none'
},
'metricsSpec': []
},
'ioConfig': {
'type': 'index',
'firehose': {
'fetchTimeout': 300000,
'type': 'http',
'uris': [
'https://static.imply.io/data/wikipedia.json.gz'
]
}
},
'tuningConfig': {
'type': 'index',
'forceExtendableShardSpecs': true,
'maxParseExceptions': 100,
'maxSavedParseExceptions': 10
}
};
}

View File

@ -171,6 +171,14 @@ export function getHeadProp(results: Record<string, any>[], prop: string): any {
// ----------------------------
export function parseJson(json: string): any {
try {
return JSON.parse(json);
} catch (e) {
return undefined;
}
}
export function validJson(json: string): boolean {
try {
JSON.parse(json);
@ -197,3 +205,14 @@ export function parseStringToJSON(s: string): JSON | null {
return JSON.parse(s);
}
}
export function filterMap<T, Q>(xs: T[], f: (x: T, i?: number) => Q | null | undefined): Q[] {
return (xs.map(f) as any).filter(Boolean);
}
export function sortWithPrefixSuffix(things: string[], prefix: string[], suffix: string[]): string[] {
const pre = things.filter((x) => prefix.includes(x)).sort();
const mid = things.filter((x) => !prefix.includes(x) && !suffix.includes(x)).sort();
const post = things.filter((x) => suffix.includes(x)).sort();
return pre.concat(mid, post);
}

View File

@ -19,6 +19,7 @@
export * from './general';
export * from './druid-query';
export * from './query-manager';
export * from './query-state';
export * from './rune-decoder';
export * from './table-column-selection-handler';
export * from './local-storage-keys';

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
*/
export const LocalStorageKeys = {
INGESTION_SPEC: 'ingestion-spec' as 'ingestion-spec',
DATASOURCE_TABLE_COLUMN_SELECTION: 'datasource-table-column-selection' as 'datasource-table-column-selection',
SEGMENT_TABLE_COLUMN_SELECTION: 'segment-table-column-selection' as 'segment-table-column-selection',
SUPERVISOR_TABLE_COLUMN_SELECTION: 'supervisor-table-column-selection' as 'supervisor-table-column-selection',

View File

@ -0,0 +1,167 @@
/*
* 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 { deepDelete, deepGet, deepSet, makePath, parsePath } from './object-change';
describe('object-change', () => {
describe('parsePath', () => {
it('works', () => {
expect(parsePath('hello.wow.0')).toEqual(['hello', 'wow', '0']);
expect(parsePath('hello.{wow.moon}.0')).toEqual(['hello', 'wow.moon', '0']);
expect(parsePath('hello.#.0.[append]')).toEqual(['hello', '#', '0', '[append]']);
});
});
describe('makePath', () => {
it('works', () => {
expect(makePath(['hello', 'wow', '0'])).toEqual('hello.wow.0');
expect(makePath(['hello', 'wow.moon', '0'])).toEqual('hello.{wow.moon}.0');
});
});
describe('deepGet', () => {
const thing = {
hello: {
'consumer.props': 'lol',
wow: [
'a',
{ test: 'moon' }
]
},
zetrix: null
};
it('works', () => {
expect(deepGet(thing, 'hello.wow.0')).toEqual('a');
expect(deepGet(thing, 'hello.wow.4')).toEqual(undefined);
expect(deepGet(thing, 'hello.{consumer.props}')).toEqual('lol');
});
});
describe('deepSet', () => {
const thing = {
hello: {
wow: [
'a',
{ test: 'moon' }
]
},
zetrix: null
};
it('works to set an existing thing', () => {
expect(deepSet(thing, 'hello.wow.0', 5)).toEqual({
hello: {
wow: [
5,
{
test: 'moon'
}
]
},
zetrix: null
});
});
it('works to set a non-existing thing', () => {
expect(deepSet(thing, 'lets.do.this.now', 5)).toEqual({
hello: {
wow: [
'a',
{
test: 'moon'
}
]
},
lets: {
do: {
this: {
now: 5
}
}
},
zetrix: null
});
});
it('works to set an existing array', () => {
expect(deepSet(thing, 'hello.wow.[append]', 5)).toEqual({
hello: {
wow: [
'a',
{
test: 'moon'
},
5
]
},
zetrix: null
});
});
});
describe('deepDelete', () => {
const thing = {
hello: {
moon: 1,
wow: [
'a',
{ test: 'moon' }
]
},
zetrix: null
};
it('works to delete an existing thing', () => {
expect(deepDelete(thing, 'hello.wow')).toEqual({
hello: { moon: 1 },
zetrix: null
});
});
it('works is harmless to delete a non-existing thing', () => {
expect(deepDelete(thing, 'hello.there.lol.why')).toEqual(thing);
});
it('removes things completely', () => {
expect(deepDelete(deepDelete(thing, 'hello.wow'), 'hello.moon')).toEqual({
zetrix: null
});
});
it('works with arrays', () => {
expect(JSON.parse(JSON.stringify(deepDelete(thing, 'hello.wow.0')))).toEqual({
hello: {
moon: 1,
wow: [
{
test: 'moon'
}
]
},
zetrix: null
});
});
});
});

View File

@ -0,0 +1,119 @@
/*
* 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.
*/
export function shallowCopy(v: any): any {
return Array.isArray(v) ? v.slice() : Object.assign({}, v);
}
function isEmpty(v: any): boolean {
return !(Array.isArray(v) ? v.length : Object.keys(v).length);
}
export function parsePath(path: string): string[] {
const parts: string[] = [];
let rest = path;
while (rest) {
const escapedMatch = rest.match(/^\{([^{}]*)\}(?:\.(.*))?$/);
if (escapedMatch) {
parts.push(escapedMatch[1]);
rest = escapedMatch[2];
continue;
}
const normalMatch = rest.match(/^([^.]*)(?:\.(.*))?$/);
if (normalMatch) {
parts.push(normalMatch[1]);
rest = normalMatch[2];
continue;
}
throw new Error(`Could not parse path ${path}`);
}
return parts;
}
export function makePath(parts: string[]): string {
return parts.map(p => p.includes('.') ? `{${p}}` : p).join('.');
}
function isAppend(key: string): boolean {
return key === '[append]' || key === '-1';
}
export function deepGet<T extends Record<string, any>>(value: T, path: string): any {
const parts = parsePath(path);
for (const part of parts) {
value = (value || {})[part];
}
return value;
}
export function deepSet<T extends Record<string, any>>(value: T, path: string, x: any): T {
const parts = parsePath(path);
let myKey = parts.shift() as string; // Must be defined
const valueCopy = shallowCopy(value);
if (Array.isArray(valueCopy) && isAppend(myKey)) myKey = String(valueCopy.length);
if (parts.length) {
const nextKey = parts[0];
const rest = makePath(parts);
valueCopy[myKey] = deepSet(value[myKey] || (isAppend(nextKey) ? [] : {}), rest, x);
} else {
valueCopy[myKey] = x;
}
return valueCopy;
}
export function deepDelete<T extends Record<string, any>>(value: T, path: string): T {
const valueCopy = shallowCopy(value);
const parts = parsePath(path);
const firstKey = parts.shift() as string; // Must be defined
if (parts.length) {
const firstKeyValue = value[firstKey];
if (firstKeyValue) {
const restPath = makePath(parts);
const prunedFirstKeyValue = deepDelete(value[firstKey], restPath);
if (isEmpty(prunedFirstKeyValue)) {
delete valueCopy[firstKey];
} else {
valueCopy[firstKey] = prunedFirstKeyValue;
}
} else {
delete valueCopy[firstKey];
}
} else {
if (Array.isArray(valueCopy) && !isNaN(Number(firstKey))) {
valueCopy.splice(Number(firstKey), 1);
} else {
delete valueCopy[firstKey];
}
}
return valueCopy;
}
export function whitelistKeys(obj: Record<string, any>, whitelist: string[]): Record<string, any> {
const newObj: Record<string, any> = {};
for (const w of whitelist) {
if (Object.prototype.hasOwnProperty.call(obj, w)) {
newObj[w] = obj[w];
}
}
return newObj;
}

View File

@ -18,7 +18,7 @@
import debounce = require('lodash.debounce');
export interface QueryState<R> {
export interface QueryStateInt<R> {
result: R | null;
loading: boolean;
error: string | null;
@ -26,20 +26,20 @@ export interface QueryState<R> {
export interface QueryManagerOptions<Q, R> {
processQuery: (query: Q) => Promise<R>;
onStateChange?: (queryResolve: QueryState<R>) => void;
onStateChange?: (queryResolve: QueryStateInt<R>) => void;
debounceIdle?: number;
debounceLoading?: number;
}
export class QueryManager<Q, R> {
private processQuery: (query: Q) => Promise<R>;
private onStateChange?: (queryResolve: QueryState<R>) => void;
private onStateChange?: (queryResolve: QueryStateInt<R>) => void;
private terminated = false;
private nextQuery: Q;
private lastQuery: Q;
private actuallyLoading = false;
private state: QueryState<R> = {
private state: QueryStateInt<R> = {
result: null,
loading: false,
error: null
@ -64,7 +64,7 @@ export class QueryManager<Q, R> {
}
}
private setState(queryState: QueryState<R>) {
private setState(queryState: QueryStateInt<R>) {
this.state = queryState;
if (this.onStateChange && !this.terminated) {
this.onStateChange(queryState);
@ -130,7 +130,7 @@ export class QueryManager<Q, R> {
return this.lastQuery;
}
public getState(): QueryState<R> {
public getState(): QueryStateInt<R> {
return this.state;
}

View File

@ -0,0 +1,53 @@
/*
* 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.
*/
export type QueryStateState = 'init' | 'loading' | 'data' | 'error';
export class QueryState<T> {
static INIT: QueryState<any> = new QueryState({});
public state: QueryStateState = 'init';
public error?: string | null;
public data?: T | null;
constructor(opts: { loading?: boolean, error?: string, data?: T }) {
if (opts.error) {
if (opts.data) {
throw new Error('can not have both error and data');
} else {
this.state = 'error';
this.error = opts.error;
}
} else {
if (opts.data) {
this.state = 'data';
this.data = opts.data;
} else {
this.state = opts.loading ? 'loading' : 'init';
}
}
}
isInit(): boolean {
return this.state === 'init';
}
isLoading(): boolean {
return this.state === 'loading';
}
}

View File

@ -0,0 +1,342 @@
/*
* 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 axios from 'axios';
import { getDruidErrorMessage } from './druid-query';
import { filterMap, sortWithPrefixSuffix } from './general';
import {
DimensionsSpec,
getEmptyTimestampSpec,
IngestionSpec,
IoConfig, MetricSpec,
Parser,
ParseSpec,
Transform, TransformSpec
} from './ingestion-spec';
import { deepGet, deepSet, shallowCopy, whitelistKeys } from './object-change';
import { QueryState } from './query-state';
const SAMPLER_URL = `/druid/indexer/v1/sampler`;
const BASE_SAMPLER_CONFIG: SamplerConfig = {
// skipCache: true,
numRows: 500
};
export interface SampleSpec {
type: 'index';
spec: IngestionSpec;
samplerConfig: SamplerConfig;
}
export interface SamplerConfig {
numRows?: number;
cacheKey?: string;
skipCache?: boolean;
}
export interface SampleResponse {
cacheKey?: string;
data: SampleEntry[];
}
export interface SampleEntry {
raw: string;
parsed?: Record<string, any>;
unparseable?: boolean;
error?: string;
}
export interface HeaderAndRows {
header: string[];
rows: SampleEntry[];
}
function dedupe(xs: string[]): string[] {
const seen: Record<string, boolean> = {};
return xs.filter((x) => {
if (seen[x]) {
return false;
} else {
seen[x] = true;
return true;
}
});
}
export function headerFromSampleResponse(sampleResponse: SampleResponse, ignoreColumn?: string): string[] {
let columns = sortWithPrefixSuffix(dedupe(
[].concat(...(filterMap(sampleResponse.data, s => s.parsed ? Object.keys(s.parsed) : null) as any))
).sort(), ['__time'], []);
if (ignoreColumn) {
columns = columns.filter(c => c !== ignoreColumn);
}
return columns;
}
export function headerAndRowsFromSampleResponse(sampleResponse: SampleResponse, ignoreColumn?: string, parsedOnly = false): HeaderAndRows {
return {
header: headerFromSampleResponse(sampleResponse, ignoreColumn),
rows: parsedOnly ? sampleResponse.data.filter((d: any) => d.parsed) : sampleResponse.data
};
}
async function postToSampler(sampleSpec: SampleSpec, forStr: string): Promise<SampleResponse> {
let sampleResp: any;
try {
sampleResp = await axios.post(`${SAMPLER_URL}?for=${forStr}`, sampleSpec);
} catch (e) {
throw new Error(getDruidErrorMessage(e));
}
return sampleResp.data;
}
export async function sampleForConnect(spec: IngestionSpec): Promise<SampleResponse> {
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || {};
const sampleSpec: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index')
// dataSchema: {
// dataSource: 'sample',
// parser: {
// type: 'string',
// parseSpec: {
// format: 'json',
// dimensionsSpec: {},
// timestampSpec: getEmptyTimestampSpec()
// }
// }
// }
} as any,
samplerConfig: BASE_SAMPLER_CONFIG
};
return postToSampler(sampleSpec, 'connect');
}
export async function sampleForParser(spec: IngestionSpec, cacheKey: string | undefined): Promise<SampleResponse> {
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || {};
const parser: Parser = deepGet(spec, 'dataSchema.parser') || {};
const sampleSpec: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index'),
dataSchema: {
dataSource: 'sample',
parser: {
type: parser.type,
parseSpec: (
parser.parseSpec ?
Object.assign({}, parser.parseSpec, {
dimensionsSpec: {},
timestampSpec: getEmptyTimestampSpec()
}) :
undefined
) as any
}
}
},
samplerConfig: Object.assign({}, BASE_SAMPLER_CONFIG, {
cacheKey
})
};
return postToSampler(sampleSpec, 'parser');
}
export async function sampleForTimestamp(spec: IngestionSpec, cacheKey: string | undefined): Promise<SampleResponse> {
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || {};
const parser: Parser = deepGet(spec, 'dataSchema.parser') || {};
const parseSpec: ParseSpec = deepGet(spec, 'dataSchema.parser.parseSpec') || {};
const sampleSpec: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index'),
dataSchema: {
dataSource: 'sample',
parser: {
type: parser.type,
parseSpec: Object.assign({}, parseSpec, {
dimensionsSpec: {}
})
}
}
},
samplerConfig: Object.assign({}, BASE_SAMPLER_CONFIG, {
cacheKey
})
};
return postToSampler(sampleSpec, 'timestamp');
}
export async function sampleForTransform(spec: IngestionSpec, cacheKey: string | undefined): Promise<SampleResponse> {
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || {};
const parser: Parser = deepGet(spec, 'dataSchema.parser') || {};
const parseSpec: ParseSpec = deepGet(spec, 'dataSchema.parser.parseSpec') || {};
const transforms: Transform[] = deepGet(spec, 'dataSchema.transformSpec.transforms') || [];
// Extra step to simulate auto detecting dimension with transforms
const specialDimensionSpec: DimensionsSpec = {};
if (transforms && transforms.length) {
const sampleSpecHack: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index'),
dataSchema: {
dataSource: 'sample',
parser: {
type: parser.type,
parseSpec: Object.assign({}, parseSpec, {
dimensionsSpec: {}
})
}
}
},
samplerConfig: Object.assign({}, BASE_SAMPLER_CONFIG, {
cacheKey
})
};
const sampleResponseHack = await postToSampler(sampleSpecHack, 'transform-pre');
specialDimensionSpec.dimensions = dedupe(headerFromSampleResponse(sampleResponseHack, '__time').concat(transforms.map(t => t.name)));
}
const sampleSpec: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index'),
dataSchema: {
dataSource: 'sample',
parser: {
type: parser.type,
parseSpec: Object.assign({}, parseSpec, {
dimensionsSpec: specialDimensionSpec // Hack Hack Hack
})
},
transformSpec: {
transforms
}
}
},
samplerConfig: Object.assign({}, BASE_SAMPLER_CONFIG, {
cacheKey
})
};
return postToSampler(sampleSpec, 'transform');
}
export async function sampleForFilter(spec: IngestionSpec, cacheKey: string | undefined): Promise<SampleResponse> {
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || {};
const parser: Parser = deepGet(spec, 'dataSchema.parser') || {};
const parseSpec: ParseSpec = deepGet(spec, 'dataSchema.parser.parseSpec') || {};
const transforms: Transform[] = deepGet(spec, 'dataSchema.transformSpec.transforms') || [];
const filter: any = deepGet(spec, 'dataSchema.transformSpec.filter');
// Extra step to simulate auto detecting dimension with transforms
const specialDimensionSpec: DimensionsSpec = {};
if (transforms && transforms.length) {
const sampleSpecHack: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index'),
dataSchema: {
dataSource: 'sample',
parser: {
type: parser.type,
parseSpec: Object.assign({}, parseSpec, {
dimensionsSpec: {}
})
}
}
},
samplerConfig: Object.assign({}, BASE_SAMPLER_CONFIG, {
cacheKey
})
};
const sampleResponseHack = await postToSampler(sampleSpecHack, 'filter-pre');
specialDimensionSpec.dimensions = dedupe(headerFromSampleResponse(sampleResponseHack, '__time').concat(transforms.map(t => t.name)));
}
const sampleSpec: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index'),
dataSchema: {
dataSource: 'sample',
parser: {
type: parser.type,
parseSpec: Object.assign({}, parseSpec, {
dimensionsSpec: specialDimensionSpec // Hack Hack Hack
})
},
transformSpec: {
transforms,
filter
}
}
},
samplerConfig: Object.assign({}, BASE_SAMPLER_CONFIG, {
cacheKey
})
};
return postToSampler(sampleSpec, 'filter');
}
export async function sampleForSchema(spec: IngestionSpec, cacheKey: string | undefined): Promise<SampleResponse> {
const ioConfig: IoConfig = deepGet(spec, 'ioConfig') || {};
const parser: Parser = deepGet(spec, 'dataSchema.parser') || {};
const transformSpec: TransformSpec = deepGet(spec, 'dataSchema.transformSpec') || ({} as TransformSpec);
const metricsSpec: MetricSpec[] = deepGet(spec, 'dataSchema.metricsSpec') || [];
const queryGranularity: string = deepGet(spec, 'dataSchema.granularitySpec.queryGranularity') || 'NONE';
const sampleSpec: SampleSpec = {
type: 'index',
spec: {
ioConfig: deepSet(ioConfig, 'type', 'index'),
dataSchema: {
dataSource: 'sample',
parser: whitelistKeys(parser, ['type', 'parseSpec']) as Parser,
transformSpec,
metricsSpec,
granularitySpec: {
queryGranularity
}
}
},
samplerConfig: Object.assign({}, BASE_SAMPLER_CONFIG, {
cacheKey
})
};
return postToSampler(sampleSpec, 'schema');
}

View File

@ -0,0 +1,76 @@
/*
* 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 { computeFlattenExprsForData } from './spec-utils';
describe('spec-utils', () => {
describe('computeFlattenExprsForData', () => {
const data = [
{
context: {'host': 'clarity', 'topic': 'moon', 'bonus': {'foo': 'bar'}},
tags: ['a', 'b', 'c'],
messages: [
{ metric: 'request/time', value: 122 },
{ metric: 'request/time', value: 434 },
{ metric: 'request/time', value: 565 }
],
'value': 5
},
{
context: {'host': 'pivot', 'popic': 'sun'},
tags: ['a', 'd'],
messages: [
{ metric: 'request/time', value: 44 },
{ metric: 'request/time', value: 65 }
],
'value': 4
},
{
context: {'host': 'imply', 'dopik': 'fun'},
tags: ['x', 'y'],
messages: [
{ metric: 'request/time', value: 4 },
{ metric: 'request/time', value: 5 }
],
'value': 2
}
];
it('works for path, ignore-arrays', () => {
expect(computeFlattenExprsForData(data, 'path', 'ignore-arrays')).toEqual([
'$.context.bonus.foo',
'$.context.dopik',
'$.context.host',
'$.context.popic',
'$.context.topic'
]);
});
it('works for jq, ignore-arrays', () => {
expect(computeFlattenExprsForData(data, 'jq', 'ignore-arrays')).toEqual([
'.context.bonus.foo',
'.context.dopik',
'.context.host',
'.context.popic',
'.context.topic'
]);
});
});
});

View File

@ -0,0 +1,70 @@
/*
* 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 { FlattenField } from './ingestion-spec';
export type ExprType = 'path' | 'jq';
export type ArrayHandling = 'ignore-arrays' | 'include-arrays';
export function computeFlattenPathsForData(data: Record<string, any>[], exprType: ExprType, arrayHandling: ArrayHandling): FlattenField[] {
return computeFlattenExprsForData(data, exprType, arrayHandling).map((expr, i) => {
return {
type: exprType,
name: `expr_${i}`,
expr
};
});
}
export function computeFlattenExprsForData(data: Record<string, any>[], exprType: ExprType, arrayHandling: ArrayHandling): string[] {
const seenPaths: Record<string, boolean> = {};
for (const datum of data) {
const datumKeys = Object.keys(datum);
for (const datumKey of datumKeys) {
const datumValue = datum[datumKey];
if (isNested(datumValue)) {
addPath(seenPaths, (exprType === 'path' ? `$.${datumKey}` : `.${datumKey}`), datumValue, arrayHandling);
}
}
}
return Object.keys(seenPaths).sort();
}
function addPath(paths: Record<string, boolean>, path: string, value: any, arrayHandling: ArrayHandling) {
if (isNested(value)) {
if (!Array.isArray(value)) {
const valueKeys = Object.keys(value);
for (const valueKey of valueKeys) {
addPath(paths, `${path}.${valueKey}`, value[valueKey], arrayHandling);
}
} else if (arrayHandling === 'include-arrays') {
for (let i = 0; i < value.length; i++) {
addPath(paths, `${path}[${i}]`, value[i], arrayHandling);
}
}
} else {
paths[path] = true;
}
}
// Checks that the given value is nested as far as Druid is concerned
function isNested(v: any): boolean {
return Boolean(v) && typeof v === 'object' && (!Array.isArray(v) || v.some(isNested));
}

View File

@ -23,6 +23,7 @@ export const DRUID_WEBSITE = 'http://druid.io';
export const DRUID_GITHUB = 'https://github.com/apache/druid';
export const DRUID_DOCS = 'http://druid.io/docs/latest';
export const DRUID_DOCS_SQL = 'http://druid.io/docs/latest/querying/sql.html';
export const DRUID_DOCS_RUNE = 'http://druid.io/docs/latest/querying/querying.html';
export const DRUID_COMMUNITY = 'http://druid.io/community/';
export const DRUID_USER_GROUP = 'https://groups.google.com/forum/#!forum/druid-user';
export const DRUID_DEVELOPER_GROUP = 'https://lists.apache.org/list.html?dev@druid.apache.org';

View File

@ -211,7 +211,7 @@ GROUP BY 1`);
} : null
}
confirmButtonText="Drop data"
successText="Data has been dropped"
successText="Data drop request acknowledged, next time the coordinator runs data will be dropped"
failText="Could not drop data"
intent={Intent.DANGER}
onClose={(success) => {

View File

@ -0,0 +1,257 @@
/*
* 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.
*/
.load-data-view {
height: 100%;
display: grid;
grid-gap: 10px 15px;
grid-template-columns: 1fr 280px;
grid-template-rows: 55px 1fr 28px;
grid-template-areas:
"navi navi"
"main ctrl"
"main next";
&.init {
display: block;
& > * {
margin-bottom: 15px;
}
.intro {
font-size: 20px;
}
.section-title {
margin-bottom: 10px;
font-weight: bold;
}
.cards {
.bp3-card {
display: inline-block;
width: 250px;
height: 140px;
margin-right: 15px;
margin-bottom: 15px;
font-size: 24px;
text-align: center;
padding-top: 47px;
}
}
}
&.partition,
&.tuning,
&.publish {
grid-gap: 20px 50px;
grid-template-columns: 1fr 1fr 1fr;
grid-template-areas:
"navi navi navi"
"main othr ctrl"
"main othr next";
.main,
.other {
overflow: auto;
}
}
.stage-nav {
grid-area: navi;
white-space: nowrap;
overflow: auto;
.stage-section {
display: inline-block;
vertical-align: top;
margin-right: 15px;
}
.stage-nav-l1 {
height: 25px;
font-weight: bold;
color: #eeeeee;
}
.stage-nav-l2 {
height: 30px;
}
}
.main {
grid-area: main;
position: relative;
.raw-lines {
position: absolute;
width: 100%;
height: 100%;
white-space: pre;
}
.table-with-control {
position: absolute;
width: 100%;
height: 100%;
.table-control {
position: absolute;
width: 100%;
top: 0;
height: 30px;
& > * {
display: inline-block;
margin-right: 15px;
}
.clearable-input {
width: 250px;
}
}
.ReactTable {
position: absolute;
width: 100%;
top: 45px;
bottom: 0;
.rt-th {
position: relative;
&.selected::after {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
content: "";
border: 2px solid #ff5d10;
border-radius: 2px;
}
&.flattened,
&.transformed {
background: rgba(201, 128, 22, 0.2);
}
.clickable {
cursor: pointer;
}
&.timestamp {
background: rgba(19, 129, 201, 0.5);
}
&.dimension {
background: rgba(38, 170, 201, 0.5);
&.long { background: rgba(19, 129, 201, 0.5); }
&.float { background: rgba(25, 145, 201, 0.5); }
}
&.metric {
background: rgba(201, 191, 55, 0.5);
}
}
.rt-td {
&.flattened,
&.transformed {
background: rgba(201, 128, 22, 0.05);
}
&.timestamp {
background: rgba(19, 129, 201, 0.15);
}
&.dimension {
background: rgba(38, 170, 201, 0.1);
&.long { background: rgba(19, 129, 201, 0.1); }
&.float { background: rgba(25, 145, 201, 0.1); }
}
&.metric {
background: rgba(201, 191, 55, 0.1);
}
}
.parse-detail {
padding: 10px;
.parse-error {
color: #9E2B0E;
margin-bottom: 12px;
}
}
}
}
}
.other {
grid-area: othr;
}
.control {
grid-area: ctrl;
.intro {
margin-bottom: 15px;
.optional {
font-style: italic;
}
}
}
.next-bar {
grid-area: next;
text-align: right;
.prev {
float: left;
}
}
.column-name {
font-weight: bold;
}
.edit-controls {
background: #30404c;
padding: 10px;
border-radius: 2px;
margin-bottom: 15px;
.controls-buttons {
position: relative;
.add-update {
margin-right: 15px;
}
.cancel {
position: absolute;
right: 0;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -36,10 +36,6 @@
.ReactTable {
flex: 1;
.null-table-cell {
font-style: italic;
}
}
}

View File

@ -20,6 +20,7 @@ import * as Hjson from 'hjson';
import * as React from 'react';
import ReactTable from 'react-table';
import { NullTableCell } from '../components/null-table-cell';
import { SqlControl } from '../components/sql-control';
import { QueryPlanDialog } from '../dialogs/query-plan-dialog';
import {
@ -200,11 +201,7 @@ export class SqlView extends React.Component<SqlViewProps, SqlViewState> {
return {
Header: h,
accessor: String(i),
Cell: row => {
const value = row.value;
if (value === '' || value === null) return <span className="null-table-cell">null</span>;
return value;
}
Cell: row => <NullTableCell value={row.value}/>
};
})
}

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { Alert, Button, ButtonGroup, Intent, Label } from '@blueprintjs/core';
import { Alert, Button, ButtonGroup, Intent, Label, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import axios from 'axios';
import * as React from 'react';
@ -34,10 +34,13 @@ import {
booleanCustomTableFilter,
countBy,
formatDuration,
getDruidErrorMessage, LocalStorageKeys,
getDruidErrorMessage, localStorageGet, LocalStorageKeys,
queryDruidSql,
QueryManager, TableColumnSelectionHandler
} from '../utils';
import { IngestionType } from '../utils/ingestion-spec';
import { LoadDataViewSeed } from './load-data-view';
import './tasks-view.scss';
@ -48,6 +51,7 @@ export interface TasksViewProps extends React.Props<any> {
taskId: string | null;
goToSql: (initSql: string) => void;
goToMiddleManager: (middleManager: string) => void;
goToLoadDataView: () => void;
noSqlMode: boolean;
}
@ -71,6 +75,7 @@ export interface TasksViewState {
supervisorSpecDialogOpen: boolean;
taskSpecDialogOpen: boolean;
initSpec: any;
alertErrorMsg: string | null;
}
@ -131,6 +136,7 @@ export class TasksView extends React.Component<TasksViewProps, TasksViewState> {
supervisorSpecDialogOpen: false,
taskSpecDialogOpen: false,
initSpec: null,
alertErrorMsg: null
};
@ -224,6 +230,14 @@ ORDER BY "rank" DESC, "created_time" DESC`);
this.taskQueryManager.terminate();
}
private closeSpecDialogs = () => {
this.setState({
supervisorSpecDialogOpen: false,
taskSpecDialogOpen: false,
initSpec: null
});
}
private submitSupervisor = async (spec: JSON) => {
try {
await axios.post('/druid/indexer/v1/supervisor', spec);
@ -447,7 +461,7 @@ ORDER BY "rank" DESC, "created_time" DESC`);
show: supervisorTableColumnSelectionHandler.showColumn('Actions')
}
]}
defaultPageSize={10}
defaultPageSize={5}
className="-striped -highlight"
/>
{this.renderResumeSupervisorAction()}
@ -614,10 +628,21 @@ ORDER BY "rank" DESC, "created_time" DESC`);
}
render() {
const { goToSql, noSqlMode } = this.props;
const { groupTasksBy, supervisorSpecDialogOpen, taskSpecDialogOpen, alertErrorMsg } = this.state;
const { goToSql, goToLoadDataView, noSqlMode } = this.props;
const { groupTasksBy, supervisorSpecDialogOpen, taskSpecDialogOpen, initSpec, alertErrorMsg } = this.state;
const { supervisorTableColumnSelectionHandler, taskTableColumnSelectionHandler } = this;
const submitTaskMenu = <Menu>
<MenuItem
text="Raw JSON task"
onClick={() => this.setState({ taskSpecDialogOpen: true })}
/>
<MenuItem
text="Go to data loader"
onClick={() => goToLoadDataView()}
/>
</Menu>;
return <div className="tasks-view app-view">
<ViewControlBar label="Supervisors">
<Button
@ -661,11 +686,9 @@ ORDER BY "rank" DESC, "created_time" DESC`);
onClick={() => goToSql(this.taskQueryManager.getLastQuery())}
/>
}
<Button
icon={IconNames.PLUS}
text="Submit task"
onClick={() => this.setState({ taskSpecDialogOpen: true })}
/>
<Popover content={submitTaskMenu} position={Position.BOTTOM_LEFT}>
<Button icon={IconNames.PLUS} text="Submit task"/>
</Popover>
<TableColumnSelection
columns={taskTableColumns}
onChange={(column) => taskTableColumnSelectionHandler.changeTableColumnSelection(column)}
@ -673,16 +696,24 @@ ORDER BY "rank" DESC, "created_time" DESC`);
/>
</ViewControlBar>
{this.renderTaskTable()}
{ supervisorSpecDialogOpen ? <SpecDialog
onClose={() => this.setState({ supervisorSpecDialogOpen: false })}
onSubmit={this.submitSupervisor}
title="Submit supervisor"
/> : null }
{ taskSpecDialogOpen ? <SpecDialog
onClose={() => this.setState({ taskSpecDialogOpen: false })}
onSubmit={this.submitTask}
title="Submit task"
/> : null }
{
supervisorSpecDialogOpen &&
<SpecDialog
onClose={this.closeSpecDialogs}
onSubmit={this.submitSupervisor}
title="Submit supervisor"
initSpec={initSpec}
/>
}
{
taskSpecDialogOpen &&
<SpecDialog
onClose={this.closeSpecDialogs}
onSubmit={this.submitTask}
title="Submit task"
initSpec={initSpec}
/>
}
<Alert
icon={IconNames.ERROR}
intent={Intent.PRIMARY}

View File

@ -26,6 +26,7 @@
"lib/sql-function-doc.ts"
],
"exclude": [
"**/*.test.ts"
"**/*.spec.ts",
"**/*.spec.tsx"
]
}

View File

@ -19,11 +19,20 @@
const process = require('process');
const path = require('path');
const postcssPresetEnv = require('postcss-preset-env');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const { version } = require('./package.json');
module.exports = (env) => {
const druidUrl = 'http://' + ((env || {}).druid_host || process.env.druid_host || 'localhost:8888');
let druidUrl = ((env || {}).druid_host || process.env.druid_host || 'localhost');
if (!druidUrl.startsWith('http')) druidUrl = 'http://' + druidUrl;
if (!/:\d+$/.test(druidUrl)) druidUrl += ':8888';
const proxyTarget = {
target: druidUrl,
secure: false
};
return {
mode: process.env.NODE_ENV || 'development',
entry: {
@ -42,10 +51,11 @@ module.exports = (env) => {
devServer: {
publicPath: '/public',
index: './index.html',
openPage: 'unified-console.html',
port: 18081,
proxy: {
'/status': druidUrl,
'/druid': druidUrl
'/status': proxyTarget,
'/druid': proxyTarget
}
},
module: {
@ -89,6 +99,9 @@ module.exports = (env) => {
]
}
]
}
},
plugins: [
// new BundleAnalyzerPlugin()
]
};
};