From c2e7047167b7b36c0fa4fccaaf9d9c777d5014cb Mon Sep 17 00:00:00 2001 From: shuqi7 Date: Mon, 11 Mar 2019 14:40:52 -0700 Subject: [PATCH 1/2] Add compaction dialog in druid console which allows users to add/edit data source compaction configuration --- web-console/src/components/auto-form.scss | 23 +++ web-console/src/components/auto-form.tsx | 24 ++- web-console/src/components/filler.tsx | 62 ++++++++ .../src/dialogs/compaction-dialog.scss | 38 +++++ web-console/src/dialogs/compaction-dialog.tsx | 139 ++++++++++++++++++ web-console/src/utils/general.tsx | 29 ++++ web-console/src/views/datasource-view.tsx | 90 ++++++++++-- 7 files changed, 383 insertions(+), 22 deletions(-) create mode 100644 web-console/src/components/auto-form.scss create mode 100644 web-console/src/dialogs/compaction-dialog.scss create mode 100644 web-console/src/dialogs/compaction-dialog.tsx diff --git a/web-console/src/components/auto-form.scss b/web-console/src/components/auto-form.scss new file mode 100644 index 000000000000..c27406c1983f --- /dev/null +++ b/web-console/src/components/auto-form.scss @@ -0,0 +1,23 @@ +/* + * 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. + */ + +.auto-form { + .ace_scroller { + background-color: #212c36; + } +} \ No newline at end of file diff --git a/web-console/src/components/auto-form.tsx b/web-console/src/components/auto-form.tsx index 39e8baa55ced..da847c0aeb91 100644 --- a/web-console/src/components/auto-form.tsx +++ b/web-console/src/components/auto-form.tsx @@ -16,26 +16,29 @@ * limitations under the License. */ -import { resolveSrv } from 'dns'; import * as React from 'react'; -import axios from 'axios'; import { InputGroup } from "@blueprintjs/core"; -import { HTMLSelect, FormGroup, NumericInput, TagInput } from "../components/filler"; +import { HTMLSelect, FormGroup, NumericInput, TagInput, JSONInput } from "../components/filler"; +import "./auto-form.scss"; +import {parseStringToJSON, validJson} from "../utils"; interface Field { name: string; label?: string; - type: 'number' | 'size-bytes' | 'string' | 'boolean' | 'string-array'; + type: 'number' | 'size-bytes' | 'string' | 'boolean' | 'string-array' | 'json'; min?: number; } export interface AutoFormProps extends React.Props { fields: Field[]; model: T | null, - onChange: (newValue: T) => void + onChange: (newValue: T) => void, + JSONErrors?: T | null, + updateJSONErrors?: (newValue: T) => void; } export interface AutoFormState { + } export class AutoForm extends React.Component, AutoFormState> { @@ -100,6 +103,15 @@ export class AutoForm extends React.Component, AutoFormState } + private renderJSONInput(field: Field): JSX.Element { + const { model, onChange, JSONErrors, updateJSONErrors } = this.props; + return onChange(Object.assign({}, model, { [field.name]: parseStringToJSON(e)}))} + updateErrors={(e: any) => updateJSONErrors!(Object.assign({}, JSONErrors, { [field.name]: validJson(e) || e === ''}))} + /> + } + private renderStringArrayInput(field: Field): JSX.Element { const { model, onChange } = this.props; const label = field.label || AutoForm.makeLabelName(field.name); @@ -119,6 +131,7 @@ export class AutoForm extends React.Component, AutoFormState case 'string': return this.renderStringInput(field); case 'boolean': return this.renderBooleanInput(field); case 'string-array': return this.renderStringArrayInput(field); + case 'json': return this.renderJSONInput(field); default: throw new Error(`unknown field type '${field.type}'`); } } @@ -132,7 +145,6 @@ export class AutoForm extends React.Component, AutoFormState render() { const { fields, model } = this.props; - return
{model && fields.map(field => this.renderField(field))}
diff --git a/web-console/src/components/filler.tsx b/web-console/src/components/filler.tsx index 30809b852952..e46872730208 100644 --- a/web-console/src/components/filler.tsx +++ b/web-console/src/components/filler.tsx @@ -20,6 +20,8 @@ import { Button } from '@blueprintjs/core'; import * as React from 'react'; import classNames from 'classnames'; import './filler.scss'; +import {parseJSONToString, parseStringToJSON, validJson} from "../utils"; +import AceEditor from "react-ace"; export const IconNames = { @@ -257,3 +259,63 @@ export class TagInput extends React.Component; } } + +interface JSONInputProps extends React.Props{ + onChange: (newValue: string) => void; + value: JSON; + updateErrors: ((newValue: string) => void) +} + +interface JSONInputState{ + stringValue: string; +} + +export class JSONInput extends React.Component { + constructor(props: JSONInputProps) { + super(props); + this.state = { + stringValue: "" + } + } + + componentDidMount(): void { + const { value } = this.props; + const stringValue = parseJSONToString(value); + this.setState({ + stringValue: stringValue + }) + } + + render() { + const { onChange, updateErrors } = this.props; + const { stringValue } = this.state; + return { + this.setState({stringValue: e}); + if(validJson(e) || e === "") onChange(e); + updateErrors(e); + }} + focus={true} + fontSize={12} + width={'100%'} + height={"8vh"} + showPrintMargin={false} + showGutter={false} + value={stringValue} + editorProps={{ + $blockScrolling: Infinity + }} + setOptions={{ + enableBasicAutocompletion: false, + enableLiveAutocompletion: false, + showLineNumbers: false, + tabSize: 2, + }} + /> + } +} diff --git a/web-console/src/dialogs/compaction-dialog.scss b/web-console/src/dialogs/compaction-dialog.scss new file mode 100644 index 000000000000..451e88585b2b --- /dev/null +++ b/web-console/src/dialogs/compaction-dialog.scss @@ -0,0 +1,38 @@ +/* + * 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. + */ + +.compaction-dialog { + top: 8%; + + .auto-form { + margin: 10px 15px; + padding: 0 5px 0 5px; + max-height: 70vh; + overflow: scroll; + + .bp3-form-group { + padding: 0 15px; + margin: 0 0 5px; + } + ; + + .bp3-html-select { + width: 195px; + } + } +} \ No newline at end of file diff --git a/web-console/src/dialogs/compaction-dialog.tsx b/web-console/src/dialogs/compaction-dialog.tsx new file mode 100644 index 000000000000..97ff266ee84c --- /dev/null +++ b/web-console/src/dialogs/compaction-dialog.tsx @@ -0,0 +1,139 @@ +/* + * 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 { AutoForm } from '../components/auto-form'; +import './compaction-dialog.scss'; +import {Button, Classes, Dialog, Intent} from "@blueprintjs/core"; + +export interface CompactionDialogProps extends React.Props { + onClose: () => void, + onSave: (config: any) => void, + onDelete: () => void, + datasource: string, + configData: any, +} + +export interface CompactionDialogState { + currentConfig: Record | null; + JSONErrors: Record | null; +} + +export class CompactionDialog extends React.Component { + constructor(props: CompactionDialogProps) { + super(props); + this.state = { + currentConfig: null, + JSONErrors: null + }; + } + + componentDidMount(): void { + const { datasource, configData } = this.props; + let config: Record = { + dataSource: datasource, + inputSegmentSizeBytes: 419430400, + keepSegmentGranularity: true, + maxNumSegmentsToCompact: 150, + skipOffsetFromLatest: "P1D", + targetCompactionSizeBytes: 419430400, + taskContext: null, + taskPriority: 25, + tuningConfig: null + }; + if (configData !== undefined) { + config = configData; + } + this.setState({ + currentConfig: config + }); + } + + render() { + const { onClose, onSave, onDelete, datasource, configData } = this.props; + const { currentConfig, JSONErrors } = this.state; + return + this.setState({currentConfig: m})} + updateJSONErrors={e => this.setState({JSONErrors: e})} + JSONErrors={JSONErrors} + /> +
+
+
+
+
+ } +} \ No newline at end of file diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx index a64015d3c9a8..6294fefb59cb 100644 --- a/web-console/src/utils/general.tsx +++ b/web-console/src/utils/general.tsx @@ -137,3 +137,32 @@ export function localStorageGet(key: string): string | null { if (typeof localStorage === 'undefined') return null; return localStorage.getItem(key); } + +// ---------------------------- + +export function validJson(json: string): boolean { + try { + JSON.parse(json); + return true; + } catch (e) { + return false; + } +} + +// parse JSON to string; if JSON is null, parse empty string "" +export function parseJSONToString(item: JSON): string { + if (item != null) { + return JSON.stringify(item, null, 2); + } else { + return ""; + } +} + +// parse string to JSON object; if string is empty, return null +export function parseStringToJSON(s: string): JSON | null { + if (s === "") { + return null; + } else { + return JSON.parse(s); + } +} \ No newline at end of file diff --git a/web-console/src/views/datasource-view.tsx b/web-console/src/views/datasource-view.tsx index 6e62ced72114..22bbe37082e3 100644 --- a/web-console/src/views/datasource-view.tsx +++ b/web-console/src/views/datasource-view.tsx @@ -18,26 +18,27 @@ import axios from 'axios'; import * as React from 'react'; -import * as classNames from 'classnames'; -import ReactTable from "react-table"; -import { Filter } from "react-table"; -import { Button, Intent, Switch } from "@blueprintjs/core"; -import { IconNames } from "../components/filler"; -import { AppToaster } from '../singletons/toaster'; -import { RuleEditor } from '../components/rule-editor'; -import { AsyncActionDialog } from '../dialogs/async-action-dialog'; -import { RetentionDialog } from '../dialogs/retention-dialog'; +import ReactTable, {Filter} from "react-table"; +import {Button, Intent, Switch} from "@blueprintjs/core"; +import {IconNames} from "../components/filler"; +import {AppToaster} from '../singletons/toaster'; +import {RuleEditor} from '../components/rule-editor'; +import {AsyncActionDialog} from '../dialogs/async-action-dialog'; +import {RetentionDialog} from '../dialogs/retention-dialog'; import { addFilter, - formatNumber, - formatBytes, countBy, + formatBytes, + formatNumber, + getDruidErrorMessage, lookupBy, - QueryManager, - pluralIfNeeded, queryDruidSql, getDruidErrorMessage + pluralIfNeeded, + queryDruidSql, + QueryManager } from "../utils"; import "./datasource-view.scss"; +import {CompactionDialog} from "../dialogs/compaction-dialog"; export interface DatasourcesViewProps extends React.Props { goToSql: (initSql: string) => void; @@ -60,6 +61,7 @@ export interface DatasourcesViewState { showDisabled: boolean; retentionDialogOpenOn: { datasource: string, rules: any[] } | null; + compactionDialogOpenOn: {datasource: string, configData: any} | null; dropDataDatasource: string | null; enableDatasource: string | null; killDatasource: string | null; @@ -94,6 +96,7 @@ export class DatasourcesView extends React.Component { + if (compactionConfig === null) return; + try { + await axios.post(`/druid/coordinator/v1/config/compaction`, compactionConfig); + this.setState({compactionDialogOpenOn: null}); + this.datasourceQueryManager.rerunLastQuery(); + } catch (e) { + AppToaster.show({ + message: e, + intent: Intent.DANGER + }) + } + } + + private deleteCompaction = async () => { + const {compactionDialogOpenOn} = this.state; + if (compactionDialogOpenOn === null) return; + const datasource = compactionDialogOpenOn.datasource; + AppToaster.show({ + message: `Are you sure you want to delete ${datasource}'s compaction?`, + intent: Intent.DANGER, + action: { + text: "Confirm", + onClick: async () => { + try { + await axios.delete(`/druid/coordinator/v1/config/compaction/${datasource}`); + this.setState({compactionDialogOpenOn: null}, ()=>this.datasourceQueryManager.rerunLastQuery()); + } catch (e) { + AppToaster.show({ + message: e, + intent: Intent.DANGER + }) + } + } + } + }); + } + renderRetentionDialog() { const { retentionDialogOpenOn, tiers } = this.state; if (!retentionDialogOpenOn) return null; @@ -285,6 +327,20 @@ GROUP BY 1`); />; } + renderCompactionDialog() { + const { datasources, compactionDialogOpenOn } = this.state; + + if (!compactionDialogOpenOn || !datasources) return; + + return this.setState({compactionDialogOpenOn: null})} + onSave={this.saveCompaction} + onDelete={this.deleteCompaction} + /> + } + renderDatasourceTable() { const { goToSegments } = this.props; const { datasources, defaultRules, datasourcesLoading, datasourcesError, datasourcesFilter, showDisabled } = this.state; @@ -376,14 +432,15 @@ GROUP BY 1`); Cell: row => { const { compaction } = row.original; let text: string; + let compactionOpenOn: {datasource: string, configData: any} | null = + {datasource:row.original.datasource, configData: compaction}; if (compaction) { text = `Target: ${formatBytes(compaction.targetCompactionSizeBytes)}`; } else { text = 'None'; } - return {text} alert('ToDo')}>✎; - }, - show: false // This feature is not ready, it will be enabled later + return {text} this.setState({compactionDialogOpenOn: compactionOpenOn})}>✎; + } }, { Header: 'Size', @@ -428,6 +485,7 @@ GROUP BY 1`); {this.renderEnableAction()} {this.renderKillAction()} {this.renderRetentionDialog()} + {this.renderCompactionDialog()} ; } From 19f5348ca84df1a9d27901a2818516b44a3db816 Mon Sep 17 00:00:00 2001 From: shuqi7 Date: Fri, 15 Mar 2019 16:57:14 -0700 Subject: [PATCH 2/2] Addressed naming issues; changed json input validating process --- web-console/src/components/auto-form.tsx | 26 ++++++++++++++----- web-console/src/components/filler.tsx | 25 +++++++++++------- web-console/src/dialogs/compaction-dialog.tsx | 11 ++++---- web-console/src/utils/general.tsx | 4 +-- web-console/src/views/datasource-view.scss | 4 +++ web-console/src/views/datasource-view.tsx | 2 +- 6 files changed, 47 insertions(+), 25 deletions(-) diff --git a/web-console/src/components/auto-form.tsx b/web-console/src/components/auto-form.tsx index da847c0aeb91..21648b70e1f4 100644 --- a/web-console/src/components/auto-form.tsx +++ b/web-console/src/components/auto-form.tsx @@ -20,7 +20,6 @@ import * as React from 'react'; import { InputGroup } from "@blueprintjs/core"; import { HTMLSelect, FormGroup, NumericInput, TagInput, JSONInput } from "../components/filler"; import "./auto-form.scss"; -import {parseStringToJSON, validJson} from "../utils"; interface Field { name: string; @@ -33,12 +32,11 @@ export interface AutoFormProps extends React.Props { fields: Field[]; model: T | null, onChange: (newValue: T) => void, - JSONErrors?: T | null, - updateJSONErrors?: (newValue: T) => void; + updateJSONValidity?: (jsonValidity: boolean) => void; } export interface AutoFormState { - + jsonInputsValidity: any } export class AutoForm extends React.Component, AutoFormState> { @@ -51,6 +49,7 @@ export class AutoForm extends React.Component, AutoFormState constructor(props: AutoFormProps) { super(props); this.state = { + jsonInputsValidity: {} } } @@ -104,11 +103,24 @@ export class AutoForm extends React.Component, AutoFormState } private renderJSONInput(field: Field): JSX.Element { - const { model, onChange, JSONErrors, updateJSONErrors } = this.props; + const { model, onChange, updateJSONValidity } = this.props; + const { jsonInputsValidity } = this.state; + + const updateInputValidity = (e: any) => { + if (updateJSONValidity) { + const newJSONInputValidity = Object.assign({}, jsonInputsValidity, { [field.name]: e}); + this.setState({ + jsonInputsValidity: newJSONInputValidity + }) + const allJSONValid: boolean = Object.keys(newJSONInputValidity).every(property => newJSONInputValidity[property] === true); + updateJSONValidity(allJSONValid); + } + } + return onChange(Object.assign({}, model, { [field.name]: parseStringToJSON(e)}))} - updateErrors={(e: any) => updateJSONErrors!(Object.assign({}, JSONErrors, { [field.name]: validJson(e) || e === ''}))} + onChange={(e: any) => onChange(Object.assign({}, model, { [field.name]: e}))} + updateInputValidity={updateInputValidity} /> } diff --git a/web-console/src/components/filler.tsx b/web-console/src/components/filler.tsx index e46872730208..378b4e19328f 100644 --- a/web-console/src/components/filler.tsx +++ b/web-console/src/components/filler.tsx @@ -20,10 +20,9 @@ import { Button } from '@blueprintjs/core'; import * as React from 'react'; import classNames from 'classnames'; import './filler.scss'; -import {parseJSONToString, parseStringToJSON, validJson} from "../utils"; +import {stringifyJSON, parseStringToJSON, validJson} from "../utils"; import AceEditor from "react-ace"; - export const IconNames = { ERROR: "error" as "error", PLUS: "plus" as "plus", @@ -261,9 +260,9 @@ export class TagInput extends React.Component{ - onChange: (newValue: string) => void; - value: JSON; - updateErrors: ((newValue: string) => void) + onChange: (newJSONValue: any) => void, + value: any, + updateInputValidity: (valueValid: boolean) => void } interface JSONInputState{ @@ -280,14 +279,22 @@ export class JSONInput extends React.Component { componentDidMount(): void { const { value } = this.props; - const stringValue = parseJSONToString(value); + const stringValue = stringifyJSON(value); this.setState({ stringValue: stringValue }) } + componentWillReceiveProps(nextProps: JSONInputProps): void { + if (JSON.stringify(nextProps.value) !== JSON.stringify(this.props.value)) { + this.setState({ + stringValue: stringifyJSON(nextProps.value) + }); + } + } + render() { - const { onChange, updateErrors } = this.props; + const { onChange, updateInputValidity } = this.props; const { stringValue } = this.state; return { name="ace-editor" onChange={(e: string) => { this.setState({stringValue: e}); - if(validJson(e) || e === "") onChange(e); - updateErrors(e); + if(validJson(e) || e === "") onChange(parseStringToJSON(e)); + updateInputValidity(validJson(e) || e === ''); }} focus={true} fontSize={12} diff --git a/web-console/src/dialogs/compaction-dialog.tsx b/web-console/src/dialogs/compaction-dialog.tsx index 97ff266ee84c..1018d3216633 100644 --- a/web-console/src/dialogs/compaction-dialog.tsx +++ b/web-console/src/dialogs/compaction-dialog.tsx @@ -31,7 +31,7 @@ export interface CompactionDialogProps extends React.Props { export interface CompactionDialogState { currentConfig: Record | null; - JSONErrors: Record | null; + allJSONValid: boolean } export class CompactionDialog extends React.Component { @@ -39,7 +39,7 @@ export class CompactionDialog extends React.Component this.setState({currentConfig: m})} - updateJSONErrors={e => this.setState({JSONErrors: e})} - JSONErrors={JSONErrors} + updateJSONValidity={e => this.setState({allJSONValid: e})} />
@@ -130,7 +129,7 @@ export class CompactionDialog extends React.Component onSave(currentConfig)} - disabled={ currentConfig === null || (JSONErrors != null && !Object.keys(JSONErrors).every(e => JSONErrors[e] === true))} + disabled={ currentConfig === null || !allJSONValid } />
diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx index 6294fefb59cb..577d151b1f23 100644 --- a/web-console/src/utils/general.tsx +++ b/web-console/src/utils/general.tsx @@ -149,8 +149,8 @@ export function validJson(json: string): boolean { } } -// parse JSON to string; if JSON is null, parse empty string "" -export function parseJSONToString(item: JSON): string { +// stringify JSON to string; if JSON is null, parse empty string "" +export function stringifyJSON(item: any): string { if (item != null) { return JSON.stringify(item, null, 2); } else { diff --git a/web-console/src/views/datasource-view.scss b/web-console/src/views/datasource-view.scss index 398343766530..b552485a2f0b 100644 --- a/web-console/src/views/datasource-view.scss +++ b/web-console/src/views/datasource-view.scss @@ -20,6 +20,10 @@ height: 100%; width: 100%; + .clickable-cell { + cursor: pointer; + } + .ReactTable { position: absolute; top: 50px; diff --git a/web-console/src/views/datasource-view.tsx b/web-console/src/views/datasource-view.tsx index 22bbe37082e3..aeafa9ab4daf 100644 --- a/web-console/src/views/datasource-view.tsx +++ b/web-console/src/views/datasource-view.tsx @@ -439,7 +439,7 @@ GROUP BY 1`); } else { text = 'None'; } - return {text} this.setState({compactionDialogOpenOn: compactionOpenOn})}>✎; + return this.setState({compactionDialogOpenOn: compactionOpenOn})}>{text} ; } }, {